[3.0] Add check-update command and CheckUpdateInterface for artifacts (#1044)

This commit is contained in:
Jerry Ma 2026-03-09 12:10:22 +09:00 committed by GitHub
commit 705435eccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 968 additions and 204 deletions

View File

@ -5,3 +5,7 @@ php-src:
license: PHP-3.01
source:
type: php-release
domain: 'https://www.php.net'
source-mirror:
type: php-release
domain: 'https://phpmirror.static-php.dev'

View File

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
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\HostedPackageBin;
use StaticPHP\Artifact\Downloader\Type\LocalDir;
use StaticPHP\Artifact\Downloader\Type\PhpRelease;
use StaticPHP\Artifact\Downloader\Type\PIE;
use StaticPHP\Artifact\Downloader\Type\Url;
/* @return array<string, DownloadTypeInterface> */
return [
'bitbuckettag' => BitBucketTag::class,
'filelist' => FileList::class,
'git' => Git::class,
'ghrel' => GitHubRelease::class,
'ghtar' => GitHubTarball::class,
'ghtagtar' => GitHubTarball::class,
'local' => LocalDir::class,
'pie' => PIE::class,
'url' => Url::class,
'php-release' => PhpRelease::class,
'hosted' => HostedPackageBin::class,
];

View File

@ -1,5 +1,19 @@
ext-bcmath:
type: php-extension
ext-mbregex:
type: php-extension
depends:
- onig
- ext-mbstring
php-extension:
arg-type: custom
build-shared: false
build-static: true
display-name: mbstring
ext-mbstring:
type: php-extension
php-extension:
arg-type: custom
ext-openssl:
type: php-extension
depends:
@ -10,6 +24,21 @@ ext-openssl:
arg-type: custom
arg-type@windows: with
build-with-php: true
ext-phar:
type: php-extension
depends:
- zlib
ext-readline:
type: php-extension
depends:
- libedit
php-extension:
support:
Windows: wip
BSD: wip
arg-type: with-path
build-shared: false
build-static: true
ext-zlib:
type: php-extension
depends:

View File

@ -2,10 +2,8 @@ ext-amqp:
type: php-extension
artifact:
source:
type: url
url: 'https://pecl.php.net/get/amqp'
extract: php-src/ext/amqp
filename: amqp.tgz
type: pecl
name: amqp
metadata:
license-files: [LICENSE]
license: PHP-3.01

View File

@ -2,10 +2,8 @@ ext-apcu:
type: php-extension
artifact:
source:
type: url
url: 'https://pecl.php.net/get/APCu'
extract: php-src/ext/apcu
filename: apcu.tgz
type: pecl
name: APCu
metadata:
license-files: [LICENSE]
license: PHP-3.01

View File

@ -0,0 +1,6 @@
ext-ast:
type: php-extension
artifact:
source:
type: pecl
name: ast

View File

@ -1,10 +0,0 @@
ext-mbregex:
type: php-extension
depends:
- onig
- ext-mbstring
php-extension:
arg-type: custom
build-shared: false
build-static: true
display-name: mbstring

View File

@ -1,4 +0,0 @@
ext-mbstring:
type: php-extension
php-extension:
arg-type: custom

View File

@ -1,4 +0,0 @@
ext-phar:
type: php-extension
depends:
- zlib

View File

@ -1,11 +0,0 @@
ext-readline:
type: php-extension
depends:
- libedit
php-extension:
support:
Windows: wip
BSD: wip
arg-type: with-path
build-shared: false
build-static: true

View File

@ -3,7 +3,7 @@ gmp:
artifact:
source:
type: filelist
url: 'https://gmplib.org/download/gmp/'
url: 'https://ftp.gnu.org/gnu/gmp/'
regex: '/href="(?<file>gmp-(?<version>[^"]+)\.tar\.xz)"/'
source-mirror:
type: url

View File

@ -10,9 +10,8 @@ libxml2:
license: MIT
depends@unix:
- libiconv
suggests@unix:
- xz
- zlib
- xz
headers:
- libxml2
pkg-configs:

View File

@ -6,8 +6,10 @@ namespace Package\Artifact;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
use StaticPHP\Attribute\Artifact\CustomBinary;
use StaticPHP\Attribute\Artifact\CustomBinaryCheckUpdate;
use StaticPHP\Exception\DownloaderException;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\GlobalEnvManager;
@ -65,6 +67,25 @@ class go_xcaddy
return DownloadResult::archive(basename($path), ['url' => $url, 'version' => $version], extract: "{$pkgroot}/go-xcaddy", verified: true, version: $version);
}
#[CustomBinaryCheckUpdate('go-xcaddy', [
'linux-x86_64',
'linux-aarch64',
'macos-x86_64',
'macos-aarch64',
])]
public function checkUpdateBinary(?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
[$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text') ?: '');
if ($version === '') {
throw new DownloaderException('Failed to get latest Go version from https://go.dev/VERSION?m=text');
}
return new CheckUpdateResult(
old: $old_version,
new: $version,
needUpdate: $old_version === null || $version !== $old_version,
);
}
#[AfterBinaryExtract('go-xcaddy', [
'linux-x86_64',
'linux-aarch64',

View File

@ -6,8 +6,10 @@ namespace Package\Artifact;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
use StaticPHP\Attribute\Artifact\CustomBinary;
use StaticPHP\Attribute\Artifact\CustomBinaryCheckUpdate;
use StaticPHP\Exception\DownloaderException;
use StaticPHP\Runtime\SystemTarget;
@ -59,6 +61,36 @@ class zig
return DownloadResult::archive(basename($path), ['url' => $url, 'version' => $latest_version], extract: PKG_ROOT_PATH . '/zig', verified: true, version: $latest_version);
}
#[CustomBinaryCheckUpdate('zig', [
'linux-x86_64',
'linux-aarch64',
'macos-x86_64',
'macos-aarch64',
])]
public function checkUpdateBinary(?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
$index_json = default_shell()->executeCurl('https://ziglang.org/download/index.json', retries: $downloader->getRetry());
$index_json = json_decode($index_json ?: '', true);
$latest_version = null;
if (!is_array($index_json)) {
throw new DownloaderException('Failed to fetch Zig version index for update check');
}
foreach ($index_json as $version => $data) {
if ($version !== 'master') {
$latest_version = $version;
break;
}
}
if (!$latest_version) {
throw new DownloaderException('Could not determine latest Zig version');
}
return new CheckUpdateResult(
old: $old_version,
new: $latest_version,
needUpdate: $old_version === null || $latest_version !== $old_version,
);
}
#[AfterBinaryExtract('zig', [
'linux-x86_64',
'linux-aarch64',

View File

