Skip to content
38 changes: 32 additions & 6 deletions src/StaticPHP/Artifact/Artifact.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@ class Artifact
/** @var null|callable Bind custom source fetcher callback */
protected mixed $custom_source_callback = null;

/** @var null|string Display label describing where the custom source callback came from */
protected ?string $custom_source_callback_origin = 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, string> Display label per platform describing where the custom binary callback came from */
protected array $custom_binary_callback_origins = [];

/** @var array<string, callable> Bind custom binary check-update callbacks */
protected array $custom_binary_check_update_callbacks = [];

Expand Down Expand Up @@ -285,15 +291,19 @@ public function getDownloadConfig(string $type): mixed
* Get source extraction directory.
*
* Rules:
* 1. If extract is not specified: SOURCE_PATH/{artifact_name}
* 2. If extract is relative path: SOURCE_PATH/{value}
* 3. If extract is absolute path: {value}
* 4. If extract is array (dict): handled by extractor (selective extraction)
* 1. If cache_type is 'local': use the absolute dirname recorded at download time (no symlink/copy).
* 2. If extract is not specified: SOURCE_PATH/{artifact_name}
* 3. If extract is relative path: SOURCE_PATH/{value}
* 4. If extract is absolute path: {value}
* 5. If extract is array (dict): handled by extractor (selective extraction)
*/
public function getSourceDir(): string
{
// Prefer cache extract path, fall back to config
$cache_info = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($this->name);
if (($cache_info['cache_type'] ?? null) === 'local' && isset($cache_info['dirname'])) {
return FileSystem::convertPath($cache_info['dirname']);
}
$extract = is_string($cache_info['extract'] ?? null)
? $cache_info['extract']
: ($this->config['source']['extract'] ?? null);
Expand Down Expand Up @@ -406,17 +416,25 @@ public function getBinaryDir(): ?string

/**
* Set custom source fetcher callback.
*
* @param string $origin Short label shown in progress output (e.g. 'package downloader', 'custom url')
*/
public function setCustomSourceCallback(callable $callback): void
public function setCustomSourceCallback(callable $callback, string $origin = 'package downloader'): void
{
$this->custom_source_callback = $callback;
$this->custom_source_callback_origin = $origin;
}

public function getCustomSourceCallback(): ?callable
{
return $this->custom_source_callback ?? null;
}

public function getCustomSourceCallbackOrigin(): ?string
{
return $this->custom_source_callback_origin;
}

/**
* Set custom source check-update callback.
*/
Expand Down Expand Up @@ -451,11 +469,19 @@ public function emitCustomBinary(): void
*
* @param string $target_os Target OS platform string (e.g. linux-x86_64)
* @param callable $callback Custom binary fetcher callback
* @param string $origin Short label shown in progress output (e.g. 'package downloader')
*/
public function setCustomBinaryCallback(string $target_os, callable $callback): void
public function setCustomBinaryCallback(string $target_os, callable $callback, string $origin = 'package downloader'): void
{
ConfigValidator::validatePlatformString($target_os);
$this->custom_binary_callbacks[$target_os] = $callback;
$this->custom_binary_callback_origins[$target_os] = $origin;
}

public function getCustomBinaryCallbackOrigin(): ?string
{
$current_platform = SystemTarget::getCurrentPlatformString();
return $this->custom_binary_callback_origins[$current_platform] ?? null;
}

/**
Expand Down
45 changes: 36 additions & 9 deletions src/StaticPHP/Artifact/ArtifactDownloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Psr\Log\LogLevel;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Artifact\Downloader\Type\BitBucketTag;
use StaticPHP\Artifact\Downloader\Type\CacheMatchInterface;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateInterface;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\Artifact\Downloader\Type\DownloadTypeInterface;
Expand Down Expand Up @@ -89,6 +90,9 @@ class ArtifactDownloader

private array $_before_files;

/** @var array<string, array> Memoized generateQueue() results, valid for one download() run (queues only depend on each artifact's own cache files) */
private array $queue_memo = [];

/**
* @param array{
* parallel?: int,
Expand Down Expand Up @@ -318,7 +322,12 @@ public function download(): void
if (!is_dir(DOWNLOAD_PATH)) {
FileSystem::createDir(DOWNLOAD_PATH);
}
logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $this->artifacts)) . " with concurrency {$this->parallel} ...");
// fresh memo for this run: queues reflect pre-download cache state
$this->queue_memo = [];
$pending = array_values(array_filter($this->artifacts, fn ($a) => $this->generateQueue($a) !== []));
if ($pending !== []) {
logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $pending)) . " with concurrency {$this->parallel} ...");
}
// Download artifacts parallelly
if ($this->parallel > 1) {
$this->downloadWithConcurrency();
Expand Down Expand Up @@ -573,8 +582,8 @@ private function downloadWithType(Artifact $artifact, int $current, int $total,
$instance = null;
$call = $this->downloaders[$item['config']['type']] ?? null;
$type_display_name = match (true) {
$item['lock'] === 'source' && ($callback = $artifact->getCustomSourceCallback()) !== null => 'user defined source downloader',
$item['lock'] === 'binary' && ($callback = $artifact->getCustomBinaryCallback()) !== null => 'user defined binary downloader',
$item['lock'] === 'source' && $artifact->getCustomSourceCallback() !== null => $artifact->getCustomSourceCallbackOrigin() ?? 'source package downloader',
$item['lock'] === 'binary' && $artifact->getCustomBinaryCallback() !== null => $artifact->getCustomBinaryCallbackOrigin() ?? 'binary package downloader',
default => SPC_DOWNLOAD_TYPE_DISPLAY_NAME[$item['config']['type']] ?? $item['config']['type'],
};
$try_h = $try ? 'Try downloading' : 'Downloading';
Expand Down Expand Up @@ -748,11 +757,29 @@ private function downloadWithConcurrency(): void
*/
private function generateQueue(Artifact $artifact): array
{
$memo_key = $artifact->getName();
if (isset($this->queue_memo[$memo_key])) {
return $this->queue_memo[$memo_key];
}
/** @var array<array{display: string, lock: string, config: array}> $queue */
$queue = [];
$binary_downloaded = $artifact->isBinaryDownloaded(compare_hash: true);
$source_downloaded = $artifact->isSourceDownloaded(compare_hash: true);

// Some download types fetch content depending on request options rather than config alone
// (e.g. php-release varies with --with-php): let them veto a stale cache entry.
// Custom source callbacks carry their own semantics, they bypass type-based checks.
if ($source_downloaded && $artifact->getCustomSourceCallback() === null) {
$source_config = $artifact->getDownloadConfig('source');
$dl_cls = is_array($source_config) ? ($this->downloaders[$source_config['type']] ?? null) : null;
if ($dl_cls !== null && is_a($dl_cls, CacheMatchInterface::class, true)) {
$source_lock = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($artifact->getName()) ?? [];
if (!(new $dl_cls())->cacheMatches($artifact->getName(), $source_config, $source_lock, $this)) {
$source_downloaded = false;
}
}
}

$item_source = ['display' => 'source', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source')];
$item_source_mirror = ['display' => 'source (mirror)', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source-mirror')];

Expand Down Expand Up @@ -802,7 +829,7 @@ private function generateQueue(Artifact $artifact): array
if (empty($queue)) {
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').');
}
return $queue;
return $this->queue_memo[$memo_key] = $queue;
}

// check if already downloaded
Expand All @@ -823,7 +850,7 @@ private function generateQueue(Artifact $artifact): array

// if already downloaded, skip
if ($has_usable_download) {
return [];
return $this->queue_memo[$memo_key] = [];
}

// validate: ensure at least one download source is available
Expand All @@ -838,7 +865,7 @@ private function generateQueue(Artifact $artifact): array
throw new ValidationException("Validation failed: Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').');
}

return $queue;
return $this->queue_memo[$memo_key] = $queue;
}

private function applyCustomDownloads(): void
Expand All @@ -847,21 +874,21 @@ private function applyCustomDownloads(): void
if (isset($this->artifacts[$artifact_name])) {
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $custom_url) {
return (new Url())->download($artifact_name, ['url' => $custom_url], $downloader);
});
}, 'custom url');
}
}
foreach ($this->custom_gits as $artifact_name => [$branch, $git_url]) {
if (isset($this->artifacts[$artifact_name])) {
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $branch, $git_url) {
return (new Git())->download($artifact_name, ['rev' => $branch, 'url' => $git_url], $downloader);
});
}, 'custom git');
}
}
foreach ($this->custom_locals as $artifact_name => $local_path) {
if (isset($this->artifacts[$artifact_name])) {
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $local_path) {
return (new LocalDir())->download($artifact_name, ['dirname' => $local_path], $downloader);
});
}, 'custom local dir');
}
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/StaticPHP/Artifact/ArtifactExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ protected function extractSource(Artifact $artifact): int
throw new WrongUsageException("Artifact source [{$name}] not downloaded, please download it first!");
}

