From 3abc193718c8439448a6c8bbd3fd2385e8ffab10 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 18 Jul 2024 23:01:40 +0400 Subject: [PATCH] Added Archive module; add code to download and extract files --- bin/dload | 2 + dload.xml | 6 +- resources/version.json | 3 + src/Command/Get.php | 116 +++++++++++++++--- src/Info.php | 47 +++++++ src/Module/Archive/Archive.php | 16 +++ src/Module/Archive/ArchiveFactory.php | 84 +++++++++++++ src/Module/Archive/Internal/Archive.php | 36 ++++++ src/Module/Archive/Internal/PharArchive.php | 13 ++ .../Archive/Internal/PharAwareArchive.php | 36 ++++++ .../Archive/Internal/TarPharArchive.php | 13 ++ .../Archive/Internal/ZipPharArchive.php | 15 +++ src/Module/Common/Config/Destination.php | 3 - src/Module/Common/Config/Embed/Software.php | 2 + src/Module/Downloader/Downloader.php | 40 +++--- .../Downloader/Internal/DownloadContext.php | 8 ++ src/Module/Downloader/Task/DownloadResult.php | 16 +++ src/Module/Downloader/Task/DownloadTask.php | 3 +- 18 files changed, 418 insertions(+), 41 deletions(-) create mode 100644 resources/version.json create mode 100644 src/Info.php create mode 100644 src/Module/Archive/Archive.php create mode 100644 src/Module/Archive/ArchiveFactory.php create mode 100644 src/Module/Archive/Internal/Archive.php create mode 100644 src/Module/Archive/Internal/PharArchive.php create mode 100644 src/Module/Archive/Internal/PharAwareArchive.php create mode 100644 src/Module/Archive/Internal/TarPharArchive.php create mode 100644 src/Module/Archive/Internal/ZipPharArchive.php create mode 100644 src/Module/Downloader/Task/DownloadResult.php diff --git a/bin/dload b/bin/dload index 10507bc..de5e0ef 100644 --- a/bin/dload +++ b/bin/dload @@ -4,6 +4,7 @@ declare(strict_types=1); use Internal\DLoad\Command; +use Internal\DLoad\Info; use Symfony\Component\Console\Application; use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; @@ -48,5 +49,6 @@ if ('cli' !== PHP_SAPI) { ]), ); $application->setDefaultCommand(Command\Get::getDefaultName(), true); + $application->setVersion(Info::version()); $application->run(); })(); diff --git a/dload.xml b/dload.xml index ce9176c..55ea365 100644 --- a/dload.xml +++ b/dload.xml @@ -3,15 +3,15 @@ - + - + - + diff --git a/resources/version.json b/resources/version.json new file mode 100644 index 0000000..96d9691 --- /dev/null +++ b/resources/version.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/src/Command/Get.php b/src/Command/Get.php index 015780d..250c614 100644 --- a/src/Command/Get.php +++ b/src/Command/Get.php @@ -5,13 +5,16 @@ namespace Internal\DLoad\Command; use Internal\DLoad\Bootstrap; +use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\Config\Destination; +use Internal\DLoad\Module\Common\Config\Embed\File; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Downloader\Downloader; use Internal\DLoad\Module\Downloader\SoftwareCollection; -use Internal\DLoad\Module\Repository\Internal\GitHub\GitHubRepository; +use Internal\DLoad\Module\Downloader\Task\DownloadResult; +use Internal\DLoad\Service\Container; use Internal\DLoad\Service\Logger; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -20,6 +23,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\StyleInterface; +use Symfony\Component\Console\Style\SymfonyStyle; /** * @internal @@ -34,6 +39,8 @@ final class Get extends Command implements SignalableCommandInterface private Logger $logger; + private Container $container; + public function configure(): void { $this->addArgument('binary', InputArgument::REQUIRED, 'Binary name, e.g. "rr", "dolt", "temporal" etc.'); @@ -76,43 +83,120 @@ protected function execute( $output->writeln('Binary to load: ' . $input->getArgument('binary')); $output->writeln('Path to store the binary: ' . $input->getOption('path')); - $container = Bootstrap::init()->withConfig( + $this->container = $container = Bootstrap::init()->withConfig( xml: \dirname(__DIR__, 2) . '/dload.xml', inputOptions: $input->getOptions(), inputArguments: $input->getArguments(), environment: \getenv(), )->finish(); $container->set($input, InputInterface::class); + $container->set(new SymfonyStyle($input, $output), StyleInterface::class); $container->set($this->logger); $output->writeln('Architecture: ' . $container->get(Architecture::class)->name); - $output->writeln('Op. system: ' . $container->get(OperatingSystem::class)->name); - $output->writeln('Stability: ' . $container->get(Stability::class)->name); - + $output->writeln(' Op. system: ' . $container->get(OperatingSystem::class)->name); + $output->writeln(' Stability: ' . $container->get(Stability::class)->name); /** @var SoftwareCollection $softwareCollection */ $softwareCollection = $container->get(SoftwareCollection::class); - + /** @var ArchiveFactory $archiveFactory */ + $archiveFactory = $container->get(ArchiveFactory::class); /** @var Downloader $downloader */ $downloader = $container->get(Downloader::class); + + // / /* $task = $downloader->download( $softwareCollection->findSoftware('rr') ?? throw new \RuntimeException('Software not found.'), - // trap(...), static fn() => null, ); + /*/ + $task = new \Internal\DLoad\Module\Downloader\Task\DownloadTask( + $softwareCollection->findSoftware('rr') ?? throw new \RuntimeException('Software not found.'), + static fn() => null, + fn(): \React\Promise\PromiseInterface => \React\Promise\resolve(new DownloadResult( + new \SplFileInfo('C:\Users\test\AppData\Local\Temp\roadrunner-2024.1.5-windows-amd64.zip'), + '2024.1.5' + )), + ); + //*/ + + ($task->handler)()->then( + function (DownloadResult $downloadResult) use ($task, $archiveFactory, $output): void { + $fileInfo = $downloadResult->file; + $archive = $archiveFactory->create($fileInfo); + $extractor = $archive->extract(); + + while ($extractor->valid()) { + $file = $extractor->current(); + \assert($file instanceof \SplFileInfo); + + $to = $this->shouldBeExtracted($file, $task->software->files); + + if ($to === null || !$this->checkExisting($to)) { + $extractor->next(); + continue; + } + + $extractor->send($to); + + // Success + $path = $to->getRealPath() ?: $to->getPathname(); + $output->writeln(\sprintf( + '%s (%s) has been installed into %s', + $to->getFilename(), + $downloadResult->version, + $path, + )); + + $to->isExecutable() or @\chmod($path, 0755); + } + }, + ); + + return Command::SUCCESS; + } - // $container->get(Destination::class), + /** + * @return bool True if the file should be extracted, false otherwise. + */ + private function checkExisting(\SplFileInfo $bin): bool + { + if (! \is_file($bin->getPathname())) { + return true; + } - ($task->handler)(); + /** @var StyleInterface $io */ + $io = $this->container->get(StyleInterface::class); + $io->warning('File already exists: ' . $bin->getPathname()); + if (!$io->confirm('Do you want overwrite it?', false)) { + $io->note('Skipping ' . $bin->getFilename() . ' installation...'); + return false; + } - // $repo = 'roadrunner-server/roadrunner'; - // trap( - // GitHubRepository::fromDsn($repo)->getReleases()->first()->getAssets() - // ->whereArchitecture($container->get(Architecture::class)) - // ->whereOperatingSystem($container->get(OperatingSystem::class)), - // ); + return true; + } + /** + * @param array $mapping + */ + private function shouldBeExtracted(\SplFileInfo $source, array $mapping): ?\SplFileInfo + { + /** @var Destination $destination */ + $destination = $this->container->get(Destination::class); + $path = $destination->path ?? \getcwd(); + + foreach ($mapping as $conf) { + if (\preg_match($conf->pattern, $source->getFilename())) { + $newName = match(true) { + $conf->rename === null => $source->getFilename(), + $source->getExtension() === '' => $conf->rename, + default => $conf->rename . '.' . $source->getExtension(), + }; + + return new \SplFileInfo($path . DIRECTORY_SEPARATOR . $newName); + } + } - return Command::SUCCESS; + return null; } } diff --git a/src/Info.php b/src/Info.php new file mode 100644 index 0000000..4983905 --- /dev/null +++ b/src/Info.php @@ -0,0 +1,47 @@ + + */ + public function extract(): \Generator; +} diff --git a/src/Module/Archive/ArchiveFactory.php b/src/Module/Archive/ArchiveFactory.php new file mode 100644 index 0000000..e53f3d6 --- /dev/null +++ b/src/Module/Archive/ArchiveFactory.php @@ -0,0 +1,84 @@ + + */ + private array $matchers = []; + + /** + * FactoryTrait constructor. + */ + public function __construct() + { + $this->bootDefaultMatchers(); + } + + public function extend(\Closure $matcher): void + { + \array_unshift($this->matchers, $matcher); + } + + public function create(\SplFileInfo $file): Archive + { + $errors = []; + + foreach ($this->matchers as $matcher) { + try { + if ($archive = $matcher($file)) { + return $archive; + } + } catch (\Throwable $e) { + $errors[] = ' - ' . $e->getMessage(); + continue; + } + } + + $error = \sprintf("Can not open the archive \"%s\":\n%s", $file->getFilename(), \implode(\PHP_EOL, $errors)); + + throw new \InvalidArgumentException($error); + } + + private function bootDefaultMatchers(): void + { + $this->extend($this->matcher( + 'zip', + static fn(\SplFileInfo $info): Archive => new ZipPharArchive($info), + )); + + $this->extend($this->matcher( + 'tar.gz', + static fn(\SplFileInfo $info): Archive => new TarPharArchive($info), + )); + + $this->extend($this->matcher( + 'phar', + static fn(\SplFileInfo $info): Archive => new PharArchive($info), + )); + } + + /** + * @param string $extension + * @param ArchiveMatcher $then + * + * @return ArchiveMatcher + */ + private function matcher(string $extension, \Closure $then): \Closure + { + return static fn(\SplFileInfo $info): ?Archive => + \str_ends_with(\strtolower($info->getFilename()), '.' . $extension) ? $then($info) : null; + } +} diff --git a/src/Module/Archive/Internal/Archive.php b/src/Module/Archive/Internal/Archive.php new file mode 100644 index 0000000..4f6c5c5 --- /dev/null +++ b/src/Module/Archive/Internal/Archive.php @@ -0,0 +1,36 @@ +assertArchiveValid($archive); + } + + /** + * @param \SplFileInfo $archive + */ + private function assertArchiveValid(\SplFileInfo $archive): void + { + if (! $archive->isFile()) { + throw new \InvalidArgumentException( + \sprintf('Archive "%s" is not a file.', $archive->getFilename()), + ); + } + + if (! $archive->isReadable()) { + throw new \InvalidArgumentException( + \sprintf('Archive file "%s" is not readable.', $archive->getFilename()), + ); + } + } +} diff --git a/src/Module/Archive/Internal/PharArchive.php b/src/Module/Archive/Internal/PharArchive.php new file mode 100644 index 0000000..b02d287 --- /dev/null +++ b/src/Module/Archive/Internal/PharArchive.php @@ -0,0 +1,13 @@ +getPathname()); + } +} diff --git a/src/Module/Archive/Internal/PharAwareArchive.php b/src/Module/Archive/Internal/PharAwareArchive.php new file mode 100644 index 0000000..5792054 --- /dev/null +++ b/src/Module/Archive/Internal/PharAwareArchive.php @@ -0,0 +1,36 @@ +archive = $this->open($archive); + } + + public function extract(): \Generator + { + $phar = $this->open($this->archive); + $phar->isReadable() or throw new \LogicException( + \sprintf('Could not open "%s" for reading.', $this->archive->getPathname()), + ); + + /** @var \PharFileInfo $file */ + foreach (new \RecursiveIteratorIterator($phar) as $file) { + /** @var \SplFileInfo|null $fileTo */ + $fileTo = yield $file->getPathname() => $file; + $fileTo instanceof \SplFileInfo and \copy( + $file->getPathname(), + $fileTo->getRealPath() ?: $fileTo->getPathname(), + ); + } + } + + abstract protected function open(\SplFileInfo $file): \PharData; +} diff --git a/src/Module/Archive/Internal/TarPharArchive.php b/src/Module/Archive/Internal/TarPharArchive.php new file mode 100644 index 0000000..98b9e7a --- /dev/null +++ b/src/Module/Archive/Internal/TarPharArchive.php @@ -0,0 +1,13 @@ +getPathname()); + } +} diff --git a/src/Module/Archive/Internal/ZipPharArchive.php b/src/Module/Archive/Internal/ZipPharArchive.php new file mode 100644 index 0000000..436d2d3 --- /dev/null +++ b/src/Module/Archive/Internal/ZipPharArchive.php @@ -0,0 +1,15 @@ +getPathname(), 0, null, $format); + } +} diff --git a/src/Module/Common/Config/Destination.php b/src/Module/Common/Config/Destination.php index f6527c2..c1e2aed 100644 --- a/src/Module/Common/Config/Destination.php +++ b/src/Module/Common/Config/Destination.php @@ -13,7 +13,4 @@ final class Destination { #[InputOption('path')] public ?string $path = null; - - #[InputOption('rename')] - public ?string $rename = null; } diff --git a/src/Module/Common/Config/Embed/Software.php b/src/Module/Common/Config/Embed/Software.php index 733a287..c0894bb 100644 --- a/src/Module/Common/Config/Embed/Software.php +++ b/src/Module/Common/Config/Embed/Software.php @@ -25,9 +25,11 @@ final class Software #[XPath('@description')] public string $description = ''; + /** @var list */ #[XPathEmbedList('repository', Repository::class)] public array $repositories = []; + /** @var list */ #[XPathEmbedList('file', File::class)] public array $files = []; diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 5ca8308..3bbcbdf 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -10,6 +10,7 @@ use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Downloader\Internal\DownloadContext; +use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; use Internal\DLoad\Module\Repository\AssetInterface; use Internal\DLoad\Module\Repository\ReleaseInterface; @@ -61,6 +62,11 @@ public function download( try { await(coroutine($this->processRepository($repository, $context))); + + return new DownloadResult( + file: $context->file, + version: $context->release->getVersion(), + ); } catch (\Throwable $e) { $this->logger->exception($e); yield; @@ -68,8 +74,6 @@ public function download( } finally { $repository instanceof Destroyable and $repository->destroy(); } - - return $context->file; }); }; @@ -95,13 +99,13 @@ private function processRepository(RepositoryInterface $repository, DownloadCont process_release: $releases === [] and throw new \RuntimeException('No relevant release found.'); - $release = \array_shift($releases); + $context->release = \array_shift($releases); - $this->logger->debug('Trying to load release `%s`', $release->getName()); + $this->logger->debug('Trying to load release `%s`', $context->release->getName()); try { - await(coroutine($this->processRelease($release, $context))); - return $release; + await(coroutine($this->processRelease($context))); + return $context->release; } catch (\Throwable $e) { $this->logger->exception($e); goto process_release; @@ -112,11 +116,11 @@ private function processRepository(RepositoryInterface $repository, DownloadCont /** * @return \Closure(): AssetInterface */ - private function processRelease(ReleaseInterface $asset, DownloadContext $context): \Closure + private function processRelease(DownloadContext $context): \Closure { - return function () use ($asset, $context): AssetInterface { + return function () use ($context): AssetInterface { /** @var AssetInterface[] $assets */ - $assets = $asset->getAssets() + $assets = $context->release->getAssets() ->whereArchitecture($this->architecture) ->whereOperatingSystem($this->operatingSystem) ->whereNameMatches($context->repoConfig->assetPattern) @@ -126,11 +130,11 @@ private function processRelease(ReleaseInterface $asset, DownloadContext $contex process_asset: $assets === [] and throw new \RuntimeException('No relevant asset found.'); - $asset = \array_shift($assets); - $this->logger->debug('Trying to load asset `%s`', $asset->getName()); + $context->asset = \array_shift($assets); + $this->logger->debug('Trying to load asset `%s`', $context->asset->getName()); try { - await(coroutine($this->processAsset($asset, $context))); - return $asset; + await(coroutine($this->processAsset($context))); + return $context->asset; } catch (\Throwable $e) { $this->logger->exception($e); goto process_asset; @@ -141,18 +145,18 @@ private function processRelease(ReleaseInterface $asset, DownloadContext $contex /** * @return \Closure(): \SplFileObject */ - private function processAsset(AssetInterface $asset, DownloadContext $context): \Closure + private function processAsset(DownloadContext $context): \Closure { - return function () use ($asset, $context): \SplFileObject { + return function () use ($context): \SplFileObject { // Create a file - $temp = $this->getTempDirectory() . DIRECTORY_SEPARATOR . $asset->getName(); + $temp = $this->getTempDirectory() . DIRECTORY_SEPARATOR . $context->asset->getName(); $file = new \SplFileObject($temp, 'wb+'); $this->logger->debug('Downloading into ' . $temp); await(coroutine( - (static function () use ($asset, $context, $file): void { - $generator = $asset->download( + (static function () use ($context, $file): void { + $generator = $context->asset->download( static fn(int $dlNow, int $dlSize, array $info) => ($context->onProgress)( new Progress( total: $dlSize, diff --git a/src/Module/Downloader/Internal/DownloadContext.php b/src/Module/Downloader/Internal/DownloadContext.php index 00beb44..50b81bc 100644 --- a/src/Module/Downloader/Internal/DownloadContext.php +++ b/src/Module/Downloader/Internal/DownloadContext.php @@ -7,6 +7,8 @@ use Internal\DLoad\Module\Common\Config\Embed\Repository; use Internal\DLoad\Module\Common\Config\Embed\Software; use Internal\DLoad\Module\Downloader\Progress; +use Internal\DLoad\Module\Repository\AssetInterface; +use Internal\DLoad\Module\Repository\ReleaseInterface; final class DownloadContext { @@ -16,6 +18,12 @@ final class DownloadContext /** Downloaded file */ public \SplFileObject $file; + /** Current asset */ + public AssetInterface $asset; + + /** Current release */ + public ReleaseInterface $release; + /** * @param \Closure(Progress): mixed $onProgress Callback to report progress. * Exception thrown in this callback will stop and revert the task. diff --git a/src/Module/Downloader/Task/DownloadResult.php b/src/Module/Downloader/Task/DownloadResult.php new file mode 100644 index 0000000..c7ae061 --- /dev/null +++ b/src/Module/Downloader/Task/DownloadResult.php @@ -0,0 +1,16 @@ + $handler + * @param \Closure(): PromiseInterface $handler */ public function __construct( public readonly Software $software,