@ -6,19 +6,30 @@ namespace Package\Library;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Attribute\Package\PatchBeforeBuild;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\PackageBuilder;
use StaticPHP\Util\FileSystem;
#[Library('bzip2')]
class bzip2
{
#[PatchBeforeBuild]
public function patchBeforeBuild(LibraryPackage $lib): void
{
FileSystem::replaceFileStr($lib->getSourceDir() . '/Makefile', 'CFLAGS=-Wall', 'CFLAGS=-fPIC -Wall');
}
#[BuildFor('Linux')]
#[BuildFor('Darwin')]
public function build(LibraryPackage $lib, PackageBuilder $builder): void
{
shell()->cd($lib->getSourceDir())->initializeEnv($lib)
->exec("make PREFIX='{$lib->getBuildRootPath()}' clean")
->exec("make -j{$builder->concurrency} PREFIX='{$lib->getBuildRootPath()}' libbz2.a")
$shell = shell()->cd($lib->getSourceDir())->initializeEnv($lib);
$env = $shell->getEnvString();
$cc_env = 'CC=' . escapeshellarg(getenv('CC') ?: 'cc') . ' AR=' . escapeshellarg(getenv('AR') ?: 'ar');
$shell->exec("make PREFIX='{$lib->getBuildRootPath()}' clean")
->exec("make -j{$builder->concurrency} {$cc_env} {$env} PREFIX='{$lib->getBuildRootPath()}' libbz2.a")
->exec('cp libbz2.a ' . $lib->getLibDir())
->exec('cp bzlib.h ' . $lib->getIncludeDir());
}

View File

@ -17,17 +17,13 @@ class libxml2
public function buildForLinux(LibraryPackage $lib): void
{
UnixCMakeExecutor::create($lib)
->optionalPackage(
'zlib',
'-DLIBXML2_WITH_ZLIB=ON ' .
"-DZLIB_LIBRARY={$lib->getLibDir()}/libz.a " .
"-DZLIB_INCLUDE_DIR={$lib->getIncludeDir()}",
'-DLIBXML2_WITH_ZLIB=OFF',
)
->optionalPackage('xz', ...cmake_boolean_args('LIBXML2_WITH_LZMA'))
->addConfigureArgs(
'-DLIBXML2_WITH_ICONV=ON',
'-DIconv_IS_BUILT_IN=OFF',
'-DLIBXML2_WITH_ZLIB=ON',
"-DZLIB_LIBRARY={$lib->getLibDir()}/libz.a",
"-DZLIB_INCLUDE_DIR={$lib->getIncludeDir()}",
'-DLIBXML2_WITH_LZMA=ON',
'-DLIBXML2_WITH_ICU=OFF', // optional, but discouraged: https://gitlab.gnome.org/GNOME/libxml2/-/blob/master/README.md
'-DLIBXML2_WITH_PYTHON=OFF',
'-DLIBXML2_WITH_PROGRAMS=OFF',

View File

@ -27,9 +27,15 @@ class Artifact
/** @var null|callable Bind custom source fetcher callback */
protected mixed $custom_source_callback = null;
/** @var null|callable Bind custom source check-update callback */
protected mixed $custom_source_check_update_callback = null;
/** @var array<string, callable> Bind custom binary fetcher callbacks */
protected mixed $custom_binary_callbacks = [];
/** @var array<string, callable> Bind custom binary check-update callbacks */
protected array $custom_binary_check_update_callbacks = [];
/** @var null|callable Bind custom source extract callback (completely takes over extraction) */
protected mixed $source_extract_callback = null;
@ -405,6 +411,19 @@ class Artifact
return $this->custom_source_callback ?? null;
}
/**
* Set custom source check-update callback.
*/
public function setCustomSourceCheckUpdateCallback(callable $callback): void
{
$this->custom_source_check_update_callback = $callback;
}
public function getCustomSourceCheckUpdateCallback(): ?callable
{
return $this->custom_source_check_update_callback ?? null;
}
public function getCustomBinaryCallback(): ?callable
{
$current_platform = SystemTarget::getCurrentPlatformString();
@ -433,6 +452,24 @@ class Artifact
$this->custom_binary_callbacks[$target_os] = $callback;
}
/**
* Set custom binary check-update callback for a specific target OS.
*
* @param string $target_os Target OS platform string (e.g. linux-x86_64)
* @param callable $callback Custom binary check-update callback
*/
public function setCustomBinaryCheckUpdateCallback(string $target_os, callable $callback): void
{
ConfigValidator::validatePlatformString($target_os);
$this->custom_binary_check_update_callbacks[$target_os] = $callback;
}
public function getCustomBinaryCheckUpdateCallback(): ?callable
{
$current_platform = SystemTarget::getCurrentPlatformString();
return $this->custom_binary_check_update_callbacks[$current_platform] ?? null;
}
// ==================== Extraction Callbacks ====================
/**

View File

@ -18,7 +18,9 @@ class ArtifactCache
* filename?: string,
* dirname?: string,
* extract: null|'&custom'|string,
* hash: null|string
* hash: null|string,
* time: int,
* downloader: null|string
* },
* binary: array{
* windows-x86_64?: null|array{
@ -28,7 +30,9 @@ class ArtifactCache
* dirname?: string,
* extract: null|'&custom'|string,
* hash: null|string,
* version?: null|string
* time: int,
* version?: null|string,
* downloader: null|string
* }
* }
* }>
@ -106,8 +110,10 @@ class ArtifactCache
'filename' => $download_result->filename,
'extract' => $download_result->extract,
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
'time' => time(),
'version' => $download_result->version,
'config' => $download_result->config,
'downloader' => $download_result->downloader,
];
} elseif ($download_result->cache_type === 'file') {
$obj = [
@ -116,8 +122,10 @@ class ArtifactCache
'filename' => $download_result->filename,
'extract' => $download_result->extract,
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
'time' => time(),
'version' => $download_result->version,
'config' => $download_result->config,
'downloader' => $download_result->downloader,
];
} elseif ($download_result->cache_type === 'git') {
$obj = [
@ -126,8 +134,10 @@ class ArtifactCache
'dirname' => $download_result->dirname,
'extract' => $download_result->extract,
'hash' => trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $download_result->dirname) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')),
'time' => time(),
'version' => $download_result->version,
'config' => $download_result->config,
'downloader' => $download_result->downloader,
];
} elseif ($download_result->cache_type === 'local') {
$obj = [
@ -136,8 +146,10 @@ class ArtifactCache
'dirname' => $download_result->dirname,
'extract' => $download_result->extract,
'hash' => null,
'time' => time(),
'version' => $download_result->version,
'config' => $download_result->config,
'downloader' => $download_result->downloader,
];
}
if ($obj === null) {
@ -157,7 +169,7 @@ class ArtifactCache
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));
file_put_contents($this->cache_file, json_encode($this->cache, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
@ -270,12 +282,22 @@ class ArtifactCache
logger()->debug("Removed binary cache entry for [{$artifact_name}] on platform [{$platform}]");
}
/**
* Get the names of all artifacts that have at least one downloaded entry (source or binary).
*
* @return array<string> Artifact names
*/
public function getCachedArtifactNames(): array
{
return array_keys($this->cache);
}
/**
* Save cache to file.
*/
public function save(): void
{
file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT));
file_put_contents($this->cache_file, json_encode($this->cache, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
private function isObjectDownloaded(?array $object, bool $compare_hash = false): bool

View File

@ -6,9 +6,19 @@ namespace StaticPHP\Artifact;
use Psr\Log\LogLevel;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Artifact\Downloader\Type\BitBucketTag;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateInterface;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
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\HostedPackageBin;
use StaticPHP\Artifact\Downloader\Type\LocalDir;
use StaticPHP\Artifact\Downloader\Type\PECL;
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;
@ -29,6 +39,21 @@ use ZM\Logger\ConsoleColor;
*/
class ArtifactDownloader
{
public const array DOWNLOADERS = [
'bitbuckettag' => BitBucketTag::class,
'filelist' => FileList::class,
'git' => Git::class,
'ghrel' => GitHubRelease::class,
'ghtar' => GitHubTarball::class,
'ghtagtar' => GitHubTarball::class,
'local' => LocalDir::class,
'pie' => PIE::class,
'pecl' => PECL::class,
'url' => Url::class,
'php-release' => PhpRelease::class,
'hosted' => HostedPackageBin::class,
];
/** @var array<string, class-string<DownloadTypeInterface>> */
protected array $downloaders = [];
@ -196,7 +221,7 @@ class ArtifactDownloader
$this->_before_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
// load downloaders
$this->downloaders = require ROOT_DIR . '/config/downloader.php';
$this->downloaders = self::DOWNLOADERS;
}
/**
@ -323,6 +348,81 @@ class ArtifactDownloader
}
}
public function checkUpdate(string $artifact_name, bool $prefer_source = false, bool $bare = false): CheckUpdateResult
{
$artifact = ArtifactLoader::getArtifactInstance($artifact_name);
if ($artifact === null) {
throw new WrongUsageException("Artifact '{$artifact_name}' not found, please check the name.");
}
if ($bare) {
[$first, $second] = $prefer_source
? [fn () => $this->probeSourceCheckUpdate($artifact, $artifact_name), fn () => $this->probeBinaryCheckUpdate($artifact, $artifact_name)]
: [fn () => $this->probeBinaryCheckUpdate($artifact, $artifact_name), fn () => $this->probeSourceCheckUpdate($artifact, $artifact_name)];
$result = $first() ?? $second();
if ($result !== null) {
return $result;
}
// logger()->warning("Artifact '{$artifact_name}' downloader does not support update checking, skipping.");
return new CheckUpdateResult(old: null, new: null, needUpdate: false, unsupported: true);
}
$cache = ApplicationContext::get(ArtifactCache::class);
if ($prefer_source) {
$info = $cache->getSourceInfo($artifact_name) ?? $cache->getBinaryInfo($artifact_name, SystemTarget::getCurrentPlatformString());
} else {
$info = $cache->getBinaryInfo($artifact_name, SystemTarget::getCurrentPlatformString()) ?? $cache->getSourceInfo($artifact_name);
}
if ($info === null) {
throw new WrongUsageException("Artifact '{$artifact_name}' is not downloaded yet, cannot check update.");
}
if (is_a($info['downloader'] ?? null, CheckUpdateInterface::class, true)) {
$cls = $info['downloader'];
/** @var CheckUpdateInterface $downloader */
$downloader = new $cls();
return $downloader->checkUpdate($artifact_name, $info['config'], $info['version'], $this);
}
if (($info['lock_type'] ?? null) === 'source' && ($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) {
return ApplicationContext::invoke($callback, [
ArtifactDownloader::class => $this,
'old_version' => $info['version'],
]);
}
if (($callback = $artifact->getCustomBinaryCheckUpdateCallback()) !== null) {
return ApplicationContext::invoke($callback, [
ArtifactDownloader::class => $this,
'old_version' => $info['version'],
]);
}
// logger()->warning("Artifact '{$artifact_name}' downloader does not support update checking, skipping.");
return new CheckUpdateResult(old: null, new: null, needUpdate: false, unsupported: true);
}
/**
* Check updates for multiple artifacts, with optional parallel processing.
*
* @param array<string> $artifact_names Artifact names to check
* @param bool $prefer_source Whether to prefer source over binary
* @param bool $bare Check without requiring artifact to be downloaded first
* @param null|callable $onResult Called immediately with (string $name, CheckUpdateResult) as each result arrives
* @return array<string, CheckUpdateResult> Results keyed by artifact name
*/
public function checkUpdates(array $artifact_names, bool $prefer_source = false, bool $bare = false, ?callable $onResult = null): array
{
if ($this->parallel > 1 && count($artifact_names) > 1) {
return $this->checkUpdatesWithConcurrency($artifact_names, $prefer_source, $bare, $onResult);
}
$results = [];
foreach ($artifact_names as $name) {
$result = $this->checkUpdate($name, $prefer_source, $bare);
$results[$name] = $result;
if ($onResult !== null) {
($onResult)($name, $result);
}
}
return $results;
}
public function getRetry(): int
{
return $this->retry;
@ -338,6 +438,105 @@ class ArtifactDownloader
return $this->options[$name] ?? $default;
}
private function checkUpdatesWithConcurrency(array $artifact_names, bool $prefer_source, bool $bare, ?callable $onResult): array
{
$results = [];
$fiber_pool = [];
$remaining = $artifact_names;
Shell::passthruCallback(function () {
\Fiber::suspend();
});
try {
while (!empty($remaining) || !empty($fiber_pool)) {
// fill pool
while (count($fiber_pool) < $this->parallel && !empty($remaining)) {
$name = array_shift($remaining);
$fiber = new \Fiber(function () use ($name, $prefer_source, $bare) {
return [$name, $this->checkUpdate($name, $prefer_source, $bare)];
});
$fiber->start();
$fiber_pool[$name] = $fiber;
}
// check pool
foreach ($fiber_pool as $fiber_name => $fiber) {
if ($fiber->isTerminated()) {
// getReturn() re-throws if the fiber threw — propagates immediately
[$artifact_name, $result] = $fiber->getReturn();
$results[$artifact_name] = $result;
if ($onResult !== null) {
($onResult)($artifact_name, $result);
}
unset($fiber_pool[$fiber_name]);
} else {
$fiber->resume();
}
}
}
} catch (\Throwable $e) {
// terminate all still-suspended fibers so their curl processes don't hang
foreach ($fiber_pool as $fiber) {
if (!$fiber->isTerminated()) {
try {
$fiber->throw($e);
} catch (\Throwable) {
// ignore — we only care about stopping them
}
}
}
throw $e;
} finally {
Shell::passthruCallback(null);
}
return $results;
}
private function probeSourceCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult
{
if (($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) {
return ApplicationContext::invoke($callback, [
ArtifactDownloader::class => $this,
'old_version' => null,
]);
}
$config = $artifact->getDownloadConfig('source');
if (!is_array($config)) {
return null;
}
$cls = $this->downloaders[$config['type']] ?? null;
if (!is_a($cls, CheckUpdateInterface::class, true)) {
return null;
}
/** @var CheckUpdateInterface $dl */
$dl = new $cls();
return $dl->checkUpdate($artifact_name, $config, null, $this);
}
private function probeBinaryCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult
{
// custom binary callback takes precedence over config-based binary
if (($callback = $artifact->getCustomBinaryCheckUpdateCallback()) !== null) {
return ApplicationContext::invoke($callback, [
ArtifactDownloader::class => $this,
'old_version' => null,
]);
}
$binary_config = $artifact->getDownloadConfig('binary');
$platform_config = is_array($binary_config) ? ($binary_config[SystemTarget::getCurrentPlatformString()] ?? null) : null;
if (!is_array($platform_config)) {
return null;
}
$cls = $this->downloaders[$platform_config['type']] ?? null;
if (!is_a($cls, CheckUpdateInterface::class, true)) {
return null;
}
/** @var CheckUpdateInterface $dl */
$dl = new $cls();
return $dl->checkUpdate($artifact_name, $platform_config, null, $this);
}
private function downloadWithType(Artifact $artifact, int $current, int $total, bool $parallel = false, bool $interactive = true): int
{
$queue = $this->generateQueue($artifact);

View File

@ -17,6 +17,7 @@ class DownloadResult
* @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.)
* @param null|string $downloader Class name of the downloader that performed this download
*/
private function __construct(
public readonly string $cache_type,
@ -27,6 +28,7 @@ class DownloadResult
public bool $verified = false,
public readonly ?string $version = null,
public readonly array $metadata = [],
public readonly ?string $downloader = null,
) {
switch ($this->cache_type) {
case 'archive':
@ -59,11 +61,12 @@ class DownloadResult
mixed $extract = null,
bool $verified = false,
?string $version = null,
array $metadata = []
array $metadata = [],
?string $downloader = null,
): DownloadResult {
// judge if it is archive or just a pure file
$cache_type = self::isArchiveFile($filename) ? 'archive' : 'file';
return new self($cache_type, config: $config, filename: $filename, extract: $extract, verified: $verified, version: $version, metadata: $metadata);
return new self($cache_type, config: $config, filename: $filename, extract: $extract, verified: $verified, version: $version, metadata: $metadata, downloader: $downloader);
}
public static function file(
@ -71,10 +74,11 @@ class DownloadResult
array $config,
bool $verified = false,
?string $version = null,
array $metadata = []
array $metadata = [],
?string $downloader = null,
): DownloadResult {
$cache_type = self::isArchiveFile($filename) ? 'archive' : 'file';
return new self($cache_type, config: $config, filename: $filename, verified: $verified, version: $version, metadata: $metadata);
return new self($cache_type, config: $config, filename: $filename, verified: $verified, version: $version, metadata: $metadata, downloader: $downloader);
}
/**
@ -85,9 +89,9 @@ class DownloadResult
* @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
public static function git(string $dirname, array $config, mixed $extract = null, ?string $version = null, array $metadata = [], ?string $downloader = null): DownloadResult
{
return new self('git', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata);
return new self('git', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata, downloader: $downloader);
}
/**
@ -98,9 +102,9 @@ class DownloadResult
* @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
public static function local(string $dirname, array $config, mixed $extract = null, ?string $version = null, array $metadata = [], ?string $downloader = null): DownloadResult
{
return new self('local', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata);
return new self('local', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata, downloader: $downloader);
}
/**
@ -136,7 +140,8 @@ class DownloadResult
$this->extract,
$this->verified,
$version,
$this->metadata
$this->metadata,
$this->downloader,
);
}
@ -154,7 +159,8 @@ class DownloadResult
$this->extract,
$this->verified,
$this->version,
array_merge($this->metadata, [$key => $value])
array_merge($this->metadata, [$key => $value]),
$this->downloader,
);
}

View File

@ -36,6 +36,6 @@ class BitBucketTag implements DownloadTypeInterface
$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);
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null, downloader: static::class);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
interface CheckUpdateInterface
{
/**
* Check if an update is available for the given artifact.
*
* @param string $name the name of the artifact
* @param array $config the configuration for the artifact
* @param null|string $old_version old version or identifier of the artifact to compare against
* @param ArtifactDownloader $downloader the artifact downloader instance
*/
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult;
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
readonly class CheckUpdateResult
{
public function __construct(
public ?string $old,
public ?string $new,
public bool $needUpdate,
public bool $unsupported = false,
) {}
}

View File

@ -9,9 +9,34 @@ use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** filelist */
class FileList implements DownloadTypeInterface
class FileList implements DownloadTypeInterface, CheckUpdateInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
[$filename, $version, $versions] = $this->fetchFileList($name, $config, $downloader);
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, version: $version, downloader: static::class);
}
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
[, $version] = $this->fetchFileList($name, $config, $downloader);
return new CheckUpdateResult(
old: $old_version,
new: $version,
needUpdate: $old_version === null || $version !== $old_version,
);
}
protected function fetchFileList(string $name, array $config, ArtifactDownloader $downloader): array
{
logger()->debug("Fetching file list from {$config['url']}");
$page = default_shell()->executeCurl($config['url'], retries: $downloader->getRetry());
@ -33,15 +58,6 @@ class FileList implements DownloadTypeInterface
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);
return [$filename, $version, $versions];
}
}

