mirror of
https://github.com/crazywhalecc/static-php-cli.git
synced 2026-07-10 02:15:36 +08:00
v3 base
This commit is contained in:
544
src/StaticPHP/Artifact/Artifact.php
Normal file
544
src/StaticPHP/Artifact/Artifact.php
Normal file
@@ -0,0 +1,544 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StaticPHP\Artifact;
|
||||
|
||||
use StaticPHP\Config\ArtifactConfig;
|
||||
use StaticPHP\Config\ConfigValidator;
|
||||
use StaticPHP\DI\ApplicationContext;
|
||||
use StaticPHP\Exception\SPCInternalException;
|
||||
use StaticPHP\Exception\WrongUsageException;
|
||||
use StaticPHP\Runtime\SystemTarget;
|
||||
use StaticPHP\Util\FileSystem;
|
||||
|
||||
class Artifact
|
||||
{
|
||||
public const int FETCH_PREFER_SOURCE = 0;
|
||||
|
||||
public const int FETCH_PREFER_BINARY = 1;
|
||||
|
||||
public const int FETCH_ONLY_SOURCE = 2;
|
||||
|
||||
public const int FETCH_ONLY_BINARY = 3;
|
||||
|
||||
protected ?array $config;
|
||||
|
||||
/** @var null|callable Bind custom source fetcher callback */
|
||||
protected mixed $custom_source_callback = null;
|
||||
|
||||
/** @var array<string, callable> Bind custom binary fetcher callbacks */
|
||||
protected mixed $custom_binary_callbacks = [];
|
||||
|
||||
/** @var null|callable Bind custom source extract callback (completely takes over extraction) */
|
||||
protected mixed $source_extract_callback = null;
|
||||
|
||||
/** @var null|array{callback: callable, platforms: string[]} Bind custom binary extract callback (completely takes over extraction) */
|
||||
protected ?array $binary_extract_callback = null;
|
||||
|
||||
/** @var array<callable> After source extract hooks */
|
||||
protected array $after_source_extract_callbacks = [];
|
||||
|
||||
/** @var array<array{callback: callable, platforms: string[]}> After binary extract hooks */
|
||||
protected array $after_binary_extract_callbacks = [];
|
||||
|
||||
public function __construct(protected readonly string $name, ?array $config = null)
|
||||
{
|
||||
$this->config = $config ?? ArtifactConfig::get($name);
|
||||
if ($this->config === null) {
|
||||
throw new WrongUsageException("Artifact '{$name}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the source of an artifact is already downloaded.
|
||||
*
|
||||
* @param bool $compare_hash Whether to compare hash of the downloaded source
|
||||
*/
|
||||
public function isSourceDownloaded(bool $compare_hash = false): bool
|
||||
{
|
||||
return ApplicationContext::get(ArtifactCache::class)->isSourceDownloaded($this->name, $compare_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the binary of an artifact is already downloaded for the specified target OS.
|
||||
*
|
||||
* @param null|string $target_os Target OS platform string, null for current platform
|
||||
* @param bool $compare_hash Whether to compare hash of the downloaded binary
|
||||
*/
|
||||
public function isBinaryDownloaded(?string $target_os = null, bool $compare_hash = false): bool
|
||||
{
|
||||
$target_os = $target_os ?? SystemTarget::getCurrentPlatformString();
|
||||
return ApplicationContext::get(ArtifactCache::class)->isBinaryDownloaded($this->name, $target_os, $compare_hash);
|
||||
}
|
||||
|
||||
public function shouldUseBinary(): bool
|
||||
{
|
||||
$platform = SystemTarget::getCurrentPlatformString();
|
||||
return $this->isBinaryDownloaded($platform) && $this->hasPlatformBinary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the source of an artifact is already extracted.
|
||||
*
|
||||
* @param bool $compare_hash Whether to compare hash of the extracted source
|
||||
*/
|
||||
public function isSourceExtracted(bool $compare_hash = false): bool
|
||||
{
|
||||
$target_path = $this->getSourceDir();
|
||||
|
||||
if (!is_dir($target_path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$compare_hash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get expected hash from cache
|
||||
$cache_info = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($this->name);
|
||||
if ($cache_info === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected_hash = $cache_info['hash'] ?? null;
|
||||
|
||||
// Local source: always consider extracted if directory exists
|
||||
if ($expected_hash === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check hash marker file
|
||||
$hash_file = "{$target_path}/.spc-hash";
|
||||
if (!file_exists($hash_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return FileSystem::readFile($hash_file) === $expected_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the binary of an artifact is already extracted for the specified target OS.
|
||||
*
|
||||
* @param null|string $target_os Target OS platform string, null for current platform
|
||||
* @param bool $compare_hash Whether to compare hash of the extracted binary
|
||||
*/
|
||||
public function isBinaryExtracted(?string $target_os = null, bool $compare_hash = false): bool
|
||||
{
|
||||
$target_os = $target_os ?? SystemTarget::getCurrentPlatformString();
|
||||
$extract_config = $this->getBinaryExtractConfig();
|
||||
$mode = $extract_config['mode'];
|
||||
|
||||
// For merge mode, check marker file
|
||||
if ($mode === 'merge') {
|
||||
$target_path = $extract_config['path'];
|
||||
$marker_file = "{$target_path}/.spc-{$this->name}-installed";
|
||||
|
||||
if (!file_exists($marker_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$compare_hash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get expected hash from cache
|
||||
$cache_info = ApplicationContext::get(ArtifactCache::class)->getBinaryInfo($this->name, $target_os);
|
||||
if ($cache_info === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected_hash = $cache_info['hash'] ?? null;
|
||||
if ($expected_hash === null) {
|
||||
return true; // Local binary
|
||||
}
|
||||
|
||||
$installed_hash = FileSystem::readFile($marker_file);
|
||||
return $installed_hash === $expected_hash;
|
||||
}
|
||||
|
||||
// For selective mode, cannot reliably check extraction status
|
||||
if ($mode === 'selective') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For standalone mode, check directory and hash
|
||||
$target_path = $extract_config['path'];
|
||||
|
||||
if (!is_dir($target_path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$compare_hash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get expected hash from cache
|
||||
$cache_info = ApplicationContext::get(ArtifactCache::class)->getBinaryInfo($this->name, $target_os);
|
||||
if ($cache_info === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected_hash = $cache_info['hash'] ?? null;
|
||||
|
||||
// Local binary: always consider extracted if directory exists
|
||||
if ($expected_hash === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check hash marker file
|
||||
$hash_file = "{$target_path}/.spc-hash";
|
||||
if (!file_exists($hash_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return FileSystem::readFile($hash_file) === $expected_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the artifact has a source defined.
|
||||
*/
|
||||
public function hasSource(): bool
|
||||
{
|
||||
return isset($this->config['source']) || $this->custom_source_callback !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the artifact has a local binary defined for the current system target.
|
||||
*/
|
||||
public function hasPlatformBinary(): bool
|
||||
{
|
||||
$target = SystemTarget::getCurrentPlatformString();
|
||||
return isset($this->config['binary'][$target]) || isset($this->custom_binary_callbacks[$target]);
|
||||
}
|
||||
|
||||
public function getDownloadConfig(string $type): mixed
|
||||
{
|
||||
return $this->config[$type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source extraction directory.
|
||||
*
|
||||
* Rules:
|
||||
* 1. If extract is not specified: SOURCE_PATH/{artifact_name}
|
||||
* 2. If extract is relative path: SOURCE_PATH/{value}
|
||||
* 3. If extract is absolute path: {value}
|
||||
* 4. If extract is array (dict): handled by extractor (selective extraction)
|
||||
*/
|
||||
public function getSourceDir(): string
|
||||
{
|
||||
// defined in config
|
||||
$extract = $this->config['source']['extract'] ?? null;
|
||||
|
||||
if ($extract === null) {
|
||||
return FileSystem::convertPath(SOURCE_PATH . '/' . $this->name);
|
||||
}
|
||||
|
||||
// Array (dict) mode - return default path, actual handling is in extractor
|
||||
if (is_array($extract)) {
|
||||
return FileSystem::convertPath(SOURCE_PATH . '/' . $this->name);
|
||||
}
|
||||
|
||||
// String path
|
||||
$path = $this->replaceExtractPathVariables($extract);
|
||||
|
||||
// Absolute path
|
||||
if (!FileSystem::isRelativePath($path)) {
|
||||
return FileSystem::convertPath($path);
|
||||
}
|
||||
|
||||
// Relative path: based on SOURCE_PATH
|
||||
return FileSystem::convertPath(SOURCE_PATH . '/' . $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binary extraction directory and mode.
|
||||
*
|
||||
* Rules:
|
||||
* 1. If extract is not specified: PKG_ROOT_PATH (standard mode)
|
||||
* 2. If extract is "hosted": BUILD_ROOT_PATH (standard mode, for pre-built libraries)
|
||||
* 3. If extract is relative path: PKG_ROOT_PATH/{value} (standard mode)
|
||||
* 4. If extract is absolute path: {value} (standard mode)
|
||||
* 5. If extract is array (dict): selective extraction mode
|
||||
*
|
||||
* @return array{path: ?string, mode: 'merge'|'selective'|'standard', files?: array}
|
||||
*/
|
||||
public function getBinaryExtractConfig(array $cache_info = []): array
|
||||
{
|
||||
if (is_string($cache_info['extract'] ?? null)) {
|
||||
return ['path' => $this->replaceExtractPathVariables($cache_info['extract']), 'mode' => 'standard'];
|
||||
}
|
||||
|
||||
$platform = SystemTarget::getCurrentPlatformString();
|
||||
$binary_config = $this->config['binary'][$platform] ?? null;
|
||||
|
||||
if ($binary_config === null) {
|
||||
return ['path' => PKG_ROOT_PATH, 'mode' => 'standard'];
|
||||
}
|
||||
|
||||
$extract = $binary_config['extract'] ?? null;
|
||||
|
||||
// Not specified: PKG_ROOT_PATH merge
|
||||
if ($extract === null) {
|
||||
return ['path' => PKG_ROOT_PATH, 'mode' => 'standard'];
|
||||
}
|
||||
|
||||
// "hosted" mode: BUILD_ROOT_PATH merge (for pre-built libraries)
|
||||
if ($extract === 'hosted' || ($binary_config['type'] ?? '') === 'hosted') {
|
||||
return ['path' => BUILD_ROOT_PATH, 'mode' => 'standard'];
|
||||
}
|
||||
|
||||
// Array (dict) mode: selective extraction
|
||||
if (is_array($extract)) {
|
||||
return [
|
||||
'path' => null,
|
||||
'mode' => 'selective',
|
||||
'files' => $extract,
|
||||
];
|
||||
}
|
||||
|
||||
// String path
|
||||
$path = $this->replaceExtractPathVariables($extract);
|
||||
|
||||
// Absolute path: standalone mode
|
||||
if (!FileSystem::isRelativePath($path)) {
|
||||
return ['path' => FileSystem::convertPath($path), 'mode' => 'standard'];
|
||||
}
|
||||
|
||||
// Relative path: PKG_ROOT_PATH/{value} standalone mode
|
||||
return ['path' => FileSystem::convertPath(PKG_ROOT_PATH . '/' . $path), 'mode' => 'standard'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the binary extraction directory.
|
||||
* For merge mode, returns the base path.
|
||||
* For standalone mode, returns the specific directory.
|
||||
*/
|
||||
public function getBinaryDir(): string
|
||||
{
|
||||
$config = $this->getBinaryExtractConfig();
|
||||
return $config['path'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom source fetcher callback.
|
||||
*/
|
||||
public function setCustomSourceCallback(callable $callback): void
|
||||
{
|
||||
$this->custom_source_callback = $callback;
|
||||
}
|
||||
|
||||
public function getCustomSourceCallback(): ?callable
|
||||
{
|
||||
return $this->custom_source_callback ?? null;
|
||||
}
|
||||
|
||||
public function getCustomBinaryCallback(): ?callable
|
||||
{
|
||||
$current_platform = SystemTarget::getCurrentPlatformString();
|
||||
return $this->custom_binary_callbacks[$current_platform] ?? null;
|
||||
}
|
||||
|
||||
public function emitCustomBinary(): void
|
||||
{
|
||||
$current_platform = SystemTarget::getCurrentPlatformString();
|
||||
if (!isset($this->custom_binary_callbacks[$current_platform])) {
|
||||
throw new SPCInternalException("No custom binary callback defined for artifact '{$this->name}' on target OS '{$current_platform}'.");
|
||||
}
|
||||
$callback = $this->custom_binary_callbacks[$current_platform];
|
||||
ApplicationContext::invoke($callback, [Artifact::class => $this]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom binary fetcher callback for a specific target OS.
|
||||
*
|
||||
* @param string $target_os Target OS platform string (e.g. linux-x86_64)
|
||||
* @param callable $callback Custom binary fetcher callback
|
||||
*/
|
||||
public function setCustomBinaryCallback(string $target_os, callable $callback): void
|
||||
{
|
||||
ConfigValidator::validatePlatformString($target_os);
|
||||
$this->custom_binary_callbacks[$target_os] = $callback;
|
||||
}
|
||||
|
||||
// ==================== Extraction Callbacks ====================
|
||||
|
||||
/**
|
||||
* Set custom source extract callback.
|
||||
* This callback completely takes over the source extraction process.
|
||||
*
|
||||
* Callback signature: function(Artifact $artifact, string $source_file, string $target_path): void
|
||||
*/
|
||||
public function setSourceExtractCallback(callable $callback): void
|
||||
{
|
||||
$this->source_extract_callback = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source extract callback.
|
||||
*/
|
||||
public function getSourceExtractCallback(): ?callable
|
||||
{
|
||||
return $this->source_extract_callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a custom source extract callback is set.
|
||||
*/
|
||||
public function hasSourceExtractCallback(): bool
|
||||
{
|
||||
return $this->source_extract_callback !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom binary extract callback.
|
||||
* This callback completely takes over the binary extraction process.
|
||||
*
|
||||
* Callback signature: function(Artifact $artifact, string $source_file, string $target_path, string $platform): void
|
||||
*
|
||||
* @param callable $callback The callback function
|
||||
* @param string[] $platforms Platform filters (empty = all platforms)
|
||||
*/
|
||||
public function setBinaryExtractCallback(callable $callback, array $platforms = []): void
|
||||
{
|
||||
$this->binary_extract_callback = [
|
||||
'callback' => $callback,
|
||||
'platforms' => $platforms,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the binary extract callback for current platform.
|
||||
*
|
||||
* @return null|callable The callback if set and matches current platform, null otherwise
|
||||
*/
|
||||
public function getBinaryExtractCallback(): ?callable
|
||||
{
|
||||
if ($this->binary_extract_callback === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$platforms = $this->binary_extract_callback['platforms'];
|
||||
$current_platform = SystemTarget::getCurrentPlatformString();
|
||||
|
||||
// Empty platforms array means all platforms
|
||||
if (empty($platforms) || in_array($current_platform, $platforms, true)) {
|
||||
return $this->binary_extract_callback['callback'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a custom binary extract callback is set for current platform.
|
||||
*/
|
||||
public function hasBinaryExtractCallback(): bool
|
||||
{
|
||||
return $this->getBinaryExtractCallback() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to run after source extraction completes.
|
||||
*
|
||||
* Callback signature: function(string $target_path): void
|
||||
*/
|
||||
public function addAfterSourceExtractCallback(callable $callback): void
|
||||
{
|
||||
$this->after_source_extract_callbacks[] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to run after binary extraction completes.
|
||||
*
|
||||
* Callback signature: function(string $target_path, string $platform): void
|
||||
*
|
||||
* @param callable $callback The callback function
|
||||
* @param string[] $platforms Platform filters (empty = all platforms)
|
||||
*/
|
||||
public function addAfterBinaryExtractCallback(callable $callback, array $platforms = []): void
|
||||
{
|
||||
$this->after_binary_extract_callbacks[] = [
|
||||
'callback' => $callback,
|
||||
'platforms' => $platforms,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit all after source extract callbacks.
|
||||
*
|
||||
* @param string $target_path The directory where source was extracted
|
||||
*/
|
||||
public function emitAfterSourceExtract(string $target_path): void
|
||||
{
|
||||
if (empty($this->after_source_extract_callbacks)) {
|
||||
logger()->debug("No after-source-extract hooks registered for [{$this->name}]");
|
||||
return;
|
||||
}
|
||||
|
||||
logger()->debug('Executing ' . count($this->after_source_extract_callbacks) . " after-source-extract hook(s) for [{$this->name}]");
|
||||
foreach ($this->after_source_extract_callbacks as $callback) {
|
||||
$callback_name = is_array($callback) ? (is_object($callback[0]) ? get_class($callback[0]) : $callback[0]) . '::' . $callback[1] : (is_string($callback) ? $callback : 'Closure');
|
||||
logger()->debug(" 🪝 Running hook: {$callback_name}");
|
||||
ApplicationContext::invoke($callback, ['target_path' => $target_path, Artifact::class => $this]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit all after binary extract callbacks for the specified platform.
|
||||
*
|
||||
* @param null|string $target_path The directory where binary was extracted
|
||||
* @param string $platform The platform string (e.g., 'linux-x86_64')
|
||||
*/
|
||||
public function emitAfterBinaryExtract(?string $target_path, string $platform): void
|
||||
{
|
||||
if (empty($this->after_binary_extract_callbacks)) {
|
||||
logger()->debug("No after-binary-extract hooks registered for [{$this->name}]");
|
||||
return;
|
||||
}
|
||||
|
||||
$executed = 0;
|
||||
foreach ($this->after_binary_extract_callbacks as $item) {
|
||||
$callback_platforms = $item['platforms'];
|
||||
|
||||
// Empty platforms array means all platforms
|
||||
if (empty($callback_platforms) || in_array($platform, $callback_platforms, true)) {
|
||||
$callback = $item['callback'];
|
||||
$callback_name = is_array($callback) ? (is_object($callback[0]) ? get_class($callback[0]) : $callback[0]) . '::' . $callback[1] : (is_string($callback) ? $callback : 'Closure');
|
||||
logger()->debug(" 🪝 Running hook: {$callback_name} (platform: {$platform})");
|
||||
ApplicationContext::invoke($callback, [
|
||||
'target_path' => $target_path,
|
||||
'platform' => $platform,
|
||||
Artifact::class => $this,
|
||||
]);
|
||||
++$executed;
|
||||
}
|
||||
}
|
||||
|
||||
logger()->debug("Executed {$executed} after-binary-extract hook(s) for [{$this->name}] on platform [{$platform}]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces variables in the extract path.
|
||||
*
|
||||
* @param string $extract the extract path with variables
|
||||
*/
|
||||
private function replaceExtractPathVariables(string $extract): string
|
||||
{
|
||||
$replacement = [
|
||||
'{artifact_name}' => $this->name,
|
||||
'{pkg_root_path}' => PKG_ROOT_PATH,
|
||||
'{build_root_path}' => BUILD_ROOT_PATH,
|
||||
'{php_sdk_path}' => getenv('PHP_SDK_PATH') ?: WORKING_DIR . '/php-sdk-binary-tools',
|
||||
'{working_dir}' => WORKING_DIR,
|
||||
'{download_path}' => DOWNLOAD_PATH,
|
||||
'{source_path}' => SOURCE_PATH,
|
||||
];
|
||||
return str_replace(array_keys($replacement), array_values($replacement), $extract);
|
||||
}
|
||||
}
|
||||
305
src/StaticPHP/Artifact/ArtifactCache.php
Normal file
305
src/StaticPHP/Artifact/ArtifactCache.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StaticPHP\Artifact;
|
||||
|
||||
use StaticPHP\Artifact\Downloader\DownloadResult;
|
||||
use StaticPHP\Exception\SPCInternalException;
|
||||
use StaticPHP\Util\FileSystem;
|
||||
|
||||
class ArtifactCache
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{
|
||||
* source: null|array{
|
||||
* lock_type: 'binary'|'source',
|
||||
* cache_type: 'archive'|'file'|'git'|'local',
|
||||
* filename?: string,
|
||||
* dirname?: string,
|
||||
* extract: null|'&custom'|string,
|
||||
* hash: null|string
|
||||
* },
|
||||
* binary: array{
|
||||
* windows-x86_64?: null|array{
|
||||
* lock_type: 'binary'|'source',
|
||||
* cache_type: 'archive'|'file'|'git'|'local',
|
||||
* filename?: string,
|
||||
* dirname?: string,
|
||||
* extract: null|'&custom'|string,
|
||||
* hash: null|string,
|
||||
* version?: null|string
|
||||
* }
|
||||
* }
|
||||
* }>
|
||||
*/
|
||||
protected array $cache = [];
|
||||
|
||||
/**
|
||||
* @param string $cache_file Lock file position
|
||||
*/
|
||||
public function __construct(protected string $cache_file = DOWNLOAD_PATH . '/.cache.json')
|
||||
{
|
||||
if (!file_exists($this->cache_file)) {
|
||||
logger()->debug("Cache file does not exist, creating new one at {$this->cache_file}");
|
||||
FileSystem::createDir(dirname($this->cache_file));
|
||||
file_put_contents($this->cache_file, json_encode([]));
|
||||
} else {
|
||||
$content = file_get_contents($this->cache_file);
|
||||
$this->cache = json_decode($content ?: '{}', true) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the source of an artifact is already downloaded.
|
||||
*
|
||||
* @param string $artifact_name Artifact name
|
||||
* @param bool $compare_hash Whether to compare hash of the downloaded source
|
||||
*/
|
||||
public function isSourceDownloaded(string $artifact_name, bool $compare_hash = false): bool
|
||||
{
|
||||
$item = $this->cache[$artifact_name] ?? null;
|
||||
if ($item === null) {
|
||||
return false;
|
||||
}
|
||||
return $this->isObjectDownloaded($this->cache[$artifact_name]['source'] ?? null, $compare_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the binary of an artifact for target OS is already downloaded.
|
||||
*
|
||||
* @param string $artifact_name Artifact name
|
||||
* @param string $target_os Target OS (accepts {windows|linux|macos}-{x86_64|aarch64})
|
||||
* @param bool $compare_hash Whether to compare hash of the downloaded binary
|
||||
*/
|
||||
public function isBinaryDownloaded(string $artifact_name, string $target_os, bool $compare_hash = false): bool
|
||||
{
|
||||
$item = $this->cache[$artifact_name] ?? null;
|
||||
if ($item === null) {
|
||||
return false;
|
||||
}
|
||||
return $this->isObjectDownloaded($this->cache[$artifact_name]['binary'][$target_os] ?? null, $compare_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the downloaded artifact info into cache.
|
||||
*
|
||||
* @param Artifact|string $artifact Artifact instance
|
||||
* @param 'binary'|'source' $lock_type Lock type ('source'|'binary')
|
||||
* @param DownloadResult $download_result Download result object
|
||||
* @param null|string $platform Target platform string for binary lock, null for source lock
|
||||
*/
|
||||
public function lock(Artifact|string $artifact, string $lock_type, DownloadResult $download_result, ?string $platform = null): void
|
||||
{
|
||||
$artifact_name = $artifact instanceof Artifact ? $artifact->getName() : $artifact;
|
||||
if (!isset($this->cache[$artifact_name])) {
|
||||
$this->cache[$artifact_name] = [
|
||||
'source' => null,
|
||||
'binary' => [],
|
||||
];
|
||||
}
|
||||
$obj = null;
|
||||
if ($download_result->cache_type === 'archive') {
|
||||
$obj = [
|
||||
'lock_type' => $lock_type,
|
||||
'cache_type' => 'archive',
|
||||
'filename' => $download_result->filename,
|
||||
'extract' => $download_result->extract,
|
||||
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
|
||||
'version' => $download_result->version,
|
||||
'config' => $download_result->config,
|
||||
];
|
||||
} elseif ($download_result->cache_type === 'file') {
|
||||
$obj = [
|
||||
'lock_type' => $lock_type,
|
||||
'cache_type' => 'file',
|
||||
'filename' => $download_result->filename,
|
||||
'extract' => $download_result->extract,
|
||||
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
|
||||
'version' => $download_result->version,
|
||||
'config' => $download_result->config,
|
||||
];
|
||||
} elseif ($download_result->cache_type === 'git') {
|
||||
$obj = [
|
||||
'lock_type' => $lock_type,
|
||||
'cache_type' => 'git',
|
||||
'dirname' => $download_result->dirname,
|
||||
'extract' => $download_result->extract,
|
||||
'hash' => trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $download_result->dirname) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')),
|
||||
'version' => $download_result->version,
|
||||
'config' => $download_result->config,
|
||||
];
|
||||
} elseif ($download_result->cache_type === 'local') {
|
||||
$obj = [
|
||||
'lock_type' => $lock_type,
|
||||
'cache_type' => 'local',
|
||||
'dirname' => $download_result->dirname,
|
||||
'extract' => $download_result->extract,
|
||||
'hash' => null,
|
||||
'version' => $download_result->version,
|
||||
'config' => $download_result->config,
|
||||
];
|
||||
}
|
||||
if ($obj === null) {
|
||||
throw new SPCInternalException("Invalid download result for locking artifact {$artifact_name}");
|
||||
}
|
||||
if ($lock_type === 'binary') {
|
||||
if ($platform === null) {
|
||||
throw new SPCInternalException("Invalid download result for locking binary artifact {$artifact_name}: platform cannot be null");
|
||||
}
|
||||
$obj['platform'] = $platform;
|
||||
}
|
||||
if ($lock_type === 'source') {
|
||||
$this->cache[$artifact_name]['source'] = $obj;
|
||||
} elseif ($lock_type === 'binary') {
|
||||
$this->cache[$artifact_name]['binary'][$platform] = $obj;
|
||||
} else {
|
||||
throw new SPCInternalException("Invalid lock type '{$lock_type}' for artifact {$artifact_name}");
|
||||
}
|
||||
// save cache to file
|
||||
file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source cache info for an artifact.
|
||||
*
|
||||
* @param string $artifact_name Artifact name
|
||||
* @return null|array Cache info array or null if not found
|
||||
*/
|
||||
public function getSourceInfo(string $artifact_name): ?array
|
||||
{
|
||||
return $this->cache[$artifact_name]['source'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binary cache info for an artifact on specific platform.
|
||||
*
|
||||
* @param string $artifact_name Artifact name
|
||||
* @param string $platform Platform string (e.g., 'linux-x86_64')
|
||||
* @return null|array{
|
||||
* lock_type: 'binary'|'source',
|
||||
* cache_type: 'archive'|'git'|'local',
|
||||
* filename?: string,
|
||||
* extract: null|'&custom'|string,
|
||||
* hash: null|string,
|
||||
* dirname?: string,
|
||||
* version?: null|string
|
||||
* } Cache info array or null if not found
|
||||
*/
|
||||
public function getBinaryInfo(string $artifact_name, string $platform): ?array
|
||||
{
|
||||
return $this->cache[$artifact_name]['binary'][$platform] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to the cached file/directory.
|
||||
*
|
||||
* @param array $cache_info Cache info from getSourceInfo() or getBinaryInfo()
|
||||
* @return string Full path to the cached file or directory
|
||||
*/
|
||||
public function getCacheFullPath(array $cache_info): string
|
||||
{
|
||||
return match ($cache_info['cache_type']) {
|
||||
'archive', 'file' => DOWNLOAD_PATH . '/' . $cache_info['filename'],
|
||||
'git' => DOWNLOAD_PATH . '/' . $cache_info['dirname'],
|
||||
'local' => $cache_info['dirname'], // local dirname is absolute path
|
||||
default => throw new SPCInternalException("Unknown cache type: {$cache_info['cache_type']}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove source cache entry for an artifact.
|
||||
*
|
||||
* @param string $artifact_name Artifact name
|
||||
* @param bool $delete_file Whether to also delete the cached file/directory
|
||||
*/
|
||||
public function removeSource(string $artifact_name, bool $delete_file = false): void
|
||||
{
|
||||
$source_info = $this->getSourceInfo($artifact_name);
|
||||
if ($source_info === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optionally delete the actual file/directory
|
||||
if ($delete_file) {
|
||||
$path = $this->getCacheFullPath($source_info);
|
||||
if (in_array($source_info['cache_type'], ['archive', 'file']) && file_exists($path)) {
|
||||
unlink($path);
|
||||
logger()->debug("Deleted cached archive: {$path}");
|
||||
} elseif ($source_info['cache_type'] === 'git' && is_dir($path)) {
|
||||
FileSystem::removeDir($path);
|
||||
logger()->debug("Deleted cached git repository: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
$this->cache[$artifact_name]['source'] = null;
|
||||
$this->save();
|
||||
logger()->debug("Removed source cache entry for [{$artifact_name}]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove binary cache entry for an artifact on specific platform.
|
||||
*
|
||||
* @param string $artifact_name Artifact name
|
||||
* @param string $platform Platform string (e.g., 'linux-x86_64')
|
||||
* @param bool $delete_file Whether to also delete the cached file/directory
|
||||
*/
|
||||
public function removeBinary(string $artifact_name, string $platform, bool $delete_file = false): void
|
||||
{
|
||||
$binary_info = $this->getBinaryInfo($artifact_name, $platform);
|
||||
if ($binary_info === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optionally delete the actual file/directory
|
||||
if ($delete_file) {
|
||||
$path = $this->getCacheFullPath($binary_info);
|
||||
if (in_array($binary_info['cache_type'], ['archive', 'file']) && file_exists($path)) {
|
||||
unlink($path);
|
||||
logger()->debug("Deleted cached binary archive: {$path}");
|
||||
} elseif ($binary_info['cache_type'] === 'git' && is_dir($path)) {
|
||||
FileSystem::removeDir($path);
|
||||
logger()->debug("Deleted cached binary git repository: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
unset($this->cache[$artifact_name]['binary'][$platform]);
|
||||
$this->save();
|
||||
logger()->debug("Removed binary cache entry for [{$artifact_name}] on platform [{$platform}]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to file.
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function isObjectDownloaded(?array $object, bool $compare_hash = false): bool
|
||||
{
|
||||
if ($object === null) {
|
||||
return false;
|
||||
}
|
||||
// check if source is cached and file/dir exists in downloads/ dir
|
||||
return match ($object['cache_type'] ?? null) {
|
||||
'archive', 'file' => isset($object['filename']) &&
|
||||
file_exists(DOWNLOAD_PATH . '/' . $object['filename']) &&
|
||||
(!$compare_hash || (
|
||||
isset($object['hash']) &&
|
||||
sha1_file(DOWNLOAD_PATH . '/' . $object['filename']) === $object['hash']
|
||||
)),
|
||||
'git' => isset($object['dirname']) &&
|
||||
is_dir(DOWNLOAD_PATH . '/' . $object['dirname'] . '/.git') &&
|
||||
(!$compare_hash || (
|
||||
isset($object['hash']) &&
|
||||
trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $object['dirname']) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')) === $object['hash']
|
||||
)),
|
||||
'local' => isset($object['dirname']) &&
|
||||
is_dir($object['dirname']), // local dirname is absolute path
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
665
src/StaticPHP/Artifact/ArtifactDownloader.php
Normal file
665
src/StaticPHP/Artifact/ArtifactDownloader.php
Normal file
@@ -0,0 +1,665 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StaticPHP\Artifact;
|
||||
|
||||
use Psr\Log\LogLevel;
|
||||
use StaticPHP\Artifact\Downloader\DownloadResult;
|
||||
use StaticPHP\Artifact\Downloader\Type\BitBucketTag;
|
||||
use StaticPHP\Artifact\Downloader\Type\DownloadTypeInterface;
|
||||
use StaticPHP\Artifact\Downloader\Type\FileList;
|
||||
use StaticPHP\Artifact\Downloader\Type\Git;
|
||||
use StaticPHP\Artifact\Downloader\Type\GitHubRelease;
|
||||
use StaticPHP\Artifact\Downloader\Type\GitHubTarball;
|
||||
use StaticPHP\Artifact\Downloader\Type\LocalDir;
|
||||
use StaticPHP\Artifact\Downloader\Type\PhpRelease;
|
||||
use StaticPHP\Artifact\Downloader\Type\PIE;
|
||||
use StaticPHP\Artifact\Downloader\Type\Url;
|
||||
use StaticPHP\Artifact\Downloader\Type\ValidatorInterface;
|
||||
use StaticPHP\DI\ApplicationContext;
|
||||
use StaticPHP\Exception\DownloaderException;
|
||||
use StaticPHP\Exception\ExecutionException;
|
||||
use StaticPHP\Exception\SPCException;
|
||||
use StaticPHP\Exception\ValidationException;
|
||||
use StaticPHP\Exception\WrongUsageException;
|
||||
use StaticPHP\Runtime\Shell\Shell;
|
||||
use StaticPHP\Runtime\SystemTarget;
|
||||
use StaticPHP\Util\FileSystem;
|
||||
use StaticPHP\Util\InteractiveTerm;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use ZM\Logger\ConsoleColor;
|
||||
|
||||
/**
|
||||
* Artifact Downloader class
|
||||
*/
|
||||
class ArtifactDownloader
|
||||
{
|
||||
/** @var array<string, Artifact> Artifact objects */
|
||||
protected array $artifacts = [];
|
||||
|
||||
/** @var int Parallel process number (1 and 0 as single-threaded mode) */
|
||||
protected int $parallel = 1;
|
||||
|
||||
protected int $retry = 0;
|
||||
|
||||
/** @var array<string, string> Override custom download urls from options */
|
||||
protected array $custom_urls = [];
|
||||
|
||||
/** @var array<string, array{0: string, 1: string}> Override custom git options from options ([branch, git url]) */
|
||||
protected array $custom_gits = [];
|
||||
|
||||
/** @var array<string, string> Override custom local paths from options */
|
||||
protected array $custom_locals = [];
|
||||
|
||||
/** @var int Fetch type preference */
|
||||
protected int $default_fetch_pref = Artifact::FETCH_PREFER_SOURCE;
|
||||
|
||||
/** @var array<string, int> Specific fetch preference */
|
||||
protected array $fetch_prefs = [];
|
||||
|
||||
/** @var array<string>|bool Whether to ignore cache for specific artifacts or all */
|
||||
protected array|bool $ignore_cache = false;
|
||||
|
||||
/** @var bool Whether to enable alternative mirror downloads */
|
||||
protected bool $alt = true;
|
||||
|
||||
private array $_before_files;
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* parallel?: int,
|
||||
* retry?: int,
|
||||
* custom-url?: array<string>,
|
||||
* custom-git?: array<string>,
|
||||
* custom-local?: array<string>,
|
||||
* prefer-source?: null|bool|string,
|
||||
* prefer-pre-built?: null|bool|string,
|
||||
* prefer-binary?: null|bool|string,
|
||||
* source-only?: null|bool|string,
|
||||
* binary-only?: null|bool|string,
|
||||
* ignore-cache?: null|bool|string,
|
||||
* ignore-cache-sources?: null|bool|string,
|
||||
* no-alt?: bool,
|
||||
* no-shallow-clone?: bool
|
||||
* } $options Downloader options
|
||||
*/
|
||||
public function __construct(protected array $options = [])
|
||||
{
|
||||
// Allow setting concurrency via options
|
||||
$this->parallel = max(1, (int) ($options['parallel'] ?? 1));
|
||||
// Allow setting retry via options
|
||||
$this->retry = max(0, (int) ($options['retry'] ?? 0));
|
||||
// Prefer source (default)
|
||||
if (array_key_exists('prefer-source', $options)) {
|
||||
if (is_string($options['prefer-source'])) {
|
||||
$ls = parse_comma_list($options['prefer-source']);
|
||||
foreach ($ls as $name) {
|
||||
$this->fetch_prefs[$name] = Artifact::FETCH_PREFER_SOURCE;
|
||||
}
|
||||
} elseif ($options['prefer-source'] === null) {
|
||||
$this->default_fetch_pref = Artifact::FETCH_PREFER_SOURCE;
|
||||
}
|
||||
}
|
||||
// Prefer binary (originally prefer-pre-built)
|
||||
if (array_key_exists('prefer-binary', $options)) {
|
||||
if (is_string($options['prefer-binary'])) {
|
||||
$ls = parse_comma_list($options['prefer-binary']);
|
||||
foreach ($ls as $name) {
|
||||
$this->fetch_prefs[$name] = Artifact::FETCH_PREFER_BINARY;
|
||||
}
|
||||
} elseif ($options['prefer-binary'] === null) {
|
||||
$this->default_fetch_pref = Artifact::FETCH_PREFER_BINARY;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('prefer-pre-built', $options)) {
|
||||
if (is_string($options['prefer-pre-built'])) {
|
||||
$ls = parse_comma_list($options['prefer-pre-built']);
|
||||
foreach ($ls as $name) {
|
||||
$this->fetch_prefs[$name] = Artifact::FETCH_PREFER_BINARY;
|
||||
}
|
||||
} elseif ($options['prefer-pre-built'] === null) {
|
||||
$this->default_fetch_pref = Artifact::FETCH_PREFER_BINARY;
|
||||
}
|
||||
}
|
||||
// Source only
|
||||
if (array_key_exists('source-only', $options)) {
|
||||
if (is_string($options['source-only'])) {
|
||||
$ls = parse_comma_list($options['source-only']);
|
||||
foreach ($ls as $name) {
|
||||
$this->fetch_prefs[$name] = Artifact::FETCH_ONLY_SOURCE;
|
||||
}
|
||||
} elseif ($options['source-only'] === null) {
|
||||
$this->default_fetch_pref = Artifact::FETCH_ONLY_SOURCE;
|
||||
}
|
||||
}
|
||||
// Binary only
|
||||
if (array_key_exists('binary-only', $options)) {
|
||||
if (is_string($options['binary-only'])) {
|
||||
$ls = parse_comma_list($options['binary-only']);
|
||||
foreach ($ls as $name) {
|
||||
$this->fetch_prefs[$name] = Artifact::FETCH_ONLY_BINARY;
|
||||
}
|
||||
} elseif ($options['binary-only'] === null) {
|
||||
$this->default_fetch_pref = Artifact::FETCH_ONLY_BINARY;
|
||||
}
|
||||
}
|
||||
// Ignore cache
|
||||
if (array_key_exists('ignore-cache', $options)) {
|
||||
if (is_string($options['ignore-cache'])) {
|
||||
$this->ignore_cache = parse_comma_list($options['ignore-cache']);
|
||||
} elseif ($options['ignore-cache'] === null) {
|
||||
$this->ignore_cache = true;
|
||||
}
|
||||
}
|
||||
// backward compatibility for ignore-cache-sources
|
||||
if (array_key_exists('ignore-cache-sources', $options)) {
|
||||
if (is_string($options['ignore-cache-sources'])) {
|
||||
$this->ignore_cache = parse_comma_list($options['ignore-cache-sources']);
|
||||
} elseif ($options['ignore-cache-sources'] === null) {
|
||||
$this->ignore_cache = true;
|
||||
}
|
||||
}
|
||||
// Allow setting custom urls via options
|
||||
foreach (($options['custom-url'] ?? []) as $value) {
|
||||
[$artifact_name, $url] = explode(':', $value, 2);
|
||||
$this->custom_urls[$artifact_name] = $url;
|
||||
$this->ignore_cache = match ($this->ignore_cache) {
|
||||
true => true,
|
||||
false => [$artifact_name],
|
||||
default => array_merge($this->ignore_cache, [$artifact_name]),
|
||||
};
|
||||
}
|
||||
// Allow setting custom git options via options
|
||||
foreach (($options['custom-git'] ?? []) as $value) {
|
||||
[$artifact_name, $branch, $git_url] = explode(':', $value, 3) + [null, null, null];
|
||||
$this->custom_gits[$artifact_name] = [$branch ?? 'main', $git_url];
|
||||
$this->ignore_cache = match ($this->ignore_cache) {
|
||||
true => true,
|
||||
false => [$artifact_name],
|
||||
default => array_merge($this->ignore_cache, [$artifact_name]),
|
||||
};
|
||||
}
|
||||
// Allow setting custom local paths via options
|
||||
foreach (($options['custom-local'] ?? []) as $value) {
|
||||
[$artifact_name, $local_path] = explode(':', $value, 2);
|
||||
$this->custom_locals[$artifact_name] = $local_path;
|
||||
$this->ignore_cache = match ($this->ignore_cache) {
|
||||
true => true,
|
||||
false => [$artifact_name],
|
||||
default => array_merge($this->ignore_cache, [$artifact_name]),
|
||||
};
|
||||
}
|
||||
// no alt
|
||||
if (array_key_exists('no-alt', $options) && $options['no-alt'] === true) {
|
||||
$this->alt = false;
|
||||
}
|
||||
|
||||
// read downloads dir
|
||||
$this->_before_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an artifact to the download list.
|
||||
*
|
||||
* @param Artifact|string $artifact Artifact instance or artifact name
|
||||
*/
|
||||
public function add(Artifact|string $artifact): static
|
||||
{
|
||||
if (is_string($artifact)) {
|
||||
$artifact_instance = ArtifactLoader::getArtifactInstance($artifact);
|
||||
} else {
|
||||
$artifact_instance = $artifact;
|
||||
}
|
||||
if ($artifact_instance === null) {
|
||||
$name = $artifact;
|
||||
throw new WrongUsageException("Artifact '{$name}' not found, please check the name.");
|
||||
}
|
||||
// only add if not already added
|
||||
if (!isset($this->artifacts[$artifact_instance->getName()])) {
|
||||
$this->artifacts[$artifact_instance->getName()] = $artifact_instance;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple artifacts to the download list.
|
||||
*
|
||||
* @param array<Artifact|string> $artifacts Multiple artifacts to add
|
||||
*/
|
||||
public function addArtifacts(array $artifacts): static
|
||||
{
|
||||
foreach ($artifacts as $artifact) {
|
||||
$this->add($artifact);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the concurrency limit for parallel downloads.
|
||||
*
|
||||
* @param int $parallel Number of concurrent downloads (default: 3)
|
||||
*/
|
||||
public function setParallel(int $parallel): static
|
||||
{
|
||||
$this->parallel = max(1, $parallel);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all artifacts, with optional parallel processing.
|
||||
*
|
||||
* @param bool $interactive Enable interactive mode with Ctrl+C handling
|
||||
*/
|
||||
public function download(bool $interactive = true): void
|
||||
{
|
||||
if ($interactive) {
|
||||
Shell::passthruCallback(function () {
|
||||
InteractiveTerm::advance();
|
||||
});
|
||||
keyboard_interrupt_register(function () {
|
||||
echo PHP_EOL;
|
||||
InteractiveTerm::error('Download cancelled by user.');
|
||||
// scan changed files
|
||||
$after_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
|
||||
$new_files = array_diff($after_files, $this->_before_files);
|
||||
|
||||
// remove new files
|
||||
foreach ($new_files as $file) {
|
||||
if ($file === '.cache.json') {
|
||||
continue;
|
||||
}
|
||||
logger()->debug("Removing corrupted artifact: {$file}");
|
||||
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $file;
|
||||
if (is_dir($path)) {
|
||||
FileSystem::removeDir($path);
|
||||
} elseif (is_file($path)) {
|
||||
FileSystem::removeFileIfExists($path);
|
||||
}
|
||||
}
|
||||
exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
$this->applyCustomDownloads();
|
||||
|
||||
$count = count($this->artifacts);
|
||||
$artifacts_str = implode(',', array_map(fn ($x) => '' . ConsoleColor::yellow($x->getName()), $this->artifacts));
|
||||
// mute the first line if not interactive
|
||||
if ($interactive) {
|
||||
InteractiveTerm::notice("Downloading {$count} artifacts: {$artifacts_str} ...");
|
||||
}
|
||||
try {
|
||||
// Create dir
|
||||
if (!is_dir(DOWNLOAD_PATH)) {
|
||||
FileSystem::createDir(DOWNLOAD_PATH);
|
||||
}
|
||||
logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $this->artifacts)) . " with concurrency {$this->parallel} ...");
|
||||
// Download artifacts parallely
|
||||
if ($this->parallel > 1) {
|
||||
$this->downloadWithConcurrency();
|
||||
} else {
|
||||
// normal sequential download
|
||||
$current = 0;
|
||||
$skipped = [];
|
||||
foreach ($this->artifacts as $artifact) {
|
||||
++$current;
|
||||
if ($this->downloadWithType($artifact, $current, $count) === SPC_DOWNLOAD_STATUS_SKIPPED) {
|
||||
$skipped[] = $artifact->getName();
|
||||
continue;
|
||||
}
|
||||
$this->_before_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
|
||||
}
|
||||
if ($interactive) {
|
||||
$skip_msg = !empty($skipped) ? ' (Skipped ' . count($skipped) . ' artifacts for being already downloaded)' : '';
|
||||
InteractiveTerm::success("Downloaded all {$count} artifacts.{$skip_msg}", true);
|
||||
echo PHP_EOL;
|
||||
}
|
||||
}
|
||||
} catch (SPCException $e) {
|
||||
array_map(fn ($x) => InteractiveTerm::error($x), explode("\n", $e->getMessage()));
|
||||
throw new WrongUsageException();
|
||||
} finally {
|
||||
if ($interactive) {
|
||||
Shell::passthruCallback(null);
|
||||
keyboard_interrupt_unregister();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getRetry(): int
|
||||
{
|
||||
return $this->retry;
|
||||
}
|
||||
|
||||
public function getArtifacts(): array
|
||||
{
|
||||
return $this->artifacts;
|
||||
}
|
||||
|
||||
public function getOption(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return $this->options[$name] ?? $default;
|
||||
}
|
||||
|
||||
private function downloadWithType(Artifact $artifact, int $current, int $total, bool $parallel = false): int
|
||||
{
|
||||
$queue = $this->generateQueue($artifact);
|
||||
// already downloaded
|
||||
if ($queue === []) {
|
||||
logger()->debug("Artifact '{$artifact->getName()}' is already downloaded, skipping.");
|
||||
return SPC_DOWNLOAD_STATUS_SKIPPED;
|
||||
}
|
||||
|
||||
$try = false;
|
||||
foreach ($queue as $item) {
|
||||
try {
|
||||
$instance = null;
|
||||
$call = match ($item['config']['type']) {
|
||||
'bitbuckettag' => BitBucketTag::class,
|
||||
'filelist' => FileList::class,
|
||||
'git' => Git::class,
|
||||
'ghrel' => GitHubRelease::class,
|
||||
'ghtar', 'ghtagtar' => GitHubTarball::class,
|
||||
'local' => LocalDir::class,
|
||||
'pie' => PIE::class,
|
||||
'url' => Url::class,
|
||||
'php-release' => PhpRelease::class,
|
||||
default => null,
|
||||
};
|
||||
$type_display_name = match (true) {
|
||||
$item['lock'] === 'source' && ($callback = $artifact->getCustomSourceCallback()) !== null => 'user defined source downloader',
|
||||
$item['lock'] === 'binary' && ($callback = $artifact->getCustomBinaryCallback()) !== null => 'user defined binary downloader',
|
||||
default => SPC_DOWNLOAD_TYPE_DISPLAY_NAME[$item['config']['type']] ?? $item['config']['type'],
|
||||
};
|
||||
$try_h = $try ? 'Try downloading' : 'Downloading';
|
||||
logger()->info("{$try_h} artifact '{$artifact->getName()}' {$item['display']} ...");
|
||||
if ($parallel === false) {
|
||||
InteractiveTerm::indicateProgress("[{$current}/{$total}] Downloading artifact " . ConsoleColor::green($artifact->getName()) . " {$item['display']} from {$type_display_name} ...");
|
||||
}
|
||||
// is valid download type
|
||||
if ($item['lock'] === 'source' && ($callback = $artifact->getCustomSourceCallback()) !== null) {
|
||||
$lock = ApplicationContext::invoke($callback, [
|
||||
Artifact::class => $artifact,
|
||||
ArtifactDownloader::class => $this,
|
||||
]);
|
||||
} elseif ($item['lock'] === 'binary' && ($callback = $artifact->getCustomBinaryCallback()) !== null) {
|
||||
$lock = ApplicationContext::invoke($callback, [
|
||||
Artifact::class => $artifact,
|
||||
ArtifactDownloader::class => $this,
|
||||
]);
|
||||
} elseif (is_a($call, DownloadTypeInterface::class, true)) {
|
||||
$instance = new $call();
|
||||
$lock = $instance->download($artifact->getName(), $item['config'], $this);
|
||||
} else {
|
||||
throw new ValidationException("Artifact has invalid download type '{$item['config']['type']}' for {$item['display']}.");
|
||||
}
|
||||
if (!$lock instanceof DownloadResult) {
|
||||
throw new ValidationException("Artifact {$artifact->getName()} has invalid custom return value. Must be instance of DownloadResult.");
|
||||
}
|
||||
// verifying hash if possible
|
||||
$hash_validator = $instance ?? null;
|
||||
$verified = $lock->verified;
|
||||
if ($hash_validator instanceof ValidatorInterface) {
|
||||
if (!$hash_validator->validate($artifact->getName(), $item['config'], $this, $lock)) {
|
||||
throw new ValidationException("Hash validation failed for artifact '{$artifact->getName()}' {$item['display']}.");
|
||||
}
|
||||
$verified = true;
|
||||
}
|
||||
// process lock
|
||||
ApplicationContext::get(ArtifactCache::class)->lock($artifact, $item['lock'], $lock, SystemTarget::getCurrentPlatformString());
|
||||
if ($parallel === false) {
|
||||
$ver = $lock->hasVersion() ? (' (' . ConsoleColor::yellow($lock->version) . ')') : '';
|
||||
InteractiveTerm::finish('Downloaded ' . ($verified ? 'and verified ' : '') . 'artifact ' . ConsoleColor::green($artifact->getName()) . $ver . " {$item['display']} .");
|
||||
}
|
||||
return SPC_DOWNLOAD_STATUS_SUCCESS;
|
||||
} catch (DownloaderException|ExecutionException $e) {
|
||||
if ($parallel === false) {
|
||||
InteractiveTerm::finish("Download artifact {$artifact->getName()} {$item['display']} failed !", false);
|
||||
InteractiveTerm::error("Failed message: {$e->getMessage()}", true);
|
||||
}
|
||||
$try = true;
|
||||
continue;
|
||||
} catch (ValidationException $e) {
|
||||
if ($parallel === false) {
|
||||
InteractiveTerm::finish("Download artifact {$artifact->getName()} {$item['display']} failed !", false);
|
||||
InteractiveTerm::error("Validation failed: {$e->getMessage()}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
$vvv = ApplicationContext::isDebug() ? "\nIf the problem persists, consider using `-vvv` to enable verbose mode, and disable parallel downloading for more details." : '';
|
||||
throw new DownloaderException("Download artifact '{$artifact->getName()}' failed. Please check your internet connection and try again.{$vvv}");
|
||||
}
|
||||
|
||||
private function downloadWithConcurrency(): void
|
||||
{
|
||||
$skipped = [];
|
||||
$fiber_pool = [];
|
||||
$old_verbosity = null;
|
||||
$old_debug = null;
|
||||
try {
|
||||
$count = count($this->artifacts);
|
||||
// must mute
|
||||
$output = ApplicationContext::get(OutputInterface::class);
|
||||
if ($output->isVerbose()) {
|
||||
$old_verbosity = $output->getVerbosity();
|
||||
$old_debug = ApplicationContext::isDebug();
|
||||
logger()->warning('Parallel download is not supported in verbose mode, I will mute the output temporarily.');
|
||||
$output->setVerbosity(OutputInterface::VERBOSITY_NORMAL);
|
||||
ApplicationContext::setDebug(false);
|
||||
logger()->setLevel(LogLevel::ERROR);
|
||||
}
|
||||
$pool_count = $this->parallel;
|
||||
$downloaded = 0;
|
||||
$total = count($this->artifacts);
|
||||
|
||||
Shell::passthruCallback(function () {
|
||||
InteractiveTerm::advance();
|
||||
\Fiber::suspend();
|
||||
});
|
||||
|
||||
InteractiveTerm::indicateProgress("[{$downloaded}/{$total}] Downloading artifacts with concurrency {$this->parallel} ...");
|
||||
$failed_downloads = [];
|
||||
while (true) {
|
||||
// fill pool
|
||||
while (count($fiber_pool) < $pool_count && ($artifact = array_shift($this->artifacts)) !== null) {
|
||||
$current = $count - count($this->artifacts);
|
||||
$fiber = new \Fiber(function () use ($artifact, $current, $count) {
|
||||
return [$artifact, $this->downloadWithType($artifact, $current, $count, true)];
|
||||
});
|
||||
$fiber->start();
|
||||
$fiber_pool[] = $fiber;
|
||||
}
|
||||
// check pool
|
||||
foreach ($fiber_pool as $index => $fiber) {
|
||||
if ($fiber->isTerminated()) {
|
||||
try {
|
||||
[$artifact, $int] = $fiber->getReturn();
|
||||
if ($int === SPC_DOWNLOAD_STATUS_SKIPPED) {
|
||||
$skipped[] = $artifact->getName();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$artifact_name = 'unknown';
|
||||
if (isset($artifact)) {
|
||||
$artifact_name = $artifact->getName();
|
||||
}
|
||||
$failed_downloads[] = ['artifact' => $artifact_name, 'error' => $e];
|
||||
InteractiveTerm::setMessage("[{$downloaded}/{$total}] Download failed: {$artifact_name}");
|
||||
InteractiveTerm::advance();
|
||||
}
|
||||
// remove from pool
|
||||
unset($fiber_pool[$index]);
|
||||
++$downloaded;
|
||||
InteractiveTerm::setMessage("[{$downloaded}/{$total}] Downloading artifacts with concurrency {$this->parallel} ...");
|
||||
InteractiveTerm::advance();
|
||||
} else {
|
||||
$fiber->resume();
|
||||
}
|
||||
}
|
||||
// all done
|
||||
if (count($this->artifacts) === 0 && count($fiber_pool) === 0) {
|
||||
if (!empty($failed_downloads)) {
|
||||
InteractiveTerm::finish('Download completed with ' . count($failed_downloads) . ' failure(s).', false);
|
||||
foreach ($failed_downloads as $failure) {
|
||||
InteractiveTerm::error("Failed to download '{$failure['artifact']}': {$failure['error']->getMessage()}");
|
||||
}
|
||||
throw new DownloaderException('Failed to download ' . count($failed_downloads) . ' artifact(s). Please check your internet connection and try again.');
|
||||
}
|
||||
$skip_msg = !empty($skipped) ? ' (Skipped ' . count($skipped) . ' artifacts for being already downloaded)' : '';
|
||||
InteractiveTerm::finish("Downloaded all {$total} artifacts.{$skip_msg}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// throw to all fibers to make them stop
|
||||
foreach ($fiber_pool as $fiber) {
|
||||
if (!$fiber->isTerminated()) {
|
||||
try {
|
||||
$fiber->throw($e);
|
||||
} catch (\Throwable) {
|
||||
// ignore errors when stopping fibers
|
||||
}
|
||||
}
|
||||
}
|
||||
InteractiveTerm::finish('Parallel download failed !', false);
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($old_verbosity !== null) {
|
||||
ApplicationContext::get(OutputInterface::class)->setVerbosity($old_verbosity);
|
||||
logger()->setLevel(match ($old_verbosity) {
|
||||
OutputInterface::VERBOSITY_VERBOSE => LogLevel::INFO,
|
||||
OutputInterface::VERBOSITY_VERY_VERBOSE, OutputInterface::VERBOSITY_DEBUG => LogLevel::DEBUG,
|
||||
default => LogLevel::WARNING,
|
||||
});
|
||||
}
|
||||
if ($old_debug !== null) {
|
||||
ApplicationContext::setDebug($old_debug);
|
||||
}
|
||||
Shell::passthruCallback(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate download queue based on type preference.
|
||||
*/
|
||||
private function generateQueue(Artifact $artifact): array
|
||||
{
|
||||
/** @var array<array{display: string, lock: string, config: array}> $queue */
|
||||
$queue = [];
|
||||
$binary_downloaded = $artifact->isBinaryDownloaded(compare_hash: true);
|
||||
$source_downloaded = $artifact->isSourceDownloaded(compare_hash: true);
|
||||
|
||||
$item_source = ['display' => 'source', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source')];
|
||||
$item_source_mirror = ['display' => 'source (mirror)', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source-mirror')];
|
||||
|
||||
// For binary config, handle both array configs and custom callbacks
|
||||
$binary_config = $artifact->getDownloadConfig('binary');
|
||||
$has_custom_binary = $artifact->getCustomBinaryCallback() !== null;
|
||||
$item_binary_config = null;
|
||||
if (is_array($binary_config)) {
|
||||
$item_binary_config = $binary_config[SystemTarget::getCurrentPlatformString()] ?? null;
|
||||
} elseif ($has_custom_binary) {
|
||||
// For custom binaries, create a dummy config to allow queue generation
|
||||
$item_binary_config = ['type' => 'custom'];
|
||||
}
|
||||
$item_binary = ['display' => 'binary', 'lock' => 'binary', 'config' => $item_binary_config];
|
||||
|
||||
$binary_mirror_config = $artifact->getDownloadConfig('binary-mirror');
|
||||
$item_binary_mirror_config = null;
|
||||
if (is_array($binary_mirror_config)) {
|
||||
$item_binary_mirror_config = $binary_mirror_config[SystemTarget::getCurrentPlatformString()] ?? null;
|
||||
}
|
||||
$item_binary_mirror = ['display' => 'binary (mirror)', 'lock' => 'binary', 'config' => $item_binary_mirror_config];
|
||||
|
||||
$pref = $this->fetch_prefs[$artifact->getName()] ?? $this->default_fetch_pref;
|
||||
if ($pref === Artifact::FETCH_PREFER_SOURCE) {
|
||||
$queue[] = $item_source['config'] !== null ? $item_source : null;
|
||||
$queue[] = $item_source_mirror['config'] !== null && $this->alt ? $item_source_mirror : null;
|
||||
$queue[] = $item_binary['config'] !== null ? $item_binary : null;
|
||||
$queue[] = $item_binary_mirror['config'] !== null && $this->alt ? $item_binary_mirror : null;
|
||||
} elseif ($pref === Artifact::FETCH_PREFER_BINARY) {
|
||||
$queue[] = $item_binary['config'] !== null ? $item_binary : null;
|
||||
$queue[] = $item_binary_mirror['config'] !== null && $this->alt ? $item_binary_mirror : null;
|
||||
$queue[] = $item_source['config'] !== null ? $item_source : null;
|
||||
$queue[] = $item_source_mirror['config'] !== null && $this->alt ? $item_source_mirror : null;
|
||||
} elseif ($pref === Artifact::FETCH_ONLY_SOURCE) {
|
||||
$queue[] = $item_source['config'] !== null ? $item_source : null;
|
||||
$queue[] = $item_source_mirror['config'] !== null && $this->alt ? $item_source_mirror : null;
|
||||
} elseif ($pref === Artifact::FETCH_ONLY_BINARY) {
|
||||
$queue[] = $item_binary['config'] !== null ? $item_binary : null;
|
||||
$queue[] = $item_binary_mirror['config'] !== null && $this->alt ? $item_binary_mirror : null;
|
||||
}
|
||||
// filter nulls
|
||||
$queue = array_values(array_filter($queue));
|
||||
|
||||
// always download
|
||||
if ($this->ignore_cache === true || is_array($this->ignore_cache) && in_array($artifact->getName(), $this->ignore_cache)) {
|
||||
// validate: ensure at least one download source is available
|
||||
if (empty($queue)) {
|
||||
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').');
|
||||
}
|
||||
return $queue;
|
||||
}
|
||||
|
||||
// check if already downloaded
|
||||
$has_usable_download = false;
|
||||
if ($pref === Artifact::FETCH_PREFER_SOURCE) {
|
||||
// prefer source: check source first, if not available check binary
|
||||
$has_usable_download = $source_downloaded || $binary_downloaded;
|
||||
} elseif ($pref === Artifact::FETCH_PREFER_BINARY) {
|
||||
// prefer binary: check binary first, if not available check source
|
||||
$has_usable_download = $binary_downloaded || $source_downloaded;
|
||||
} elseif ($pref === Artifact::FETCH_ONLY_SOURCE) {
|
||||
// source-only: only check if source is downloaded
|
||||
$has_usable_download = $source_downloaded;
|
||||
} elseif ($pref === Artifact::FETCH_ONLY_BINARY) {
|
||||
// binary-only: only check if binary for current platform is downloaded
|
||||
$has_usable_download = $binary_downloaded;
|
||||
}
|
||||
|
||||
// if already downloaded, skip
|
||||
if ($has_usable_download) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// validate: ensure at least one download source is available
|
||||
if (empty($queue)) {
|
||||
if ($pref === Artifact::FETCH_ONLY_SOURCE) {
|
||||
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide source download, cannot use --source-only mode.");
|
||||
}
|
||||
if ($pref === Artifact::FETCH_ONLY_BINARY) {
|
||||
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide binary download for current platform (" . SystemTarget::getCurrentPlatformString() . '), cannot use --binary-only mode.');
|
||||
}
|
||||
// prefer modes should also throw error if no download source available
|
||||
throw new ValidationException("Validation failed: Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').');
|
||||
}
|
||||
|
||||
return $queue;
|
||||
}
|
||||
|
||||
private function applyCustomDownloads(): void
|
||||
{
|
||||
foreach ($this->custom_urls as $artifact_name => $custom_url) {
|
||||
if (isset($this->artifacts[$artifact_name])) {
|
||||
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $custom_url) {
|
||||
return (new Url())->download($artifact_name, ['url' => $custom_url], $downloader);
|
||||
});
|
||||
}
|
||||
}
|
||||
foreach ($this->custom_gits as $artifact_name => [$branch, $git_url]) {
|
||||
if (isset($this->artifacts[$artifact_name])) {
|
||||
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $branch, $git_url) {
|
||||
return (new Git())->download($artifact_name, ['rev' => $branch, 'url' => $git_url], $downloader);
|
||||
});
|
||||
}
|
||||
}
|
||||
foreach ($this->custom_locals as $artifact_name => $local_path) {
|
||||
if (isset($this->artifacts[$artifact_name])) {
|
||||
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $local_path) {
|
||||
return (new LocalDir())->download($artifact_name, ['dirname' => $local_path], $downloader);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
619
src/StaticPHP/Artifact/ArtifactExtractor.php
Normal file
619
src/StaticPHP/Artifact/ArtifactExtractor.php
Normal file
@@ -0,0 +1,619 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StaticPHP\Artifact;
|
||||
|
||||
use StaticPHP\DI\ApplicationContext;
|
||||
use StaticPHP\Exception\FileSystemException;
|
||||
use StaticPHP\Exception\SPCInternalException;
|
||||
use StaticPHP\Exception\WrongUsageException;
|
||||
use StaticPHP\Package\Package;
|
||||
use StaticPHP\Runtime\Shell\Shell;
|
||||
use StaticPHP\Runtime\SystemTarget;
|
||||
use StaticPHP\Util\FileSystem;
|
||||
use StaticPHP\Util\InteractiveTerm;
|
||||
use StaticPHP\Util\V2CompatLayer;
|
||||
|
||||
/**
|
||||
* ArtifactExtractor is responsible for extracting downloaded artifacts to their target locations.
|
||||
*
|
||||
* Extraction rules for source:
|
||||
* 1. If extract is not specified: SOURCE_PATH/{artifact_name}
|
||||
* 2. If extract is relative path: SOURCE_PATH/{value}
|
||||
* 3. If extract is absolute path: {value}
|
||||
* 4. If extract is array (dict): selective extraction (file mapping)
|
||||
*
|
||||
* Extraction rules for binary:
|
||||
* 1. If extract is not specified: PKG_ROOT_PATH (standard mode)
|
||||
* 2. If extract is "hosted": BUILD_ROOT_PATH (standard mode, for pre-built libraries)
|
||||
* 3. If extract is relative path: PKG_ROOT_PATH/{value} (standard mode)
|
||||
* 4. If extract is absolute path: {value} (standard mode)
|
||||
* 5. If extract is array (dict): selective extraction mode
|
||||
*/
|
||||
class ArtifactExtractor
|
||||
{
|
||||
/** @var array<string, bool> Track extracted artifacts to avoid duplicate extraction */
|
||||
protected array $extracted = [];
|
||||
|
||||
public function __construct(
|
||||
protected ArtifactCache $cache,
|
||||
protected bool $interactive = true
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Extract all artifacts for a list of packages.
|
||||
*
|
||||
* @param array<Package> $packages Packages to extract artifacts for
|
||||
* @param bool $force_source If true, always extract source (ignore binary)
|
||||
*/
|
||||
public function extractForPackages(array $packages, bool $force_source = false): void
|
||||
{
|
||||
// Collect all unique artifacts
|
||||
$artifacts = [];
|
||||
foreach ($packages as $package) {
|
||||
$artifact = $package->getArtifact();
|
||||
if ($artifact !== null && !isset($artifacts[$artifact->getName()])) {
|
||||
$artifacts[$artifact->getName()] = $artifact;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: php-src should be extracted first (extensions depend on it)
|
||||
uksort($artifacts, function (string $a, string $b): int {
|
||||
if ($a === 'php-src') {
|
||||
return -1;
|
||||
}
|
||||
if ($b === 'php-src') {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Extract each artifact
|
||||
foreach ($artifacts as $artifact) {
|
||||
$this->extract($artifact, $force_source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single artifact.
|
||||
*
|
||||
* @param Artifact $artifact The artifact to extract
|
||||
* @param bool $force_source If true, always extract source (ignore binary)
|
||||
*/
|
||||
public function extract(Artifact $artifact, bool $force_source = false): int
|
||||
{
|
||||
$name = $artifact->getName();
|
||||
|
||||
// Already extracted in this session
|
||||
if (isset($this->extracted[$name])) {
|
||||
logger()->debug("Artifact [{$name}] already extracted in this session, skip.");
|
||||
return SPC_STATUS_ALREADY_EXTRACTED;
|
||||
}
|
||||
|
||||
// Determine: use binary or source?
|
||||
$use_binary = !$force_source && $artifact->shouldUseBinary();
|
||||
|
||||
if ($this->interactive) {
|
||||
Shell::passthruCallback(function () {
|
||||
InteractiveTerm::advance();
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
V2CompatLayer::beforeExtractHook($artifact);
|
||||
if ($use_binary) {
|
||||
$status = $this->extractBinary($artifact);
|
||||
} else {
|
||||
$status = $this->extractSource($artifact);
|
||||
}
|
||||
V2CompatLayer::afterExtractHook($artifact);
|
||||
} finally {
|
||||
if ($this->interactive) {
|
||||
Shell::passthruCallback(null);
|
||||
}
|
||||
}
|
||||
|
||||
$this->extracted[$name] = true;
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract source artifact.
|
||||
*/
|
||||
protected function extractSource(Artifact $artifact): int
|
||||
{
|
||||
$name = $artifact->getName();
|
||||
$cache_info = $this->cache->getSourceInfo($name);
|
||||
|
||||
if ($cache_info === null) {
|
||||
throw new WrongUsageException("Artifact source [{$name}] not downloaded, please download it first!");
|
||||
}
|
||||
|
||||
$source_file = $this->cache->getCacheFullPath($cache_info);
|
||||
$target_path = $artifact->getSourceDir();
|
||||
|
||||
// Check for custom extract callback
|
||||
if ($artifact->hasSourceExtractCallback()) {
|
||||
logger()->info("Extracting source [{$name}] using custom callback...");
|
||||
$callback = $artifact->getSourceExtractCallback();
|
||||
ApplicationContext::invoke($callback, [
|
||||
Artifact::class => $artifact,
|
||||
'source_file' => $source_file,
|
||||
'target_path' => $target_path,
|
||||
]);
|
||||
// Emit after hooks
|
||||
$artifact->emitAfterSourceExtract($target_path);
|
||||
logger()->debug("Emitted after-source-extract hooks for [{$name}]");
|
||||
return SPC_STATUS_EXTRACTED;
|
||||
}
|
||||
|
||||
// Check for selective extraction (dict mode)
|
||||
$extract_config = $artifact->getDownloadConfig('source')['extract'] ?? null;
|
||||
if (is_array($extract_config)) {
|
||||
$this->doSelectiveExtract($name, $cache_info, $extract_config);
|
||||
$artifact->emitAfterSourceExtract($target_path);
|
||||
logger()->debug("Emitted after-source-extract hooks for [{$name}]");
|
||||
return SPC_STATUS_EXTRACTED;
|
||||
}
|
||||
|
||||
// Standard extraction
|
||||
$hash = $cache_info['hash'] ?? null;
|
||||
|
||||
if ($this->isAlreadyExtracted($target_path, $hash)) {
|
||||
logger()->debug("Source [{$name}] already extracted at {$target_path}, skip.");
|
||||
return SPC_STATUS_ALREADY_EXTRACTED;
|
||||
}
|
||||
|
||||
// Remove old directory if hash mismatch
|
||||
if (is_dir($target_path)) {
|
||||
logger()->notice("Source [{$name}] hash mismatch, re-extracting...");
|
||||
FileSystem::removeDir($target_path);
|
||||
}
|
||||
|
||||
logger()->info("Extracting source [{$name}] to {$target_path}...");
|
||||
$this->doStandardExtract($name, $cache_info, $target_path);
|
||||
|
||||
// Emit after hooks
|
||||
$artifact->emitAfterSourceExtract($target_path);
|
||||
logger()->debug("Emitted after-source-extract hooks for [{$name}]");
|
||||
|
||||
// Write hash marker
|
||||
if ($hash !== null) {
|
||||
FileSystem::writeFile("{$target_path}/.spc-hash", $hash);
|
||||
}
|
||||
return SPC_STATUS_EXTRACTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract binary artifact.
|
||||
*/
|
||||
protected function extractBinary(Artifact $artifact): int
|
||||
{
|
||||
$name = $artifact->getName();
|
||||
$platform = SystemTarget::getCurrentPlatformString();
|
||||
$cache_info = $this->cache->getBinaryInfo($name, $platform);
|
||||
|
||||
if ($cache_info === null) {
|
||||
throw new WrongUsageException("Artifact binary [{$name}] for platform [{$platform}] not downloaded!");
|
||||
}
|
||||
|
||||
$source_file = $this->cache->getCacheFullPath($cache_info);
|
||||
$extract_config = $artifact->getBinaryExtractConfig($cache_info);
|
||||
$target_path = $extract_config['path'];
|
||||
|
||||
// Check for custom extract callback
|
||||
if ($artifact->hasBinaryExtractCallback()) {
|
||||
logger()->info("Extracting binary [{$name}] using custom callback...");
|
||||
$callback = $artifact->getBinaryExtractCallback();
|
||||
ApplicationContext::invoke($callback, [
|
||||
Artifact::class => $artifact,
|
||||
'source_file' => $source_file,
|
||||
'target_path' => $target_path,
|
||||
'platform' => $platform,
|
||||
]);
|
||||
// Emit after hooks
|
||||
$artifact->emitAfterBinaryExtract($target_path, $platform);
|
||||
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
|
||||
return SPC_STATUS_EXTRACTED;
|
||||
}
|
||||
|
||||
// Handle different extraction modes
|
||||
$mode = $extract_config['mode'];
|
||||
|
||||
if ($mode === 'selective') {
|
||||
$this->doSelectiveExtract($name, $cache_info, $extract_config['files']);
|
||||
$artifact->emitAfterBinaryExtract($target_path, $platform);
|
||||
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
|
||||
return SPC_STATUS_EXTRACTED;
|
||||
}
|
||||
|
||||
$hash = $cache_info['hash'] ?? null;
|
||||
|
||||
if ($this->isAlreadyExtracted($target_path, $hash)) {
|
||||
logger()->debug("Binary [{$name}] already extracted at {$target_path}, skip.");
|
||||
return SPC_STATUS_ALREADY_EXTRACTED;
|
||||
}
|
||||
|
||||
logger()->info("Extracting binary [{$name}] to {$target_path}...");
|
||||
$this->doStandardExtract($name, $cache_info, $target_path);
|
||||
|
||||
$artifact->emitAfterBinaryExtract($target_path, $platform);
|
||||
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
|
||||
|
||||
if ($hash !== null && $cache_info['cache_type'] !== 'file') {
|
||||
FileSystem::writeFile("{$target_path}/.spc-hash", $hash);
|
||||
}
|
||||
return SPC_STATUS_EXTRACTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard extraction: extract entire archive to target directory.
|
||||
*/
|
||||
protected function doStandardExtract(string $name, array $cache_info, string $target_path): void
|
||||
{
|
||||
$source_file = $this->cache->getCacheFullPath($cache_info);
|
||||
$cache_type = $cache_info['cache_type'];
|
||||
|
||||
// Validate source file exists before extraction
|
||||
$this->validateSourceFile($name, $source_file, $cache_type);
|
||||
|
||||
$this->extractWithType($cache_type, $source_file, $target_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selective extraction: extract specific files to specific locations.
|
||||
*
|
||||
* @param string $name Artifact name
|
||||
* @param array $cache_info Cache info
|
||||
* @param array<string,string> $file_map Map of source path => destination path
|
||||
*/
|
||||
protected function doSelectiveExtract(string $name, array $cache_info, array $file_map): void
|
||||
{
|
||||
// Extract to temp directory first
|
||||
$temp_path = sys_get_temp_dir() . '/spc_extract_' . $name . '_' . bin2hex(random_bytes(8));
|
||||
|
||||
try {
|
||||
logger()->info("Extracting [{$name}] with selective file mapping...");
|
||||
|
||||
$source_file = $this->cache->getCacheFullPath($cache_info);
|
||||
$cache_type = $cache_info['cache_type'];
|
||||
|
||||
// Validate source file exists before extraction
|
||||
$this->validateSourceFile($name, $source_file, $cache_type);
|
||||
|
||||
$this->extractWithType($cache_type, $source_file, $temp_path);
|
||||
|
||||
// Process file mappings
|
||||
foreach ($file_map as $src_pattern => $dst_path) {
|
||||
$dst_path = $this->replacePathVariables($dst_path);
|
||||
$src_full = "{$temp_path}/{$src_pattern}";
|
||||
|
||||
// Handle glob patterns
|
||||
if (str_contains($src_pattern, '*')) {
|
||||
$matches = glob($src_full);
|
||||
if (empty($matches)) {
|
||||
logger()->warning("No files matched pattern [{$src_pattern}] in [{$name}]");
|
||||
continue;
|
||||
}
|
||||
foreach ($matches as $match) {
|
||||
$filename = basename($match);
|
||||
$target = rtrim($dst_path, '/') . '/' . $filename;
|
||||
$this->copyFileOrDir($match, $target);
|
||||
}
|
||||
} else {
|
||||
// Direct file/directory copy
|
||||
if (!file_exists($src_full) && !is_dir($src_full)) {
|
||||
logger()->warning("Source [{$src_pattern}] not found in [{$name}]");
|
||||
continue;
|
||||
}
|
||||
$this->copyFileOrDir($src_full, $dst_path);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
if (is_dir($temp_path)) {
|
||||
FileSystem::removeDir($temp_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if artifact is already extracted with correct hash.
|
||||
*/
|
||||
protected function isAlreadyExtracted(string $path, ?string $expected_hash): bool
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Local source: always re-extract
|
||||
if ($expected_hash === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash_file = "{$path}/.spc-hash";
|
||||
if (!file_exists($hash_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return FileSystem::readFile($hash_file) === $expected_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the source file/directory exists before extraction.
|
||||
*
|
||||
* @param string $name Artifact name (for error messages)
|
||||
* @param string $source_file Path to the source file or directory
|
||||
* @param string $cache_type Cache type: archive, git, local
|
||||
*
|
||||
* @throws WrongUsageException if source file does not exist
|
||||
*/
|
||||
protected function validateSourceFile(string $name, string $source_file, string $cache_type): void
|
||||
{
|
||||
$converted_path = FileSystem::convertPath($source_file);
|
||||
|
||||
switch ($cache_type) {
|
||||
case 'archive':
|
||||
if (!file_exists($converted_path)) {
|
||||
throw new WrongUsageException(
|
||||
"Artifact [{$name}] source archive not found at: {$converted_path}\n" .
|
||||
"The file may have been deleted or moved. Please run 'spc download {$name}' to re-download it."
|
||||
);
|
||||
}
|
||||
if (!is_file($converted_path)) {
|
||||
throw new WrongUsageException(
|
||||
"Artifact [{$name}] source path exists but is not a file: {$converted_path}\n" .
|
||||
'Expected an archive file. Please check your downloads directory.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'file':
|
||||
if (!file_exists($converted_path)) {
|
||||
throw new WrongUsageException(
|
||||
"Artifact [{$name}] source file not found at: {$converted_path}\n" .
|
||||
"The file may have been deleted or moved. Please run 'spc download {$name}' to re-download it."
|
||||
);
|
||||
}
|
||||
if (!is_file($converted_path)) {
|
||||
throw new WrongUsageException(
|
||||
"Artifact [{$name}] source path exists but is not a file: {$converted_path}\n" .
|
||||
'Expected a regular file. Please check your downloads directory.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'git':
|
||||
if (!is_dir($converted_path)) {
|
||||
throw new WrongUsageException(
|
||||
"Artifact [{$name}] git repository not found at: {$converted_path}\n" .
|
||||
"The directory may have been deleted. Please run 'spc download {$name}' to re-clone it."
|
||||
);
|
||||
}
|
||||
// Optionally check for .git directory to ensure it's a valid git repo
|
||||
if (!is_dir("{$converted_path}/.git")) {
|
||||
logger()->warning("Artifact [{$name}] directory exists but may not be a valid git repository (missing .git)");
|
||||
}
|
||||
break;
|
||||
case 'local':
|
||||
if (!file_exists($converted_path) && !is_dir($converted_path)) {
|
||||
throw new WrongUsageException(
|
||||
"Artifact [{$name}] local source not found at: {$converted_path}\n" .
|
||||
'Please ensure the local path is correct and accessible.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new SPCInternalException("Unknown cache type: {$cache_type}");
|
||||
}
|
||||
|
||||
logger()->debug("Validated source file for [{$name}]: {$converted_path} (type: {$cache_type})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file or directory to destination.
|
||||
*/
|
||||
protected function copyFileOrDir(string $src, string $dst): void
|
||||
{
|
||||
$dst_dir = dirname($dst);
|
||||
if (!is_dir($dst_dir)) {
|
||||
FileSystem::createDir($dst_dir);
|
||||
}
|
||||
|
||||
if (is_dir($src)) {
|
||||
FileSystem::copyDir($src, $dst);
|
||||
} else {
|
||||
copy($src, $dst);
|
||||
}
|
||||
|
||||
logger()->debug("Copied {$src} -> {$dst}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract source based on cache type.
|
||||
*
|
||||
* @param string $cache_type Cache type: archive, git, local
|
||||
* @param string $source_file Path to source file or directory
|
||||
* @param string $target_path Target extraction path
|
||||
*/
|
||||
protected function extractWithType(string $cache_type, string $source_file, string $target_path): void
|
||||
{
|
||||
match ($cache_type) {
|
||||
'archive' => $this->extractArchive($source_file, $target_path),
|
||||
'file' => $this->copyFile($source_file, $target_path),
|
||||
'git' => FileSystem::copyDir(FileSystem::convertPath($source_file), $target_path),
|
||||
'local' => symlink(FileSystem::convertPath($source_file), $target_path),
|
||||
default => throw new SPCInternalException("Unknown cache type: {$cache_type}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract archive file to target directory.
|
||||
*
|
||||
* Supports: tar, tar.gz, tgz, tar.bz2, tar.xz, txz, zip, exe
|
||||
*/
|
||||
protected function extractArchive(string $filename, string $target): void
|
||||
{
|
||||
$target = FileSystem::convertPath($target);
|
||||
$filename = FileSystem::convertPath($filename);
|
||||
|
||||
FileSystem::createDir($target);
|
||||
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
// Use 7za.exe for Windows
|
||||
$is_txz = str_ends_with($filename, '.txz') || str_ends_with($filename, '.tar.xz');
|
||||
default_shell()->execute7zExtract($filename, $target, $is_txz);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unix-like systems: determine compression type
|
||||
if (str_ends_with($filename, '.tar.gz') || str_ends_with($filename, '.tgz')) {
|
||||
default_shell()->executeTarExtract($filename, $target, 'gz');
|
||||
} elseif (str_ends_with($filename, '.tar.bz2')) {
|
||||
default_shell()->executeTarExtract($filename, $target, 'bz2');
|
||||
} elseif (str_ends_with($filename, '.tar.xz') || str_ends_with($filename, '.txz')) {
|
||||
default_shell()->executeTarExtract($filename, $target, 'xz');
|
||||
} elseif (str_ends_with($filename, '.tar')) {
|
||||
default_shell()->executeTarExtract($filename, $target, 'none');
|
||||
} elseif (str_ends_with($filename, '.zip')) {
|
||||
// Zip requires special handling for strip-components
|
||||
$this->unzipWithStrip($filename, $target);
|
||||
} elseif (str_ends_with($filename, '.exe')) {
|
||||
// exe just copy to target
|
||||
$dest_file = FileSystem::convertPath("{$target}/" . basename($filename));
|
||||
FileSystem::copy($filename, $dest_file);
|
||||
} else {
|
||||
throw new FileSystemException("Unknown archive format: {$filename}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unzip file with stripping top-level directory.
|
||||
*/
|
||||
protected function unzipWithStrip(string $zip_file, string $extract_path): void
|
||||
{
|
||||
$temp_dir = FileSystem::convertPath(sys_get_temp_dir() . '/spc_unzip_' . bin2hex(random_bytes(16)));
|
||||
$zip_file = FileSystem::convertPath($zip_file);
|
||||
$extract_path = FileSystem::convertPath($extract_path);
|
||||
|
||||
// Extract to temp dir
|
||||
FileSystem::createDir($temp_dir);
|
||||
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
default_shell()->execute7zExtract($zip_file, $temp_dir);
|
||||
} else {
|
||||
default_shell()->executeUnzip($zip_file, $temp_dir);
|
||||
}
|
||||
|
||||
// Scan first level dirs (relative, not recursive, include dirs)
|
||||
$contents = FileSystem::scanDirFiles($temp_dir, false, true, true);
|
||||
if ($contents === false) {
|
||||
throw new FileSystemException('Cannot scan unzip temp dir: ' . $temp_dir);
|
||||
}
|
||||
|
||||
// If extract path already exists, remove it
|
||||
if (is_dir($extract_path)) {
|
||||
FileSystem::removeDir($extract_path);
|
||||
}
|
||||
|
||||
// If only one dir, move its contents to extract_path
|
||||
$subdir = FileSystem::convertPath("{$temp_dir}/{$contents[0]}");
|
||||
if (count($contents) === 1 && is_dir($subdir)) {
|
||||
$this->moveFileOrDir($subdir, $extract_path);
|
||||
} else {
|
||||
// Else, if it contains only one dir, strip dir and copy other files
|
||||
$dircount = 0;
|
||||
$dir = [];
|
||||
$top_files = [];
|
||||
foreach ($contents as $item) {
|
||||
if (is_dir(FileSystem::convertPath("{$temp_dir}/{$item}"))) {
|
||||
++$dircount;
|
||||
$dir[] = $item;
|
||||
} else {
|
||||
$top_files[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract dir contents to extract_path
|
||||
FileSystem::createDir($extract_path);
|
||||
|
||||
// Extract move dir
|
||||
if ($dircount === 1) {
|
||||
$sub_contents = FileSystem::scanDirFiles("{$temp_dir}/{$dir[0]}", false, true, true);
|
||||
if ($sub_contents === false) {
|
||||
throw new FileSystemException("Cannot scan unzip temp sub-dir: {$dir[0]}");
|
||||
}
|
||||
foreach ($sub_contents as $sub_item) {
|
||||
$this->moveFileOrDir(
|
||||
FileSystem::convertPath("{$temp_dir}/{$dir[0]}/{$sub_item}"),
|
||||
FileSystem::convertPath("{$extract_path}/{$sub_item}")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
foreach ($dir as $item) {
|
||||
$this->moveFileOrDir(
|
||||
FileSystem::convertPath("{$temp_dir}/{$item}"),
|
||||
FileSystem::convertPath("{$extract_path}/{$item}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Move top-level files to extract_path
|
||||
foreach ($top_files as $top_file) {
|
||||
$this->moveFileOrDir(
|
||||
FileSystem::convertPath("{$temp_dir}/{$top_file}"),
|
||||
FileSystem::convertPath("{$extract_path}/{$top_file}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
FileSystem::removeDir($temp_dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move file or directory to destination.
|
||||
*/
|
||||
protected function moveFileOrDir(string $source, string $dest): void
|
||||
{
|
||||
$source = FileSystem::convertPath($source);
|
||||
$dest = FileSystem::convertPath($dest);
|
||||
|
||||
// Try rename first (fast, atomic)
|
||||
if (@rename($source, $dest)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_dir($source)) {
|
||||
FileSystem::copyDir($source, $dest);
|
||||
FileSystem::removeDir($source);
|
||||
} else {
|
||||
if (!copy($source, $dest)) {
|
||||
throw new FileSystemException("Failed to copy file from {$source} to {$dest}");
|
||||
}
|
||||
if (!unlink($source)) {
|
||||
throw new FileSystemException("Failed to remove source file: {$source}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace path variables.
|
||||
*/
|
||||
protected function replacePathVariables(string $path): string
|
||||
{
|
||||
$replacement = [
|
||||
'{pkg_root_path}' => PKG_ROOT_PATH,
|
||||
'{build_root_path}' => BUILD_ROOT_PATH,
|
||||
'{source_path}' => SOURCE_PATH,
|
||||
'{download_path}' => DOWNLOAD_PATH,
|
||||
'{working_dir}' => WORKING_DIR,
|
||||
];
|
||||
return str_replace(array_keys($replacement), array_values($replacement), $path);
|
||||
}
|
||||
|
||||
private function copyFile(string $source_file, string $target_path): void
|
||||
{
|
||||
FileSystem::createDir(dirname($target_path));
|
||||
FileSystem::copy(FileSystem::convertPath($source_file), $target_path);
|
||||
}
|
||||
}
|
||||
190
src/StaticPHP/Artifact/ArtifactLoader.php
Normal file
190
src/StaticPHP/Artifact/ArtifactLoader.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StaticPHP\Artifact;
|
||||
|
||||
use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
|
||||
use StaticPHP\Attribute\Artifact\AfterSourceExtract;
|
||||
use StaticPHP\Attribute\Artifact\BinaryExtract;
|
||||
use StaticPHP\Attribute\Artifact\CustomBinary;
|
||||
use StaticPHP\Attribute\Artifact\CustomSource;
|
||||
use StaticPHP\Attribute\Artifact\SourceExtract;
|
||||
use StaticPHP\Config\ArtifactConfig;
|
||||
use StaticPHP\Exception\ValidationException;
|
||||
use StaticPHP\Util\FileSystem;
|
||||
|
||||
class ArtifactLoader
|
||||
{
|
||||
/** @var null|array<string, Artifact> Artifact instances */
|
||||
private static ?array $artifacts = null;
|
||||
|
||||
public static function initArtifactInstances(): void
|
||||
{
|
||||
if (self::$artifacts !== null) {
|
||||
return;
|
||||
}
|
||||
foreach (ArtifactConfig::getAll() as $name => $item) {
|
||||
$artifact = new Artifact($name, $item);
|
||||
self::$artifacts[$name] = $artifact;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getArtifactInstance(string $artifact_name): ?Artifact
|
||||
{
|
||||
self::initArtifactInstances();
|
||||
return self::$artifacts[$artifact_name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artifact definitions from PSR-4 directory.
|
||||
*
|
||||
* @param string $dir Directory path
|
||||
* @param string $base_namespace Base namespace for dir's PSR-4 mapping
|
||||
* @param bool $auto_require Whether to auto-require PHP files (for external plugins not in autoload)
|
||||
*/
|
||||
public static function loadFromPsr4Dir(string $dir, string $base_namespace, bool $auto_require = false): void
|
||||
{
|
||||
self::initArtifactInstances();
|
||||
$classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require);
|
||||
foreach ($classes as $class) {
|
||||
self::loadFromClass($class);
|
||||
}
|
||||
}
|
||||
|
||||
public static function loadFromClass(string $class): void
|
||||
{
|
||||
$ref = new \ReflectionClass($class);
|
||||
|
||||
$class_instance = $ref->newInstance();
|
||||
|
||||
foreach ($ref->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
self::processCustomSourceAttribute($ref, $method, $class_instance);
|
||||
self::processCustomBinaryAttribute($ref, $method, $class_instance);
|
||||
self::processSourceExtractAttribute($ref, $method, $class_instance);
|
||||
self::processBinaryExtractAttribute($ref, $method, $class_instance);
|
||||
self::processAfterSourceExtractAttribute($ref, $method, $class_instance);
|
||||
self::processAfterBinaryExtractAttribute($ref, $method, $class_instance);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process #[CustomSource] attribute.
|
||||
*/
|
||||
private static function processCustomSourceAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
|
||||
{
|
||||
$attributes = $method->getAttributes(CustomSource::class);
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var CustomSource $instance */
|
||||
$instance = $attribute->newInstance();
|
||||
$artifact_name = $instance->artifact_name;
|
||||
if (isset(self::$artifacts[$artifact_name])) {
|
||||
self::$artifacts[$artifact_name]->setCustomSourceCallback([$class_instance, $method->getName()]);
|
||||
} else {
|
||||
throw new ValidationException("Artifact '{$artifact_name}' not found for #[CustomSource] on '{$ref->getName()}::{$method->getName()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process #[CustomBinary] attribute.
|
||||
*/
|
||||
private static function processCustomBinaryAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
|
||||
{
|
||||
$attributes = $method->getAttributes(CustomBinary::class);
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var CustomBinary $instance */
|
||||
$instance = $attribute->newInstance();
|
||||
$artifact_name = $instance->artifact_name;
|
||||
if (isset(self::$artifacts[$artifact_name])) {
|
||||
foreach ($instance->support_os as $os) {
|
||||
self::$artifacts[$artifact_name]->setCustomBinaryCallback($os, [$class_instance, $method->getName()]);
|
||||
}
|
||||
} else {
|
||||
throw new ValidationException("Artifact '{$artifact_name}' not found for #[CustomBinary] on '{$ref->getName()}::{$method->getName()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process #[SourceExtract] attribute.
|
||||
* This attribute allows completely taking over the source extraction process.
|
||||
*/
|
||||
private static function processSourceExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
|
||||
{
|
||||
$attributes = $method->getAttributes(SourceExtract::class);
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var SourceExtract $instance */
|
||||
$instance = $attribute->newInstance();
|
||||
$artifact_name = $instance->artifact_name;
|
||||
if (isset(self::$artifacts[$artifact_name])) {
|
||||
self::$artifacts[$artifact_name]->setSourceExtractCallback([$class_instance, $method->getName()]);
|
||||
} else {
|
||||
throw new ValidationException("Artifact '{$artifact_name}' not found for #[SourceExtract] on '{$ref->getName()}::{$method->getName()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process #[BinaryExtract] attribute.
|
||||
* This attribute allows completely taking over the binary extraction process.
|
||||
*/
|
||||
private static function processBinaryExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
|
||||
{
|
||||
$attributes = $method->getAttributes(BinaryExtract::class);
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var BinaryExtract $instance */
|
||||
$instance = $attribute->newInstance();
|
||||
$artifact_name = $instance->artifact_name;
|
||||
if (isset(self::$artifacts[$artifact_name])) {
|
||||
self::$artifacts[$artifact_name]->setBinaryExtractCallback(
|
||||
[$class_instance, $method->getName()],
|
||||
$instance->platforms
|
||||
);
|
||||
} else {
|
||||
throw new ValidationException("Artifact '{$artifact_name}' not found for #[BinaryExtract] on '{$ref->getName()}::{$method->getName()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process #[AfterSourceExtract] attribute.
|
||||
* This attribute registers a hook that runs after source extraction completes.
|
||||
*/
|
||||
private static function processAfterSourceExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
|
||||
{
|
||||
$attributes = $method->getAttributes(AfterSourceExtract::class);
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var AfterSourceExtract $instance */
|
||||
$instance = $attribute->newInstance();
|
||||
$artifact_name = $instance->artifact_name;
|
||||
if (isset(self::$artifacts[$artifact_name])) {
|
||||
self::$artifacts[$artifact_name]->addAfterSourceExtractCallback([$class_instance, $method->getName()]);
|
||||
} else {
|
||||
throw new ValidationException("Artifact '{$artifact_name}' not found for #[AfterSourceExtract] on '{$ref->getName()}::{$method->getName()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process #[AfterBinaryExtract] attribute.
|
||||
* This attribute registers a hook that runs after binary extraction completes.
|
||||
*/
|
||||
private static function processAfterBinaryExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
|
||||
{
|
||||
$attributes = $method->getAttributes(AfterBinaryExtract::class);
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var AfterBinaryExtract $instance */
|
||||
$instance = $attribute->newInstance();
|
||||
$artifact_name = $instance->artifact_name;
|
||||
if (isset(self::$artifacts[$artifact_name])) {
|
||||
self::$artifacts[$artifact_name]->addAfterBinaryExtractCallback(
|
||||
[$class_instance, $method->getName()],
|
||||
$instance->platforms
|
||||
);
|
||||
} else {
|
||||
throw new ValidationException("Artifact '{$artifact_name}' not found for #[AfterBinaryExtract] on '{$ref->getName()}::{$method->getName()}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/StaticPHP/Artifact/Downloader/DownloadResult.php
Normal file
146
src/StaticPHP/Artifact/Downloader/DownloadResult.php
Normal 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])
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/StaticPHP/Artifact/Downloader/Type/BitBucketTag.php
Normal file
41
src/StaticPHP/Artifact/Downloader/Type/BitBucketTag.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
46
src/StaticPHP/Artifact/Downloader/Type/FileList.php
Normal file
46
src/StaticPHP/Artifact/Downloader/Type/FileList.php
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/StaticPHP/Artifact/Downloader/Type/Git.php
Normal file
22
src/StaticPHP/Artifact/Downloader/Type/Git.php
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php
Normal file
98
src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
src/StaticPHP/Artifact/Downloader/Type/GitHubTarball.php
Normal file
78
src/StaticPHP/Artifact/Downloader/Type/GitHubTarball.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
18
src/StaticPHP/Artifact/Downloader/Type/LocalDir.php
Normal file
18
src/StaticPHP/Artifact/Downloader/Type/LocalDir.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/StaticPHP/Artifact/Downloader/Type/PIE.php
Normal file
47
src/StaticPHP/Artifact/Downloader/Type/PIE.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php
Normal file
76
src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
src/StaticPHP/Artifact/Downloader/Type/Url.php
Normal file
23
src/StaticPHP/Artifact/Downloader/Type/Url.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
103
src/StaticPHP/Artifact/DownloaderOptions.php
Normal file
103
src/StaticPHP/Artifact/DownloaderOptions.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StaticPHP\Artifact;
|
||||
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* Downloader options definition and extraction helper.
|
||||
* Used to share download-related options between DownloadCommand and BuildTargetCommand.
|
||||
*/
|
||||
class DownloaderOptions
|
||||
{
|
||||
/**
|
||||
* Option keys used by the downloader.
|
||||
*/
|
||||
private const array OPTION_KEYS = [
|
||||
'with-php',
|
||||
'parallel',
|
||||
'retry',
|
||||
'prefer-source',
|
||||
'prefer-binary',
|
||||
'prefer-pre-built',
|
||||
'source-only',
|
||||
'binary-only',
|
||||
'ignore-cache',
|
||||
'ignore-cache-sources',
|
||||
'no-alt',
|
||||
'no-shallow-clone',
|
||||
'custom-url',
|
||||
'custom-git',
|
||||
'custom-local',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns all downloader-related InputOption definitions.
|
||||
*
|
||||
* @param string $prefix Optional prefix for option names (e.g., 'dl' becomes '--dl-parallel')
|
||||
* @return InputOption[]
|
||||
*/
|
||||
public static function getConsoleOptions(string $prefix = ''): array
|
||||
{
|
||||
$p = $prefix ? "{$prefix}-" : '';
|
||||
$shortP = $prefix ? null : 'P'; // Disable short options when using prefix
|
||||
$shortR = $prefix ? null : 'R';
|
||||
$shortp = $prefix ? null : 'p';
|
||||
$shortU = $prefix ? null : 'U';
|
||||
$shortG = $prefix ? null : 'G';
|
||||
$shortL = $prefix ? null : 'L';
|
||||
|
||||
return [
|
||||
// php version option
|
||||
new InputOption("{$p}with-php", null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.4)', '8.4'),
|
||||
|
||||
// download preference options
|
||||
new InputOption("{$p}prefer-source", null, InputOption::VALUE_OPTIONAL, 'Prefer source downloads when both source and binary are available', false),
|
||||
new InputOption("{$p}prefer-binary", $shortp, InputOption::VALUE_OPTIONAL, 'Prefer binary downloads when both source and binary are available', false),
|
||||
new InputOption("{$p}source-only", null, InputOption::VALUE_OPTIONAL, 'Only download source artifacts, skip binary artifacts', false),
|
||||
new InputOption("{$p}binary-only", null, InputOption::VALUE_OPTIONAL, 'Only download binary artifacts, skip source artifacts', false),
|
||||
|
||||
// download behavior options
|
||||
new InputOption("{$p}parallel", $shortP, InputOption::VALUE_REQUIRED, 'Number of parallel downloads (default 1)', '1'),
|
||||
new InputOption("{$p}retry", $shortR, InputOption::VALUE_REQUIRED, 'Number of download retries on failure (default 0)', '0'),
|
||||
new InputOption("{$p}ignore-cache", null, InputOption::VALUE_OPTIONAL, 'Ignore some caches when downloading, comma separated, e.g "php-src,curl,openssl"', false),
|
||||
new InputOption("{$p}no-alt", null, null, 'Do not use alternative mirror download artifacts for sources'),
|
||||
new InputOption("{$p}no-shallow-clone", null, null, 'Do not clone shallowly repositories when downloading sources'),
|
||||
|
||||
// custom overrides
|
||||
new InputOption("{$p}custom-url", $shortU, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Specify custom source download url, e.g "php-src:https://example.com/php.tar.gz"'),
|
||||
new InputOption("{$p}custom-git", $shortG, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Specify custom source git url, e.g "php-src:master:https://github.com/php/php-src.git"'),
|
||||
new InputOption("{$p}custom-local", $shortL, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Specify custom local source path, e.g "php-src:/path/to/php-src"'),
|
||||
|
||||
// deprecated options (for backward compatibility)
|
||||
new InputOption("{$p}prefer-pre-built", null, null, 'Deprecated, use `--' . $p . 'prefer-binary` instead'),
|
||||
new InputOption("{$p}ignore-cache-sources", null, InputOption::VALUE_OPTIONAL, 'Deprecated, use `--' . $p . 'ignore-cache` instead', false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract downloader-related options from console input options array.
|
||||
* Handles both prefixed and non-prefixed options.
|
||||
*
|
||||
* @param array $allOptions All options from InputInterface::getOptions()
|
||||
* @param string $prefix The prefix used when defining options (empty for DownloadCommand)
|
||||
* @return array Options array suitable for ArtifactDownloader constructor
|
||||
*/
|
||||
public static function extractFromConsoleOptions(array $allOptions, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
$p = $prefix ? "{$prefix}-" : '';
|
||||
|
||||
foreach (self::OPTION_KEYS as $key) {
|
||||
$prefixedKey = $p . $key;
|
||||
if (array_key_exists($prefixedKey, $allOptions)) {
|
||||
// Store with non-prefixed key for ArtifactDownloader
|
||||
$result[$key] = $allOptions[$prefixedKey];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user