99use SPC \exception \SPCException ;
1010use SPC \store \Config ;
1111use SPC \store \Downloader ;
12+ use SPC \store \FileSystem ;
1213use SPC \store \LockFile ;
14+ use SPC \store \source \CustomSourceBase ;
1315use SPC \util \DependencyUtil ;
1416use SPC \util \SPCTarget ;
1517use 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}
0 commit comments