View File

@ -10,7 +10,7 @@ use StaticPHP\Exception\DownloaderException;
use StaticPHP\Util\FileSystem;
/** git */
class Git implements DownloadTypeInterface
class Git implements DownloadTypeInterface, CheckUpdateInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
@ -21,8 +21,11 @@ class Git implements DownloadTypeInterface
// direct branch clone
if (isset($config['rev'])) {
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);
$shell = PHP_OS_FAMILY === 'Windows' ? cmd(false) : shell(false);
$hash_result = $shell->execWithResult(SPC_GIT_EXEC . ' -C ' . escapeshellarg($path) . ' rev-parse --short HEAD');
$hash = ($hash_result[0] === 0 && !empty($hash_result[1])) ? trim($hash_result[1][0]) : '';
$version = $hash !== '' ? "dev-{$config['rev']}+{$hash}" : "dev-{$config['rev']}";
return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class);
}
if (!isset($config['regex'])) {
throw new DownloaderException('Either "rev" or "regex" must be specified for git download type.');
@ -64,8 +67,62 @@ class Git implements DownloadTypeInterface
$branch = $matched_version_branch[$version];
logger()->info("Matched version {$version} from branch {$branch} for {$name}");
default_shell()->executeGitClone($config['url'], $branch, $path, $shallow, $config['submodules'] ?? null);
return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version);
return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class);
}
throw new DownloaderException("No matching branch found for regex {$config['regex']} (checked {$matched_count} branches).");
}
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
if (isset($config['rev'])) {
$shell = PHP_OS_FAMILY === 'Windows' ? cmd(false) : shell(false);
$result = $shell->execWithResult(SPC_GIT_EXEC . ' ls-remote ' . escapeshellarg($config['url']) . ' ' . escapeshellarg('refs/heads/' . $config['rev']));
if ($result[0] !== 0 || empty($result[1])) {
throw new DownloaderException("Failed to ls-remote from {$config['url']}");
}
$new_hash = substr($result[1][0], 0, 7);
$new_version = "dev-{$config['rev']}+{$new_hash}";
// Extract stored hash from "dev-{rev}+{hash}", null if bare mode or old format without hash
$old_hash = ($old_version !== null && str_contains($old_version, '+')) ? substr(strrchr($old_version, '+'), 1) : null;
return new CheckUpdateResult(
old: $old_version,
new: $new_version,
needUpdate: $old_hash === null || $new_hash !== $old_hash,
);
}
if (!isset($config['regex'])) {
throw new DownloaderException('Either "rev" or "regex" must be specified for git download type.');
}
$shell = PHP_OS_FAMILY === 'Windows' ? cmd(false) : shell(false);
$result = $shell->execWithResult(SPC_GIT_EXEC . ' ls-remote ' . escapeshellarg($config['url']));
if ($result[0] !== 0) {
throw new DownloaderException("Failed to ls-remote from {$config['url']}");
}
$refs = $result[1];
$matched_version_branch = [];
$regex = '/^' . $config['regex'] . '$/';
foreach ($refs as $ref) {
$matches = null;
if (preg_match('/^[0-9a-f]{40}\s+refs\/heads\/(.+)$/', $ref, $matches)) {
$branch = $matches[1];
if (preg_match($regex, $branch, $vermatch) && isset($vermatch['version'])) {
$matched_version_branch[$vermatch['version']] = $vermatch[0];
}
}
}
uksort($matched_version_branch, function ($a, $b) {
return version_compare($b, $a);
});
if (!empty($matched_version_branch)) {
$version = array_key_first($matched_version_branch);
return new CheckUpdateResult(
old: $old_version,
new: $version,
needUpdate: $old_version === null || $version !== $old_version,
);
}
throw new DownloaderException("No matching branch found for regex {$config['regex']}.");
}
}

