Implement check-update functionality for artifacts and enhance download result handling

This commit is contained in:
crazywhalecc 2026-02-28 13:44:23 +08:00
parent 2d550a8db4
commit ed5a516004
No known key found for this signature in database
GPG Key ID: 1F4BDD59391F2680
18 changed files with 368 additions and 77 deletions

View File

@ -18,7 +18,8 @@ class ArtifactCache
* filename?: string, * filename?: string,
* dirname?: string, * dirname?: string,
* extract: null|'&custom'|string, * extract: null|'&custom'|string,
* hash: null|string * hash: null|string,
* downloader: null|string
* }, * },
* binary: array{ * binary: array{
* windows-x86_64?: null|array{ * windows-x86_64?: null|array{
@ -28,7 +29,8 @@ class ArtifactCache
* dirname?: string, * dirname?: string,
* extract: null|'&custom'|string, * extract: null|'&custom'|string,
* hash: null|string, * hash: null|string,
* version?: null|string * version?: null|string,
* downloader: null|string
* } * }
* } * }
* }> * }>
@ -108,6 +110,7 @@ class ArtifactCache
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename), 'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
'version' => $download_result->version, 'version' => $download_result->version,
'config' => $download_result->config, 'config' => $download_result->config,
'downloader' => $download_result->downloader,
]; ];
} elseif ($download_result->cache_type === 'file') { } elseif ($download_result->cache_type === 'file') {
$obj = [ $obj = [
@ -118,6 +121,7 @@ class ArtifactCache
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename), 'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
'version' => $download_result->version, 'version' => $download_result->version,
'config' => $download_result->config, 'config' => $download_result->config,
'downloader' => $download_result->downloader,
]; ];
} elseif ($download_result->cache_type === 'git') { } elseif ($download_result->cache_type === 'git') {
$obj = [ $obj = [
@ -128,6 +132,7 @@ class ArtifactCache
'hash' => trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $download_result->dirname) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')), 'hash' => trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $download_result->dirname) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')),
'version' => $download_result->version, 'version' => $download_result->version,
'config' => $download_result->config, 'config' => $download_result->config,
'downloader' => $download_result->downloader,
]; ];
} elseif ($download_result->cache_type === 'local') { } elseif ($download_result->cache_type === 'local') {
$obj = [ $obj = [
@ -138,6 +143,7 @@ class ArtifactCache
'hash' => null, 'hash' => null,
'version' => $download_result->version, 'version' => $download_result->version,
'config' => $download_result->config, 'config' => $download_result->config,
'downloader' => $download_result->downloader,
]; ];
} }
if ($obj === null) { if ($obj === null) {

View File

@ -6,6 +6,8 @@ namespace StaticPHP\Artifact;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use StaticPHP\Artifact\Downloader\DownloadResult; use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateInterface;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\Artifact\Downloader\Type\DownloadTypeInterface; use StaticPHP\Artifact\Downloader\Type\DownloadTypeInterface;
use StaticPHP\Artifact\Downloader\Type\Git; use StaticPHP\Artifact\Downloader\Type\Git;
use StaticPHP\Artifact\Downloader\Type\LocalDir; use StaticPHP\Artifact\Downloader\Type\LocalDir;
@ -323,6 +325,43 @@ 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) {
$config = $artifact->getDownloadConfig('source');
if (!is_array($config)) {
throw new WrongUsageException("Artifact '{$artifact_name}' has no source config for bare update check.");
}
$cls = $this->downloaders[$config['type']] ?? null;
if (!is_a($cls, CheckUpdateInterface::class, true)) {
throw new WrongUsageException("Artifact '{$artifact_name}' downloader does not support update checking.");
}
/** @var CheckUpdateInterface $downloader */
$downloader = new $cls();
return $downloader->checkUpdate($artifact_name, $config, null, $this);
}
$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);
}
throw new WrongUsageException("Artifact '{$artifact_name}' downloader does not support update checking, exit.");
}
public function getRetry(): int public function getRetry(): int
{ {
return $this->retry; return $this->retry;

View File

@ -17,6 +17,7 @@ class DownloadResult
* @param bool $verified Whether the download has been verified (hash check) * @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 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 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( private function __construct(
public readonly string $cache_type, public readonly string $cache_type,
@ -27,6 +28,7 @@ class DownloadResult
public bool $verified = false, public bool $verified = false,
public readonly ?string $version = null, public readonly ?string $version = null,
public readonly array $metadata = [], public readonly array $metadata = [],
public readonly ?string $downloader = null,
) { ) {
switch ($this->cache_type) { switch ($this->cache_type) {
case 'archive': case 'archive':
@ -59,11 +61,12 @@ class DownloadResult
mixed $extract = null, mixed $extract = null,
bool $verified = false, bool $verified = false,
?string $version = null, ?string $version = null,
array $metadata = [] array $metadata = [],
?string $downloader = null,
): DownloadResult { ): DownloadResult {
// judge if it is archive or just a pure file // judge if it is archive or just a pure file
$cache_type = self::isArchiveFile($filename) ? 'archive' : '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( public static function file(
@ -71,10 +74,11 @@ class DownloadResult
array $config, array $config,
bool $verified = false, bool $verified = false,
?string $version = null, ?string $version = null,
array $metadata = [] array $metadata = [],
?string $downloader = null,
): DownloadResult { ): DownloadResult {
$cache_type = self::isArchiveFile($filename) ? 'archive' : 'file'; $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 null|string $version Version string (tag, branch, or commit)
* @param array $metadata Additional metadata (e.g., commit hash) * @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 null|string $version Version string if known
* @param array $metadata Additional metadata * @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->extract,
$this->verified, $this->verified,
$version, $version,
$this->metadata $this->metadata,
$this->downloader,
); );
} }
@ -154,7 +159,8 @@ class DownloadResult
$this->extract, $this->extract,
$this->verified, $this->verified,
$this->version, $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; $path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} version {$ver} from BitBucket: {$download_url}"); logger()->debug("Downloading {$name} version {$ver} from BitBucket: {$download_url}");
default_shell()->executeCurlDownload($download_url, $path, retries: $downloader->getRetry()); 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 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,14 @@
<?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,
) {}
}

