2026-06-06 08:58:52 -05:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
|
|
|
*
|
|
|
|
|
* 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
|
2026-06-07 15:20:20 -05:00
|
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
2026-06-06 08:58:52 -05:00
|
|
|
* PATH: /lib/Enterprise/SourceResolver.php
|
|
|
|
|
* BRIEF: Resolve the root-level source directory across repos (source/, src/, htdocs/)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-06-20 20:21:26 -05:00
|
|
|
namespace MokoCli;
|
2026-06-06 08:58:52 -05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Source Directory Resolver
|
|
|
|
|
*
|
|
|
|
|
* Provides a single, consistent fallback chain for locating the root-level
|
2026-06-20 20:21:26 -05:00
|
|
|
* source directory in any MokoCli repository. The preferred directory
|
2026-06-06 08:58:52 -05:00
|
|
|
* 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'];
|
|
|
|
|
|
2026-06-20 21:02:04 -05:00
|
|
|
/** Cache of API-resolved entry points keyed by "org/repo". */
|
|
|
|
|
private static array $apiCache = [];
|
|
|
|
|
|
2026-06-06 08:58:52 -05:00
|
|
|
/**
|
|
|
|
|
* Resolve the source directory name for a repository root.
|
|
|
|
|
*
|
2026-06-20 21:02:04 -05:00
|
|
|
* 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)
|
2026-06-06 08:58:52 -05:00
|
|
|
*
|
|
|
|
|
* @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
|
|
|
|
|
{
|
2026-06-20 21:02:04 -05:00
|
|
|
// Try API first (CI environments where token + repo are available)
|
|
|
|
|
$apiResult = self::resolveFromApi($root);
|
|
|
|
|
if ($apiResult !== null) {
|
|
|
|
|
return $apiResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filesystem fallback
|
2026-06-06 08:58:52 -05:00
|
|
|
foreach (self::CANDIDATES as $candidate) {
|
|
|
|
|
if (is_dir("{$root}/{$candidate}")) {
|
|
|
|
|
return $candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'source';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:02:04 -05:00
|
|
|
/**
|
|
|
|
|
* 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 ['', ''];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 08:58:52 -05:00
|
|
|
/**
|
|
|
|
|
* 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)) {
|
2026-06-20 20:21:26 -05:00
|
|
|
fwrite(STDERR, "⚠ WARNING: This repo uses src/ which is deprecated. Rename to source/ per MokoCli conventions.\n");
|
2026-06-06 08:58:52 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|