View File

@ -9,7 +9,7 @@ use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** ghrel */
class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
class GitHubRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpdateInterface
{
use GitHubTokenSetupTrait;
@ -48,6 +48,7 @@ class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
*/
public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_stable, string $match_asset, ?string $query = null): array
{
logger()->debug("Fetching {$name} GitHub release from {$repo}");
$url = str_replace('{repo}', $repo, self::API_URL);
$url .= ($query ?? '');
$headers = $this->getGitHubTokenHeaders();
@ -95,7 +96,7 @@ class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
$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);
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null, version: $this->version, downloader: static::class);
}
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
@ -117,4 +118,18 @@ class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
logger()->debug("No sha256 digest found for GitHub release asset of {$name}, skipping hash validation");
return true;
}
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
if (!isset($config['match'])) {
throw new DownloaderException("GitHubRelease downloader requires 'match' config for {$name}");
}
$this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match'], $config['query'] ?? null);
$new_version = $this->version ?? $old_version ?? '';
return new CheckUpdateResult(
old: $old_version,
new: $new_version,
needUpdate: $old_version === null || $new_version !== $old_version,
);
}
}

View File

@ -10,7 +10,7 @@ use StaticPHP\Exception\DownloaderException;
/** ghtar */
/** ghtagtar */
class GitHubTarball implements DownloadTypeInterface
class GitHubTarball implements DownloadTypeInterface, CheckUpdateInterface
{
use GitHubTokenSetupTrait;
@ -42,12 +42,12 @@ class GitHubTarball implements DownloadTypeInterface
}
if ($match_url === null) {
$url = $rel['tarball_url'] ?? null;
$version = $rel['tag_name'] ?? null;
$version = $rel['tag_name'] ?? $rel['name'] ?? null;
break;
}
if (preg_match("|{$match_url}|", $rel['tarball_url'] ?? '')) {
$url = $rel['tarball_url'];
$version = $rel['tag_name'] ?? null;
$version = $rel['tag_name'] ?? $rel['name'] ?? null;
break;
}
}
@ -77,6 +77,22 @@ class GitHubTarball implements DownloadTypeInterface
[$url, $filename] = $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null);
$path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, headers: $this->getGitHubTokenHeaders());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null, version: $this->version);
return DownloadResult::archive($filename, $config, $config['extract'] ?? null, version: $this->version, downloader: static::class);
}
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
$rel_type = match ($config['type']) {
'ghtar' => 'releases',
'ghtagtar' => 'tags',
default => throw new DownloaderException("Invalid GitHubTarball type for {$name}"),
};
$this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null);
$new_version = $this->version ?? $old_version ?? '';
return new CheckUpdateResult(
old: $old_version,
new: $new_version,
needUpdate: $old_version === null || $new_version !== $old_version,
);
}
}