// Local (--custom-local): source lives in place at $cache_info['dirname'].
if (($cache_info['cache_type'] ?? null) === 'local') {
$artifact->emitAfterSourceExtract($artifact->getSourceDir());
return SPC_STATUS_ALREADY_EXTRACTED;
}

$source_file = $this->cache->getCacheFullPath($cache_info);
$target_path = $artifact->getSourceDir();

Expand Down Expand Up @@ -174,8 +180,12 @@ protected function extractSource(Artifact $artifact): int
return SPC_STATUS_ALREADY_EXTRACTED;
}

// Remove old directory if hash mismatch
if (is_dir($target_path)) {
// Remove old directory if hash mismatch.
// Guard: a symlink at $target_path (left over from older local-source handling) must be
// unlinked directly — never recurse into the link target, that would wipe the user's tree.
if (is_link($target_path)) {
@unlink($target_path);
} elseif (is_dir($target_path)) {
logger()->notice("Source [{$name}] hash mismatch, re-extracting...");
FileSystem::removeDir($target_path);
}
Expand Down
31 changes: 31 additions & 0 deletions src/StaticPHP/Artifact/Downloader/Type/CacheMatchInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace StaticPHP\Artifact\Downloader\Type;

use StaticPHP\Artifact\ArtifactDownloader;

/**
* Optional hook for download types whose fetched content depends on downloader options
* rather than only on the artifact config (e.g. php-release varies with --with-php).
* When the download cache reports an artifact as downloaded, generateQueue() asks the
* type whether the cached entry still satisfies the current request; returning false
* forces a re-download.
*
* Currently only wired for source locks: no binary download type varies by request
* options (binary content is expressed by per-platform config keys), but the lock
* entry shape is identical, so binaries can be hooked in later without signature changes.
*/
interface CacheMatchInterface
{
/**
* Check whether a cached lock entry satisfies the current request options.
*
* @param string $name the name of the artifact
* @param array $config the source configuration for the artifact
* @param array $lock_entry the cached lock entry (version, cache_type, ...)
* @param ArtifactDownloader $downloader the artifact downloader instance
*/
public function cacheMatches(string $name, array $config, array $lock_entry, ArtifactDownloader $downloader): bool;
}
15 changes: 14 additions & 1 deletion src/StaticPHP/Artifact/Downloader/Type/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use StaticPHP\Util\FileSystem;

/** git */
class Git implements DownloadTypeInterface, CheckUpdateInterface
class Git implements DownloadTypeInterface, CheckUpdateInterface, CacheMatchInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
Expand Down Expand Up @@ -72,6 +72,19 @@ public function download(string $name, array $config, ArtifactDownloader $downlo
throw new DownloaderException("No matching branch found for regex {$config['regex']} (checked {$matched_count} branches).");
}

public function cacheMatches(string $name, array $config, array $lock_entry, ArtifactDownloader $downloader): bool
{
// The lock hash (rev-parse HEAD) only proves the cached clone is self-consistent;
// a changed url/rev/regex in the config must invalidate it.
$locked = $lock_entry['config'] ?? [];
foreach (['url', 'rev', 'regex'] as $key) {
if (($locked[$key] ?? null) !== ($config[$key] ?? null)) {
return false;
}
}
return true;
}

public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult
{
if (isset($config['rev'])) {
Expand Down
10 changes: 10 additions & 0 deletions src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_
if (!is_array($data)) {
throw new DownloaderException("Failed to get GitHub release API info for {$repo} from {$url}");
}
// GitHub's /releases list is ordered by publish date, so a newer-tagged prerelease
// (e.g. 2.2.x-alpha) can precede the latest stable (2.1.x-stable). /releases/latest
// returns the semantically latest stable release regardless of order; check it first.
if ($prefer_stable) {
$latest_url = str_replace('{repo}', $repo, self::API_URL) . '/latest';
$latest = json_decode(default_shell()->executeCurl($latest_url, headers: $headers, retries: $retries) ?: '', true);
if (is_array($latest) && isset($latest['assets'])) {
array_unshift($data, $latest);
}
}
foreach ($data as $release) {
if ($prefer_stable && $release['prerelease'] === true) {
continue;
Expand Down
21 changes: 20 additions & 1 deletion src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;

class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpdateInterface
class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpdateInterface, CacheMatchInterface
{
public const string DEFAULT_PHP_DOMAIN = 'https://www.php.net';

Expand Down Expand Up @@ -88,6 +88,25 @@ public function checkUpdate(string $name, array $config, ?string $old_version, A
);
}

public function cacheMatches(string $name, array $config, array $lock_entry, ArtifactDownloader $downloader): bool
{
$requested = $downloader->getOption('with-php');
// No explicit version request (option left at its null default): accept whatever
// is cached. The '8.5' default only applies when actually fetching, so a sticky
// cache is never invalidated by a version the user did not ask about.
if ($requested === null || $requested === '' || $requested === false) {
return true;
}
$cached_version = $lock_entry['version'] ?? null;
$cache_type = $lock_entry['cache_type'] ?? null;
if ($requested === 'git') {
return $cache_type === 'git';
}
return $cached_version !== null
&& $cache_type !== 'git'
&& ($cached_version === $requested || str_starts_with($cached_version, $requested . '.'));
}

protected function fetchPhpReleaseInfo(string $name, array $config, ArtifactDownloader $downloader): array
{
$phpver = $downloader->getOption('with-php', '8.5');
Expand Down
9 changes: 8 additions & 1 deletion src/StaticPHP/Artifact/Downloader/Type/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use StaticPHP\Artifact\Downloader\DownloadResult;

/** url */
class Url implements DownloadTypeInterface
class Url implements DownloadTypeInterface, CacheMatchInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
Expand All @@ -20,4 +20,11 @@ public function download(string $name, array $config, ArtifactDownloader $downlo
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class);
}

public function cacheMatches(string $name, array $config, array $lock_entry, ArtifactDownloader $downloader): bool
{
// A changed filename already invalidates via the file-exists check; a changed url
// with an unchanged filename (mirror switch, fixed-name tarball) does not.
return ($lock_entry['config']['url'] ?? null) === ($config['url'] ?? null);
}
}
5 changes: 3 additions & 2 deletions src/StaticPHP/Artifact/DownloaderOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ public static function getConsoleOptions(string $prefix = ''): array
$shortI = $prefix ? null : 'i';

return [
// php version option
new InputOption("{$p}with-php", null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.5)', '8.5'),
// php version option (null default: only enforced against the download cache when
// explicitly given; PhpRelease falls back to the latest 8.5.x when actually fetching)
new InputOption("{$p}with-php", null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.5)'),

// download preference options
new InputOption("{$p}prefer-source", null, InputOption::VALUE_OPTIONAL, 'Prefer source downloads when both source and binary are available', false),
Expand Down
Loading
Loading