This commit is contained in:
crazywhalecc
2025-11-30 15:35:04 +08:00
parent f6c818d3c0
commit 14bfb4198a
179 changed files with 19502 additions and 655 deletions

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader;
use StaticPHP\Exception\DownloaderException;
use StaticPHP\Util\FileSystem;
class DownloadResult
{
/**
* @param string $cache_type Type of cache: 'archive', 'git', or 'local'
* @param null|string $filename Filename for archive type
* @param null|string $dirname Directory name for git/local type
* @param mixed $extract Extraction path or configuration
* @param bool $verified Whether the download has been verified (hash check)
* @param null|string $version Version of the downloaded artifact (e.g., "1.2.3", "v2.0.0")
* @param array $metadata Additional metadata (e.g., commit hash, release notes, etc.)
*/
private function __construct(
public readonly string $cache_type,
public readonly array $config,
public readonly ?string $filename = null,
public readonly ?string $dirname = null,
public mixed $extract = null,
public bool $verified = false,
public readonly ?string $version = null,
public readonly array $metadata = [],
) {
switch ($this->cache_type) {
case 'archive':
$this->filename !== null ?: throw new DownloaderException('Archive download result must have a filename.');
$fn = FileSystem::isRelativePath($this->filename) ? (DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $this->filename) : $this->filename;
file_exists($fn) ?: throw new DownloaderException("Downloaded archive file does not exist: {$fn}");
break;
case 'git':
case 'local':
$this->dirname !== null ?: throw new DownloaderException('Git/local download result must have a dirname.');
$dn = FileSystem::isRelativePath($this->dirname) ? (DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $this->dirname) : $this->dirname;
file_exists($dn) ?: throw new DownloaderException("Downloaded directory does not exist: {$dn}");
break;
}
}
/**
* Create a download result for an archive file.
*
* @param string $filename Filename of the downloaded archive
* @param mixed $extract Extraction path or configuration
* @param bool $verified Whether the archive has been hash-verified
* @param null|string $version Version string of the downloaded artifact
* @param array $metadata Additional metadata
*/
public static function archive(
string $filename,
array $config,
mixed $extract = null,
bool $verified = false,
?string $version = null,
array $metadata = []
): DownloadResult {
return new self('archive', config: $config, filename: $filename, extract: $extract, verified: $verified, version: $version, metadata: $metadata);
}
/**
* Create a download result for a git clone.
*
* @param string $dirname Directory name of the cloned repository
* @param mixed $extract Extraction path or configuration
* @param null|string $version Version string (tag, branch, or commit)
* @param array $metadata Additional metadata (e.g., commit hash)
*/
public static function git(string $dirname, array $config, mixed $extract = null, ?string $version = null, array $metadata = []): DownloadResult
{
return new self('git', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata);
}
/**
* Create a download result for a local directory.
*
* @param string $dirname Directory name
* @param mixed $extract Extraction path or configuration
* @param null|string $version Version string if known
* @param array $metadata Additional metadata
*/
public static function local(string $dirname, array $config, mixed $extract = null, ?string $version = null, array $metadata = []): DownloadResult
{
return new self('local', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata);
}
/**
* Check if version information is available.
*/
public function hasVersion(): bool
{
return $this->version !== null;
}
/**
* Get a metadata value by key.
*
* @param string $key Metadata key
* @param mixed $default Default value if key doesn't exist
*/
public function getMeta(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}
/**
* Create a new DownloadResult with updated version.
* (Immutable pattern - returns a new instance)
*/
public function withVersion(string $version): self
{
return new self(
$this->cache_type,
$this->config,
$this->filename,
$this->dirname,
$this->extract,
$this->verified,
$version,
$this->metadata
);
}
/**
* Create a new DownloadResult with additional metadata.
* (Immutable pattern - returns a new instance)
*/
public function withMeta(string $key, mixed $value): self
{
return new self(
$this->cache_type,
$this->config,
$this->filename,
$this->dirname,
$this->extract,
$this->verified,
$this->version,
array_merge($this->metadata, [$key => $value])
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** bitbuckettag */
class BitBucketTag implements DownloadTypeInterface
{
public const string BITBUCKET_API_URL = 'https://api.bitbucket.org/2.0/repositories/{repo}/refs/tags';
public const string BITBUCKET_DOWNLOAD_URL = 'https://bitbucket.org/{repo}/get/{version}.tar.gz';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Fetching {$name} API info from bitbucket tag");
$data = default_shell()->executeCurl(str_replace('{repo}', $config['repo'], self::BITBUCKET_API_URL), retries: $downloader->getRetry());
$data = json_decode($data ?: '', true);
$ver = $data['values'][0]['name'] ?? null;
if (!$ver) {
throw new DownloaderException("Failed to get {$name} version from BitBucket API");
}
$download_url = str_replace(['{repo}', '{version}'], [$config['repo'], $ver], self::BITBUCKET_DOWNLOAD_URL);
$headers = default_shell()->executeCurl($download_url, method: 'HEAD', retries: $downloader->getRetry());
preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?<filename>.+\.tar\.gz)\1/im', $headers, $matches);
if ($matches) {
$filename = $matches['filename'];
} else {
$filename = "{$name}-{$data['tag_name']}.tar.gz";
}
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} version {$ver} from BitBucket: {$download_url}");
default_shell()->executeCurlDownload($download_url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
interface DownloadTypeInterface
{
/**
* @param string $name Download item name
* @param array $config Input configuration for the download
* @param ArtifactDownloader $downloader Downloader instance
*/
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** filelist */
class FileList implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Fetching file list from {$config['url']}");
$page = default_shell()->executeCurl($config['url'], retries: $downloader->getRetry());
preg_match_all($config['regex'], $page ?: '', $matches);
if (!$matches) {
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}");
}
$versions = [];
foreach ($matches['version'] as $i => $version) {
$lower = strtolower($version);
foreach (['alpha', 'beta', 'rc', 'pre', 'nightly', 'snapshot', 'dev'] as $beta) {
if (str_contains($lower, $beta)) {
continue 2;
}
}
$versions[$version] = $matches['file'][$i];
}
uksort($versions, 'version_compare');
$filename = end($versions);
$version = array_key_last($versions);
if (isset($config['download-url'])) {
$url = str_replace(['{file}', '{version}'], [$filename, $version], $config['download-url']);
} else {
$url = $config['url'] . $filename;
}
$filename = end($versions);
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} from URL: {$url}");
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
/** git */
class Git implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$path = DOWNLOAD_PATH . "/{$name}";
logger()->debug("Cloning git repository for {$name} from {$config['url']}");
$shallow = !$downloader->getOption('no-shallow-clone', false);
default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null);
$version = "dev-{$config['rev']}";
return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** ghrel */
class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
{
use GitHubTokenSetupTrait;
public const string API_URL = 'https://api.github.com/repos/{repo}/releases';
public const string ASSET_URL = 'https://api.github.com/repos/{repo}/releases/assets/{id}';
private string $sha256 = '';
private ?string $version = null;
/**
* Get the latest GitHub release assets for a given repository.
* match_asset is provided, only return the asset that matches the regex.
*/
public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_stable, string $match_asset): array
{
$url = str_replace('{repo}', $repo, self::API_URL);
$headers = $this->getGitHubTokenHeaders();
$data2 = default_shell()->executeCurl($url, headers: $headers);
$data = json_decode($data2 ?: '', true);
if (!is_array($data)) {
throw new DownloaderException("Failed to get GitHub release API info for {$repo} from {$url}");
}
foreach ($data as $release) {
if ($prefer_stable && $release['prerelease'] === true) {
continue;
}
foreach ($release['assets'] as $asset) {
if (preg_match("|{$match_asset}|", $asset['name'])) {
if (isset($asset['id'], $asset['name'])) {
// store ghrel asset array (id: ghrel.{$repo}.{stable|unstable}.{$match_asset})
if ($asset['digest'] !== null && str_starts_with($asset['digest'], 'sha256:')) {
$this->sha256 = substr($asset['digest'], 7);
}
$this->version = $release['tag_name'] ?? null;
return $asset;
}
throw new DownloaderException("Failed to get asset name and id for {$repo}");
}
}
}
throw new DownloaderException("No suitable GitHub release found for {$repo}");
}
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Fetching GitHub release for {$name} from {$config['repo']}");
if (!isset($config['match'])) {
throw new DownloaderException("GitHubRelease downloader requires 'match' config for {$name}");
}
$rel = $this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match']);
// download file using curl
$asset_url = str_replace(['{repo}', '{id}'], [$config['repo'], $rel['id']], self::ASSET_URL);
$headers = array_merge(
$this->getGitHubTokenHeaders(),
['Accept: application/octet-stream']
);
$filename = $rel['name'];
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} asset from URL: {$asset_url}");
default_shell()->executeCurlDownload($asset_url, $path, headers: $headers, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null, version: $this->version);
}
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
{
if ($result->cache_type != 'archive') {
logger()->warning("GitHub release validator only supports archive download type for {$name} .");
return false;
}
if ($this->sha256 !== '') {
$calculated_hash = hash_file('sha256', DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $result->filename);
if ($this->sha256 !== $calculated_hash) {
logger()->error("Hash mismatch for downloaded GitHub release asset of {$name}: expected {$this->sha256}, got {$calculated_hash}");
return false;
}
logger()->debug("Hash verified for downloaded GitHub release asset of {$name}");
return true;
}
logger()->debug("No sha256 digest found for GitHub release asset of {$name}, skipping hash validation");
return true;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** ghtar */
/** ghtagtar */
class GitHubTarball implements DownloadTypeInterface
{
use GitHubTokenSetupTrait;
public const string API_URL = 'https://api.github.com/repos/{repo}/{rel_type}';
private ?string $version = null;
/**
* Get the GitHub tarball URL for a given repository and release type.
* If match_url is provided, only return the tarball that matches the regex.
* Otherwise, return the first tarball found.
*/
public function getGitHubTarballInfo(string $name, string $repo, string $rel_type, bool $prefer_stable = true, ?string $match_url = null, ?string $basename = null): array
{
$url = str_replace(['{repo}', '{rel_type}'], [$repo, $rel_type], self::API_URL);
$data = default_shell()->executeCurl($url, headers: $this->getGitHubTokenHeaders());
$data = json_decode($data ?: '', true);
if (!is_array($data)) {
throw new DownloaderException("Failed to get GitHub tarball URL for {$repo} from {$url}");
}
$url = null;
foreach ($data as $rel) {
if (($rel['prerelease'] ?? false) === true && $prefer_stable) {
continue;
}
if ($match_url === null) {
$url = $rel['tarball_url'] ?? null;
$version = $rel['tag_name'] ?? null;
break;
}
if (preg_match("|{$match_url}|", $rel['tarball_url'] ?? '')) {
$url = $rel['tarball_url'];
$version = $rel['tag_name'] ?? null;
break;
}
}
if (!$url) {
throw new DownloaderException("No suitable GitHub tarball found for {$repo}");
}
$this->version = $version ?? null;
$head = default_shell()->executeCurl($url, 'HEAD', headers: $this->getGitHubTokenHeaders()) ?: '';
preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?<filename>.+\.tar\.gz)\1/im', $head, $matches);
if ($matches) {
$filename = $matches['filename'];
} else {
$basename = $basename ?? basename($repo);
$filename = "{$basename}-" . ($rel_type === 'releases' ? $data['tag_name'] : $data['name']) . '.tar.gz';
}
return [$url, $filename];
}
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Downloading GitHub tarball for {$name} from {$config['repo']}");
$rel_type = match ($config['type']) {
'ghtar' => 'releases',
'ghtagtar' => 'tags',
default => throw new DownloaderException("Invalid GitHubTarball type for {$name}"),
};
[$url, $filename] = $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name);
$path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, headers: $this->getGitHubTokenHeaders());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null, version: $this->version);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
trait GitHubTokenSetupTrait
{
public function getGitHubTokenHeaders(): array
{
// GITHUB_TOKEN support
if (($token = getenv('GITHUB_TOKEN')) !== false && ($user = getenv('GITHUB_USER')) !== false) {
logger()->debug("Using 'GITHUB_TOKEN' with user {$user} for authentication");
return ['Authorization: Basic ' . base64_encode("{$user}:{$token}")];
}
if (($token = getenv('GITHUB_TOKEN')) !== false) {
logger()->debug("Using 'GITHUB_TOKEN' for authentication");
return ["Authorization: Bearer {$token}"];
}
return [];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
/** local */
class LocalDir implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Using local source directory for {$name} from {$config['dirname']}");
return DownloadResult::local($config['dirname'], $config, extract: $config['extract'] ?? null);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** pie */
class PIE implements DownloadTypeInterface
{
public const string PACKAGIST_URL = 'https://repo.packagist.org/p2/';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$packagist_url = self::PACKAGIST_URL . "{$config['repo']}.json";
logger()->debug("Fetching {$name} source from packagist index: {$packagist_url}");
$data = default_shell()->executeCurl($packagist_url, retries: $downloader->getRetry());
if ($data === false) {
throw new DownloaderException("Failed to fetch packagist index for {$name} from {$packagist_url}");
}
$data = json_decode($data, true);
if (!isset($data['packages'][$config['repo']]) || !is_array($data['packages'][$config['repo']])) {
throw new DownloaderException("failed to find {$name} repo info from packagist");
}
// get the first version
$first = $data['packages'][$config['repo']][0] ?? [];
// check 'type' => 'php-ext' or contains 'php-ext' key
if (!isset($first['php-ext'])) {
throw new DownloaderException("failed to find {$name} php-ext info from packagist, maybe not a php extension package");
}
// get download link from dist
$dist_url = $first['dist']['url'] ?? null;
$dist_type = $first['dist']['type'] ?? null;
if (!$dist_url || !$dist_type) {
throw new DownloaderException("failed to find {$name} dist info from packagist");
}
$name = str_replace('/', '_', $config['repo']);
$version = $first['version'] ?? 'unknown';
$filename = "{$name}-{$version}." . ($dist_type === 'zip' ? 'zip' : 'tar.gz');
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
default_shell()->executeCurlDownload($dist_url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
class PhpRelease implements DownloadTypeInterface, ValidatorInterface
{
public const string PHP_API = 'https://www.php.net/releases/index.php?json&version={version}';
public const string DOWNLOAD_URL = 'https://www.php.net/distributions/php-{version}.tar.xz';
private ?string $sha256 = '';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$phpver = $downloader->getOption('with-php', '8.4');
// Handle 'git' version to clone from php-src repository
if ($phpver === 'git') {
$this->sha256 = null;
return (new Git())->download($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $downloader);
}
// Fetch PHP release info first
$info = default_shell()->executeCurl(str_replace('{version}', $phpver, self::PHP_API), retries: $downloader->getRetry());
if ($info === false) {
throw new DownloaderException("Failed to fetch PHP release info for version {$phpver}");
}
$info = json_decode($info, true);
if (!is_array($info) || !isset($info['version'])) {
throw new DownloaderException("Invalid PHP release info received for version {$phpver}");
}
$version = $info['version'];
foreach ($info['source'] as $source) {
if (str_ends_with($source['filename'], '.tar.xz')) {
$this->sha256 = $source['sha256'];
$filename = $source['filename'];
break;
}
}
if (!isset($filename)) {
throw new DownloaderException("No suitable source tarball found for PHP version {$version}");
}
$url = str_replace('{version}', $version, self::DOWNLOAD_URL);
logger()->debug("Downloading PHP release {$version} from {$url}");
$path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version);
}
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
{
if ($this->sha256 === null) {
logger()->debug('Php-src is downloaded from non-release source, skipping validation.');
return true;
}
if ($this->sha256 === '') {
logger()->error("No SHA256 checksum available for validation of {$name}.");
return false;
}
$path = DOWNLOAD_PATH . "/{$result->filename}";
$hash = hash_file('sha256', $path);
if ($hash !== $this->sha256) {
logger()->error("SHA256 checksum mismatch for {$name}: expected {$this->sha256}, got {$hash}");
return false;
}
logger()->debug("SHA256 checksum validated successfully for {$name}.");
return true;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
/** url */
class Url implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$url = $config['url'];
$filename = $config['filename'] ?? basename($url);
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} from URL: {$url}");
$version = $config['version'] ?? null;
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
interface ValidatorInterface
{
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool;
}