View File

@ -16,10 +16,12 @@ trait GitHubTokenSetupTrait
// GITHUB_TOKEN support
if (($token = getenv('GITHUB_TOKEN')) !== false && ($user = getenv('GITHUB_USER')) !== false) {
logger()->debug("Using 'GITHUB_TOKEN' with user {$user} for authentication");
spc_add_log_filter([$user, $token]);
return ['Authorization: Basic ' . base64_encode("{$user}:{$token}")];
}
if (($token = getenv('GITHUB_TOKEN')) !== false) {
logger()->debug("Using 'GITHUB_TOKEN' for authentication");
spc_add_log_filter($token);
return ["Authorization: Bearer {$token}"];
}
return [];

View File

@ -26,7 +26,7 @@ class HostedPackageBin implements DownloadTypeInterface
public static function getReleaseInfo(): array
{
if (empty(self::$release_info)) {
$rel = (new GitHubRelease())->getGitHubReleases('hosted', self::BASE_REPO);
$rel = new GitHubRelease()->getGitHubReleases('hosted', self::BASE_REPO);
if (empty($rel)) {
throw new DownloaderException('No releases found for hosted package-bin');
}
@ -55,7 +55,7 @@ class HostedPackageBin implements DownloadTypeInterface
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
$headers = $this->getGitHubTokenHeaders();
default_shell()->executeCurlDownload($download_url, $path, headers: $headers, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null, version: $version);
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class);
}
}
throw new DownloaderException("No matching asset found for hosted package-bin {$name}: {$find_str}");

View File

@ -13,6 +13,6 @@ 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);
return DownloadResult::local($config['dirname'], $config, extract: $config['extract'] ?? null, downloader: static::class);
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/* pecl */
class PECL implements DownloadTypeInterface, CheckUpdateInterface
{
private const string PECL_BASE_URL = 'https://pecl.php.net';
/** REST API: returns XML with <r><v>VERSION</v><s>STATE</s></r> per release */
private const string PECL_REST_URL = 'https://pecl.php.net/rest/r/%s/allreleases.xml';
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
[, $version] = $this->fetchPECLInfo($name, $config, $downloader);
return new CheckUpdateResult(
old: $old_version,
new: $version,
needUpdate: $old_version === null || $version !== $old_version,
);
}
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
[$filename, $version] = $this->fetchPECLInfo($name, $config, $downloader);
$url = self::PECL_BASE_URL . '/get/' . $filename;
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} from URL: {$url}");
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
$extract = $config['extract'] ?? ('php-src/ext/' . $this->getExtractName($name));
return DownloadResult::archive($filename, $config, $extract, version: $version, downloader: static::class);
}
protected function fetchPECLInfo(string $name, array $config, ArtifactDownloader $downloader): array
{
$peclName = strtolower($config['name'] ?? $this->getExtractName($name));
$url = sprintf(self::PECL_REST_URL, $peclName);
logger()->debug("Fetching PECL release list for {$name} from REST API");
$xml = default_shell()->executeCurl($url, retries: $downloader->getRetry());
if ($xml === false) {
throw new DownloaderException("Failed to fetch PECL release list for {$name}");
}
// Match <r><v>VERSION</v><s>STATE</s></r>
preg_match_all('/<r><v>(?P<version>[^<]+)<\/v><s>(?P<state>[^<]+)<\/s><\/r>/', $xml, $matches);
if (empty($matches['version'])) {
throw new DownloaderException("Failed to parse PECL release list for {$name}");
}
$versions = [];
logger()->debug('Matched ' . count($matches['version']) . " releases for {$name} from PECL");
foreach ($matches['version'] as $i => $version) {
if ($matches['state'][$i] !== 'stable') {
continue;
}
$versions[$version] = $peclName . '-' . $version . '.tgz';
}
if (empty($versions)) {
throw new DownloaderException("No stable releases found for {$name} on PECL");
}
uksort($versions, 'version_compare');
$filename = end($versions);
$version = array_key_last($versions);
return [$filename, $version, $versions];
}
/**
* Derive the lowercase PECL package / extract name from the artifact name.
* e.g. "ext-apcu" -> "apcu", "ext-ast" -> "ast"
*/
private function getExtractName(string $name): string
{
return strtolower(preg_replace('/^ext-/i', '', $name));
}
}

View File

@ -9,28 +9,13 @@ use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** pie */
class PIE implements DownloadTypeInterface
class PIE implements DownloadTypeInterface, CheckUpdateInterface
{
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");
}
$first = $this->fetchPackagistInfo($name, $config, $downloader);
// get download link from dist
$dist_url = $first['dist']['url'] ?? null;
$dist_type = $first['dist']['type'] ?? null;
@ -42,6 +27,39 @@ class PIE implements DownloadTypeInterface
$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);
return DownloadResult::archive($filename, $config, $config['extract'] ?? null, version: $version, downloader: static::class);
}
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
$first = $this->fetchPackagistInfo($name, $config, $downloader);
$new_version = $first['version'] ?? null;
if ($new_version === null) {
throw new DownloaderException("failed to find version info for {$name} from packagist");
}
return new CheckUpdateResult(
old: $old_version,
new: $new_version,
needUpdate: $old_version === null || $new_version !== $old_version,
);
}
protected function fetchPackagistInfo(string $name, array $config, ArtifactDownloader $downloader): array
{
$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");
}
$first = $data['packages'][$config['repo']][0] ?? [];
if (!isset($first['php-ext'])) {
throw new DownloaderException("failed to find {$name} php-ext info from packagist, maybe not a php extension package");
}
return $first;
}
}

