Skip to content

Commit 134efa1

Browse files
committed
add --update flag to DownloadCommand to check all downloaded sources if they need an update
1 parent 3c0cc5b commit 134efa1

File tree

5 files changed

+389
-1
lines changed

5 files changed

+389
-1
lines changed

src/SPC/command/DownloadCommand.php

Lines changed: 310 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
use SPC\exception\SPCException;
1010
use SPC\store\Config;
1111
use SPC\store\Downloader;
12+
use SPC\store\FileSystem;
1213
use SPC\store\LockFile;
14+
use SPC\store\source\CustomSourceBase;
1315
use SPC\util\DependencyUtil;
1416
use SPC\util\SPCTarget;
1517
use Symfony\Component\Console\Attribute\AsCommand;
@@ -27,7 +29,7 @@ class DownloadCommand extends BaseCommand
2729

2830
public function configure(): void
2931
{
30-
$this->addArgument('sources', InputArgument::REQUIRED, 'The sources will be compiled, comma separated');
32+
$this->addArgument('sources', InputArgument::OPTIONAL, 'The sources will be compiled, comma separated');
3133
$this->addOption('shallow-clone', null, null, 'Clone shallow');
3234
$this->addOption('with-openssl11', null, null, 'Use openssl 1.1');
3335
$this->addOption('with-php', null, InputOption::VALUE_REQUIRED, 'version in major.minor format, comma-separated for multiple versions (default 8.4)', '8.4');
@@ -43,10 +45,31 @@ public function configure(): void
4345
$this->addOption('retry', 'R', InputOption::VALUE_REQUIRED, 'Set retry time when downloading failed (default: 0)', '0');
4446
$this->addOption('prefer-pre-built', 'P', null, 'Download pre-built libraries when available');
4547
$this->addOption('no-alt', null, null, 'Do not download alternative sources');
48+
$this->addOption('update', null, null, 'Check and update downloaded sources');
4649
}
4750

