mirror of
https://github.com/crazywhalecc/static-php-cli.git
synced 2026-03-18 21:04:52 +08:00
545 lines
18 KiB
PHP
545 lines
18 KiB
PHP
<?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);
|
|
}
|
|
}
|