View File

@ -8,11 +8,17 @@ use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
class PhpRelease implements DownloadTypeInterface, ValidatorInterface
class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpdateInterface
{
public const string PHP_API = 'https://www.php.net/releases/index.php?json&version={version}';
public const string DEFAULT_PHP_DOMAIN = 'https://www.php.net';
public const string DOWNLOAD_URL = 'https://www.php.net/distributions/php-{version}.tar.xz';
public const string API_URL = '/releases/index.php?json&version={version}';
public const string DOWNLOAD_URL = '/distributions/php-{version}.tar.xz';
public const string GIT_URL = 'https://github.com/php/php-src.git';
public const string GIT_REV = 'master';
private ?string $sha256 = '';
@ -22,18 +28,9 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface
// 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}");
return (new Git())->download($name, ['url' => self::GIT_URL, 'rev' => self::GIT_REV], $downloader);
}
$info = $this->fetchPhpReleaseInfo($name, $config, $downloader);
$version = $info['version'];
foreach ($info['source'] as $source) {
if (str_ends_with($source['filename'], '.tar.xz')) {
@ -45,11 +42,12 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface
if (!isset($filename)) {
throw new DownloaderException("No suitable source tarball found for PHP version {$version}");
}
$url = str_replace('{version}', $version, self::DOWNLOAD_URL);
$url = $config['domain'] ?? self::DEFAULT_PHP_DOMAIN;
$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);
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class);
}
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
@ -73,4 +71,46 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface
logger()->debug("SHA256 checksum validated successfully for {$name}.");
return true;
}
public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
$phpver = $downloader->getOption('with-php', '8.4');
if ($phpver === 'git') {
// git version: delegate to Git checkUpdate with master branch
return (new Git())->checkUpdate($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $old_version, $downloader);
}
$info = $this->fetchPhpReleaseInfo($name, $config, $downloader);
$new_version = $info['version'];
return new CheckUpdateResult(
old: $old_version,
new: $new_version,
needUpdate: $old_version === null || $new_version !== $old_version,
);
}
protected function fetchPhpReleaseInfo(string $name, array $config, ArtifactDownloader $downloader): array
{
$phpver = $downloader->getOption('with-php', '8.4');
// Handle 'git' version to clone from php-src repository
if ($phpver === 'git') {
// cannot fetch release info for git version, return empty info to skip validation
throw new DownloaderException("Cannot fetch PHP release info for 'git' version.");
}
$url = $config['domain'] ?? self::DEFAULT_PHP_DOMAIN;
$url .= self::API_URL;
$url = str_replace('{version}', $phpver, $url);
logger()->debug("Fetching PHP release info for version {$phpver} from {$url}");
// Fetch PHP release info first
$info = default_shell()->executeCurl($url, 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}");
}
return $info;
}
}

View File

@ -18,6 +18,6 @@ class Url implements DownloadTypeInterface
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);
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class CustomBinaryCheckUpdate
{
public function __construct(public string $artifact_name, public array $support_os) {}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class CustomSourceCheckUpdate
{
public function __construct(public string $artifact_name) {}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand('check-update', description: 'Check for updates for a specific artifact')]
class CheckUpdateCommand extends BaseCommand
{
public bool $no_motd = true;
public function configure(): void
{
$this->addArgument('artifact', InputArgument::OPTIONAL, 'The name of the artifact(s) to check for updates, comma-separated (default: all downloaded artifacts)');
$this->addOption('json', null, null, 'Output result in JSON format');
$this->addOption('bare', null, null, 'Check update without requiring the artifact to be downloaded first (old version will be null)');
$this->addOption('parallel', 'p', InputOption::VALUE_REQUIRED, 'Number of parallel update checks (default: 10)', 10);
// --with-php option for checking updates with a specific PHP version context
$this->addOption('with-php', null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.4)', '8.4');
}
public function handle(): int
{
$artifact_arg = $this->input->getArgument('artifact');
if ($artifact_arg === null) {
$artifacts = ApplicationContext::get(ArtifactCache::class)->getCachedArtifactNames();
if (empty($artifacts)) {
$this->output->writeln('<comment>No downloaded artifacts found.</comment>');
return static::OK;
}
} else {
$artifacts = parse_comma_list($artifact_arg);
}
try {
$downloader = new ArtifactDownloader($this->input->getOptions());
$bare = (bool) $this->getOption('bare');
if ($this->getOption('json')) {
$results = $downloader->checkUpdates($artifacts, bare: $bare);
$outputs = [];
foreach ($results as $artifact => $result) {
$outputs[$artifact] = [
'need-update' => $result->needUpdate,
'unsupported' => $result->unsupported,
'old' => $result->old,
'new' => $result->new,
];
}
$this->output->writeln(json_encode($outputs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return static::OK;
}
$downloader->checkUpdates($artifacts, bare: $bare, onResult: function (string $artifact, CheckUpdateResult $result) {
if ($result->unsupported) {
$this->output->writeln("Artifact <info>{$artifact}</info> does not support update checking, <comment>skipped</comment>");
} elseif (!$result->needUpdate) {
$ver = $result->new ? "(<comment>{$result->new}</comment>)" : '';
$this->output->writeln("Artifact <info>{$artifact}</info> is already up to date {$ver}");
} else {
[$old, $new] = [$result->old ?? 'unavailable', $result->new ?? 'unknown'];
$this->output->writeln("Update available for <info>{$artifact}</info>: <comment>{$old}</comment> -> <comment>{$new}</comment>");
}
});
return static::OK;
} catch (SPCException $e) {
$e->setSimpleOutput();
throw $e;
}
}
}

View File

@ -17,26 +17,28 @@ trait ReturnCode
{
public const int OK = 0;
public const SUCCESS = 0; // alias of OK
public const SUCCESS = 0; // alias
public const int INTERNAL_ERROR = 1; // unsorted or internal error
public const FAILURE = 1; // generic failure
/** @deprecated Use specified error code instead */
public const FAILURE = 1;
// 64-69: reserved for standard errors
public const int USER_ERROR = 64; // wrong usage, bad arguments
public const int USER_ERROR = 2; // wrong usage or user error
public const int VALIDATION_ERROR = 65; // invalid input or config values
public const int ENVIRONMENT_ERROR = 3; // environment not suitable for operation
public const int ENVIRONMENT_ERROR = 69; // required tools/env not available
public const int VALIDATION_ERROR = 4; // validation failed
// 70+: application-specific errors
public const int INTERNAL_ERROR = 70; // internal logic error or unexpected state
public const int FILE_SYSTEM_ERROR = 5; // file system related error
public const int BUILD_ERROR = 72; // build / compile process failed
public const int DOWNLOAD_ERROR = 6; // network related error
public const int PATCH_ERROR = 73; // patching or modifying files failed
public const int BUILD_ERROR = 7; // build process error
public const int FILE_SYSTEM_ERROR = 74; // filesystem / IO error
public const int PATCH_ERROR = 8; // patching process error
public const int DOWNLOAD_ERROR = 75; // network / remote resource error
public const int INTERRUPT_SIGNAL = 130; // process interrupted by user (e.g., Ctrl+C)
// 128+: reserved for standard signals and interrupts
public const int INTERRUPT_SIGNAL = 130; // SIGINT (Ctrl+C)
}

View File

@ -89,7 +89,8 @@ class ConfigValidator
'bitbuckettag' => [['repo'], ['extract']],
'local' => [['dirname'], ['extract']],
'pie' => [['repo'], ['extract']],
'php-release' => [[], ['extract']],
'pecl' => [['name'], ['extract']],
'php-release' => [['domain'], ['extract']],
'custom' => [[], ['func']],
];

View File

@ -6,6 +6,7 @@ namespace StaticPHP;
use StaticPHP\Command\BuildLibsCommand;
use StaticPHP\Command\BuildTargetCommand;
use StaticPHP\Command\CheckUpdateCommand;
use StaticPHP\Command\Dev\DumpCapabilitiesCommand;
use StaticPHP\Command\Dev\DumpStagesCommand;
use StaticPHP\Command\Dev\EnvCommand;
@ -63,6 +64,7 @@ class ConsoleApplication extends Application
new SPCConfigCommand(),
new DumpLicenseCommand(),
new ResetCommand(),
new CheckUpdateCommand(),
// dev commands
new ShellCommand(),

View File

@ -29,12 +29,6 @@ class ExceptionHandler
RegistryException::class,
];
public const array MINOR_LOG_EXCEPTIONS = [
InterruptException::class,
WrongUsageException::class,
RegistryException::class,
];
/** @var array<string, mixed> Build PHP extra info binding */
private static array $build_php_extra_info = [];
@ -57,10 +51,7 @@ class ExceptionHandler
};
self::logError($head_msg);
// ----------------------------------------
$minor_logs = in_array($class, self::MINOR_LOG_EXCEPTIONS, true);
if ($minor_logs) {
if ($e->isSimpleOutput()) {
return self::getReturnCode($e);
}
@ -124,7 +115,7 @@ class ExceptionHandler
$msg = explode("\n", (string) $message);
foreach ($msg as $v) {
$line = str_pad($v, strlen($v) + $indent_space, ' ', STR_PAD_LEFT);
fwrite($spc_log, strip_ansi_colors($line) . PHP_EOL);
spc_write_log($spc_log, strip_ansi_colors($line) . PHP_EOL);
if ($output_log) {
InteractiveTerm::plain(ConsoleColor::$color($line) . '', 'error');
}
@ -283,6 +274,6 @@ class ExceptionHandler
self::printArrayInfo($info);
}
self::logError("---------------------------------------------------------\n", color: 'none');
self::logError("-----------------------------------------------------------\n", color: 'none');
}
}