4851
public function initialize(InputInterface $input, OutputInterface $output): void
4952
{
53+
// mode: --update
54+
if ($input->getOption('update') && empty($input->getArgument('sources')) && empty($input->getOption('for-extensions')) && empty($input->getOption('for-libs'))) {
55+
if (!file_exists(LockFile::LOCK_FILE)) {
56+
parent::initialize($input, $output);
57+
return;
58+
}
59+
$lock_content = json_decode(file_get_contents(LockFile::LOCK_FILE), true);
60+
if (is_array($lock_content)) {
61+
// Filter out pre-built sources
62+
$sources_to_check = array_filter($lock_content, function ($name) {
63+
return
64+
!str_contains($name, '-Linux-') &&
65+
!str_contains($name, '-Windows-') &&
66+
!str_contains($name, '-Darwin-');
67+
});
68+
$input->setArgument('sources', implode(',', array_keys($sources_to_check)));
69+
}
70+
parent::initialize($input, $output);
71+
return;
72+
}
5073
// mode: --all
5174
if ($input->getOption('all')) {
5275
$input->setArgument('sources', implode(',', array_keys(Config::getSources())));
@@ -94,6 +117,10 @@ public function handle(): int
94117
return $this->downloadFromZip($path);
95118
}
96119

120+
if ($this->getOption('update')) {
121+
return $this->handleUpdate();
122+
}
123+
97124
// Define PHP major version(s)
98125
$php_versions_str = $this->getOption('with-php');
99126
$php_versions = array_map('trim', explode(',', $php_versions_str));
@@ -393,4 +420,286 @@ private function _clean(): int
393420
}
394421
return static::FAILURE;
395422
}
423+
424+
private function handleUpdate(): int
425+
{
426+
logger()->info('Checking sources for updates...');
427+
428+
// Get lock file content
429+
$lock_file_path = LockFile::LOCK_FILE;
430+
if (!file_exists($lock_file_path)) {
431+
logger()->warning('No lock file found. Please download sources first using "bin/spc download"');
432+
return static::FAILURE;
433+
}
434+
435+
$lock_content = json_decode(file_get_contents($lock_file_path), true);
436+
if ($lock_content === null || !is_array($lock_content)) {
437+
logger()->error('Failed to parse lock file');
438+
return static::FAILURE;
439+
}
440+
441+
// Filter sources to check
442+
$sources_arg = $this->getArgument('sources');
443+
if (!empty($sources_arg)) {
444+
$requested_sources = array_map('trim', array_filter(explode(',', $sources_arg)));
445+
$sources_to_check = [];
446+
foreach ($requested_sources as $source) {
447+
if (isset($lock_content[$source])) {
448+
$sources_to_check[$source] = $lock_content[$source];
449+
} else {
450+
logger()->warning("Source '{$source}' not found in lock file, skipping");
451+
}
452+
}
453+
} else {
454+
$sources_to_check = $lock_content;
455+
}
456+
457+
// Filter out pre-built sources (they are derivatives)
458+
$sources_to_check = array_filter($sources_to_check, function ($lock_item, $name) {
459+
// Skip pre-built sources (they contain OS/arch in the name)
460+
if (str_contains($name, '-Linux-') || str_contains($name, '-Windows-') || str_contains($name, '-Darwin-')) {
461+
logger()->debug("Skipping pre-built source: {$name}");
462+
return false;
463+
}
464+
return true;
465+
}, ARRAY_FILTER_USE_BOTH);
466+
467+
if (empty($sources_to_check)) {
468+
logger()->warning('No sources to check');
469+
return static::FAILURE;
470+
}
471+
472+
$total = count($sources_to_check);
473+
$current = 0;
474+
$updated_sources = [];
475+
476+
foreach ($sources_to_check as $name => $lock_item) {
477+
++$current;
478+
try {
479+
// Handle version-specific php-src (php-src-8.2, php-src-8.3, etc.)
480+
if (preg_match('/^php-src-[\d.]+$/', $name)) {
481+
$config = Config::getSource('php-src');
482+
} else {
483+
$config = Config::getSource($name);
484+
}
485+
486+
if ($config === null) {
487+
logger()->warning("[{$current}/{$total}] Source '{$name}' not found in source config, skipping");
488+
continue;
489+
}
490+
491+
// Check and update based on source type
492+
$source_type = $lock_item['source_type'] ?? 'unknown';
493+
494+
if ($source_type === SPC_SOURCE_ARCHIVE) {
495+
if ($this->checkArchiveSourceUpdate($name, $lock_item, $config, $current, $total)) {
496+
$updated_sources[] = $name;
497+
}
498+
} elseif ($source_type === SPC_SOURCE_GIT) {
499+
if ($this->checkGitSourceUpdate($name, $lock_item, $config, $current, $total)) {
500+
$updated_sources[] = $name;
501+
}
502+
} elseif ($source_type === SPC_SOURCE_LOCAL) {
503+
logger()->debug("[{$current}/{$total}] Source '{$name}' is local, skipping");
504+
} else {
505+
logger()->warning("[{$current}/{$total}] Unknown source type '{$source_type}' for '{$name}', skipping");
506+
}
507+
} catch (\Throwable $e) {
508+
logger()->error("[{$current}/{$total}] Error checking '{$name}': {$e->getMessage()}");
509+
continue;
510+
}
511+
}
512+
513+
// Output summary
514+
if (empty($updated_sources)) {
515+
logger()->info('All sources are up to date.');
516+
} else {
517+
logger()->info('Updated sources: ' . implode(', ', $updated_sources));
518+
519+
// Write updated sources to file
520+
$date = date('Y-m-d');
521+
$update_file = DOWNLOAD_PATH . '/.update-' . $date . '.txt';
522+
$content = implode(',', $updated_sources);
523+
file_put_contents($update_file, $content);
524+
logger()->debug("Updated sources written to: {$update_file}");
525+
}
526+
527+
return static::SUCCESS;
528+
}
529+
530+
private function checkCustomSourceUpdate(string $name, array $lock, array $config, int $current, int $total): bool
531+
{
532+
$classes = FileSystem::getClassesPsr4(ROOT_DIR . '/src/SPC/store/source', 'SPC\store\source');
533+
foreach ($classes as $class) {
534+
// Support php-src and php-src-X.Y patterns
535+
$matches = ($class::NAME === $name) ||
536+
($class::NAME === 'php-src' && preg_match('/^php-src(-[\d.]+)?$/', $name));
537+
if (is_a($class, CustomSourceBase::class, true) && $matches) {
538+
try {
539+
$config['source_name'] = $name;
540+
$updated = (new $class())->update($lock, $config);
541+
if ($updated) {
542+
logger()->info("[{$current}/{$total}] Source '{$name}' updated");
543+
} else {
544+
logger()->info("[{$current}/{$total}] Source '{$name}' is up to date");
545+
}
546+
return $updated;
547+
} catch (\Throwable $e) {
548+
logger()->warning("[{$current}/{$total}] Failed to check '{$name}': {$e->getMessage()}");
549+
return false;
550+
}
551+
}
552+
}
553+
logger()->warning("[{$current}/{$total}] Custom source handler for '{$name}' not found");
554+
return false;
555+
}
556+
557+
/**
558+
* Check and update an archive source
559+
*
560+
* @param string $name Source name
561+
* @param array $lock Lock file entry
562+
* @param array $config Source configuration
563+
* @param int $current Current progress number
564+
* @param int $total Total sources to check
565+
* @return bool True if updated, false otherwise
566+
*/
567+
private function checkArchiveSourceUpdate(string $name, array $lock, array $config, int $current, int $total): bool
568+
{
569+
$type = $config['type'] ?? 'unknown';
570+
$locked_filename = $lock['filename'] ?? '';
571+
572+
// Skip local types that don't support version detection
573+
if (in_array($type, ['url', 'local', 'unknown'])) {
574+
logger()->debug("[{$current}/{$total}] Source '{$name}' (type: {$type}) doesn't support version detection, skipping");
575+
return false;
576+
}
577+
578+
try {
579+
// Get latest version info
580+
$latest_info = match ($type) {
581+
'ghtar' => Downloader::getLatestGithubTarball($name, $config),
582+
'ghtagtar' => Downloader::getLatestGithubTarball($name, $config, 'tags'),
583+
'ghrel' => Downloader::getLatestGithubRelease($name, $config),
584+
'pie' => Downloader::getPIEInfo($name, $config),
585+
'bitbuckettag' => Downloader::getLatestBitbucketTag($name, $config),
586+
'filelist' => Downloader::getFromFileList($name, $config),
587+
'url' => Downloader::getLatestUrlInfo($name, $config),
588+
'custom' => $this->checkCustomSourceUpdate($name, $lock, $config, $current, $total),
589+
default => null,
590+
};
591+
592+
if ($latest_info === null) {
593+
logger()->warning("[{$current}/{$total}] Could not get version info for '{$name}' (type: {$type})");
594+
return false;
595+
}
596+
597+
$latest_filename = $latest_info[1] ?? '';
598+
599+
// Compare filenames
600+
if ($locked_filename !== $latest_filename) {
601+
logger()->info("[{$current}/{$total}] Update available for '{$name}': {$locked_filename}{$latest_filename}");
602+
$this->downloadSourceForUpdate($name, $config, $current, $total);
603+
return true;
604+
}
605+
606+
logger()->info("[{$current}/{$total}] Source '{$name}' is up to date");
607+
return false;
608+
} catch (DownloaderException $e) {
609+
logger()->warning("[{$current}/{$total}] Failed to check '{$name}': {$e->getMessage()}");
610+
return false;
611+
}
612+
}
613+
614+
/**
615+
* Check and update a git source
616+
*
617+
* @param string $name Source name
618+
* @param array $lock Lock file entry
619+
* @param array $config Source configuration
620+
* @param int $current Current progress number
621+
* @param int $total Total sources to check
622+
* @return bool True if updated, false otherwise
623+
*/
624+
private function checkGitSourceUpdate(string $name, array $lock, array $config, int $current, int $total): bool
625+
{
626+
$locked_hash = $lock['hash'] ?? '';
627+
$url = $config['url'] ?? '';
628+
$branch = $config['rev'] ?? 'main';
629+
630+
if (empty($url)) {
631+
logger()->warning("[{$current}/{$total}] No URL found for git source '{$name}'");
632+
return false;
633+
}
634+
635+
try {
636+
$remote_hash = $this->getRemoteGitCommit($url, $branch);
637+
638+
if ($remote_hash === null) {
639+
logger()->warning("[{$current}/{$total}] Could not fetch remote commit for '{$name}'");
640+
return false;
641+
}
642+
643+
// Compare hashes (use first 7 chars for display)
644+
$locked_short = substr($locked_hash, 0, 7);
645+
$remote_short = substr($remote_hash, 0, 7);
646+
647+
if ($locked_hash !== $remote_hash) {
648+
logger()->info("[{$current}/{$total}] Update available for '{$name}': {$locked_short}{$remote_short}");
649+
$this->downloadSourceForUpdate($name, $config, $current, $total);
650+
return true;
651+
}
652+
653+
logger()->info("[{$current}/{$total}] Source '{$name}' is up to date");
654+
return false;
655+
} catch (\Throwable $e) {
656+
logger()->warning("[{$current}/{$total}] Failed to check '{$name}': {$e->getMessage()}");
657+
return false;
658+
}
659+
}
660+
661+
/**
662+
* Download a source after removing old lock entry
663+
*
664+
* @param string $name Source name
665+
* @param array $config Source configuration
666+
* @param int $current Current progress number
667+
* @param int $total Total sources to check
668+
*/
669+
private function downloadSourceForUpdate(string $name, array $config, int $current, int $total): void
670+
{
671+
logger()->info("[{$current}/{$total}] Downloading '{$name}'...");
672+
673+
// Remove old lock entry (this triggers cleanup of old files)
674+
LockFile::put($name, null);
675+
676+
// Download new version
677+
Downloader::downloadSource($name, $config, true);
678+
}
679+
680+
/**
681+
* Get remote git commit hash without cloning
682+
*
683+
* @param string $url Git repository URL
684+
* @param string $branch Branch or tag to check
685+
* @return null|string Remote commit hash or null on failure
686+
*/
687+
private function getRemoteGitCommit(string $url, string $branch): ?string
688+
{
689+
try {
690+
$cmd = SPC_GIT_EXEC . ' ls-remote ' . escapeshellarg($url) . ' ' . escapeshellarg($branch);
691+
f_exec($cmd, $output, $ret);
692+
693+
if ($ret !== 0 || empty($output)) {
694+
return null;
695+
}
696+
697+
// Output format: "commit_hash\trefs/heads/branch" or "commit_hash\tHEAD"
698+
$parts = preg_split('/\s+/', $output[0]);
699+
return $parts[0] ?? null;
700+
} catch (\Throwable $e) {
701+
logger()->debug("Failed to fetch remote git commit: {$e->getMessage()}");
702+
return null;
703+
}
704+
}
396705
}