View File

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

@ -10,7 +10,7 @@ use StaticPHP\Exception\DownloaderException;
use StaticPHP\Util\FileSystem; use StaticPHP\Util\FileSystem;
/** git */ /** git */
class Git implements DownloadTypeInterface class Git implements DownloadTypeInterface, CheckUpdateInterface
{ {
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{ {
@ -21,8 +21,10 @@ class Git implements DownloadTypeInterface
// direct branch clone // direct branch clone
if (isset($config['rev'])) { if (isset($config['rev'])) {
default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null); default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null);
$version = "dev-{$config['rev']}"; $hash_result = shell(false)->execWithResult(SPC_GIT_EXEC . ' -C ' . escapeshellarg($path) . ' rev-parse HEAD');
return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version); $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'])) { if (!isset($config['regex'])) {
throw new DownloaderException('Either "rev" or "regex" must be specified for git download type.'); throw new DownloaderException('Either "rev" or "regex" must be specified for git download type.');
@ -64,8 +66,62 @@ class Git implements DownloadTypeInterface
$branch = $matched_version_branch[$version]; $branch = $matched_version_branch[$version];
logger()->info("Matched version {$version} from branch {$branch} for {$name}"); logger()->info("Matched version {$version} from branch {$branch} for {$name}");
default_shell()->executeGitClone($config['url'], $branch, $path, $shallow, $config['submodules'] ?? null); 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)."); 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, 40);
$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_compare($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; use StaticPHP\Exception\DownloaderException;
/** ghrel */ /** ghrel */
class GitHubRelease implements DownloadTypeInterface, ValidatorInterface class GitHubRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpdateInterface
{ {
use GitHubTokenSetupTrait; 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 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 = str_replace('{repo}', $repo, self::API_URL);
$url .= ($query ?? ''); $url .= ($query ?? '');
$headers = $this->getGitHubTokenHeaders(); $headers = $this->getGitHubTokenHeaders();
@ -95,7 +96,7 @@ class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename; $path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} asset from URL: {$asset_url}"); logger()->debug("Downloading {$name} asset from URL: {$asset_url}");
default_shell()->executeCurlDownload($asset_url, $path, headers: $headers, retries: $downloader->getRetry()); 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 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"); logger()->debug("No sha256 digest found for GitHub release asset of {$name}, skipping hash validation");
return true; 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 */ /** ghtar */
/** ghtagtar */ /** ghtagtar */
class GitHubTarball implements DownloadTypeInterface class GitHubTarball implements DownloadTypeInterface, CheckUpdateInterface
{ {
use GitHubTokenSetupTrait; use GitHubTokenSetupTrait;
@ -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); [$url, $filename] = $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name, $config['query'] ?? null);
$path = DOWNLOAD_PATH . "/{$filename}"; $path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, headers: $this->getGitHubTokenHeaders()); 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

@ -26,7 +26,7 @@ class HostedPackageBin implements DownloadTypeInterface
public static function getReleaseInfo(): array public static function getReleaseInfo(): array
{ {
if (empty(self::$release_info)) { if (empty(self::$release_info)) {
$rel = (new GitHubRelease())->getGitHubReleases('hosted', self::BASE_REPO); $rel = new GitHubRelease()->getGitHubReleases('hosted', self::BASE_REPO);
if (empty($rel)) { if (empty($rel)) {
throw new DownloaderException('No releases found for hosted package-bin'); throw new DownloaderException('No releases found for hosted package-bin');
} }
@ -55,7 +55,7 @@ class HostedPackageBin implements DownloadTypeInterface
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename; $path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
$headers = $this->getGitHubTokenHeaders(); $headers = $this->getGitHubTokenHeaders();
default_shell()->executeCurlDownload($download_url, $path, headers: $headers, retries: $downloader->getRetry()); 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}"); 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 public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{ {
logger()->debug("Using local source directory for {$name} from {$config['dirname']}"); 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

@ -9,28 +9,13 @@ use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException; use StaticPHP\Exception\DownloaderException;
/** pie */ /** pie */
class PIE implements DownloadTypeInterface class PIE implements DownloadTypeInterface, CheckUpdateInterface
{ {
public const string PACKAGIST_URL = 'https://repo.packagist.org/p2/'; public const string PACKAGIST_URL = 'https://repo.packagist.org/p2/';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{ {
$packagist_url = self::PACKAGIST_URL . "{$config['repo']}.json"; $first = $this->fetchPackagistInfo($name, $config, $downloader);
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 // get download link from dist
$dist_url = $first['dist']['url'] ?? null; $dist_url = $first['dist']['url'] ?? null;
$dist_type = $first['dist']['type'] ?? null; $dist_type = $first['dist']['type'] ?? null;
@ -42,6 +27,39 @@ class PIE implements DownloadTypeInterface
$filename = "{$name}-{$version}." . ($dist_type === 'zip' ? 'zip' : 'tar.gz'); $filename = "{$name}-{$version}." . ($dist_type === 'zip' ? 'zip' : 'tar.gz');
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename; $path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
default_shell()->executeCurlDownload($dist_url, $path, retries: $downloader->getRetry()); 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, 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 || version_compare($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,7 +8,7 @@ use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult; use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException; 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 PHP_API = 'https://www.php.net/releases/index.php?json&version={version}';
@ -24,16 +24,7 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface
$this->sha256 = null; $this->sha256 = null;
return (new Git())->download($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $downloader); return (new Git())->download($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $downloader);
} }
$info = $this->fetchPhpReleaseInfo($name, $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']; $version = $info['version'];
foreach ($info['source'] as $source) { foreach ($info['source'] as $source) {
if (str_ends_with($source['filename'], '.tar.xz')) { if (str_ends_with($source['filename'], '.tar.xz')) {
@ -49,7 +40,7 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface
logger()->debug("Downloading PHP release {$version} from {$url}"); logger()->debug("Downloading PHP release {$version} from {$url}");
$path = DOWNLOAD_PATH . "/{$filename}"; $path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry()); 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 public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
@ -73,4 +64,41 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface
logger()->debug("SHA256 checksum validated successfully for {$name}."); logger()->debug("SHA256 checksum validated successfully for {$name}.");
return true; 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, $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, 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.");
}
// 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 $info;
}
} }

View File

@ -18,6 +18,6 @@ class Url implements DownloadTypeInterface
logger()->debug("Downloading {$name} from URL: {$url}"); logger()->debug("Downloading {$name} from URL: {$url}");
$version = $config['version'] ?? null; $version = $config['version'] ?? null;
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry()); 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,64 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Artifact\ArtifactDownloader;
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::REQUIRED, 'The name of the artifact(s) to check for updates, comma-separated');
$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)');
// --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
{
$artifacts = parse_comma_list($this->input->getArgument('artifact'));
try {
$downloader = new ArtifactDownloader($this->input->getOptions());
$bare = (bool) $this->getOption('bare');
if ($this->getOption('json')) {
$outputs = [];
foreach ($artifacts as $artifact) {
$result = $downloader->checkUpdate($artifact, bare: $bare);
$outputs[$artifact] = [
'need-update' => $result->needUpdate,
'old' => $result->old,
'new' => $result->new,
];
}
$this->output->writeln(json_encode($outputs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return static::OK;
}
foreach ($artifacts as $artifact) {
$result = $downloader->checkUpdate($artifact, bare: $bare);
if (!$result->needUpdate) {
$this->output->writeln("Artifact <info>{$artifact}</info> is already up to date (version: {$result->new})");
} else {
$this->output->writeln("<comment>Update available for artifact: {$artifact}</comment>");
$this->output->writeln(" Old version: <error>{$result->old}</error>");
$this->output->writeln(" New version: <info>{$result->new}</info>");
}
}
return static::OK;
} catch (SPCException $e) {
$e->setSimpleOutput();
throw $e;
}
}
}

View File

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

View File

@ -29,12 +29,6 @@ class ExceptionHandler
RegistryException::class, RegistryException::class,
]; ];
public const array MINOR_LOG_EXCEPTIONS = [
InterruptException::class,
WrongUsageException::class,
RegistryException::class,
];
/** @var array<string, mixed> Build PHP extra info binding */ /** @var array<string, mixed> Build PHP extra info binding */
private static array $build_php_extra_info = []; private static array $build_php_extra_info = [];
@ -57,10 +51,7 @@ class ExceptionHandler
}; };
self::logError($head_msg); self::logError($head_msg);
// ---------------------------------------- if ($e->isSimpleOutput()) {
$minor_logs = in_array($class, self::MINOR_LOG_EXCEPTIONS, true);
if ($minor_logs) {
return self::getReturnCode($e); return self::getReturnCode($e);
} }
@ -283,6 +274,6 @@ class ExceptionHandler
self::printArrayInfo($info); self::printArrayInfo($info);
} }
self::logError("---------------------------------------------------------\n", color: 'none'); self::logError("-----------------------------------------------------------\n", color: 'none');
} }
} }