View File

@ -7,4 +7,7 @@ namespace StaticPHP\Exception;
/**
* Exception caused by manual intervention.
*/
class InterruptException extends SPCException {}
class InterruptException extends SPCException
{
protected bool $simple_output = true;
}

View File

@ -4,4 +4,7 @@ declare(strict_types=1);
namespace StaticPHP\Exception;
class RegistryException extends SPCException {}
class RegistryException extends SPCException
{
protected bool $simple_output = true;
}

View File

@ -20,6 +20,8 @@ use StaticPHP\Package\TargetPackage;
*/
abstract class SPCException extends \Exception
{
protected bool $simple_output = false;
/** @var null|array Package information */
private ?array $package_info = null;
@ -155,6 +157,16 @@ abstract class SPCException extends \Exception
return $this->extra_log_files;
}
public function isSimpleOutput(): bool
{
return $this->simple_output;
}
public function setSimpleOutput(bool $simple_output = true): void
{
$this->simple_output = $simple_output;
}
/**
* Load stack trace information to detect Package, Builder, and Installer context.
*/

View File

@ -10,4 +10,7 @@ namespace StaticPHP\Exception;
* This exception is used to indicate that the SPC is being used incorrectly.
* Such as when a command is not supported or an invalid argument is provided.
*/
class WrongUsageException extends SPCException {}
class WrongUsageException extends SPCException
{
protected bool $simple_output = true;
}

View File

@ -80,7 +80,7 @@ class PhpExtensionPackage extends Package
}
$escapedPath = str_replace("'", '', escapeshellarg(BUILD_ROOT_PATH)) !== BUILD_ROOT_PATH || str_contains(BUILD_ROOT_PATH, ' ') ? escapeshellarg(BUILD_ROOT_PATH) : BUILD_ROOT_PATH;
$name = str_replace('_', '-', $this->getExtensionName());
$ext_config = PackageConfig::get($name, 'php-extension', []);
$ext_config = PackageConfig::get($this->getName(), 'php-extension', []);
$arg_type = match (SystemTarget::getTargetOS()) {
'Windows' => $ext_config['arg-type@windows'] ?? $ext_config['arg-type'] ?? 'enable',

View File

@ -9,7 +9,9 @@ use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
use StaticPHP\Attribute\Artifact\AfterSourceExtract;
use StaticPHP\Attribute\Artifact\BinaryExtract;
use StaticPHP\Attribute\Artifact\CustomBinary;
use StaticPHP\Attribute\Artifact\CustomBinaryCheckUpdate;
use StaticPHP\Attribute\Artifact\CustomSource;
use StaticPHP\Attribute\Artifact\CustomSourceCheckUpdate;
use StaticPHP\Attribute\Artifact\SourceExtract;
use StaticPHP\Config\ArtifactConfig;
use StaticPHP\Exception\ValidationException;
@ -61,7 +63,9 @@ class ArtifactLoader
foreach ($ref->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
self::processCustomSourceAttribute($ref, $method, $class_instance);
self::processCustomSourceCheckUpdateAttribute($ref, $method, $class_instance);
self::processCustomBinaryAttribute($ref, $method, $class_instance);
self::processCustomBinaryCheckUpdateAttribute($ref, $method, $class_instance);
self::processSourceExtractAttribute($ref, $method, $class_instance);
self::processBinaryExtractAttribute($ref, $method, $class_instance);
self::processAfterSourceExtractAttribute($ref, $method, $class_instance);
@ -98,6 +102,24 @@ class ArtifactLoader
}
}
/**
* Process #[CustomSourceCheckUpdate] attribute.
*/
private static function processCustomSourceCheckUpdateAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(CustomSourceCheckUpdate::class);
foreach ($attributes as $attribute) {
/** @var CustomSourceCheckUpdate $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
self::$artifacts[$artifact_name]->setCustomSourceCheckUpdateCallback([$class_instance, $method->getName()]);
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[CustomSourceCheckUpdate] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[CustomBinary] attribute.
*/
@ -118,6 +140,26 @@ class ArtifactLoader
}
}
/**
* Process #[CustomBinaryCheckUpdate] attribute.
*/
private static function processCustomBinaryCheckUpdateAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(CustomBinaryCheckUpdate::class);
foreach ($attributes as $attribute) {
/** @var CustomBinaryCheckUpdate $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]->setCustomBinaryCheckUpdateCallback($os, [$class_instance, $method->getName()]);
}
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[CustomBinaryCheckUpdate] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[SourceExtract] attribute.
* This attribute allows completely taking over the source extraction process.

View File

@ -25,7 +25,7 @@ class DefaultShell extends Shell
/**
* Execute a cURL command to fetch data from a URL.
*/
public function executeCurl(string $url, string $method = 'GET', array $headers = [], array $hooks = [], int $retries = 0): false|string
public function executeCurl(string $url, string $method = 'GET', array $headers = [], array $hooks = [], int $retries = 0, bool $compressed = false): false|string
{
foreach ($hooks as $hook) {
$hook($method, $url, $headers);
@ -39,7 +39,8 @@ class DefaultShell extends Shell
};
$header_arg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers));
$retry_arg = $retries > 0 ? "--retry {$retries}" : '';
$cmd = SPC_CURL_EXEC . " -sfSL {$retry_arg} {$method_arg} {$header_arg} {$url_arg}";
$compressed_arg = $compressed ? '--compressed' : '';
$cmd = SPC_CURL_EXEC . " -sfSL --max-time 3600 {$retry_arg} {$compressed_arg} {$method_arg} {$header_arg} {$url_arg}";
$this->logCommandInfo($cmd);
$result = $this->passthru($cmd, capture_output: true, throw_on_error: false);
@ -72,7 +73,7 @@ class DefaultShell extends Shell
$header_arg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers));
$retry_arg = $retries > 0 ? "--retry {$retries}" : '';
$check = $this->console_putput ? '#' : 's';
$cmd = clean_spaces(SPC_CURL_EXEC . " -{$check}fSL {$retry_arg} {$header_arg} -o {$path_arg} {$url_arg}");
$cmd = clean_spaces(SPC_CURL_EXEC . " -{$check}fSL --max-time 3600 {$retry_arg} {$header_arg} -o {$path_arg} {$url_arg}");
$this->logCommandInfo($cmd);
logger()->debug('[CURL DOWNLOAD] ' . $cmd);
$this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true);
@ -93,7 +94,7 @@ class DefaultShell extends Shell
$path_arg = escapeshellarg($path);
$shallow_arg = $shallow ? '--depth 1 --single-branch' : '';
$submodules_arg = ($submodules === null && $shallow) ? '--recursive --shallow-submodules' : ($submodules === null ? '--recursive' : '');
$cmd = clean_spaces("{$git} clone --config core.autocrlf=false --branch {$branch_arg} {$shallow_arg} {$submodules_arg} {$url_arg} {$path_arg}");
$cmd = clean_spaces("{$git} clone -c http.lowSpeedLimit=1 -c http.lowSpeedTime=3600 --config core.autocrlf=false --branch {$branch_arg} {$shallow_arg} {$submodules_arg} {$url_arg} {$path_arg}");
$this->logCommandInfo($cmd);
logger()->debug("[GIT CLONE] {$cmd}");
$this->passthru($cmd, $this->console_putput);