src/SPC/store/Downloader.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,39 @@ public static function getFromFileList(string $name, array $source): array
220220
return [$source['url'] . end($versions), end($versions), key($versions)];
221221
}
222222

223+
/**
224+
* Get latest version from direct URL (detect redirect and filename)
225+
*
226+
* @param string $name Source name
227+
* @param array $source Source meta info: [url]
228+
* @return array<int, string> [url, filename]
229+
*/
230+
public static function getLatestUrlInfo(string $name, array $source): array
231+
{
232+
logger()->debug("finding {$name} source from direct url");
233+
$url = $source['url'];
234+
$headers = self::curlExec(
235+
url: $url,
236+
method: 'HEAD',
237+
retries: self::getRetryAttempts()
238+
);
239+
240+
// Find redirect location if any
241+
if (preg_match('/^location:\s+(?<url>.+)$/im', $headers, $matches)) {
242+
$url = trim($matches['url']);
243+
// If it's a relative URL, we need to handle it, but usually it's absolute for downloads
244+
}
245+
246+
// Find filename from content-disposition
247+
if (preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?<filename>.+)\1/im', $headers, $matches)) {
248+
$filename = trim($matches['filename']);
249+
} else {
250+
$filename = $source['filename'] ?? basename($url);
251+
}
252+
253+
return [$url, $filename];
254+
}
255+
223256
/**
224257
* Download file from URL
225258
*

0 commit comments

Comments
 (0)