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

View File

@@ -0,0 +1,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);
}
}

View 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,
};
}
}

View 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);
});
}
}
}
}

View 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);
}
}

View 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()}'");
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}