View File

@ -114,22 +114,22 @@ abstract class Shell
if (!$this->enable_log_file) {
return;
}
// write executed command to log file using fwrite
// write executed command to log file using spc_write_log
$log_file = fopen(SPC_SHELL_LOG, 'a');
fwrite($log_file, "\n>>>>>>>>>>>>>>>>>>>>>>>>>> [" . date('Y-m-d H:i:s') . "]\n");
fwrite($log_file, "> Executing command: {$cmd}\n");
spc_write_log($log_file, "\n>>>>>>>>>>>>>>>>>>>>>>>>>> [" . date('Y-m-d H:i:s') . "]\n");
spc_write_log($log_file, "> Executing command: {$cmd}\n");
// get the backtrace to find the file and line number
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
if (isset($backtrace[1]['file'], $backtrace[1]['line'])) {
$file = $backtrace[1]['file'];
$line = $backtrace[1]['line'];
fwrite($log_file, "> Called from: {$file} at line {$line}\n");
spc_write_log($log_file, "> Called from: {$file} at line {$line}\n");
}
fwrite($log_file, "> Environment variables: {$this->getEnvString()}\n");
spc_write_log($log_file, "> Environment variables: {$this->getEnvString()}\n");
if ($this->cd !== null) {
fwrite($log_file, "> Working dir: {$this->cd}\n");
spc_write_log($log_file, "> Working dir: {$this->cd}\n");
}
fwrite($log_file, "\n");
spc_write_log($log_file, "\n");
}
/**
@ -154,7 +154,7 @@ abstract class Shell
): array {
$file_res = null;
if ($this->enable_log_file) {
// write executed command to the log file using fwrite
// write executed command to the log file using spc_write_log
$file_res = fopen(SPC_SHELL_LOG, 'a');
}
if ($console_output) {
@ -194,10 +194,10 @@ abstract class Shell
foreach ([$pipes[1], $pipes[2]] as $pipe) {
while (($chunk = fread($pipe, 8192)) !== false && $chunk !== '') {
if ($console_output) {
fwrite($console_res, $chunk);
spc_write_log($console_res, $chunk);
}
if ($file_res !== null) {
fwrite($file_res, $chunk);
spc_write_log($file_res, $chunk);
}
if ($capture_output) {
$output_value .= $chunk;
@ -207,7 +207,7 @@ abstract class Shell
// check exit code
if ($throw_on_error && $status['exitcode'] !== 0) {
if ($file_res !== null) {
fwrite($file_res, "Command exited with non-zero code: {$status['exitcode']}\n");
spc_write_log($file_res, "Command exited with non-zero code: {$status['exitcode']}\n");
}
throw new ExecutionException(
cmd: $original_command ?? $cmd,
@ -238,10 +238,10 @@ abstract class Shell
foreach ($read as $pipe) {
while (($chunk = fread($pipe, 8192)) !== false && $chunk !== '') {
if ($console_output) {
fwrite($console_res, $chunk);
spc_write_log($console_res, $chunk);
}
if ($file_res !== null) {
fwrite($file_res, $chunk);
spc_write_log($file_res, $chunk);
}
if ($capture_output) {
$output_value .= $chunk;

View File

@ -52,7 +52,7 @@ if (filter_var(getenv('SPC_ENABLE_LOG_FILE'), FILTER_VALIDATE_BOOLEAN)) {
$log_file_fd = fopen(SPC_OUTPUT_LOG, 'a');
$ob_logger->addLogCallback(function ($level, $output) use ($log_file_fd) {
if ($log_file_fd) {
fwrite($log_file_fd, strip_ansi_colors($output) . "\n");
spc_write_log($log_file_fd, strip_ansi_colors($output) . "\n");
}
return true;
});

View File

@ -104,7 +104,7 @@ const SPC_DOWNLOAD_TYPE_DISPLAY_NAME = [
'local' => 'local dir',
'pie' => 'PHP Installer for Extensions (PIE)',
'url' => 'url',
'php-release' => 'php.net',
'php-release' => 'PHP website release',
'custom' => 'custom downloader',
];

View File

@ -132,6 +132,32 @@ function patch_point(): string
return '';
}
// Add log filter value(s) to prevent secret leak
function spc_add_log_filter(array|string $filter): void
{
global $spc_log_filters;
if (!is_array($spc_log_filters)) {
$spc_log_filters = [];
}
if (is_string($filter)) {
if (!in_array($filter, $spc_log_filters, true)) {
$spc_log_filters[] = $filter;
}
} elseif (is_array($filter)) {
$spc_log_filters = array_values(array_unique(array_merge($spc_log_filters, $filter)));
}
}
function spc_write_log(mixed $stream, string $data): false|int
{
// get filter
global $spc_log_filters;
if (is_array($spc_log_filters)) {
$data = str_replace($spc_log_filters, '***', $data);
}
return fwrite($stream, $data);
}
function patch_point_interrupt(int $retcode, string $msg = ''): InterruptException
{
return new InterruptException(message: $msg, code: $retcode);