diff --git a/.gitattributes b/.gitattributes index 4b38854..e027ad7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -20,3 +20,7 @@ /src/** -export-ignore /bin -export-ignore /bin/** -export-ignore + +# The empty DLoad config passed to `dload get --config`. +/resources -export-ignore +/resources/** -export-ignore diff --git a/README.md b/README.md index 83b461e..da4b6b1 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,16 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur ### Common options -The binary commands accept `--filter` (`-f`, version constraint), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. Releases are fetched from the GitHub API; set the `GITHUB_TOKEN` environment variable to avoid its rate limits, and `RR_GITHUB_API_URL` to use another API endpoint (GitHub Enterprise, a mirror; defaults to `https://api.github.com`). +The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` `*` means `>=3.0 <2000` and any other `||` alternative without an upper bound gets `<2000`, so the calendar releases have to be named explicitly, e.g. `2025.*` or `>=2025`), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. `get-binary` and `download-protoc-binary` download the binaries with [DLoad](https://github.com/php-internal/dload); a project's `dload.xml` does not affect them. + +On a musl host such as Alpine they install the musl build of Linux on their own, even with `--os=linux`; `--os=unknown-musl` forces the musl build from any host. + +DLoad comes with its own `vendor/bin/dload` and keeps a version registry in the user cache: `~/.cache/dload`, `$XDG_CACHE_HOME/dload` when that is set, `%LOCALAPPDATA%\dload\cache` on Windows. `DLOAD_CACHE_DIR` moves it, `DLOAD_CACHE_TTL=0` turns it off. + +Releases are fetched from the GitHub API at `RR_GITHUB_API_URL`, `https://api.github.com` by default. For `get-binary` and `download-protoc-binary` it is either `https://api.github.com` or a GitHub Enterprise Server API URL ending with `/api/v3` (e.g. `https://ghe.example.com/api/v3`); `versions` also accepts a mirror. + +All commands pick the API token the same way, to avoid the rate limits: + +- the public GitHub gets `DLOAD_TOKEN_GITHUB_COM`, or `GITHUB_TOKEN` when that one is not set; +- any other server gets only its own `DLOAD_TOKEN_`: the host upper-cased, `.` as `_`, `-` as `__`, a non-default port after `___` (e.g. `DLOAD_TOKEN_GHE_EXAMPLE_COM`, `DLOAD_TOKEN_GHE_EXAMPLE_COM___8443`); `GITHUB_TOKEN` never leaves the public GitHub; +- over plain `http` a token is sent only to a loopback host, and it is never carried over a redirect to another host. diff --git a/composer.json b/composer.json index e2de837..03816db 100644 --- a/composer.json +++ b/composer.json @@ -39,13 +39,12 @@ ], "require": { "php": ">=8.2", - "ext-json": "*", "composer-runtime-api": "^2.0", "composer/semver": "^3.4", + "internal/dload": "^1.19.0", "roadrunner/worker": "^3.8", - "spiral/tokenizer": "^2.13 || ^3.15", - "symfony/console": "^5.3 || ^6.0 || ^7.0 || ^8.0", - "symfony/http-client": "^4.4.51 || ^5.4.49 || ^6.4.17 || ^7.2 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/http-client": "^6.4.17 || ^7.2 || ^8.0", "symfony/yaml": "^5.4.49 || ^6.4.17 || ^7.2 || ^8.0" }, "require-dev": { diff --git a/psalm.xml b/psalm.xml index faa1d08..ea1d20b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,15 +8,13 @@ resolveFromConfigFile="true" findUnusedPsalmSuppress="true" findUnusedBaselineEntry="true" - findUnusedCode="false" + findUnusedCode="true" findUnusedVariablesAndParams="true" ensureArrayStringOffsetsExist="true" addParamDefaultToDocblockType="true" > - - diff --git a/resources/dload.xml b/resources/dload.xml new file mode 100644 index 0000000..4b55865 --- /dev/null +++ b/resources/dload.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/Archive/Archive.php b/src/Archive/Archive.php deleted file mode 100644 index 57f70e2..0000000 --- a/src/Archive/Archive.php +++ /dev/null @@ -1,35 +0,0 @@ -assertArchiveValid($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/Archive/ArchiveInterface.php b/src/Archive/ArchiveInterface.php deleted file mode 100644 index a4e4137..0000000 --- a/src/Archive/ArchiveInterface.php +++ /dev/null @@ -1,21 +0,0 @@ - $mappings - * @return \Generator - */ - public function extract(iterable $mappings): \Generator; -} diff --git a/src/Archive/Factory.php b/src/Archive/Factory.php deleted file mode 100644 index 0d92588..0000000 --- a/src/Archive/Factory.php +++ /dev/null @@ -1,126 +0,0 @@ - - */ - private array $matchers = []; - - /** - * FactoryTrait constructor. - */ - public function __construct() - { - $this->bootDefaultMatchers(); - } - - #[\Override] - public function extend(\Closure $matcher): self - { - \array_unshift($this->matchers, $matcher); - - return $this; - } - - #[\Override] - public function create(\SplFileInfo $file): ArchiveInterface - { - $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":%s', $file->getFilename(), \PHP_EOL) . - \implode(\PHP_EOL, $errors) - ; - - throw new \InvalidArgumentException($error); - } - - #[\Override] - public function fromAsset(AssetInterface $asset, ?\Closure $progress = null, ?string $temp = null): ArchiveInterface - { - $temp = $this->getTempDirectory($temp) . '/' . $asset->getName(); - - $file = new \SplFileObject($temp, 'wb+'); - - try { - foreach ($asset->download($progress) as $chunk) { - $file->fwrite($chunk); - } - } catch (\Throwable $e) { - @\unlink($temp); - - throw $e; - } - - return $this->create($file); - } - - private function bootDefaultMatchers(): void - { - $this->extend($this->matcher( - 'zip', - static fn(\SplFileInfo $info): ArchiveInterface => new ZipPharArchive($info), - )); - - $this->extend($this->matcher( - 'tar.gz', - static fn(\SplFileInfo $info): ArchiveInterface => new TarPharArchive($info), - )); - - $this->extend($this->matcher( - 'phar', - static fn(\SplFileInfo $info): ArchiveInterface => new PharArchive($info), - )); - } - - /** - * @param ArchiveMatcher $then - * @return ArchiveMatcher - */ - private function matcher(string $extension, \Closure $then): \Closure - { - return static fn(\SplFileInfo $info): ?ArchiveInterface => - \str_ends_with(\strtolower($info->getFilename()), '.' . $extension) ? $then($info) : null - ; - } - - private function getTempDirectory(?string $temp): string - { - if ($temp !== null && $temp !== '') { - if (! \is_dir($temp) || ! \is_writable($temp)) { - throw new \LogicException(\sprintf('Directory "%s" is not writeable', $temp)); - } - - return $temp; - } - - return \sys_get_temp_dir(); - } -} diff --git a/src/Archive/FactoryInterface.php b/src/Archive/FactoryInterface.php deleted file mode 100644 index fa6d215..0000000 --- a/src/Archive/FactoryInterface.php +++ /dev/null @@ -1,30 +0,0 @@ -getPathname()); - } -} diff --git a/src/Archive/PharAwareArchive.php b/src/Archive/PharAwareArchive.php deleted file mode 100644 index 3fc3c4d..0000000 --- a/src/Archive/PharAwareArchive.php +++ /dev/null @@ -1,49 +0,0 @@ -archive = $this->open($archive); - } - - /** - * @param iterable $mappings - * @return \Generator - */ - #[\Override] - public function extract(iterable $mappings): \Generator - { - $phar = $this->open($this->archive); - - if (! $phar->isReadable()) { - throw new \LogicException(\sprintf('Could not open "%s" for reading', $this->archive->getPathname())); - } - - /** @var \PharFileInfo $file */ - foreach (new \RecursiveIteratorIterator($phar) as $file) { - foreach ($mappings as $from => $to) { - if ($file->getFilename() === $from && (yield $file => new \SplFileInfo($to)) !== false) { - \copy($file->getPathname(), $to); - } - } - } - } - - abstract protected function open(\SplFileInfo $file): \PharData; -} diff --git a/src/Archive/TarPharArchive.php b/src/Archive/TarPharArchive.php deleted file mode 100644 index 9b1f570..0000000 --- a/src/Archive/TarPharArchive.php +++ /dev/null @@ -1,21 +0,0 @@ -getPathname()); - } -} diff --git a/src/Archive/ZipPharArchive.php b/src/Archive/ZipPharArchive.php deleted file mode 100644 index 1f68c86..0000000 --- a/src/Archive/ZipPharArchive.php +++ /dev/null @@ -1,23 +0,0 @@ -getPathname(), 0, null, $format); - } -} diff --git a/src/Command.php b/src/Command.php deleted file mode 100644 index 27e726a..0000000 --- a/src/Command.php +++ /dev/null @@ -1,76 +0,0 @@ - \array_filter([ - 'authorization' => $token !== null && $token !== '' ? 'token ' . $token : null, - ]), - ]); - - return new RepositoriesCollection([ - GitHubRepository::create( - 'roadrunner-server', - 'roadrunner', - $client, - $apiUrl !== null && $apiUrl !== '' ? $apiUrl : GitHubRepository::DEFAULT_API_URL, - ), - ]); - } - - protected function io(InputInterface $input, OutputInterface $output): StyleInterface - { - return new SymfonyStyle($input, $output); - } - - protected function confirm(InputInterface $input, OutputInterface $out, string $message): bool - { - $question = new ConfirmationQuestion($message); - - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - - return (bool) $helper->ask($input, $out, $question); - } -} diff --git a/src/Command/Option.php b/src/Command/Option.php deleted file mode 100644 index 583f65f..0000000 --- a/src/Command/Option.php +++ /dev/null @@ -1,66 +0,0 @@ -name = $name; - - $this->register($command, $name, $short ?? $name); - } - - #[\Override] - public function getName(): string - { - return $this->name; - } - - #[\Override] - public function get(InputInterface $input, StyleInterface $io): string - { - return self::toString($input->getOption($this->name) ?: $this->default()); - } - - /** - * @return InputOptionType - */ - protected function getMode(): int - { - return InputOption::VALUE_OPTIONAL; - } - - abstract protected function getDescription(): string; - - abstract protected function default(): ?string; - - private static function toString(mixed $value): string - { - return \is_string($value) ? $value : ''; - } - - private function register(Command $command, string $name, string $short): void - { - $command->addOption($name, $short, $this->getMode(), $this->getDescription(), $this->default()); - } -} diff --git a/src/Command/OptionInterface.php b/src/Command/OptionInterface.php deleted file mode 100644 index f84098d..0000000 --- a/src/Command/OptionInterface.php +++ /dev/null @@ -1,22 +0,0 @@ - $release->getVersion(), - $releases->toArray(), - ); - - return \implode(', ', \array_unique($versions)); - } - - public function find(InputInterface $input, StyleInterface $io, RepositoryInterface $repo): ReleasesCollection - { - $constraint = $this->get($input, $io); - - // All available releases - $available = $repo->getReleases() - ->sortByVersion() - ; - - // With constraints - $filtered = $available->satisfies($constraint); - - $this->validateNotEmpty($filtered, $available, $constraint); - - return $filtered; - } - - #[\Override] - protected function getDescription(): string - { - return 'Required version of RoadRunner binaries'; - } - - #[\Override] - protected function default(): string - { - return RoadRunnerVersion::constraint(); - } - - private function validateNotEmpty(ReleasesCollection $filtered, ReleasesCollection $all, string $constraint): void - { - if ($filtered->empty()) { - $header = 'Could not find any available RoadRunner binary version which meets version criterion (--%s=%s)'; - $header = \sprintf($header, $this->name, $constraint); - - $footer = 'Available: ' . $this->choices($all); - - throw new \UnexpectedValueException(\implode("\n", [$header, $footer])); - } - } -} diff --git a/src/Configuration/Plugins.php b/src/Configuration/Plugins.php deleted file mode 100644 index e63cdac..0000000 --- a/src/Configuration/Plugins.php +++ /dev/null @@ -1,124 +0,0 @@ -> - * - * Default plugins in a class-string format. - */ - private array $defaultPlugins = [ - Version::class, - Rpc::class, - Server::class, - Http::class, - Jobs::class, - Kv::class, - Metrics::class, - ]; - - /** - * @var string[] - * - * Requested plugins in a shortname format. - */ - private array $requestedPlugins; - - /** - * @psalm-var list> - * - * All plugins. - */ - private array $available; - - /** - * @param string[] $plugins - */ - private function __construct(array $plugins) - { - $this->available = $this->getAvailable(); - $this->requestedPlugins = $plugins; - } - - /** - * @param string[] $plugins Plugin short names. - */ - public static function fromPlugins(array $plugins): self - { - return new self($plugins); - } - - public static function fromPreset(string $preset): self - { - $plugins = []; - switch ($preset) { - case Presets::WEB_PRESET_NANE: - $plugins = Presets::WEB_PLUGINS; - } - - return new self(\array_map(static function (string $plugin) { - return $plugin::getShortName(); - }, $plugins)); - } - - /** - * @return list> - */ - public function getPlugins(): array - { - if ($this->requestedPlugins === []) { - return $this->defaultPlugins; - } - - $plugins = []; - foreach ($this->available as $plugin) { - if (\in_array($plugin::getShortName(), $this->requestedPlugins, true)) { - $plugins[] = $plugin; - } - } - - return $plugins; - } - - /** - * @return list> - */ - private function getAvailable(): array - { - $finder = new Finder(); - $finder->files()->in(__DIR__ . '/Section')->name('*.php'); - - $locator = new ClassLocator($finder); - - $available = []; - foreach ($locator->getClasses() as $class) { - if ($this->isPlugin($class)) { - /** @var class-string $name */ - $name = $class->getName(); - $available[] = $name; - } - } - - return $available; - } - - private function isPlugin(\ReflectionClass $class): bool - { - return $class->implementsInterface(SectionInterface::class) && !$class->isAbstract() && !$class->isInterface(); - } -} diff --git a/src/Configuration/Presets.php b/src/Configuration/Presets.php deleted file mode 100644 index 13c2625..0000000 --- a/src/Configuration/Presets.php +++ /dev/null @@ -1,17 +0,0 @@ -downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'download-protoc-binary'); $this->os = new OperatingSystemOption($this); @@ -53,94 +52,42 @@ public function getDescription(): string #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - $io = $this->io($input, $output); + $io = new SymfonyStyle($input, $output); $target = $this->location->get($input, $io); - $repository = $this->getRepository(); + $os = $this->os->get($input, $io); $output->writeln(''); $output->writeln(' Environment:'); $output->writeln(\sprintf(' - Version: %s', $this->version->get($input, $io))); $output->writeln(\sprintf(' - Stability: %s', $this->stability->get($input, $io))); - $output->writeln(\sprintf(' - Operating System: %s', $this->os->get($input, $io))); + $output->writeln(\sprintf(' - Operating System: %s', $os)); $output->writeln(\sprintf(' - Architecture: %s', $this->arch->get($input, $io))); $output->writeln(''); - // List of all available releases - $releases = $this->version->find($input, $io, $repository); - - /** - * @var AssetInterface $asset - * @var ReleaseInterface $release - */ - [$asset, $release] = $this->findAsset($repository, $releases, $input, $io); - - // Installation - $output->writeln( - \sprintf(" - %s", $release->getRepositoryName()) . - \sprintf(' (%s):', $release->getVersion()) . - ' Downloading...', - ); - - if ($output->isVerbose()) { - $output->writeln(\sprintf(" -- %s", $asset->getName())); - } - - // Install rr binary - $file = $this->installBinary($target, $release, $asset, $io, $output); + $binary = $target . ($os === OperatingSystem::OS_WINDOWS ? '/protoc-gen-php-grpc.exe' : '/protoc-gen-php-grpc'); - // Success - if ($file === null) { + if (!$this->checkExisting($binary, $io)) { $io->warning('protoc-gen-php-grpc has not been installed'); return 1; } - return 0; - } - - private function installBinary( - string $target, - ReleaseInterface $release, - AssetInterface $asset, - StyleInterface $io, - OutputInterface $out, - ): ?\SplFileInfo { - $extractor = $this->assetToArchive($asset, $out) - ->extract([ - 'protoc-gen-php-grpc.exe' => $target . '/protoc-gen-php-grpc.exe', - 'protoc-gen-php-grpc' => $target . '/protoc-gen-php-grpc', - ]); - - $file = null; - while ($extractor->valid()) { - $file = $extractor->current(); - \assert($file instanceof \SplFileInfo); - - if (!$this->checkExisting($file, $io)) { - $extractor->send(false); - continue; - } - - // Success - $path = $file->getRealPath() ?: $file->getPathname(); - $message = 'protoc-gen-php-grpc (%s) has been installed into %s'; - $message = \sprintf($message, $release->getVersion(), $path); - $out->writeln($message); - - $extractor->next(); - - if (!$file->isExecutable()) { - @\chmod($file->getRealPath(), 0755); - } - } - - return $file; + return $this->downloader->download( + software: 'protoc-gen-php-grpc', + constraint: $this->version->get($input, $io), + stability: $this->stability->get($input, $io), + os: $os, + arch: $this->arch->get($input, $io), + location: $target, + force: true, + output: $output, + ); } - private function checkExisting(\SplFileInfo $bin, StyleInterface $io): bool + private function checkExisting(string $binary, StyleInterface $io): bool { - if (\is_file($bin->getPathname())) { + if (\is_file($binary)) { $io->warning('protoc-gen-php-grpc binary file already exists!'); if (!$io->confirm('Do you want overwrite it?', false)) { @@ -152,86 +99,4 @@ private function checkExisting(\SplFileInfo $bin, StyleInterface $io): bool return true; } - - private function findAsset( - RepositoryInterface $repo, - ReleasesCollection $releases, - InputInterface $in, - StyleInterface $io, - ): array { - $osOption = $this->os->get($in, $io); - $archOption = $this->arch->get($in, $io); - $stabilityOption = $this->stability->get($in, $io); - - /** @var ReleaseInterface[] $filtered */ - $filtered = $releases - ->minimumStability($stabilityOption) - ->withAssets(); - - foreach ($filtered as $release) { - $asset = $release->getAssets() - ->filter( - static fn(AssetInterface $asset): bool => - \str_starts_with($asset->getName(), 'protoc-gen-php-grpc'), - ) - ->whereArchitecture($archOption) - ->whereOperatingSystem($osOption) - ->first(); - - if ($asset === null) { - $io->warning( - \vsprintf('%s %s does not contain available assembly (further search in progress)', [ - $repo->getName(), - $release->getVersion(), - ]), - ); - - continue; - } - - return [$asset, $release]; - } - - $message = \vsprintf(self::ERROR_ENVIRONMENT, [ - $this->os->getName(), - $osOption, - $this->arch->getName(), - $archOption, - $this->stability->getName(), - $stabilityOption, - $this->version->choices($releases), - ]); - - throw new \UnexpectedValueException($message); - } - - private function assetToArchive(AssetInterface $asset, OutputInterface $out, ?string $temp = null): ArchiveInterface - { - $factory = new Factory(); - - $progress = new ProgressBar($out); - $progress->setFormat(' [%bar%] %percent:3s%% (%size%Kb/%total%Kb)'); - $progress->setMessage('0.00', 'size'); - $progress->setMessage('?.??', 'total'); - $progress->display(); - - try { - return $factory->fromAsset($asset, static function (int $size, int $total) use ($progress): void { - if ($progress->getMaxSteps() !== $total) { - $progress->setMaxSteps($total); - } - - if ($progress->getStartTime() === 0) { - $progress->start(); - } - - $progress->setMessage(\number_format($size / 1000, 2), 'size'); - $progress->setMessage(\number_format($total / 1000, 2), 'total'); - - $progress->setProgress($size); - }, $temp); - } finally { - $progress->clear(); - } - } } diff --git a/src/Environment/Architecture.php b/src/Environment/Architecture.php index b04dc72..93708fd 100644 --- a/src/Environment/Architecture.php +++ b/src/Environment/Architecture.php @@ -12,45 +12,52 @@ namespace Spiral\RoadRunner\Console\Environment; use JetBrains\PhpStorm\ExpectedValues; -use Spiral\RoadRunner\Console\Environment\Architecture\Factory; /** + * Processor architectures in the names of the RoadRunner release assets. + * * @psalm-type ArchitectureType = Architecture::ARCH_* */ final class Architecture { - /** - * @var string - */ public const ARCH_X86_64 = 'amd64'; + public const ARCH_ARM_64 = 'arm64'; /** - * @var string + * Values of `php_uname('m')` by architecture. */ - public const ARCH_ARM_64 = 'arm64'; + private const UNAME_MAPPINGS = [ + self::ARCH_X86_64 => ['AMD64', 'amd64', 'x86', 'x64', 'x86_64'], + self::ARCH_ARM_64 => ['arm64', 'aarch64'], + ]; /** * @return ArchitectureType + * @throws \OutOfRangeException When the current architecture is none of the known ones. */ #[ExpectedValues(valuesFromClass: Architecture::class)] public static function createFromGlobals(): string { - return (new Factory())->createFromGlobals(); + $uname = \php_uname('m'); + + foreach (self::UNAME_MAPPINGS as $result => $available) { + if (\in_array($uname, $available, true)) { + return $result; + } + } + + throw new \OutOfRangeException(\sprintf('Current architecture (%s) may not be supported', $uname)); } /** - * @return array + * @return array Architectures by constant name. */ public static function all(): array { - static $values; - - if ($values === null) { - $values = Enum::values(self::class, 'ARCH_'); - } - - /** @psalm-var array $values */ - return $values; + return [ + 'ARCH_X86_64' => self::ARCH_X86_64, + 'ARCH_ARM_64' => self::ARCH_ARM_64, + ]; } public static function isValid(string $value): bool diff --git a/src/Environment/Architecture/Factory.php b/src/Environment/Architecture/Factory.php deleted file mode 100644 index 8972c04..0000000 --- a/src/Environment/Architecture/Factory.php +++ /dev/null @@ -1,63 +0,0 @@ -> - */ - private const UNAME_MAPPINGS = [ - Architecture::ARCH_X86_64 => [ - 'AMD64', - 'amd64', - 'x86', - 'x64', - 'x86_64', - ], - Architecture::ARCH_ARM_64 => [ - 'arm64', - 'aarch64', - ], - ]; - - /** - * @return ArchitectureType - */ - #[ExpectedValues(valuesFromClass: Architecture::class)] - public function createFromGlobals(): string - { - $uname = \php_uname('m'); - - foreach (self::UNAME_MAPPINGS as $result => $available) { - if (\in_array($uname, $available, true)) { - return $result; - } - } - - throw new \OutOfRangeException(\sprintf(self::ERROR_UNKNOWN_ARCH, $uname)); - } -} diff --git a/src/Environment/Enum.php b/src/Environment/Enum.php deleted file mode 100644 index 31e2273..0000000 --- a/src/Environment/Enum.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ - public static function values(string $class, string $prefix): array - { - $result = []; - - try { - $reflection = new \ReflectionClass($class); - } catch (\ReflectionException $e) { - return []; - } - - /** @psalm-var int|string $value */ - foreach ($reflection->getConstants() as $name => $value) { - if (\str_starts_with($name, $prefix)) { - $result[$name] = $value; - } - } - - return $result; - } -} diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php deleted file mode 100644 index 57a250d..0000000 --- a/src/Environment/Environment.php +++ /dev/null @@ -1,31 +0,0 @@ -createFromGlobals(); - } - - public static function isValid(string $value): bool + public static function createFromGlobals(): string { - return \in_array($value, self::all(), true); + return match (\PHP_OS_FAMILY) { + 'Windows' => self::OS_WINDOWS, + 'BSD' => self::OS_BSD, + 'Darwin' => self::OS_DARWIN, + 'Linux' => \str_contains(\PHP_OS, 'Alpine') ? self::OS_ALPINE : self::OS_LINUX, + default => throw new \OutOfRangeException(\sprintf('Current OS (%s) may not be supported', \PHP_OS_FAMILY)), + }; } /** - * @return array + * @return array Operating systems by constant name. */ public static function all(): array { - static $values; - - if ($values === null) { - $values = Enum::values(self::class, 'OS_'); - } + return [ + 'OS_DARWIN' => self::OS_DARWIN, + 'OS_BSD' => self::OS_BSD, + 'OS_LINUX' => self::OS_LINUX, + 'OS_WINDOWS' => self::OS_WINDOWS, + 'OS_ALPINE' => self::OS_ALPINE, + ]; + } - /** @psalm-var array $values */ - return $values; + public static function isValid(string $value): bool + { + return \in_array($value, self::all(), true); } } diff --git a/src/Environment/OperatingSystem/Factory.php b/src/Environment/OperatingSystem/Factory.php deleted file mode 100644 index 36af320..0000000 --- a/src/Environment/OperatingSystem/Factory.php +++ /dev/null @@ -1,57 +0,0 @@ -downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'get-binary'); $this->os = new OperatingSystemOption($this); @@ -69,49 +64,50 @@ public function getDescription(): string #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - $io = $this->io($input, $output); + $io = new SymfonyStyle($input, $output); $target = $this->location->get($input, $io); - $repository = $this->getRepository(); + $os = $this->os->get($input, $io); $output->writeln(''); $output->writeln(' Environment:'); $output->writeln(\sprintf(' - Version: %s', $this->version->get($input, $io))); $output->writeln(\sprintf(' - Stability: %s', $this->stability->get($input, $io))); - $output->writeln(\sprintf(' - Operating System: %s', $this->os->get($input, $io))); + $output->writeln(\sprintf(' - Operating System: %s', $os)); $output->writeln(\sprintf(' - Architecture: %s', $this->arch->get($input, $io))); $output->writeln(''); + $binary = $target . ($os === OperatingSystem::OS_WINDOWS ? '/rr.exe' : '/rr'); + $installed = false; + + if ($this->checkExisting($binary, $io)) { + $code = $this->downloader->download( + software: 'rr', + constraint: $this->version->get($input, $io), + stability: $this->stability->get($input, $io), + os: $os, + arch: $this->arch->get($input, $io), + location: $target, + force: true, + output: $output, + ); + + if ($code !== self::SUCCESS) { + return $code; + } - // List of all available releases - $releases = $this->version->find($input, $io, $repository); - - [$asset, $release] = $this->findAsset($repository, $releases, $input, $io); - - // Installation - $output->writeln( - \sprintf(" - %s", $release->getRepositoryName()) . - \sprintf(' (%s):', $release->getVersion()) . - ' Downloading...', - ); - - if ($output->isVerbose()) { - $output->writeln(\sprintf(" -- %s", $asset->getName())); + $installed = true; } - // Install rr binary - $file = $this->installBinary($target, $release, $asset, $io, $output); - $this->installConfig($target, $input, $io); - // Success - if ($file === null) { + if (! $installed) { $io->warning('RoadRunner has not been installed'); return 1; } - $io->success('Your project is now ready in ' . $file->getPath()); + $io->success('Your project is now ready in ' . $target); $io->title('Whats Next?'); $io->listing([ @@ -121,7 +117,7 @@ public function execute(InputInterface $input, OutputInterface $output): int // 2) 'To run the application, use the following command: ' . - '$ ' . $file->getFilename() . ' serve', + '$ ' . \basename($binary) . ' serve', ]); return 0; @@ -155,56 +151,16 @@ protected function configure(): void /** * @throws \Throwable */ - private function installBinary( - string $target, - ReleaseInterface $release, - AssetInterface $asset, - StyleInterface $io, - OutputInterface $out, - ): ?\SplFileInfo { - $extractor = $this->assetToArchive($asset, $out) - ->extract([ - 'rr.exe' => $target . '/rr.exe', - 'rr' => $target . '/rr', - ]) - ; - - $file = null; - while ($extractor->valid()) { - $file = $extractor->current(); - \assert($file instanceof \SplFileInfo); - - if (! $this->checkExisting($file, $io)) { - $extractor->send(false); - continue; - } - - // Success - $path = $file->getRealPath() ?: $file->getPathname(); - $message = 'RoadRunner (%s) has been installed into %s'; - $message = \sprintf($message, $release->getVersion(), $path); - $out->writeln($message); - - $extractor->next(); - - if (! $file->isExecutable()) { - @\chmod($file->getRealPath(), 0755); - } - } - - return $file; - } - - private function installConfig(string $to, InputInterface $in, StyleInterface $io): bool + private function installConfig(string $to, InputInterface $in, StyleInterface $io): void { $to .= '/.rr.yaml'; if (\is_file($to) || \is_file((string) \getcwd() . '/.rr.yaml')) { - return false; + return; } if ($in->getOption('no-config') || ! $io->confirm('Do you want create default ".rr.yaml" configuration file?', true)) { - return false; + return; } $generator = new Generator(); @@ -218,17 +174,16 @@ private function installConfig(string $to, InputInterface $in, StyleInterface $i $config = $generator->generate($plugins); } catch (\Throwable $e) { $io->error($e->getMessage()); + + return; } - /** @psalm-suppress PossiblyUndefinedVariable https://github.com/roadrunner-php/cli/issues/62 */ \file_put_contents($to, $config); - - return true; } - private function checkExisting(\SplFileInfo $bin, StyleInterface $io): bool + private function checkExisting(string $binary, StyleInterface $io): bool { - if (\is_file($bin->getPathname())) { + if (\is_file($binary)) { $io->warning('RoadRunner binary file already exists!'); if (! $io->confirm('Do you want overwrite it?', false)) { @@ -240,90 +195,4 @@ private function checkExisting(\SplFileInfo $bin, StyleInterface $io): bool return true; } - - /** - * @return array{0: AssetInterface, 1: ReleaseInterface} - */ - private function findAsset( - RepositoryInterface $repo, - ReleasesCollection $releases, - InputInterface $in, - StyleInterface $io, - ): array { - $osOption = $this->os->get($in, $io); - $archOption = $this->arch->get($in, $io); - $stabilityOption = $this->stability->get($in, $io); - - /** @var ReleaseInterface[] $filtered */ - $filtered = $releases - ->minimumStability($stabilityOption) - ->withAssets() - ; - - foreach ($filtered as $release) { - $asset = $release->getAssets() - ->onlyRoadrunner() - ->exceptDebPackages() - ->whereArchitecture($archOption) - ->whereOperatingSystem($osOption) - ->first() - ; - - if ($asset === null) { - $io->warning(\vsprintf('%s %s does not contain available assembly (further search in progress)', [ - $repo->getName(), - $release->getVersion(), - ])); - - continue; - } - - return [$asset, $release]; - } - - $message = \vsprintf(self::ERROR_ENVIRONMENT, [ - $this->os->getName(), - $osOption, - $this->arch->getName(), - $archOption, - $this->stability->getName(), - $stabilityOption, - $this->version->choices($releases), - ]); - - throw new \UnexpectedValueException($message); - } - - /** - * @throws \Throwable - */ - private function assetToArchive(AssetInterface $asset, OutputInterface $out, ?string $temp = null): ArchiveInterface - { - $factory = new Factory(); - - $progress = new ProgressBar($out); - $progress->setFormat(' [%bar%] %percent:3s%% (%size%Kb/%total%Kb)'); - $progress->setMessage('0.00', 'size'); - $progress->setMessage('?.??', 'total'); - $progress->display(); - - try { - return $factory->fromAsset($asset, static function (int $size, int $total) use ($progress): void { - if ($progress->getMaxSteps() !== $total) { - $progress->setMaxSteps($total); - } - - if ($progress->getStartTime() === 0) { - $progress->start(); - } - - $progress->setMessage(\number_format($size / 1000, 2), 'size'); - $progress->setMessage(\number_format($total / 1000, 2), 'total'); - - $progress->setProgress($size); - }, $temp); - } finally { - $progress->clear(); - } - } } diff --git a/src/Configuration/Generator.php b/src/Internal/Configuration/Generator.php similarity index 66% rename from src/Configuration/Generator.php rename to src/Internal/Configuration/Generator.php index f7d79b2..f2447d7 100644 --- a/src/Configuration/Generator.php +++ b/src/Internal/Configuration/Generator.php @@ -2,23 +2,26 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Configuration; +namespace Spiral\RoadRunner\Console\Internal\Configuration; -use Spiral\RoadRunner\Console\Configuration\Section\Rpc; -use Spiral\RoadRunner\Console\Configuration\Section\SectionInterface; -use Spiral\RoadRunner\Console\Configuration\Section\Version; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Rpc; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\SectionInterface; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Version; use Symfony\Component\Yaml\Yaml; -class Generator +/** + * @internal + */ +final class Generator { /** @psalm-var non-empty-array> */ - protected const REQUIRED_SECTIONS = [ + private const REQUIRED_SECTIONS = [ Version::class, Rpc::class, ]; /** @var SectionInterface[] */ - protected array $sections = []; + private array $sections = []; public function generate(Plugins $plugins): string { @@ -27,7 +30,7 @@ public function generate(Plugins $plugins): string return Yaml::dump($this->getContent(), 10); } - protected function getContent(): array + private function getContent(): array { $content = []; foreach ($this->sections as $section) { @@ -40,7 +43,7 @@ protected function getContent(): array /** * @param array> $plugins */ - protected function collectSections(array $plugins): void + private function collectSections(array $plugins): void { $sections = \array_merge(self::REQUIRED_SECTIONS, $plugins); @@ -49,7 +52,7 @@ protected function collectSections(array $plugins): void } } - protected function fromSection(SectionInterface $section): void + private function fromSection(SectionInterface $section): void { if (!isset($this->sections[\get_class($section)])) { $this->sections[\get_class($section)] = $section; diff --git a/src/Internal/Configuration/Plugins.php b/src/Internal/Configuration/Plugins.php new file mode 100644 index 0000000..7898a3d --- /dev/null +++ b/src/Internal/Configuration/Plugins.php @@ -0,0 +1,125 @@ +> + */ + private const AVAILABLE = [ + Amqp::class, + Beanstalk::class, + Boltdb::class, + Broadcast::class, + Endure::class, + Fileserver::class, + Grpc::class, + Http::class, + Jobs::class, + Kv::class, + Logs::class, + Metrics::class, + Nats::class, + Otel::class, + Redis::class, + Reload::class, + Rpc::class, + Server::class, + Service::class, + Sqs::class, + Status::class, + Tcp::class, + Temporal::class, + Version::class, + Websockets::class, + ]; + + /** + * @var list> + */ + private const DEFAULT = [ + Version::class, + Rpc::class, + Server::class, + Http::class, + Jobs::class, + Kv::class, + Metrics::class, + ]; + + /** + * @param string[] $requestedPlugins Plugin short names. + */ + private function __construct( + private readonly array $requestedPlugins, + ) {} + + /** + * @param string[] $plugins Plugin short names. + */ + public static function fromPlugins(array $plugins): self + { + return new self($plugins); + } + + public static function fromPreset(string $preset): self + { + $plugins = $preset === Presets::WEB_PRESET_NANE ? Presets::WEB_PLUGINS : []; + + return new self(\array_map(static fn(string $plugin): string => $plugin::getShortName(), $plugins)); + } + + /** + * @return list> + */ + public function getPlugins(): array + { + if ($this->requestedPlugins === []) { + return self::DEFAULT; + } + + $plugins = []; + foreach (self::AVAILABLE as $plugin) { + if (\in_array($plugin::getShortName(), $this->requestedPlugins, true)) { + $plugins[] = $plugin; + } + } + + return $plugins; + } +} diff --git a/src/Internal/Configuration/Presets.php b/src/Internal/Configuration/Presets.php new file mode 100644 index 0000000..c91146f --- /dev/null +++ b/src/Internal/Configuration/Presets.php @@ -0,0 +1,20 @@ +=3.0 ' . self::CALENDAR_BOUND; + + /** + * DLoad serves the API of a GitHub Enterprise Server at `scheme://host[:port]/api/v3`, so no other path can be mapped. + */ + private const ENTERPRISE_API_PATH = '/api/v3'; + + /** + * The RoadRunner entries of DLoad's built-in registry, with the server `scheme://host[:port]` to substitute. + * + * DLoad takes a self-hosted GitHub from the repository `uri` itself: `scheme://host[:port]/owner/repo`. + */ + private const CONFIG_WITH_SERVER = <<<'XML' + + + + + + + + + + + + + + XML; + + private readonly Command $get; + private readonly ?string $githubApiUrl; + + /** + * @param Command|null $get Runs the download with the input of DLoad's `get` command; DLoad's own command by default. + * @param string|null $githubApiUrl Base URL of the GitHub API to fetch releases from; the RR_GITHUB_API_URL + * environment variable by default, and DLoad's own default when that is not set either. + */ + public function __construct(?Command $get = null, ?string $githubApiUrl = null) + { + /** @psalm-suppress InternalClass DLoad has no public PHP API yet; its `get` command is the stable contract */ + $this->get = $get ?? new Get(); + $this->githubApiUrl = $githubApiUrl ?? Environment::get(self::ENV_GITHUB_API_URL); + } + + /** + * @param non-empty-string $software DLoad software alias, e.g. "rr" or "protoc-gen-php-grpc". + * @param string $constraint Composer version constraint; "*" or empty means 3.x or a later semver line. + * @param string $stability Minimum stability, e.g. "stable" or "beta". + * @param bool $force Overwrite the binary if it already exists in the location. + * + * @return int Command exit code. + */ + public function download( + string $software, + string $constraint, + string $stability, + string $os, + string $arch, + string $location, + bool $force, + OutputInterface $output, + ): int { + $config = $this->createConfig(); + + $input = new ArrayInput([ + 'software' => [$software . self::versionSuffix($constraint, $stability)], + '--config' => $config ?? self::CONFIG, + '--path' => $location, + '--os' => $os, + '--arch' => $arch, + '--stability' => $stability, + '--force' => $force, + // The version registry cache could hide a release published a moment ago + '--refresh' => true, + ]); + $input->setInteractive(false); + + try { + return $this->get->run($input, $output); + } finally { + if ($config !== null) { + @\unlink($config); + } + } + } + + /** + * DLoad ignores `--stability` once a version is given: the stability has to be a part of the constraint. + */ + private static function versionSuffix(string $constraint, string $stability): string + { + [$version, $explicitStability] = \explode('@', \trim($constraint), 2) + [1 => null]; + $version = \trim($version); + + if ($version === '' || $version === '*') { + $version = self::ANY_VERSION; + } + + // DLoad has no parentheses, so the bound goes into each alternative + $alternatives = \preg_split('/\s*\|\|?\s*/', $version) ?: [$version]; + $version = \implode(' || ', \array_map(self::boundAlternative(...), $alternatives)); + + return ':' . $version . '@' . ($explicitStability ?? $stability); + } + + /** + * An alternative without an upper bound gets {@see self::CALENDAR_BOUND}, unless its lower bound is a calendar version. + */ + private static function boundAlternative(string $alternative): string + { + $bounded = \preg_match('/<(?!>)|[\^~]|\d\.[*x]|\s-\s/i', $alternative) === 1 + || \preg_match('/^(?:==?)?\s*v?\d[^\s,]*$/i', $alternative) === 1; + $calendar = \preg_match('/(?=?\s*v?(\d+)/', $alternative, $lower) === 1 && (int) $lower[1] >= 2000; + + return $bounded || $calendar ? $alternative : $alternative . ' ' . self::CALENDAR_BOUND; + } + + /** + * @return non-empty-string|null `scheme://host[:port]` of a GitHub Enterprise Server, null for the public GitHub. + */ + private static function server(string $url): ?string + { + $parts = \parse_url(\rtrim(\trim($url), '/')); + $parts = \is_array($parts) ? $parts : []; + $scheme = \strtolower($parts['scheme'] ?? ''); + $host = \strtolower($parts['host'] ?? ''); + + if (! \in_array($scheme, ['http', 'https'], true) || $host === '' + || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) + ) { + throw new \InvalidArgumentException(\sprintf('Invalid %s value "%s"', self::ENV_GITHUB_API_URL, $url)); + } + + $server = $scheme . '://' . $host . (isset($parts['port']) ? ':' . $parts['port'] : ''); + $path = $parts['path'] ?? ''; + + if ($server === self::PUBLIC_API_URL && $path === '') { + return null; + } + + if ($server === self::PUBLIC_API_URL || $path !== self::ENTERPRISE_API_PATH) { + throw new \InvalidArgumentException(\sprintf( + '%s must be %s or a GitHub Enterprise Server API URL ending with %s, "%s" given', + self::ENV_GITHUB_API_URL, + self::PUBLIC_API_URL, + self::ENTERPRISE_API_PATH, + $url, + )); + } + + return $server; + } + + /** + * @return string|null Path to a temporary config pointing DLoad at a GitHub Enterprise Server, if one is set. + */ + private function createConfig(): ?string + { + if ($this->githubApiUrl === null || $this->githubApiUrl === '') { + return null; + } + + $server = self::server($this->githubApiUrl); + if ($server === null) { + return null; + } + + $file = \tempnam(\sys_get_temp_dir(), 'rr-dload-'); + if ($file === false) { + throw new \RuntimeException('Can not create a temporary DLoad config'); + } + + \file_put_contents($file, \sprintf(self::CONFIG_WITH_SERVER, \htmlspecialchars($server, \ENT_XML1 | \ENT_QUOTES))); + + return $file; + } +} diff --git a/src/Internal/Environment/Environment.php b/src/Internal/Environment/Environment.php new file mode 100644 index 0000000..22cb2be --- /dev/null +++ b/src/Internal/Environment/Environment.php @@ -0,0 +1,29 @@ + 4, - self::STABILITY_RC => 3, - self::STABILITY_BETA => 2, - self::STABILITY_ALPHA => 1, - self::STABILITY_DEV => 0, + self::STABILITY_RC => 3, + self::STABILITY_BETA => 2, + self::STABILITY_ALPHA => 1, + self::STABILITY_DEV => 0, ]; /** * @param StabilityType|string $type Unknown stabilities weigh as "dev". - * @return positive-int|0 + * @return int<0, 4> */ public static function toInt(string $type): int { @@ -62,22 +42,15 @@ public static function toInt(string $type): int } /** - * @return array + * @return list From the most to the least stable. */ public static function all(): array { - static $values; - - if ($values === null) { - $values = Enum::values(self::class, 'STABILITY_'); - } - - /** @psalm-var array $values */ - return $values; + return \array_keys(self::WEIGHT); } public static function isValid(string $value): bool { - return \in_array($value, self::all(), true); + return isset(self::WEIGHT[$value]); } } diff --git a/src/Internal/GitHub/Release.php b/src/Internal/GitHub/Release.php new file mode 100644 index 0000000..49a490c --- /dev/null +++ b/src/Internal/GitHub/Release.php @@ -0,0 +1,97 @@ + $assets File names of the release assets. + */ + public function __construct( + public readonly string $name, + public readonly string $stability, + public readonly array $assets = [], + ) {} + + /** + * @param array $release An item of the GitHub "List releases" response. + */ + public static function fromApiResponse(array $release): self + { + $tag = self::string($release['tag_name'] ?? null); + if ($tag === null || $tag === '') { + throw new \UnexpectedValueException('A GitHub release must have a "tag_name" string'); + } + + $title = self::string($release['name'] ?? null); + $name = self::normalize($tag) ?? ($title === null ? null : self::normalize($title)) ?? 'dev-' . $tag; + + $assets = []; + /** @var mixed $asset */ + foreach ((array) ($release['assets'] ?? []) as $asset) { + $assetName = \is_array($asset) ? self::string($asset['name'] ?? null) : null; + if ($assetName !== null) { + $assets[] = $assetName; + } + } + + return new self($name, VersionParser::parseStability($name), $assets); + } + + /** + * Orders releases from the newest to the oldest. + * + * RoadRunner went 1.x → 2.x → calendar 2023.x–2025.x → 3.x, so calendar releases are compared + * as "2..…": after every 2.x and before every 3.x release. + */ + public static function newestFirst(self $a, self $b): int + { + $key = static fn(self $release): string => (string) \preg_replace('/^(20\d{2}\.)/', '2.$1', $release->name); + + return \version_compare($key($b), $key($a)); + } + + public function satisfies(string $constraint): bool + { + return Semver::satisfies($this->name, $constraint); + } + + private static function string(mixed $value): ?string + { + return \is_string($value) ? $value : null; + } + + /** + * @return non-empty-string|null "3.5.0-RC1" for "v3.5.0-rc.1", null when the value is not a version. + */ + private static function normalize(string $version): ?string + { + try { + $normalized = (new VersionParser())->normalize($version); + } catch (\UnexpectedValueException) { + return null; + } + + /** @var non-empty-string */ + return \preg_replace('/^(\d+\.\d+\.\d+)\.0(?=-|$)/', '$1', $normalized); + } +} diff --git a/src/Internal/GitHub/ReleasesClient.php b/src/Internal/GitHub/ReleasesClient.php new file mode 100644 index 0000000..161ab14 --- /dev/null +++ b/src/Internal/GitHub/ReleasesClient.php @@ -0,0 +1,96 @@ +apiUrl = \rtrim($apiUrl, '/'); + } + + /** + * Takes the API URL from RR_GITHUB_API_URL and the token for it as DLoad does, see {@see Token}. + */ + public static function fromEnvironment(): self + { + $apiUrl = Environment::get(self::ENV_GITHUB_API_URL); + $apiUrl = $apiUrl !== null && $apiUrl !== '' ? $apiUrl : self::DEFAULT_API_URL; + + return new self(HttpClient::create(), $apiUrl, Token::forApiUrl($apiUrl)); + } + + /** + * @return list In the order of the API: the newest first, by creation date. + * @throws ExceptionInterface + */ + public function releases(): array + { + $releases = []; + $page = 0; + + do { + $response = $this->client->request('GET', $this->apiUrl . '/repos/' . self::REPOSITORY . '/releases', [ + 'headers' => \array_filter([ + 'accept' => 'application/vnd.github.v3+json', + 'authorization' => $this->token === null ? null : 'Bearer ' . $this->token, + ]), + 'query' => ['page' => ++$page, 'per_page' => self::PER_PAGE], + ]); + + /** @var mixed $release */ + foreach ($response->toArray() as $release) { + if (\is_array($release)) { + $releases[] = Release::fromApiResponse($release); + } + } + + $link = $response->getHeaders()['link'][0] ?? ''; + } while (\str_contains($link, 'rel="next"')); + + return $releases; + } +} diff --git a/src/Internal/GitHub/Token.php b/src/Internal/GitHub/Token.php new file mode 100644 index 0000000..90fe6e8 --- /dev/null +++ b/src/Internal/GitHub/Token.php @@ -0,0 +1,118 @@ + 80, 'https' => 443]; + + /** + * Dot-separated labels of letters, digits and inner hyphens: with no hyphen at either end of a label, + * distinct hosts never map to the same variable name. + */ + private const HOSTNAME = '/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/'; + + /** + * @return non-empty-string|null The token, null when none is declared for the host or the host may not receive one. + */ + public static function forApiUrl(string $apiUrl): ?string + { + $parts = \parse_url(\trim($apiUrl)); + if (! \is_array($parts) || ! isset($parts['host'], $parts['scheme'])) { + return null; + } + + $scheme = \strtolower($parts['scheme']); + $host = \strtolower($parts['host']); + if (! isset(self::DEFAULT_PORTS[$scheme])) { + return null; + } + + $port = $parts['port'] ?? null; + $port === self::DEFAULT_PORTS[$scheme] and $port = null; + + if ($scheme === 'https' && $host === self::PUBLIC_API_HOST && $port === null) { + return self::env(self::PUBLIC_VARIABLE) ?? self::env('GITHUB_TOKEN'); + } + + // Plain HTTP would expose the token on the wire, unless the traffic never leaves the machine + if ($scheme === 'http' && ! self::isLoopback($host)) { + return null; + } + + $variable = self::variable($host, $port); + + return $variable === null ? null : self::env($variable); + } + + /** + * `ghe.my-corp.com:8443` gives `DLOAD_TOKEN_GHE_MY__CORP_COM___8443`, `[::1]` gives `DLOAD_TOKEN__0_0_0_0_0_0_0_1`. + * + * @return non-empty-string|null Null for a malformed host. + */ + private static function variable(string $host, ?int $port): ?string + { + if (\str_starts_with($host, '[')) { + $address = \filter_var(\substr($host, 1, -1), \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6); + if ($address === false) { + return null; + } + + /** @var array $groups */ + $groups = \unpack('n8', (string) \inet_pton($address)); + $name = '_' . \implode('_', \array_map(\dechex(...), $groups)); + } elseif (\preg_match(self::HOSTNAME, $host) === 1) { + $name = \strtr($host, ['.' => '_', '-' => '__']); + } else { + return null; + } + + return \strtoupper('DLOAD_TOKEN_' . $name . ($port === null ? '' : '___' . $port)); + } + + private static function isLoopback(string $host): bool + { + return $host === 'localhost' + || $host === '[::1]' + || \str_ends_with($host, '.localhost') + || \preg_match('/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $host) === 1; + } + + /** + * @return non-empty-string|null + */ + private static function env(string $name): ?string + { + $value = Environment::get($name); + + return $value === null || $value === '' ? null : $value; + } +} diff --git a/src/Command/ArchitectureOption.php b/src/Internal/Option/ArchitectureOption.php similarity index 81% rename from src/Command/ArchitectureOption.php rename to src/Internal/Option/ArchitectureOption.php index b61ebef..da32652 100644 --- a/src/Command/ArchitectureOption.php +++ b/src/Internal/Option/ArchitectureOption.php @@ -9,14 +9,17 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Command; +namespace Spiral\RoadRunner\Console\Internal\Option; use Spiral\RoadRunner\Console\Environment\Architecture; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\StyleInterface; -class ArchitectureOption extends Option +/** + * @internal + */ +final class ArchitectureOption extends Option { public function __construct(Command $command, string $name = 'arch', string $short = 'a') { @@ -30,7 +33,7 @@ public function get(InputInterface $input, StyleInterface $io): string if (! Architecture::isValid($architecture)) { $message = 'Possibly invalid architecture (--%s=%s) option (available: %s)'; - $io->warning(\sprintf($message, $this->name, $architecture, $this->choices())); + $io->warning(\sprintf($message, $this->name, $architecture, self::choices())); } return $architecture; @@ -39,7 +42,7 @@ public function get(InputInterface $input, StyleInterface $io): string #[\Override] protected function getDescription(): string { - return 'Required processor architecture (one of: ' . $this->choices() . ')'; + return 'Required processor architecture (one of: ' . self::choices() . ')'; } #[\Override] @@ -48,7 +51,7 @@ protected function default(): string return Architecture::createFromGlobals(); } - private function choices(): string + private static function choices(): string { return \implode(', ', Architecture::all()); } diff --git a/src/Command/InstallationLocationOption.php b/src/Internal/Option/InstallationLocationOption.php similarity index 90% rename from src/Command/InstallationLocationOption.php rename to src/Internal/Option/InstallationLocationOption.php index 391c531..9d2efd3 100644 --- a/src/Command/InstallationLocationOption.php +++ b/src/Internal/Option/InstallationLocationOption.php @@ -9,13 +9,16 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Command; +namespace Spiral\RoadRunner\Console\Internal\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\StyleInterface; -class InstallationLocationOption extends Option +/** + * @internal + */ +final class InstallationLocationOption extends Option { public function __construct(Command $command, string $name = 'location', string $short = 'l') { diff --git a/src/Command/OperatingSystemOption.php b/src/Internal/Option/OperatingSystemOption.php similarity index 78% rename from src/Command/OperatingSystemOption.php rename to src/Internal/Option/OperatingSystemOption.php index 409d8cd..02b0a7e 100644 --- a/src/Command/OperatingSystemOption.php +++ b/src/Internal/Option/OperatingSystemOption.php @@ -9,14 +9,17 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Command; +namespace Spiral\RoadRunner\Console\Internal\Option; use Spiral\RoadRunner\Console\Environment\OperatingSystem; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\StyleInterface; -class OperatingSystemOption extends Option +/** + * @internal + */ +final class OperatingSystemOption extends Option { public function __construct(Command $command, string $name = 'os', string $short = 'o') { @@ -30,7 +33,7 @@ public function get(InputInterface $input, StyleInterface $io): string if (! OperatingSystem::isValid($os)) { $message = 'Possibly invalid operating system (--%s=%s) option (available: %s)'; - $io->warning(\sprintf($message, $this->name, $os, $this->choices())); + $io->warning(\sprintf($message, $this->name, $os, self::choices())); } return $os; @@ -39,7 +42,7 @@ public function get(InputInterface $input, StyleInterface $io): string #[\Override] protected function getDescription(): string { - return 'Required operating system (one of: ' . $this->choices() . ')'; + return 'Required operating system (one of: ' . self::choices() . ')'; } #[\Override] @@ -48,7 +51,7 @@ protected function default(): string return OperatingSystem::createFromGlobals(); } - private function choices(): string + private static function choices(): string { return \implode(', ', OperatingSystem::all()); } diff --git a/src/Internal/Option/Option.php b/src/Internal/Option/Option.php new file mode 100644 index 0000000..4dc6251 --- /dev/null +++ b/src/Internal/Option/Option.php @@ -0,0 +1,45 @@ +addOption($name, $short, InputOption::VALUE_OPTIONAL, $this->getDescription(), $this->default()); + } + + public function get(InputInterface $input, StyleInterface $io): string + { + /** @var mixed $value */ + $value = $input->getOption($this->name); + + return \is_string($value) && $value !== '' ? $value : $this->default(); + } + + abstract protected function getDescription(): string; + + abstract protected function default(): string; +} diff --git a/src/Command/StabilityOption.php b/src/Internal/Option/StabilityOption.php similarity index 76% rename from src/Command/StabilityOption.php rename to src/Internal/Option/StabilityOption.php index 6561f40..f287ee6 100644 --- a/src/Command/StabilityOption.php +++ b/src/Internal/Option/StabilityOption.php @@ -9,27 +9,23 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Command; +namespace Spiral\RoadRunner\Console\Internal\Option; -use Spiral\RoadRunner\Console\Environment\Stability; +use Spiral\RoadRunner\Console\Internal\Environment\Stability; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\StyleInterface; /** - * @psalm-import-type StabilityType from Stability + * @internal */ -class StabilityOption extends Option +final class StabilityOption extends Option { public function __construct(Command $command, string $name = 'stability', string $short = 's') { parent::__construct($command, $name, $short); } - /** - * - * @return StabilityType|string - */ #[\Override] public function get(InputInterface $input, StyleInterface $io): string { @@ -37,7 +33,7 @@ public function get(InputInterface $input, StyleInterface $io): string if (! Stability::isValid($stability)) { $message = 'Possibly invalid stability (--%s=%s) option (available: %s)'; - $io->warning(\sprintf($message, $this->name, $stability, $this->choices())); + $io->warning(\sprintf($message, $this->name, $stability, \implode(', ', Stability::all()))); } return $stability; @@ -54,9 +50,4 @@ protected function default(): string { return Stability::STABILITY_STABLE; } - - private function choices(): string - { - return \implode(', ', Stability::all()); - } } diff --git a/src/Internal/Option/VersionFilterOption.php b/src/Internal/Option/VersionFilterOption.php new file mode 100644 index 0000000..fc91c14 --- /dev/null +++ b/src/Internal/Option/VersionFilterOption.php @@ -0,0 +1,48 @@ +default = $default ?? RoadRunnerVersion::constraint(); + parent::__construct($command, $name, $short); + } + + #[\Override] + protected function getDescription(): string + { + return 'Required version of RoadRunner binaries'; + } + + #[\Override] + protected function default(): string + { + return $this->default; + } +} diff --git a/src/MakeConfigCommand.php b/src/MakeConfigCommand.php index 5c87e11..18fdd34 100644 --- a/src/MakeConfigCommand.php +++ b/src/MakeConfigCommand.php @@ -4,14 +4,19 @@ namespace Spiral\RoadRunner\Console; -use Spiral\RoadRunner\Console\Command\InstallationLocationOption; -use Spiral\RoadRunner\Console\Configuration\Generator; -use Spiral\RoadRunner\Console\Configuration\Plugins; +use Spiral\RoadRunner\Console\Internal\Configuration\Generator; +use Spiral\RoadRunner\Console\Internal\Configuration\Plugins; +use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; -class MakeConfigCommand extends Command +/** + * @psalm-api + */ +final class MakeConfigCommand extends Command { private InstallationLocationOption $location; @@ -28,7 +33,7 @@ public function __construct(?string $name = null) #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - $io = $this->io($input, $output); + $io = new SymfonyStyle($input, $output); $target = $this->location->get($input, $io) . '/.rr.yaml'; diff --git a/src/Repository/Asset.php b/src/Repository/Asset.php deleted file mode 100644 index 8033b11..0000000 --- a/src/Repository/Asset.php +++ /dev/null @@ -1,36 +0,0 @@ -name = $name; - $this->uri = $uri; - } - - #[\Override] - public function getName(): string - { - return $this->name; - } - - #[\Override] - public function getUri(): string - { - return $this->uri; - } -} diff --git a/src/Repository/AssetInterface.php b/src/Repository/AssetInterface.php deleted file mode 100644 index b866e95..0000000 --- a/src/Repository/AssetInterface.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function download(?\Closure $progress = null): iterable; -} diff --git a/src/Repository/AssetsCollection.php b/src/Repository/AssetsCollection.php deleted file mode 100644 index 781f47a..0000000 --- a/src/Repository/AssetsCollection.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ -final class AssetsCollection extends Collection -{ - /** - * @return $this - */ - public function onlyRoadrunner(): self - { - return $this->filter( - static fn(AssetInterface $asset): bool => - \str_starts_with($asset->getName(), 'roadrunner'), - ); - } - - /** - * @return $this - */ - public function exceptDebPackages(): self - { - return $this->except( - static fn(AssetInterface $asset): bool => - \str_ends_with(\strtolower($asset->getName()), '.deb'), - ); - } - - /** - * @return $this - */ - public function whereArchitecture(string $arch): self - { - return $this->filter( - static fn(AssetInterface $asset): bool => - \str_contains($asset->getName(), '-' . \strtolower($arch) . '.'), - ); - } - - /** - * @return $this - */ - public function whereOperatingSystem(string $os): self - { - return $this->filter( - static fn(AssetInterface $asset): bool => - \str_contains($asset->getName(), '-' . \strtolower($os) . '-'), - ); - } -} diff --git a/src/Repository/Collection.php b/src/Repository/Collection.php deleted file mode 100644 index 1fa3d13..0000000 --- a/src/Repository/Collection.php +++ /dev/null @@ -1,164 +0,0 @@ - - * - * The constructor is final and every subclass is final with a fixed item type, so `new static` is safe. - * @psalm-suppress UnsafeGenericInstantiation - */ -abstract class Collection implements \IteratorAggregate, \Countable -{ - /** - * @var array - */ - protected array $items; - - /** - * @param array $items - */ - final public function __construct(array $items) - { - $this->items = $items; - } - - /** - * @param mixed|iterable|\Closure $items - * @return static - */ - public static function create($items): self - { - switch (true) { - case $items instanceof static: - return $items; - - case $items instanceof \Traversable: - $items = \iterator_to_array($items); - - // no break - case \is_array($items): - return new static($items); - - case $items instanceof \Closure: - return static::from($items); - - default: - throw new \InvalidArgumentException( - \sprintf('Unsupported iterable type %s', \get_debug_type($items)), - ); - } - } - - /** - * @return static - */ - public static function from(\Closure $generator): self - { - return static::create($generator()); - } - - /** - * @param callable(T): bool $filter - * @return $this - */ - public function filter(callable $filter): self - { - return new static(\array_filter($this->items, $filter)); - } - - /** - * @param callable(T): mixed $map - * @return $this - */ - public function map(callable $map): self - { - return new static(\array_map($map, $this->items)); - } - - /** - * @param callable(T): bool $filter - * @return $this - * - * @psalm-suppress MissingClosureParamType - * @psalm-suppress MixedArgument - */ - public function except(callable $filter): self - { - $callback = static fn(...$args): bool => ! $filter(...$args); - - return new static(\array_filter($this->items, $callback)); - } - - /** - * @param null|callable(T): bool $filter - * @return T|null - */ - public function first(?callable $filter = null): ?object - { - $self = $filter === null ? $this : $this->filter($filter); - - return $self->items === [] ? null : \reset($self->items); - } - - /** - * @param callable(): T $otherwise - * @param null|callable(T): bool $filter - * @return T - */ - public function firstOr(callable $otherwise, ?callable $filter = null): object - { - return $this->first($filter) ?? $otherwise(); - } - - #[\Override] - public function getIterator(): \Traversable - { - return new \ArrayIterator($this->items); - } - - #[\Override] - public function count(): int - { - return \count($this->items); - } - - /** - * @return $this - */ - public function whenEmpty(callable $then): self - { - if ($this->empty()) { - $then(); - } - - return $this; - } - - public function empty(): bool - { - return $this->items === []; - } - - /** - * @return array - */ - public function toArray(): array - { - return \array_values($this->items); - } -} diff --git a/src/Repository/GitHub/GitHubAsset.php b/src/Repository/GitHub/GitHubAsset.php deleted file mode 100644 index 279eb65..0000000 --- a/src/Repository/GitHub/GitHubAsset.php +++ /dev/null @@ -1,75 +0,0 @@ -client = $client; - - parent::__construct($name, $uri); - } - - /** - * @param GitHubAssetApiResponse $asset - * @return static - * - * @psalm-suppress DocblockTypeContradiction - */ - public static function fromApiResponse(HttpClientInterface $client, array $asset): self - { - // Validate name - if (! isset($asset['name']) || ! \is_string($asset['name'])) { - throw new \InvalidArgumentException( - 'Passed array must contain "name" value of type string', - ); - } - - // Validate uri - if (! isset($asset['browser_download_url']) || ! \is_string($asset['browser_download_url'])) { - throw new \InvalidArgumentException( - 'Passed array must contain "browser_download_url" key of type string', - ); - } - - return new self($client, $asset['name'], $asset['browser_download_url']); - } - - /** - * - * @throws ExceptionInterface - */ - #[\Override] - public function download(?\Closure $progress = null): \Traversable - { - $response = $this->client->request('GET', $this->getUri(), [ - 'on_progress' => $progress, - ]); - - foreach ($this->client->stream($response) as $chunk) { - yield $chunk->getContent(); - } - } -} diff --git a/src/Repository/GitHub/GitHubRelease.php b/src/Repository/GitHub/GitHubRelease.php deleted file mode 100644 index 80b4886..0000000 --- a/src/Repository/GitHub/GitHubRelease.php +++ /dev/null @@ -1,106 +0,0 @@ - - * } - */ -final class GitHubRelease extends Release -{ - private HttpClientInterface $client; - - /** - * @param iterable|array $assets - */ - public function __construct( - HttpClientInterface $client, - string $name, - string $version, - string $repository, - iterable $assets = [], - ) { - $this->client = $client; - - parent::__construct($name, $version, $repository, $assets); - } - - /** - * @param GitHubReleaseApiResponse $release - * @return static - */ - public static function fromApiResponse(GitHubRepository $repository, HttpClientInterface $client, array $release): self - { - if (! isset($release['name'])) { - throw new \InvalidArgumentException( - 'Passed array must contain "name" value of type string', - ); - } - - $instantiator = static function () use ($client, $release): \Generator { - foreach ($release['assets'] ?? [] as $item) { - yield GitHubAsset::fromApiResponse($client, $item); - } - }; - - $name = self::getTagName($release); - $version = $release['tag_name'] ?? $release['name']; - - return new self($client, $name, $version, $repository->getName(), AssetsCollection::from($instantiator)); - } - - #[\Override] - public function getConfig(): string - { - $config = \vsprintf('https://raw.githubusercontent.com/%s/%s/.rr.yaml', [ - $this->getRepositoryName(), - $this->getVersion(), - ]); - - $response = $this->client->request('GET', $config); - - return $response->getContent(); - } - - /** - * Returns pretty-formatted tag (release) name. - * - * Note: The return value is "pretty", but that does not mean that the - * tag physically exists. - * - * @param GitHubReleaseApiResponse $release - */ - private static function getTagName(array $release): string - { - $parser = new VersionParser(); - - try { - return $parser->normalize($release['tag_name']); - } catch (\Throwable $e) { - try { - return $parser->normalize($release['name']); - } catch (\Throwable $e) { - return 'dev-' . $release['tag_name']; - } - } - } -} diff --git a/src/Repository/GitHub/GitHubRepository.php b/src/Repository/GitHub/GitHubRepository.php deleted file mode 100644 index 6c79ee5..0000000 --- a/src/Repository/GitHub/GitHubRepository.php +++ /dev/null @@ -1,145 +0,0 @@ - 'application/vnd.github.v3+json', - ]; - - /** - * @param string $apiUrl Base URL of the GitHub REST API: GitHub Enterprise, a mirror or a mock server. - */ - public function __construct( - string $owner, - string $repository, - ?HttpClientInterface $client = null, - string $apiUrl = self::DEFAULT_API_URL, - ) { - $this->name = $owner . '/' . $repository; - $this->client = $client ?? HttpClient::create(); - $this->apiUrl = \rtrim($apiUrl, '/'); - } - - public static function create( - string $owner, - string $name, - ?HttpClientInterface $client = null, - string $apiUrl = self::DEFAULT_API_URL, - ): GitHubRepository { - return new GitHubRepository($owner, $name, $client, $apiUrl); - } - - /** - * - * @throws ExceptionInterface - */ - #[\Override] - public function getReleases(): ReleasesCollection - { - return ReleasesCollection::from(function () { - $page = 0; - - do { - $response = $this->releasesRequest(++$page); - - /** @psalm-var GitHubReleaseApiResponse $data */ - foreach ($response->toArray() as $data) { - yield GitHubRelease::fromApiResponse($this, $this->client, $data); - } - } while ($this->hasNextPage($response)); - }); - } - - #[\Override] - public function getName(): string - { - return $this->name; - } - - /** - * @throws TransportExceptionInterface - * @see HttpClientInterface::request() - */ - protected function request(string $method, string $uri, array $options = []): ResponseInterface - { - // Merge headers with defaults - $options['headers'] = \array_merge($this->headers, (array) ($options['headers'] ?? [])); - - return $this->client->request($method, $uri, $options); - } - - /** - * @param positive-int $page - * @throws TransportExceptionInterface - */ - private function releasesRequest(int $page): ResponseInterface - { - return $this->request('GET', $this->uri(self::URL_RELEASES), [ - 'query' => [ - 'page' => $page, - 'per_page' => self::PER_PAGE, - ], - ]); - } - - private function uri(string $pattern): string - { - return \sprintf($pattern, $this->apiUrl, $this->getName()); - } - - /** - * @throws ExceptionInterface - */ - private function hasNextPage(ResponseInterface $response): bool - { - $headers = $response->getHeaders(); - $link = $headers['link'] ?? []; - - if (! isset($link[0])) { - return false; - } - - return \str_contains($link[0], 'rel="next"'); - } -} diff --git a/src/Repository/Release.php b/src/Repository/Release.php deleted file mode 100644 index ea7fee2..0000000 --- a/src/Repository/Release.php +++ /dev/null @@ -1,104 +0,0 @@ -version = $version; - $this->repository = $repository; - - $this->name = $this->simplifyReleaseName($name); - $this->assets = AssetsCollection::create($assets); - - $this->stability = $this->parseStability($version); - } - - #[\Override] - public function getName(): string - { - return $this->name; - } - - #[\Override] - public function getVersion(): string - { - return $this->version; - } - - #[\Override] - public function getRepositoryName(): string - { - return $this->repository; - } - - #[\Override] - #[ExpectedValues(valuesFromClass: Stability::class)] - public function getStability(): string - { - return $this->stability; - } - - #[\Override] - public function getAssets(): AssetsCollection - { - return $this->assets; - } - - #[\Override] - public function satisfies(string $constraint): bool - { - return Semver::satisfies($this->getName(), $constraint); - } - - /** - * @return StabilityType - */ - private function parseStability(string $version): string - { - return VersionParser::parseStability($version); - } - - private function simplifyReleaseName(string $name): string - { - $version = (new VersionParser())->normalize($name); - - $parts = \explode('-', $version); - $number = \substr($parts[0], 0, -2); - - return isset($parts[1]) - ? $number . '-' . $parts[1] - : $number - ; - } -} diff --git a/src/Repository/ReleaseInterface.php b/src/Repository/ReleaseInterface.php deleted file mode 100644 index cae7dbc..0000000 --- a/src/Repository/ReleaseInterface.php +++ /dev/null @@ -1,49 +0,0 @@ - - * @psalm-import-type StabilityType from Stability - */ -final class ReleasesCollection extends Collection -{ - /** - * @return $this - */ - public function satisfies(string ...$constraints): self - { - $result = $this; - - foreach ($this->constraints($constraints) as $constraint) { - $result = $result->filter(static fn(ReleaseInterface $r): bool => $r->satisfies($constraint)); - } - - return $result; - } - - /** - * @return $this - */ - public function notSatisfies(string ...$constraints): self - { - $result = $this; - - foreach ($this->constraints($constraints) as $constraint) { - $result = $result->except(static fn(ReleaseInterface $r): bool => $r->satisfies($constraint)); - } - - return $result; - } - - /** - * @return $this - */ - public function withAssets(): self - { - return $this->filter( - static fn(ReleaseInterface $r): bool => ! $r->getAssets() - ->empty(), - ); - } - - /** - * @return $this - */ - public function sortByVersion(): self - { - $result = $this->items; - - $sort = function (ReleaseInterface $a, ReleaseInterface $b): int { - return \version_compare($this->comparisonVersionString($b), $this->comparisonVersionString($a)); - }; - - \uasort($result, $sort); - - return new self($result); - } - - /** - * @return $this - */ - public function stable(): self - { - return $this->stability(Stability::STABILITY_STABLE); - } - - /** - * @param StabilityType $stability - * @return $this - */ - public function stability(string $stability): self - { - $filter = static fn(ReleaseInterface $rel): bool => $rel->getStability() === $stability; - - return $this->filter($filter); - } - - /** - * @param StabilityType|string $stability Unknown stabilities weigh as "dev". - * @return $this - */ - public function minimumStability(string $stability): self - { - $weight = Stability::toInt($stability); - - return $this->filter(static function (ReleaseInterface $release) use ($weight): bool { - return Stability::toInt($release->getStability()) >= $weight; - }); - } - - /** - * @param array $constraints - * @return array - */ - private function constraints(array $constraints): array - { - $result = []; - - foreach ($constraints as $constraint) { - foreach (\explode('|', $constraint) as $expression) { - $result[] = $expression; - } - } - - return \array_unique( - \array_filter( - \array_map('\\trim', $result), - ), - ); - } - - /** - * The release name is normalized by composer/semver ("3.0.0-RC1", "3.0.0-beta2"), - * so version_compare() orders pre-releases below the stable release. - * - * RoadRunner went 1.x → 2.x → calendar 2023.x–2025.x → 3.x, so a plain version_compare() - * would rank 2025.1.15 above 3.0.0. Calendar releases are rewritten as "2..…" to sort - * them after every 2.x and before every 3.x release. - */ - private function comparisonVersionString(ReleaseInterface $release): string - { - return (string) \preg_replace('/^(20\d{2}\.)/', '2.$1', $release->getName()); - } -} diff --git a/src/Repository/RepositoriesCollection.php b/src/Repository/RepositoriesCollection.php deleted file mode 100644 index 0755934..0000000 --- a/src/Repository/RepositoriesCollection.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - private array $repositories; - - /** - * @param array $repositories - */ - public function __construct(array $repositories) - { - $this->repositories = $repositories; - } - - #[\Override] - public function getName(): string - { - return 'unknown/unknown'; - } - - #[\Override] - public function getReleases(): ReleasesCollection - { - return ReleasesCollection::from(function () { - foreach ($this->repositories as $repository) { - yield from $repository->getReleases(); - } - }); - } -} diff --git a/src/Repository/RepositoryInterface.php b/src/Repository/RepositoryInterface.php deleted file mode 100644 index 44d2328..0000000 --- a/src/Repository/RepositoryInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -}> - */ - private const RELEASES_DATA = [ - [ - 'name' => '1.9.1', - 'version' => 'v1.9.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.9.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.9.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.9.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.9.1-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.9.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.9.0', - 'version' => 'v1.9.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.9.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.0/roadrunner-1.9.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.9.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.0/roadrunner-1.9.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.9.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.0/roadrunner-1.9.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.9.0-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.0/roadrunner-1.9.0-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.9.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.9.0/roadrunner-1.9.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.8.4', - 'version' => 'v1.8.4', - 'assets' => [ - [ - 'name' => 'roadrunner-1.8.4-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.4/roadrunner-1.8.4-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.4-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.4/roadrunner-1.8.4-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.4-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.4/roadrunner-1.8.4-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.8.4-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.4/roadrunner-1.8.4-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.4-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.4/roadrunner-1.8.4-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.8.3', - 'version' => 'v1.8.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.8.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.3/roadrunner-1.8.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.3/roadrunner-1.8.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.3/roadrunner-1.8.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.8.3-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.3/roadrunner-1.8.3-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.3/roadrunner-1.8.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.8.2', - 'version' => 'v1.8.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.8.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.2/roadrunner-1.8.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.2/roadrunner-1.8.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.2/roadrunner-1.8.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.8.2-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.2/roadrunner-1.8.2-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.2/roadrunner-1.8.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.8.1', - 'version' => 'v1.8.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.8.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.1/roadrunner-1.8.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.1/roadrunner-1.8.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.1/roadrunner-1.8.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.8.1-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.1/roadrunner-1.8.1-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.1/roadrunner-1.8.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.8.0', - 'version' => 'v1.8.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.8.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.0/roadrunner-1.8.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.0/roadrunner-1.8.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.0/roadrunner-1.8.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.8.0-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.0/roadrunner-1.8.0-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.8.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.8.0/roadrunner-1.8.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.7.1', - 'version' => 'v1.7.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.7.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.1/roadrunner-1.7.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.7.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.1/roadrunner-1.7.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.7.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.1/roadrunner-1.7.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.7.1-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.1/roadrunner-1.7.1-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.7.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.1/roadrunner-1.7.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.7.0', - 'version' => 'v1.7.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.7.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.0/roadrunner-1.7.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.7.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.0/roadrunner-1.7.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.7.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.0/roadrunner-1.7.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.7.0-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.0/roadrunner-1.7.0-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.7.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.7.0/roadrunner-1.7.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.6.4', - 'version' => 'v1.6.4', - 'assets' => [ - [ - 'name' => 'roadrunner-1.6.4-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.4/roadrunner-1.6.4-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.4-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.4/roadrunner-1.6.4-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.4-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.4/roadrunner-1.6.4-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.6.4-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.4/roadrunner-1.6.4-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.4-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.4/roadrunner-1.6.4-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.6.3', - 'version' => 'v1.6.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.6.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.3/roadrunner-1.6.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.3/roadrunner-1.6.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.3/roadrunner-1.6.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.6.3-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.3/roadrunner-1.6.3-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.3/roadrunner-1.6.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.6.2', - 'version' => 'v1.6.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.6.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.2/roadrunner-1.6.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.2/roadrunner-1.6.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.2/roadrunner-1.6.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.6.2-unknown-musl-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.2/roadrunner-1.6.2-unknown-musl-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.2/roadrunner-1.6.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.6.1', - 'version' => 'v1.6.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.6.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.1/roadrunner-1.6.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.1/roadrunner-1.6.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.1/roadrunner-1.6.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.6.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.1/roadrunner-1.6.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.6.0', - 'version' => 'v1.6.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.6.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.0/roadrunner-1.6.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.0/roadrunner-1.6.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.6.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.0/roadrunner-1.6.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.6.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.6.0/roadrunner-1.6.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.5.3', - 'version' => 'v1.5.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.5.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.3/roadrunner-1.5.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.3/roadrunner-1.5.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.3/roadrunner-1.5.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.5.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.3/roadrunner-1.5.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.5.2', - 'version' => 'v1.5.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.5.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.2/roadrunner-1.5.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.2/roadrunner-1.5.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.2/roadrunner-1.5.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.5.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.2/roadrunner-1.5.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.5.1', - 'version' => 'v1.5.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.5.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.1/roadrunner-1.5.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.1/roadrunner-1.5.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.1/roadrunner-1.5.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.5.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.1/roadrunner-1.5.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.5.0', - 'version' => 'v1.5.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.5.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.0/roadrunner-1.5.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.0/roadrunner-1.5.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.5.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.0/roadrunner-1.5.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.5.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.5.0/roadrunner-1.5.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.8', - 'version' => 'v1.4.8', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.8-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.8/roadrunner-1.4.8-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.8-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.8/roadrunner-1.4.8-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.8-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.8/roadrunner-1.4.8-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.8-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.8/roadrunner-1.4.8-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.7', - 'version' => 'v1.4.7', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.7-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.7/roadrunner-1.4.7-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.7-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.7/roadrunner-1.4.7-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.7-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.7/roadrunner-1.4.7-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.7-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.7/roadrunner-1.4.7-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.6', - 'version' => 'v1.4.6', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.6-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.6/roadrunner-1.4.6-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.6-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.6/roadrunner-1.4.6-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.6-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.6/roadrunner-1.4.6-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.6-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.6/roadrunner-1.4.6-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.5', - 'version' => 'v1.4.5', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.5-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.5/roadrunner-1.4.5-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.5-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.5/roadrunner-1.4.5-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.5-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.5/roadrunner-1.4.5-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.5-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.5/roadrunner-1.4.5-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.4', - 'version' => 'v1.4.4', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.4-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.4/roadrunner-1.4.4-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.4-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.4/roadrunner-1.4.4-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.4-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.4/roadrunner-1.4.4-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.4-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.4/roadrunner-1.4.4-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.3', - 'version' => 'v1.4.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.3/roadrunner-1.4.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.3/roadrunner-1.4.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.3/roadrunner-1.4.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.3/roadrunner-1.4.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.2', - 'version' => 'v1.4.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.2/roadrunner-1.4.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.2/roadrunner-1.4.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.2/roadrunner-1.4.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.2/roadrunner-1.4.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.1', - 'version' => 'v1.4.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.1/roadrunner-1.4.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.1/roadrunner-1.4.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.1/roadrunner-1.4.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.1/roadrunner-1.4.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.4.0', - 'version' => 'v1.4.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.4.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.0/roadrunner-1.4.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.0/roadrunner-1.4.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.4.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.0/roadrunner-1.4.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.4.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.4.0/roadrunner-1.4.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.7', - 'version' => 'v1.3.7', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.7-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.7/roadrunner-1.3.7-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.7-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.7/roadrunner-1.3.7-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.7-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.7/roadrunner-1.3.7-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.7-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.7/roadrunner-1.3.7-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.6', - 'version' => 'v1.3.6', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.6-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.6/roadrunner-1.3.6-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.6-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.6/roadrunner-1.3.6-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.6-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.6/roadrunner-1.3.6-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.6-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.6/roadrunner-1.3.6-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.5', - 'version' => 'v1.3.5', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.5-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.5/roadrunner-1.3.5-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.5-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.5/roadrunner-1.3.5-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.5-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.5/roadrunner-1.3.5-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.5-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.5/roadrunner-1.3.5-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.4', - 'version' => 'v1.3.4', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.4-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.4/roadrunner-1.3.4-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.4-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.4/roadrunner-1.3.4-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.4-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.4/roadrunner-1.3.4-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.4-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.4/roadrunner-1.3.4-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.3', - 'version' => 'v1.3.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.3/roadrunner-1.3.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.3/roadrunner-1.3.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.3/roadrunner-1.3.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.3/roadrunner-1.3.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.2', - 'version' => 'v1.3.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.2/roadrunner-1.3.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.2/roadrunner-1.3.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.2/roadrunner-1.3.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.2/roadrunner-1.3.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.1', - 'version' => 'v1.3.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.1/roadrunner-1.3.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.1/roadrunner-1.3.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.1/roadrunner-1.3.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.1/roadrunner-1.3.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.3.0', - 'version' => 'v1.3.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.3.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.0/roadrunner-1.3.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.0/roadrunner-1.3.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.3.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.0/roadrunner-1.3.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.3.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.3.0/roadrunner-1.3.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.8', - 'version' => 'v1.2.8', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.8-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.8/roadrunner-1.2.8-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.8-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.8/roadrunner-1.2.8-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.8-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.8/roadrunner-1.2.8-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.8-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.8/roadrunner-1.2.8-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.7', - 'version' => 'v1.2.7', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.7-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.7/roadrunner-1.2.7-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.7-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.7/roadrunner-1.2.7-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.7-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.7/roadrunner-1.2.7-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.7-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.7/roadrunner-1.2.7-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.6', - 'version' => 'v1.2.6', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.6-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.6/roadrunner-1.2.6-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.6-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.6/roadrunner-1.2.6-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.6-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.6/roadrunner-1.2.6-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.6-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.6/roadrunner-1.2.6-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.5', - 'version' => 'v1.2.5', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.5-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.5/roadrunner-1.2.5-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.5-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.5/roadrunner-1.2.5-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.5-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.5/roadrunner-1.2.5-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.5-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.5/roadrunner-1.2.5-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.4', - 'version' => 'v1.2.4', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.4-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.4/roadrunner-1.2.4-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.4-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.4/roadrunner-1.2.4-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.4-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.4/roadrunner-1.2.4-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.4-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.4/roadrunner-1.2.4-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.3', - 'version' => 'v1.2.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.3/roadrunner-1.2.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.3/roadrunner-1.2.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.3/roadrunner-1.2.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.3/roadrunner-1.2.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.2', - 'version' => 'v1.2.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.2/roadrunner-1.2.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.2/roadrunner-1.2.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.2/roadrunner-1.2.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.2/roadrunner-1.2.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.1', - 'version' => 'v1.2.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.1/roadrunner-1.2.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.1/roadrunner-1.2.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.1/roadrunner-1.2.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.1/roadrunner-1.2.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.2.0', - 'version' => 'v1.2.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.2.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.0/roadrunner-1.2.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.0/roadrunner-1.2.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.2.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.0/roadrunner-1.2.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.2.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.2.0/roadrunner-1.2.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.1.1', - 'version' => 'v1.1.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.1.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.1/roadrunner-1.1.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.1.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.1/roadrunner-1.1.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.1.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.1/roadrunner-1.1.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.1.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.1/roadrunner-1.1.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.1.0', - 'version' => 'v1.1.0', - 'assets' => [ - [ - 'name' => 'roadrunner-1.1.0-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.0/roadrunner-1.1.0-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.1.0-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.0/roadrunner-1.1.0-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.1.0-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.0/roadrunner-1.1.0-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.1.0-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.1.0/roadrunner-1.1.0-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.0.5', - 'version' => 'v1.0.5', - 'assets' => [ - [ - 'name' => 'roadrunner-1.0.5-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.5/roadrunner-1.0.5-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.5-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.5/roadrunner-1.0.5-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.5-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.5/roadrunner-1.0.5-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.0.5-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.5/roadrunner-1.0.5-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.0.4', - 'version' => 'untagged-f7dbae972208eff54361', - 'assets' => [ - [ - 'name' => 'roadrunner-1.0.4-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/untagged-f7dbae972208eff54361/roadrunner-1.0.4-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.4-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/untagged-f7dbae972208eff54361/roadrunner-1.0.4-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.4-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/untagged-f7dbae972208eff54361/roadrunner-1.0.4-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.0.4-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/untagged-f7dbae972208eff54361/roadrunner-1.0.4-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.0.3', - 'version' => 'v1.0.3', - 'assets' => [ - [ - 'name' => 'roadrunner-1.0.3-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.3/roadrunner-1.0.3-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.3-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.3/roadrunner-1.0.3-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.3-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.3/roadrunner-1.0.3-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.0.3-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.3/roadrunner-1.0.3-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.0.2', - 'version' => 'v1.0.2', - 'assets' => [ - [ - 'name' => 'roadrunner-1.0.2-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.2/roadrunner-1.0.2-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.2-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.2/roadrunner-1.0.2-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.2-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.2/roadrunner-1.0.2-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.0.2-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.2/roadrunner-1.0.2-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.0.1', - 'version' => 'v1.0.1', - 'assets' => [ - [ - 'name' => 'roadrunner-1.0.1-darwin-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.1/roadrunner-1.0.1-darwin-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.1-freebsd-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.1/roadrunner-1.0.1-freebsd-amd64.zip', - ], - [ - 'name' => 'roadrunner-1.0.1-linux-amd64.tar.gz', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.1/roadrunner-1.0.1-linux-amd64.tar.gz', - ], - [ - 'name' => 'roadrunner-1.0.1-windows-amd64.zip', - 'uri' => 'https://github.com/spiral/roadrunner/releases/download/v1.0.1/roadrunner-1.0.1-windows-amd64.zip', - ], - ], - ], - [ - 'name' => '1.0.0', - 'version' => 'v1.0.0', - 'assets' => [], - ], - [ - 'name' => '0.9.0', - 'version' => 'v0.9.0', - 'assets' => [], - ], - ]; - - /** - * @var array - */ - private array $releases = []; - - private HttpClientInterface $client; - - public function __construct(?HttpClientInterface $client = null) - { - $this->client = $client ?? HttpClient::create(); - - foreach (self::RELEASES_DATA as $release) { - $this->releases[] = $this->createRelease($release); - } - } - - #[\Override] - public function getName(): string - { - return 'spiral/roadrunner'; - } - - #[\Override] - public function getReleases(): ReleasesCollection - { - return new ReleasesCollection($this->releases); - } - - /** - * @param array{name: string, version: string, assets: array} $release - */ - private function createRelease(array $release): ReleaseInterface - { - $assets = []; - - foreach ($release['assets'] as $asset) { - $assets[] = $this->createAsset($asset); - } - - return new GitHubRelease($this->client, $release['name'], $release['version'], $this->getName(), $assets); - } - - /** - * @param array{name: string, uri: string} $asset - */ - private function createAsset(array $asset): AssetInterface - { - return new GitHubAsset($this->client, $asset['name'], $asset['uri']); - } -} diff --git a/src/VersionsCommand.php b/src/VersionsCommand.php index b9cb21b..ce8eceb 100644 --- a/src/VersionsCommand.php +++ b/src/VersionsCommand.php @@ -11,39 +11,43 @@ namespace Spiral\RoadRunner\Console; -use Spiral\RoadRunner\Console\Command\ArchitectureOption; -use Spiral\RoadRunner\Console\Command\OperatingSystemOption; -use Spiral\RoadRunner\Console\Command\StabilityOption; -use Spiral\RoadRunner\Console\Command\VersionFilterOption; -use Spiral\RoadRunner\Console\Environment\RoadRunnerVersion; -use Spiral\RoadRunner\Console\Environment\Stability; -use Spiral\RoadRunner\Console\Repository\ReleaseInterface; +use Spiral\RoadRunner\Console\Internal\Environment\RoadRunnerVersion; +use Spiral\RoadRunner\Console\Internal\Environment\Stability; +use Spiral\RoadRunner\Console\Internal\GitHub\Release; +use Spiral\RoadRunner\Console\Internal\GitHub\ReleasesClient; +use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; +use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; +use Spiral\RoadRunner\Console\Internal\Option\StabilityOption; +use Spiral\RoadRunner\Console\Internal\Option\VersionFilterOption; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\StyleInterface; +use Symfony\Component\Console\Style\SymfonyStyle; -class VersionsCommand extends Command +/** + * @psalm-api + */ +final class VersionsCommand extends Command { private OperatingSystemOption $os; private ArchitectureOption $arch; private VersionFilterOption $version; private StabilityOption $stability; - public function __construct(?string $name = null) - { + /** + * @param ReleasesClient|null $releases Configured from the environment on every run by default. + */ + public function __construct( + ?string $name = null, + private readonly ?ReleasesClient $releases = null, + ) { parent::__construct($name ?? 'versions'); $this->os = new OperatingSystemOption($this); $this->arch = new ArchitectureOption($this); $this->stability = new StabilityOption($this); - $this->version = new class($this) extends VersionFilterOption { - #[\Override] - protected function default(): string - { - return '*'; - } - }; + $this->version = new VersionFilterOption($this, default: '*'); } #[\Override] @@ -55,112 +59,79 @@ public function getDescription(): string #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - $io = $this->io($input, $output); + $io = new SymfonyStyle($input, $output); + + $filter = $this->version->get($input, $io); + $constraint = \trim($filter); + $stability = $this->stability->get($input, $io); + $os = $this->os->get($input, $io); + $arch = $this->arch->get($input, $io); $output->writeln(''); $output->writeln(' Environment:'); - $output->writeln(\sprintf(' - Version: %s', $this->version->get($input, $io))); - $output->writeln(\sprintf(' - Stability: %s', $this->stability->get($input, $io))); + $output->writeln(\sprintf(' - Version: %s', $filter)); + $output->writeln(\sprintf(' - Stability: %s', $stability)); $output->writeln(''); - $rows = []; - - $versions = $this->getRepository() - ->getReleases() - ->sortByVersion() - ->minimumStability($this->stability->get($input, $io)) - ->filter(static fn(ReleaseInterface $release): bool => \count($release->getAssets()) > 0) - ->satisfies($this->version->get($input, $io)) - ; + $minimumStability = Stability::toInt($stability); + $releases = \array_filter( + ($this->releases ?? ReleasesClient::fromEnvironment())->releases(), + static fn(Release $release): bool => $release->assets !== [] + && Stability::toInt($release->stability) >= $minimumStability + && ($constraint === '' || $release->satisfies($constraint)), + ); + \usort($releases, Release::newestFirst(...)); - foreach ($versions as $version) { + $rows = []; + foreach ($releases as $release) { $rows[] = [ - $this->versionToString($version), - $this->stabilityToString($version), - $this->assetsToString($version), - $this->compatibilityToString($version, $input, $io), + $release->name, + self::stabilityToString($release->stability), + \sprintf(' ✓ (%d)', \count($release->assets)), + self::compatibilityToString($release, $os, $arch), ]; } $io->table(['Release', 'Stability', 'Binaries', 'Compatibility'], $rows); - return 0; + return self::SUCCESS; } - private function compatibilityToString(ReleaseInterface $release, InputInterface $input, StyleInterface $io): string + private static function compatibilityToString(Release $release, string $os, string $arch): string { $template = ' ✖ (reason: %s)'; - // Validate version if (! $release->satisfies(RoadRunnerVersion::constraint())) { return \sprintf($template, 'incompatible version'); } - // Validate assets - $assets = $release->getAssets(); - - if ($assets->empty()) { - return \sprintf($template, 'no binaries'); - } - - // Validate OS - $assets = $assets->whereOperatingSystem( - $os = $this->os->get($input, $io), + $assets = \array_filter( + $release->assets, + static fn(string $asset): bool => \str_contains($asset, '-' . \strtolower($os) . '-'), ); - - if ($assets->empty()) { + if ($assets === []) { return \sprintf($template, 'no assembly for ' . $os); } - // Validate architecture - $assets = $assets->whereArchitecture( - $arch = $this->arch->get($input, $io), + $assets = \array_filter( + $assets, + static fn(string $asset): bool => \str_contains($asset, '-' . \strtolower($arch) . '.'), ); - - if ($assets->empty()) { + if ($assets === []) { return \sprintf($template, 'no assembly for ' . $arch); } return ' ✓ '; } - private function versionToString(ReleaseInterface $release): string - { - return $release->getName(); - } - - private function stabilityToString(ReleaseInterface $release): string + private static function stabilityToString(string $stability): string { - $stability = $release->getStability(); - - switch ($stability) { - case Stability::STABILITY_STABLE: - return " $stability "; - - case Stability::STABILITY_RC: - return " $stability "; - - case Stability::STABILITY_BETA: - return " $stability "; - - case Stability::STABILITY_ALPHA: - return " $stability "; - - default: - return " $stability "; - } - } - - private function assetsToString(ReleaseInterface $release): string - { - $count = $release->getAssets() - ->count() - ; - - if ($count > 0) { - return \sprintf(' ✓ (%d)', $count); - } - - return ' ✖ '; + return match ($stability) { + Stability::STABILITY_STABLE => " $stability ", + Stability::STABILITY_RC => " $stability ", + Stability::STABILITY_BETA => " $stability ", + Stability::STABILITY_ALPHA => " $stability ", + default => " $stability ", + }; } } diff --git a/tests/Acceptance/AuthenticationTest.php b/tests/Acceptance/AuthenticationTest.php new file mode 100644 index 0000000..f7fb5b3 --- /dev/null +++ b/tests/Acceptance/AuthenticationTest.php @@ -0,0 +1,39 @@ +rr()->withEnv('GITHUB_TOKEN', 'github-com-token')->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::false(\in_array(true, \array_column(FakeGitHub::releaseRequests(), 'authorized'), true)); + } + + public function sendsTokenDeclaredForEnterpriseServer(): void + { + $port = (string) \parse_url(FakeGitHub::url(), \PHP_URL_PORT); + + $result = $this->rr()->withEnv('DLOAD_TOKEN_127_0_0_1___' . $port, 'server-token')->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\array_column(FakeGitHub::releaseRequests(), 'authorized'), [true]); + } +} diff --git a/tests/Acceptance/ExistingBinaryTest.php b/tests/Acceptance/ExistingBinaryTest.php index 7aaf5ec..0a00b59 100644 --- a/tests/Acceptance/ExistingBinaryTest.php +++ b/tests/Acceptance/ExistingBinaryTest.php @@ -7,7 +7,6 @@ use Spiral\RoadRunner\Console\Tests\Acceptance\Support\FakeGitHubCase; use Testo\Assert; use Testo\Data\DataSet; -use Testo\Skip; use Testo\Test; /** @@ -43,7 +42,6 @@ public function keepsBinaryWhenOverwriteIsDeclined(): void #[DataSet([null], 'no interaction')] #[DataSet([['no']], 'declined')] - #[Skip('A kept binary exits with 0 and reports success, see https://github.com/roadrunner-php/cli/issues/69')] public function failsWhenBinaryIsKept(?array $answers): void { $this->dir->write('rr', self::OLD); diff --git a/tests/Acceptance/FailureTest.php b/tests/Acceptance/FailureTest.php index f78d2ea..34e3fbc 100644 --- a/tests/Acceptance/FailureTest.php +++ b/tests/Acceptance/FailureTest.php @@ -8,7 +8,6 @@ use Spiral\RoadRunner\Console\Tests\Acceptance\Support\FakeGitHubCase; use Testo\Assert; use Testo\Data\DataSet; -use Testo\Skip; use Testo\Test; /** @@ -69,7 +68,6 @@ public function failsForUnknownPlatform(array $options): void #[DataSet([['--os=plan9']], 'operating system')] #[DataSet([['--arch=sparc']], 'architecture')] - #[Skip('An unknown --os or --arch still fetches the releases, see https://github.com/roadrunner-php/cli/issues/68')] public function rejectsUnknownPlatformWithoutRequests(array $options): void { $result = $this->rr()->run([...self::ARGS, ...$options]); diff --git a/tests/Acceptance/Server/releases.json b/tests/Acceptance/Server/releases.json index 90047a7..ceac630 100644 --- a/tests/Acceptance/Server/releases.json +++ b/tests/Acceptance/Server/releases.json @@ -37,6 +37,7 @@ "prerelease": false, "assets": [ "roadrunner-2025.1.5-linux-amd64.tar.gz", + "protoc-gen-php-grpc-2025.1.5-linux-amd64.tar.gz", "roadrunner-2025.1.5-linux-arm64.tar.gz", "roadrunner-2025.1.5-darwin-arm64.tar.gz", "roadrunner-2025.1.5-windows-amd64.zip" diff --git a/tests/Acceptance/Server/router.php b/tests/Acceptance/Server/router.php index 89bc072..f272446 100644 --- a/tests/Acceptance/Server/router.php +++ b/tests/Acceptance/Server/router.php @@ -3,14 +3,17 @@ /** * A fake GitHub for the acceptance tests, run as `php -S 127.0.0.1: router.php`. * - * The first path segment selects a scenario, so one server serves every test through a different base URL: + * A server plays one scenario, named by the FAKE_GITHUB_SCENARIO environment variable: DLoad takes a GitHub + * Enterprise Server from the repository uri `scheme://host:port/owner/repo`, so a scenario can not be a part of the path. * - `github` — releases from releases.json, paginated by `per_page` like GitHub (30 by default, 100 at most); * - `paged` — the same releases, 2 per page whatever `per_page` asks for; * - `rate-limit` — every API request fails with the 403 GitHub sends when the rate limit is exhausted; - * - `missing-asset` — releases are listed, but every download is a 404. + * - `missing-asset` — releases are listed, but every download is a 404; + * - `redirect` — every request is redirected to the same path of the server named by FAKE_GITHUB_REDIRECT_TO. * - * Release lists are served at `//repos/roadrunner-server/roadrunner/releases`, assets at - * `//download//`. Archives are built on request: `roadrunner---/rr[.exe]` + * Like GitHub Enterprise Server, the API is served under `/api/v3`: release lists at + * `/api/v3/repos/roadrunner-server/roadrunner/releases`, assets at + * `/download//`. Archives are built on request: `roadrunner---/rr[.exe]` * holding the text `fake rr `, so a test can tell which asset ended up installed. * * Each request is appended to the JSON Lines file named by the FAKE_GITHUB_LOG environment variable. @@ -23,8 +26,9 @@ $path = (string) \parse_url($_SERVER['REQUEST_URI'], \PHP_URL_PATH); \parse_str((string) \parse_url($_SERVER['REQUEST_URI'], \PHP_URL_QUERY), $query); $segments = \explode('/', \trim($path, '/')); -$scenario = \array_shift($segments); -$base = 'http://' . $_SERVER['HTTP_HOST'] . '/' . $scenario; +$scenario = (string) \getenv('FAKE_GITHUB_SCENARIO'); +$server = 'http://' . $_SERVER['HTTP_HOST']; +$api = $server . '/api/v3'; $respond = static function (int $status, string $body, array $headers = []) use ($path, $query): void { $log = \getenv('FAKE_GITHUB_LOG'); @@ -76,6 +80,12 @@ return $content; }; +if ($scenario === 'redirect') { + $respond(302, '', ['Location' => \getenv('FAKE_GITHUB_REDIRECT_TO') . $_SERVER['REQUEST_URI']]); + + return true; +} + if ($scenario === 'rate-limit') { $respond(403, \json_encode([ 'message' => 'API rate limit exceeded for 127.0.0.1. (But here\'s the good news: Authenticated requests get a higher rate limit.)', @@ -90,7 +100,7 @@ return true; } -if (\implode('/', $segments) === 'repos/roadrunner-server/roadrunner/releases') { +if (\implode('/', $segments) === 'api/v3/repos/roadrunner-server/roadrunner/releases') { $releases = \json_decode((string) \file_get_contents(__DIR__ . '/releases.json'), true); $perPage = $scenario === 'paged' ? 2 : \min(100, \max(1, (int) ($query['per_page'] ?? 30))); $page = \max(1, (int) ($query['page'] ?? 1)); @@ -104,16 +114,20 @@ 'name' => $tag, 'draft' => false, 'prerelease' => $release['prerelease'], + 'published_at' => '2026-01-01T00:00:00Z', 'assets' => \array_map(static fn(string $asset): array => [ 'name' => $asset, - 'browser_download_url' => "$base/download/$tag/$asset", + 'browser_download_url' => "$server/download/$tag/$asset", + // Archives are built on request, so their real size is unknown here + 'size' => 0, + 'content_type' => \str_ends_with($asset, '.zip') ? 'application/zip' : 'application/gzip', ], $release['assets']), ]; } $link = static fn(int $to, string $rel): string => \sprintf( '<%s/repos/roadrunner-server/roadrunner/releases?per_page=%d&page=%d>; rel="%s"', - $base, + $api, $perPage, $to, $rel, diff --git a/tests/Acceptance/Support/FakeGitHub.php b/tests/Acceptance/Support/FakeGitHub.php index 130f23c..d750ce3 100644 --- a/tests/Acceptance/Support/FakeGitHub.php +++ b/tests/Acceptance/Support/FakeGitHub.php @@ -5,82 +5,60 @@ namespace Spiral\RoadRunner\Console\Tests\Acceptance\Support; /** - * Runs `Server/router.php` in PHP's built-in web server on a free local port. + * Runs `Server/router.php` in PHP's built-in web server, one server on a free local port per scenario. */ final class FakeGitHub { - /** @var resource|null */ - private static $process = null; + /** @var array Server processes by scenario. */ + private static array $processes = []; + + /** @var array Server URLs by scenario. */ + private static array $servers = []; - private static string $url = ''; private static string $log = ''; private static string $output = ''; public static function start(): void { - if (self::$process !== null) { + if (self::$log !== '') { return; } - $port = self::freePort(); $dir = \sys_get_temp_dir() . '/rr-cli-acceptance'; @\mkdir($dir, 0777, true); self::$log = \tempnam($dir, 'requests-'); self::$output = \tempnam($dir, 'server-'); - - $env = Rr::baseEnvironment(); - $env['FAKE_GITHUB_LOG'] = self::$log; - - $process = \proc_open( - [\PHP_BINARY, '-S', "127.0.0.1:$port", \dirname(__DIR__) . '/Server/router.php'], - [0 => ['pipe', 'r'], 1 => ['file', self::$output, 'a'], 2 => ['file', self::$output, 'a']], - $pipes, - null, - $env, - ); - \assert(\is_resource($process)); - \fclose($pipes[0]); - - self::$process = $process; - self::$url = "http://127.0.0.1:$port"; - - $deadline = \microtime(true) + 10; - while (($socket = @\fsockopen('127.0.0.1', $port, $errno, $error, 0.2)) === false) { - if (\microtime(true) > $deadline || ! \proc_get_status($process)['running']) { - self::stop(); - throw new \RuntimeException('The fake GitHub server did not start: ' . self::serverOutput()); - } - \usleep(50_000); - } - \fclose($socket); } public static function stop(): void { - if (self::$process !== null) { - \proc_terminate(self::$process); - \proc_close(self::$process); - self::$process = null; + foreach (self::$processes as $process) { + \proc_terminate($process); + \proc_close($process); } + self::$processes = []; + self::$servers = []; @\unlink(self::$log); @\unlink(self::$output); + self::$log = ''; + self::$output = ''; } /** - * Base API URL of a scenario, see the router for the list. + * Base API URL of a scenario, see the router for the list; its server starts on first use. */ public static function url(string $scenario = 'github'): string { - return self::$url . '/' . $scenario; + return (self::$servers[$scenario] ??= self::startServer($scenario)) . '/api/v3'; } /** - * A base URL nothing listens on. + * A base API URL nothing listens on. */ public static function unreachableUrl(): string { - return 'http://127.0.0.1:' . self::freePort() . '/github'; + return 'http://127.0.0.1:' . self::freePort() . '/api/v3'; } public static function resetRequests(): void @@ -124,6 +102,48 @@ public static function downloads(): array return $names; } + /** + * @return string `http://127.0.0.1:` + */ + private static function startServer(string $scenario): string + { + self::start(); + $env = Rr::baseEnvironment(); + if ($scenario === 'redirect') { + // Before the port is picked: the target server takes a free port too + $env['FAKE_GITHUB_REDIRECT_TO'] = \dirname(self::url(), 2); + } + + $port = self::freePort(); + $env['FAKE_GITHUB_LOG'] = self::$log; + $env['FAKE_GITHUB_SCENARIO'] = $scenario; + + $process = \proc_open( + [\PHP_BINARY, '-S', "127.0.0.1:$port", \dirname(__DIR__) . '/Server/router.php'], + [0 => ['pipe', 'r'], 1 => ['file', self::$output, 'a'], 2 => ['file', self::$output, 'a']], + $pipes, + null, + $env, + ); + \assert(\is_resource($process)); + \fclose($pipes[0]); + + self::$processes[$scenario] = $process; + + $deadline = \microtime(true) + 10; + while (($socket = @\fsockopen('127.0.0.1', $port, $errno, $error, 0.2)) === false) { + if (\microtime(true) > $deadline || ! \proc_get_status($process)['running']) { + $output = self::serverOutput(); + self::stop(); + throw new \RuntimeException("The fake GitHub server of the $scenario scenario did not start: $output"); + } + \usleep(50_000); + } + \fclose($socket); + + return "http://127.0.0.1:$port"; + } + private static function serverOutput(): string { return (string) @\file_get_contents(self::$output); diff --git a/tests/Acceptance/VersionSelectionTest.php b/tests/Acceptance/VersionSelectionTest.php index 6937e7f..0df1d58 100644 --- a/tests/Acceptance/VersionSelectionTest.php +++ b/tests/Acceptance/VersionSelectionTest.php @@ -8,7 +8,6 @@ use Spiral\RoadRunner\Console\Tests\Acceptance\Support\FakeGitHubCase; use Testo\Assert; use Testo\Data\DataSet; -use Testo\Skip; use Testo\Test; /** @@ -33,6 +32,7 @@ public function installsNewestStableReleaseOfDefaultMajor(string $command): void #[DataSet(['3.1.*', '3.1.0'])] #[DataSet(['2025.*', '2025.1.5'])] #[DataSet(['^2.12', '2.12.3'])] + #[DataSet(['>=2024', '2025.1.5'])] public function installsNewestReleaseMatchingFilter(string $filter, string $version): void { $result = $this->rr()->run(['get', '--filter=' . $filter, '--os=linux', '--arch=amd64']); @@ -41,9 +41,46 @@ public function installsNewestReleaseMatchingFilter(string $filter, string $vers Assert::same(\trim($this->dir->read('rr')), "fake rr $version linux amd64"); } + /** + * 2025.1.5 is newer than any 3.x by number, but `*` stands for 3.x. + */ + #[DataSet(['stable', '3.4.0'])] + #[DataSet(['beta', '3.5.0-rc.1'])] + public function treatsWildcardFilterAsAnyV3Release(string $stability, string $version): void + { + $result = $this->rr()->run(['get', '--filter=*', '--stability=' . $stability, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), "fake rr $version linux amd64"); + } + + #[DataSet(['*'])] + #[DataSet(['>=3.0'])] + public function keepsProtocPluginFilterBelowCalendarReleases(string $filter): void + { + $result = $this->rr()->run(['download-protoc-binary', '--filter=' . $filter, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 3.4.0 linux amd64'); + } + + /** + * A filter without an upper bound stays below 2025.1.5, which DLoad ranks above 3.x by number. + */ + #[DataSet(['>=3.0'])] + #[DataSet(['>=2.12'])] + #[DataSet(['!=3.1.0'])] + #[DataSet(['^2.12 || >=3.2'])] + public function keepsUnboundedFilterBelowCalendarReleases(string $filter): void + { + $result = $this->rr()->run(['get', '--filter=' . $filter, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.4.0 linux amd64'); + } + #[DataSet(['stable', '3.*', '3.4.0'])] #[DataSet(['RC', '3.*', '3.5.0-rc.1'])] - #[DataSet(['beta', '3.5.0-beta.1', '3.5.0-beta.1'])] public function honoursMinimumStability(string $stability, string $filter, string $version): void { $result = $this->rr()->run(['get', '--stability=' . $stability, '--filter=' . $filter, '--os=linux', '--arch=amd64']); @@ -52,9 +89,16 @@ public function honoursMinimumStability(string $stability, string $filter, strin Assert::same(\trim($this->dir->read('rr')), "fake rr $version linux amd64"); } + public function installsExactPreRelease(): void + { + $result = $this->rr()->run(['get', '--stability=beta', '--filter=3.5.0-beta.1', '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.5.0-beta.1 linux amd64'); + } + #[DataSet(['rc'])] #[DataSet(['beta'])] - #[Skip('-rc tags sort below -beta ones and --stability=rc means dev, see https://github.com/roadrunner-php/cli/issues/58 and https://github.com/roadrunner-php/cli/issues/67')] public function prefersReleaseCandidateOverOlderBeta(string $stability): void { $result = $this->rr()->run(['get', '--stability=' . $stability, '--os=linux', '--arch=amd64']); @@ -109,8 +153,13 @@ public function followsPagination(): void Assert::same($result->exitCode, 0, (string) $result); Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.4.0 linux amd64'); + // Unique: DLoad derives the page of the older releases from per_page=100, which this scenario ignores, + // so the first page is asked for twice Assert::same( - \array_map(static fn(array $request): string => (string) $request['query']['page'], FakeGitHub::releaseRequests()), + \array_values(\array_unique(\array_map( + static fn(array $request): string => (string) $request['query']['page'], + FakeGitHub::releaseRequests(), + ))), ['1', '2', '3', '4'], ); } diff --git a/tests/Acceptance/VersionsTest.php b/tests/Acceptance/VersionsTest.php new file mode 100644 index 0000000..efd0786 --- /dev/null +++ b/tests/Acceptance/VersionsTest.php @@ -0,0 +1,141 @@ +rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(self::releases($result->stdout), ['3.4.0', '3.2.0', '3.1.0', '2025.1.5', '2.12.3']); + Assert::string($result->stdout) + ->matchesRegex('/^\s*3\.4\.0\s+stable\s+✓\s+\(7\)\s+✓\s*$/mu') + ->matchesRegex('/^\s*2025\.1\.5\s+stable\s+.*\(reason: incompatible version\)/mu'); + Assert::same(\count(FakeGitHub::releaseRequests()), 1); + } + + public function listsPreReleasesOfLowerStability(): void + { + $result = $this->rr()->run([...self::ARGS, '--stability=beta']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same( + self::releases($result->stdout), + ['3.5.0-RC1', '3.5.0-beta1', '3.4.0', '3.2.0', '3.1.0', '2025.1.5', '2.12.3'], + ); + Assert::string($result->stdout) + ->matchesRegex('/^\s*3\.5\.0-RC1\s+RC\s+✓\s+\(4\)\s+✓\s*$/mu') + ->matchesRegex('/^\s*3\.5\.0-beta1\s+beta\s+✓\s+\(4\)\s+✓\s*$/mu'); + } + + public function filtersByVersionConstraint(): void + { + $result = $this->rr()->run([...self::ARGS, '--filter=^3.1 <3.4']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(self::releases($result->stdout), ['3.2.0', '3.1.0']); + } + + public function explainsMissingAssembly(): void + { + $result = $this->rr()->run(['versions', '--os=darwin', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->stdout) + ->matchesRegex('/^\s*3\.4\.0\s+.*\(reason: no assembly for amd64\)/mu') + ->matchesRegex('/^\s*2\.12\.3\s+.*\(reason: incompatible version\)/mu'); + + $result = $this->rr()->run(['versions', '--os=freebsd', '--arch=amd64']); + + Assert::string($result->stdout)->matchesRegex('/^\s*3\.4\.0\s+.*\(reason: no assembly for freebsd\)/mu'); + } + + public function followsPagination(): void + { + $result = $this->rr(FakeGitHub::url('paged'))->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(self::releases($result->stdout), ['3.4.0', '3.2.0', '3.1.0', '2025.1.5', '2.12.3']); + Assert::same(\array_column(\array_column(FakeGitHub::releaseRequests(), 'query'), 'page'), ['1', '2', '3', '4']); + } + + public function keepsGitHubTokenFromEnterpriseServer(): void + { + $result = $this->rr()->withEnv('GITHUB_TOKEN', 'github-com-token')->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\array_column(FakeGitHub::releaseRequests(), 'authorized'), [false]); + } + + public function sendsTokenDeclaredForEnterpriseServer(): void + { + $result = $this->rr()->withEnv(self::tokenVariable(FakeGitHub::url()), 'server-token')->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\array_column(FakeGitHub::releaseRequests(), 'authorized'), [true]); + } + + public function dropsTokenOnRedirectToAnotherServer(): void + { + $url = FakeGitHub::url('redirect'); + + $result = $this->rr($url)->withEnv(self::tokenVariable($url), 'server-token')->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(self::releases($result->stdout), ['3.4.0', '3.2.0', '3.1.0', '2025.1.5', '2.12.3']); + Assert::same( + \array_map( + static fn(array $request): array => [$request['status'], $request['authorized']], + FakeGitHub::releaseRequests(), + ), + [[302, true], [200, false]], + ); + } + + public function failsWhenRateLimited(): void + { + $result = $this->rr(FakeGitHub::url('rate-limit'))->run(self::ARGS); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::string($result->output())->matchesRegex('/403|rate limit/i'); + } + + public function failsWhenGitHubIsUnreachable(): void + { + $result = $this->rr(FakeGitHub::unreachableUrl())->run(self::ARGS); + + Assert::notSame($result->exitCode, 0, (string) $result); + } + + private static function tokenVariable(string $apiUrl): string + { + return 'DLOAD_TOKEN_127_0_0_1___' . \parse_url($apiUrl, \PHP_URL_PORT); + } + + /** + * @return list The first column of the table. + */ + private static function releases(string $output): array + { + \preg_match_all('/^\s+(\d[\w.-]*)\s+(?:stable|RC|beta|alpha|dev)\s/m', $output, $matches); + + return $matches[1]; + } +} diff --git a/tests/Unit/Archive/FactoryTest.php b/tests/Unit/Archive/FactoryTest.php deleted file mode 100644 index 3de80f3..0000000 --- a/tests/Unit/Archive/FactoryTest.php +++ /dev/null @@ -1,140 +0,0 @@ -dir = TempDirectory::create(); - } - - #[AfterTest] - public function removeDirectory(): void - { - TempDirectory::remove($this->dir); - } - - #[DataSet(['rr.zip', 'rr.zip', ZipPharArchive::class])] - #[DataSet(['rr.zip', 'RR.ZIP', ZipPharArchive::class], 'upper-case extension')] - #[DataSet(['rr.tar.gz', 'rr.tar.gz', TarPharArchive::class])] - public function createPicksArchiveByExtension(string $source, string $name, string $class): void - { - $path = TempDirectory::archive($this->dir, $source, ['rr' => 'binary']); - \rename($path, $this->dir . '/' . $name); - - $archive = (new Factory())->create(new \SplFileInfo($this->dir . '/' . $name)); - - Assert::instanceOf($archive, $class); - } - - public function createRejectsUnknownExtension(): never - { - \file_put_contents($this->dir . '/rr.rar', 'data'); - - Expect::exception(\InvalidArgumentException::class) - ->withMessageContaining('Can not open the archive "rr.rar"'); - - (new Factory())->create(new \SplFileInfo($this->dir . '/rr.rar')); - } - - public function createReportsErrorsOfMatchingFormats(): never - { - Expect::exception(\InvalidArgumentException::class) - ->withMessageContaining('Archive "missing.zip" is not a file'); - - (new Factory())->create(new \SplFileInfo($this->dir . '/missing.zip')); - } - - public function extendedMatcherTakesPriority(): void - { - $path = TempDirectory::archive($this->dir, 'rr.zip', ['rr' => 'binary']); - $custom = new class implements ArchiveInterface { - public function extract(iterable $mappings): \Generator - { - yield from []; - } - }; - - $archive = (new Factory()) - ->extend(static fn(\SplFileInfo $file): ArchiveInterface => $custom) - ->create(new \SplFileInfo($path)); - - Assert::same($archive, $custom); - } - - public function extendedMatcherMayDecline(): void - { - $path = TempDirectory::archive($this->dir, 'rr.zip', ['rr' => 'binary']); - - $archive = (new Factory()) - ->extend(static fn(\SplFileInfo $file): ?ArchiveInterface => null) - ->create(new \SplFileInfo($path)); - - Assert::instanceOf($archive, ZipPharArchive::class); - } - - public function fromAssetDownloadsIntoTempDirectory(): void - { - $source = TempDirectory::archive($this->dir, 'source.zip', ['rr' => 'binary']); - $temp = $this->dir . '/temp'; - \mkdir($temp); - $asset = new GitHubAsset( - new MockHttpClient(new MockResponse((string) \file_get_contents($source))), - 'roadrunner-2024.1.0-linux-amd64.zip', - 'https://example.com/rr.zip', - ); - - $archive = (new Factory())->fromAsset($asset, null, $temp); - - Assert::instanceOf($archive, ZipPharArchive::class); - Assert::same(\file_get_contents($temp . '/roadrunner-2024.1.0-linux-amd64.zip'), \file_get_contents($source)); - } - - public function fromAssetRejectsMissingTempDirectory(): never - { - Expect::exception(\LogicException::class) - ->withMessageContaining('is not writeable'); - - (new Factory())->fromAsset( - new GitHubAsset(new MockHttpClient(), 'rr.zip', 'https://example.com/rr.zip'), - null, - $this->dir . '/missing', - ); - } - - public function fromAssetRethrowsDownloadErrors(): never - { - $asset = new GitHubAsset( - new MockHttpClient(new MockResponse('', ['error' => 'connection refused'])), - 'rr.zip', - 'https://example.com/rr.zip', - ); - - Expect::exception(\RuntimeException::class) - ->withMessageContaining('connection refused'); - - (new Factory())->fromAsset($asset, null, $this->dir); - } -} diff --git a/tests/Unit/Archive/PharAwareArchiveTest.php b/tests/Unit/Archive/PharAwareArchiveTest.php deleted file mode 100644 index 5fedf0f..0000000 --- a/tests/Unit/Archive/PharAwareArchiveTest.php +++ /dev/null @@ -1,94 +0,0 @@ -dir = TempDirectory::create(); - } - - #[AfterTest] - public function removeDirectory(): void - { - TempDirectory::remove($this->dir); - } - - #[DataSet(['rr.zip', ZipPharArchive::class])] - #[DataSet(['rr.tar.gz', TarPharArchive::class])] - #[DataSet(['rr.tar', PharArchive::class])] - public function extractCopiesMappedFiles(string $name, string $class): void - { - $path = TempDirectory::archive($this->dir, $name, [ - 'roadrunner-2024.1.0/rr' => 'binary', - 'roadrunner-2024.1.0/README.md' => 'readme', - ]); - $archive = new $class(new \SplFileInfo($path)); - - $extracted = []; - foreach ($archive->extract(['rr' => $this->dir . '/rr', 'rr.exe' => $this->dir . '/rr.exe']) as $from => $to) { - $extracted[$from->getFilename()] = $to->getPathname(); - } - - Assert::same($extracted, ['rr' => $this->dir . '/rr']); - Assert::same(\file_get_contents($this->dir . '/rr'), 'binary'); - Assert::false(\is_file($this->dir . '/README.md')); - } - - public function extractSkipsFileWhenConsumerDeclines(): void - { - $path = TempDirectory::archive($this->dir, 'rr.zip', ['roadrunner/rr' => 'binary']); - $extractor = (new ZipPharArchive(new \SplFileInfo($path)))->extract(['rr' => $this->dir . '/rr']); - - Assert::true($extractor->valid()); - $extractor->send(false); - - Assert::false($extractor->valid()); - Assert::false(\is_file($this->dir . '/rr')); - } - - #[Skip('Bug: extract() reopens $this->archive, whose pathname points at the first archive entry rather than the archive itself, so a binary in the archive root cannot be extracted')] - public function extractFindsBinaryInArchiveRoot(): void - { - $path = TempDirectory::archive($this->dir, 'rr.zip', ['LICENSE' => 'license', 'rr' => 'binary']); - - \iterator_to_array((new ZipPharArchive(new \SplFileInfo($path)))->extract(['rr' => $this->dir . '/rr'])); - - Assert::same(\file_get_contents($this->dir . '/rr'), 'binary'); - } - - public function rejectsMissingFile(): never - { - Expect::exception(\InvalidArgumentException::class) - ->withMessage('Archive "missing.zip" is not a file'); - - new ZipPharArchive(new \SplFileInfo($this->dir . '/missing.zip')); - } - - public function rejectsDirectory(): never - { - Expect::exception(\InvalidArgumentException::class) - ->withMessageContaining('is not a file'); - - new ZipPharArchive(new \SplFileInfo($this->dir)); - } -} diff --git a/tests/Unit/Command/VersionFilterOptionTest.php b/tests/Unit/Command/VersionFilterOptionTest.php deleted file mode 100644 index 1a0b115..0000000 --- a/tests/Unit/Command/VersionFilterOptionTest.php +++ /dev/null @@ -1,69 +0,0 @@ -command); - $input = $host->input(); - - Assert::same($option->get($input, $host->io($input)), RoadRunnerVersion::constraint()); - } - - public function findReturnsMatchingReleasesNewestFirst(): void - { - $host = new OptionHost(); - $option = new VersionFilterOption($host->command); - $input = $host->input(['--filter' => '^2024.1']); - $repository = new InMemoryRepository( - Releases::release('v2024.1.0'), - Releases::release('v2023.3.12'), - Releases::release('v2024.2.1'), - ); - - $releases = $option->find($input, $host->io($input), $repository); - - Assert::same(Releases::versions($releases), ['v2024.2.1', 'v2024.1.0']); - } - - public function findFailsWhenNothingMatches(): never - { - $host = new OptionHost(); - $option = new VersionFilterOption($host->command); - $input = $host->input(['-f' => '^2025.1']); - $repository = new InMemoryRepository(Releases::release('v2023.3.12'), Releases::release('v2024.1.0')); - - Expect::exception(\UnexpectedValueException::class) - ->withMessage( - "Could not find any available RoadRunner binary version which meets version criterion (--filter=^2025.1)\n" - . 'Available: v2024.1.0, v2023.3.12', - ); - - $option->find($input, $host->io($input), $repository); - } - - public function choicesListsUniqueVersions(): void - { - $option = new VersionFilterOption((new OptionHost())->command); - - Assert::same( - $option->choices(Releases::collection('v2024.1.0', 'v2023.3.12', 'v2024.1.0')), - 'v2024.1.0, v2023.3.12', - ); - } -} diff --git a/tests/Unit/CommandTest.php b/tests/Unit/CommandTest.php deleted file mode 100644 index 6fcb39a..0000000 --- a/tests/Unit/CommandTest.php +++ /dev/null @@ -1,53 +0,0 @@ -getName(), 'unknown/unknown'); - } - - public function repositoryRequestsApiUrlFromEnvironment(): void - { - // The unsupported scheme makes the client fail before any connection, with the URL in the message. - $_SERVER[self::ENV_API_URL] = 'ftp://github.mock/api'; - - try { - Expect::exception(\InvalidArgumentException::class) - ->withMessageContaining('ftp://github.mock/api/repos/roadrunner-server/roadrunner/releases'); - - self::repository()->getReleases()->empty(); - } finally { - unset($_SERVER[self::ENV_API_URL]); - } - } - - private static function repository(): RepositoryInterface - { - $command = new class('test') extends Command { - public function repository(): RepositoryInterface - { - return $this->getRepository(); - } - }; - - return $command->repository(); - } -} diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php index c89f13a..afb1dc0 100644 --- a/tests/Unit/DownloadProtocBinaryCommandTest.php +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -5,15 +5,33 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; use Spiral\RoadRunner\Console\DownloadProtocBinaryCommand; +use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Tests\Unit\Stub\DLoadGetSpy; +use Spiral\RoadRunner\Console\Tests\Unit\Stub\TempDirectory; +use Symfony\Component\Console\Tester\CommandTester; use Testo\Assert; +use Testo\Data\DataSet; +use Testo\Lifecycle\AfterTest; +use Testo\Lifecycle\BeforeTest; use Testo\Test; -/** - * The command is final and always queries GitHub, so only its definition is covered here. - */ #[Test] final class DownloadProtocBinaryCommandTest { + private string $dir; + + #[BeforeTest] + public function createDirectory(): void + { + $this->dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + public function describesItself(): void { $command = new DownloadProtocBinaryCommand(); @@ -30,4 +48,77 @@ public function definesEnvironmentOptions(): void ->sameElementsAs(['os', 'arch', 'filter', 'location', 'stability']); Assert::same($definition->getOption('stability')->getDefault(), 'stable'); } + + #[DataSet([0])] + #[DataSet([1], 'failure')] + public function installsBinaryThroughDLoad(int $exitCode): void + { + $get = new DLoadGetSpy(exitCode: $exitCode, binary: 'protoc-gen-php-grpc'); + $tester = $this->tester($get); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, $exitCode); + Assert::count($get->calls, 1); + Assert::same($get->calls[0]->getArgument('software'), ['protoc-gen-php-grpc:2025.1.*@stable']); + Assert::same($get->calls[0]->getOption('path'), $this->dir); + Assert::same($get->calls[0]->getOption('os'), 'darwin'); + Assert::same($get->calls[0]->getOption('arch'), 'arm64'); + Assert::string($tester->getDisplay()) + ->contains('Version: 2025.1.*') + ->contains('Operating System: darwin') + ->contains('Architecture: arm64'); + } + + #[DataSet(['linux', 'protoc-gen-php-grpc'])] + #[DataSet(['windows', 'protoc-gen-php-grpc.exe'])] + public function keepsExistingBinaryUnlessConfirmed(string $os, string $binary): void + { + \file_put_contents($this->dir . '/' . $binary, 'old binary'); + $get = new DLoadGetSpy(); + $tester = $this->tester($get); + + $status = $tester->execute($this->input(['--os' => $os]), ['interactive' => false]); + + Assert::same($status, 1); + Assert::same($get->calls, []); + Assert::same(\file_get_contents($this->dir . '/' . $binary), 'old binary'); + Assert::string($tester->getDisplay()) + ->contains('protoc-gen-php-grpc binary file already exists!') + ->contains('Skipping protoc-gen-php-grpc installation...') + ->contains('protoc-gen-php-grpc has not been installed'); + } + + public function overwritesExistingBinaryWhenConfirmed(): void + { + \file_put_contents($this->dir . '/protoc-gen-php-grpc', 'old binary'); + $get = new DLoadGetSpy(binary: 'protoc-gen-php-grpc'); + $tester = $this->tester($get); + $tester->setInputs(['yes']); + + $status = $tester->execute($this->input(['--os' => 'linux'])); + + Assert::same($status, 0); + Assert::true($get->calls[0]->getOption('force')); + Assert::same(\file_get_contents($this->dir . '/protoc-gen-php-grpc'), 'new binary'); + } + + /** + * @param array $input + * @return array + */ + private function input(array $input = []): array + { + return $input + [ + '--location' => $this->dir, + '--os' => 'darwin', + '--arch' => 'arm64', + '--filter' => '2025.1.*', + ]; + } + + private function tester(DLoadGetSpy $get): CommandTester + { + return new CommandTester(new DownloadProtocBinaryCommand(downloader: new DLoadDownloader($get))); + } } diff --git a/tests/Unit/Environment/EnumTest.php b/tests/Unit/Environment/EnumTest.php deleted file mode 100644 index 0933935..0000000 --- a/tests/Unit/Environment/EnumTest.php +++ /dev/null @@ -1,29 +0,0 @@ - 'RC']); - } - - public function returnsEmptyListWhenNothingMatches(): void - { - Assert::same(Enum::values(Stability::class, 'ARCH_'), []); - } - - public function returnsEmptyListForUnknownClass(): void - { - Assert::same(Enum::values('Spiral\RoadRunner\Console\NonExistent', 'X'), []); - } -} diff --git a/tests/Unit/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php index 779a89a..28d22a5 100644 --- a/tests/Unit/GetBinaryCommandTest.php +++ b/tests/Unit/GetBinaryCommandTest.php @@ -5,17 +5,12 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; use Spiral\RoadRunner\Console\GetBinaryCommand; -use Spiral\RoadRunner\Console\Repository\ReleaseInterface; -use Spiral\RoadRunner\Console\Repository\RepositoryInterface; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\InMemoryRepository; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\Releases; +use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Tests\Unit\Stub\DLoadGetSpy; use Spiral\RoadRunner\Console\Tests\Unit\Stub\TempDirectory; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Yaml\Yaml; use Testo\Assert; -use Testo\Expect; use Testo\Lifecycle\AfterTest; use Testo\Lifecycle\BeforeTest; use Testo\Test; @@ -25,10 +20,6 @@ final class GetBinaryCommandTest { private string $dir; private string $target; - - /** @var list */ - private array $downloads = []; - private string $cwd; /** @@ -60,60 +51,66 @@ public function describesItself(): void Assert::same($command->getDescription(), 'Install or update RoadRunner binary'); } - public function installsBinaryOfNewestMatchingRelease(): void + public function keepsOptions(): void { - $tester = $this->tester( - $this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip']), - $this->release('v2024.2.0', [ - 'roadrunner-2024.2.0-linux-amd64.deb', - 'roadrunner-2024.2.0-linux-amd64.zip', - 'roadrunner-2024.2.0-darwin-amd64.zip', - 'roadrunner-2024.2.0-linux-arm64.zip', - ]), - $this->release('v2024.3.0-beta.1', ['roadrunner-2024.3.0-beta.1-linux-amd64.zip']), - $this->release('v2025.1.0', ['roadrunner-2025.1.0-linux-amd64.zip']), - ); + Assert::array(\array_keys((new GetBinaryCommand())->getDefinition()->getOptions())) + ->sameElementsAs(['os', 'arch', 'filter', 'location', 'stability', 'plugin', 'preset', 'no-config']); + } + + public function installsBinaryThroughDLoad(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get); - $status = $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + $status = $tester->execute( + $this->input(['--no-config' => true, '--stability' => 'beta']), + ['interactive' => false], + ); Assert::same($status, 0); - Assert::same($this->downloads, ['https://example.com/download/roadrunner-2024.2.0-linux-amd64.zip']); - Assert::same(\file_get_contents($this->target . '/rr'), 'binary v2024.2.0'); + Assert::count($get->calls, 1); + Assert::same($get->calls[0]->getArgument('software'), ['rr:^2024.1@beta']); + Assert::same($get->calls[0]->getOption('path'), $this->target); + Assert::same($get->calls[0]->getOption('os'), 'linux'); + Assert::same($get->calls[0]->getOption('arch'), 'amd64'); + Assert::same(\file_get_contents($this->target . '/rr'), 'new binary'); Assert::false(\is_file($this->target . '/.rr.yaml')); Assert::string($tester->getDisplay()) - ->contains('roadrunner-server/roadrunner (v2024.2.0): Downloading...') - ->contains('RoadRunner (v2024.2.0) has been installed into'); + ->contains('Version: ^2024.1') + ->contains('Stability: beta') + ->contains('dload: rr:^2024.1@beta') + ->ignoringWhitespace(lineBreaks: true) + ->contains('Your project is now ready in ' . $this->target) + ->contains('$ rr serve'); } - public function skipsReleasesWithoutSuitableAssembly(): void + public function installsWindowsBinary(): void { - $tester = $this->tester( - $this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip']), - $this->release('v2024.2.0', ['roadrunner-2024.2.0-darwin-amd64.zip']), - ); + $tester = $this->tester(new DLoadGetSpy(binary: 'rr.exe')); - $status = $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + $status = $tester->execute( + $this->input(['--no-config' => true, '--os' => 'windows']), + ['interactive' => false], + ); Assert::same($status, 0); - Assert::same(\file_get_contents($this->target . '/rr'), 'binary v2024.1.0'); - Assert::string($tester->getDisplay()) - ->ignoringWhitespace(lineBreaks: true) - ->contains('roadrunner-server/roadrunner v2024.2.0 does not contain available assembly'); + Assert::string($tester->getDisplay())->contains('$ rr.exe serve'); } - public function failsWhenNoReleaseHasSuitableAssembly(): never + public function failsWhenDLoadFails(): void { - $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-darwin-amd64.zip'])); + $tester = $this->tester(new DLoadGetSpy(exitCode: 1)); - Expect::exception(\UnexpectedValueException::class) - ->withMessageContaining('(--os=linux --arch=amd64 --stability=stable). Available: v2024.1.0'); + $status = $tester->execute($this->input(['--preset' => 'web']), ['interactive' => false]); - $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + Assert::same($status, 1); + Assert::false(\is_file($this->target . '/.rr.yaml')); + Assert::string($tester->getDisplay())->notContains('Your project is now ready'); } public function generatesConfigurationForPreset(): void { - $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + $tester = $this->tester(new DLoadGetSpy()); $status = $tester->execute($this->input(['--preset' => 'web']), ['interactive' => false]); @@ -122,10 +119,21 @@ public function generatesConfigurationForPreset(): void ->sameElementsAs(['version', 'rpc', 'http', 'jobs', 'server']); } + public function generatesConfigurationForPlugins(): void + { + $tester = $this->tester(new DLoadGetSpy()); + + $status = $tester->execute($this->input(['--plugin' => ['kv']]), ['interactive' => false]); + + Assert::same($status, 0); + Assert::array(\array_keys(Yaml::parseFile($this->target . '/.rr.yaml'))) + ->sameElementsAs(['version', 'rpc', 'kv']); + } + public function keepsExistingConfiguration(): void { \file_put_contents($this->target . '/.rr.yaml', 'existing'); - $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + $tester = $this->tester(new DLoadGetSpy()); $status = $tester->execute($this->input(['--plugin' => ['kv']]), ['interactive' => false]); @@ -133,28 +141,47 @@ public function keepsExistingConfiguration(): void Assert::same(\file_get_contents($this->target . '/.rr.yaml'), 'existing'); } + public function keepsConfigurationInWorkingDirectory(): void + { + \file_put_contents($this->dir . '/cwd/.rr.yaml', 'existing'); + $tester = $this->tester(new DLoadGetSpy()); + + $status = $tester->execute($this->input(['--plugin' => ['kv']]), ['interactive' => false]); + + Assert::same($status, 0); + Assert::false(\is_file($this->target . '/.rr.yaml')); + } + public function keepsExistingBinaryUnlessConfirmed(): void { \file_put_contents($this->target . '/rr', 'old binary'); - $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + $get = new DLoadGetSpy(); + $tester = $this->tester($get); - $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + $status = $tester->execute($this->input(['--preset' => 'web']), ['interactive' => false]); + Assert::same($status, 1); + Assert::same($get->calls, []); Assert::same(\file_get_contents($this->target . '/rr'), 'old binary'); + Assert::true(\is_file($this->target . '/.rr.yaml')); Assert::string($tester->getDisplay()) ->contains('RoadRunner binary file already exists!') - ->contains('Skipping RoadRunner installation...'); + ->contains('Skipping RoadRunner installation...') + ->contains('RoadRunner has not been installed'); } public function overwritesExistingBinaryWhenConfirmed(): void { \file_put_contents($this->target . '/rr', 'old binary'); - $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + $get = new DLoadGetSpy(); + $tester = $this->tester($get); $tester->setInputs(['yes']); - $tester->execute($this->input(['--no-config' => true])); + $status = $tester->execute($this->input(['--no-config' => true])); - Assert::same(\file_get_contents($this->target . '/rr'), 'binary v2024.1.0'); + Assert::same($status, 0); + Assert::true($get->calls[0]->getOption('force')); + Assert::same(\file_get_contents($this->target . '/rr'), 'new binary'); } /** @@ -163,49 +190,16 @@ public function overwritesExistingBinaryWhenConfirmed(): void */ private function input(array $input): array { - return [ + return $input + [ '--location' => $this->target, '--os' => 'linux', '--arch' => 'amd64', '--filter' => '^2024.1', - ] + $input; - } - - /** - * Creates a release whose assets are zip archives with an "rr" file named after the release. - * - * @param list $assets - */ - private function release(string $tag, array $assets): ReleaseInterface - { - $archive = TempDirectory::archive($this->dir, \bin2hex(\random_bytes(4)) . '.zip', [ - 'roadrunner/rr' => 'binary ' . $tag, - ]); - $content = (string) \file_get_contents($archive); - $client = new MockHttpClient(function (string $method, string $url) use ($content): MockResponse { - $this->downloads[] = $url; - - return new MockResponse($content); - }); - - return Releases::release($tag, $assets, $client); + ]; } - private function tester(ReleaseInterface ...$releases): CommandTester + private function tester(DLoadGetSpy $get): CommandTester { - $command = new class(new InMemoryRepository(...$releases)) extends GetBinaryCommand { - public function __construct( - private readonly RepositoryInterface $repository, - ) { - parent::__construct(); - } - - protected function getRepository(): RepositoryInterface - { - return $this->repository; - } - }; - - return new CommandTester($command); + return new CommandTester(new GetBinaryCommand(downloader: new DLoadDownloader($get))); } } diff --git a/tests/Unit/Configuration/GeneratorTest.php b/tests/Unit/Internal/Configuration/GeneratorTest.php similarity index 87% rename from tests/Unit/Configuration/GeneratorTest.php rename to tests/Unit/Internal/Configuration/GeneratorTest.php index c244d69..60d61f8 100644 --- a/tests/Unit/Configuration/GeneratorTest.php +++ b/tests/Unit/Internal/Configuration/GeneratorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Configuration; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Configuration; -use Spiral\RoadRunner\Console\Configuration\Generator; -use Spiral\RoadRunner\Console\Configuration\Plugins; +use Spiral\RoadRunner\Console\Internal\Configuration\Generator; +use Spiral\RoadRunner\Console\Internal\Configuration\Plugins; use Symfony\Component\Yaml\Yaml; use Testo\Assert; use Testo\Data\DataSet; diff --git a/tests/Unit/Configuration/PluginsTest.php b/tests/Unit/Internal/Configuration/PluginsTest.php similarity index 69% rename from tests/Unit/Configuration/PluginsTest.php rename to tests/Unit/Internal/Configuration/PluginsTest.php index fc36fef..4738ace 100644 --- a/tests/Unit/Configuration/PluginsTest.php +++ b/tests/Unit/Internal/Configuration/PluginsTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Configuration; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Configuration; -use Spiral\RoadRunner\Console\Configuration\Plugins; -use Spiral\RoadRunner\Console\Configuration\Section\Http; -use Spiral\RoadRunner\Console\Configuration\Section\Jobs; -use Spiral\RoadRunner\Console\Configuration\Section\Kv; -use Spiral\RoadRunner\Console\Configuration\Section\Metrics; -use Spiral\RoadRunner\Console\Configuration\Section\Rpc; -use Spiral\RoadRunner\Console\Configuration\Section\SectionInterface; -use Spiral\RoadRunner\Console\Configuration\Section\Server; -use Spiral\RoadRunner\Console\Configuration\Section\Version; +use Spiral\RoadRunner\Console\Internal\Configuration\Plugins; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Http; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Jobs; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Kv; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Metrics; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Rpc; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\SectionInterface; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Server; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Version; use Testo\Assert; use Testo\Data\DataProvider; use Testo\Test; @@ -35,8 +35,8 @@ final class PluginsTest */ public static function sections(): iterable { - foreach (\glob(\dirname(__DIR__, 3) . '/src/Configuration/Section/*.php') as $file) { - $class = 'Spiral\\RoadRunner\\Console\\Configuration\\Section\\' . \basename($file, '.php'); + foreach (\glob(\dirname(__DIR__, 4) . '/src/Internal/Configuration/Section/*.php') as $file) { + $class = 'Spiral\\RoadRunner\\Console\\Internal\\Configuration\\Section\\' . \basename($file, '.php'); $reflection = new \ReflectionClass($class); if ($reflection->isInstantiable()) { yield $reflection->getShortName() => [$class]; diff --git a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php new file mode 100644 index 0000000..56ac0a9 --- /dev/null +++ b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php @@ -0,0 +1,261 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + public function passesEnvironmentToDLoad(): void + { + $get = new DLoadGetSpy(); + + $code = $this->download($get, constraint: '^2024.1', stability: 'beta', force: false); + + Assert::same($code, 0); + Assert::count($get->calls, 1); + $input = $get->calls[0]; + Assert::same($input->getArgument('software'), ['rr:^2024.1@beta']); + Assert::same($input->getOption('path'), $this->dir); + Assert::same($input->getOption('os'), 'linux'); + Assert::same($input->getOption('arch'), 'arm64'); + Assert::same($input->getOption('stability'), 'beta'); + Assert::false($input->getOption('force')); + Assert::true($input->getOption('refresh')); + Assert::false($input->isInteractive()); + } + + public function passesForceFlag(): void + { + $get = new DLoadGetSpy(); + + $this->download($get, force: true); + + Assert::true($get->calls[0]->getOption('force')); + } + + /** + * A project's `./dload.xml` must not take part in the download. + */ + public function usesBundledEmptyConfig(): void + { + $get = new DLoadGetSpy(); + + $this->download($get); + + $config = (string) $get->calls[0]->getOption('config'); + Assert::true(\is_file($config)); + Assert::same(\simplexml_load_file($config)?->getName(), 'dload'); + Assert::same(\simplexml_load_file($config)?->count(), 0); + } + + #[DataSet(['https://ghe.example.com/api/v3', 'https://ghe.example.com/roadrunner-server/roadrunner'])] + #[DataSet(['https://GHE.example.com/api/v3/', 'https://ghe.example.com/roadrunner-server/roadrunner'], 'trailing slash')] + #[DataSet(['http://127.0.0.1:8080/api/v3', 'http://127.0.0.1:8080/roadrunner-server/roadrunner'], 'scheme and port are kept')] + public function pointsRegistryAtEnterpriseServer(string $url, string $uri): void + { + $get = new DLoadGetSpy(); + + (new DLoadDownloader($get, $url)) + ->download('rr', '*', 'stable', 'linux', 'amd64', $this->dir, false, new BufferedOutput()); + + $config = \simplexml_load_string($get->configs[0]); + Assert::notSame($config, false); + $repositories = []; + foreach ($config->registry->software as $software) { + $repositories[(string) $software['alias']] = [ + (string) $software->repository['uri'], + isset($software->repository['server']), + ]; + } + Assert::same($repositories, ['rr' => [$uri, false], 'protoc-gen-php-grpc' => [$uri, false]]); + Assert::false(\is_file((string) $get->calls[0]->getOption('config'))); + } + + #[DataSet(['https://api.github.com'])] + #[DataSet(['https://api.github.com/'], 'trailing slash')] + #[DataSet([''], 'empty')] + public function keepsBundledConfigForPublicGitHub(string $url): void + { + $get = new DLoadGetSpy(); + + (new DLoadDownloader($get, $url)) + ->download('rr', '*', 'stable', 'linux', 'amd64', $this->dir, false, new BufferedOutput()); + + Assert::same(\simplexml_load_string($get->configs[0])?->count(), 0); + } + + #[DataSet(['http://'], 'no host')] + #[DataSet(['ghe.example.com/api/v3'], 'no scheme')] + #[DataSet(['ftp://ghe.example.com/api/v3'], 'unsupported scheme')] + #[DataSet(['https://ghe.example.com'], 'no API path')] + #[DataSet(['http://127.0.0.1:8080/github'], 'other API path')] + #[DataSet(['https://api.github.com/api/v3'], 'public GitHub with enterprise path')] + #[DataSet(['https://user:secret@ghe.example.com/api/v3'], 'credentials')] + public function rejectsGitHubApiUrlDLoadCanNotUse(string $url): never + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining('RR_GITHUB_API_URL'); + + (new DLoadDownloader(new DLoadGetSpy(), $url)) + ->download('rr', '*', 'stable', 'linux', 'amd64', $this->dir, false, new BufferedOutput()); + } + + public function readsGitHubApiUrlFromEnvironment(): void + { + $get = new DLoadGetSpy(); + $_SERVER['RR_GITHUB_API_URL'] = 'http://127.0.0.1:8080/api/v3'; + + try { + $this->download($get); + } finally { + unset($_SERVER['RR_GITHUB_API_URL']); + } + + Assert::string($get->configs[0])->contains('uri="http://127.0.0.1:8080/roadrunner-server/roadrunner"'); + } + + #[DataSet(['3.*', 'stable', 'rr:3.*@stable'])] + #[DataSet([' 2025.1.* ', 'rc', 'rr:2025.1.*@rc'], 'trimmed')] + #[DataSet(['3.*@beta', 'stable', 'rr:3.*@beta'], 'explicit stability')] + public function putsStabilityIntoVersionConstraint(string $constraint, string $stability, string $software): void + { + $get = new DLoadGetSpy(); + + $this->download($get, constraint: $constraint, stability: $stability); + + Assert::same($get->calls[0]->getArgument('software'), [$software]); + Assert::same($get->calls[0]->getOption('stability'), $stability); + } + + /** + * Calendar releases (2025.x) rank above 3.x numerically, so "any version" must not reach DLoad unbounded. + */ + #[DataSet(['*', 'stable', 'rr:>=3.0 <2000@stable'])] + #[DataSet([' * ', 'beta', 'rr:>=3.0 <2000@beta'], 'trimmed')] + #[DataSet(['', 'rc', 'rr:>=3.0 <2000@rc'], 'empty constraint')] + #[DataSet(['*@beta', 'stable', 'rr:>=3.0 <2000@beta'], 'explicit stability')] + #[DataSet(['@alpha', 'stable', 'rr:>=3.0 <2000@alpha'], 'stability only')] + public function treatsWildcardAsAnySemverRelease(string $constraint, string $stability, string $software): void + { + $get = new DLoadGetSpy(); + + $this->download($get, constraint: $constraint, stability: $stability); + + Assert::same($get->calls[0]->getArgument('software'), [$software]); + } + + #[DataSet(['>=3.0', 'rr:>=3.0 <2000@stable'], 'lower bound')] + #[DataSet(['>= 2.12', 'rr:>= 2.12 <2000@stable'], 'spaced operator')] + #[DataSet(['!=3.1.0', 'rr:!=3.1.0 <2000@stable'], 'exclusion')] + #[DataSet(['<>3.1.0', 'rr:<>3.1.0 <2000@stable'], 'exclusion as <>')] + #[DataSet(['>=3.0@beta', 'rr:>=3.0 <2000@beta'], 'explicit stability')] + #[DataSet(['3.* || >=3.2', 'rr:3.* || >=3.2 <2000@stable'], 'each alternative')] + #[DataSet(['>=3.0 | 2025.*', 'rr:>=3.0 <2000 || 2025.*@stable'], 'single pipe')] + public function keepsUnboundedFilterBelowCalendarReleases(string $constraint, string $software): void + { + $get = new DLoadGetSpy(); + + $this->download($get, constraint: $constraint); + + Assert::same($get->calls[0]->getArgument('software'), [$software]); + } + + #[DataSet(['3.4.0'], 'exact release')] + #[DataSet(['3.5.0-beta.1'], 'exact pre-release')] + #[DataSet(['^3.2'], 'caret')] + #[DataSet(['~3.2.0'], 'tilde')] + #[DataSet(['3.x'], 'wildcard')] + #[DataSet(['>=3.0 <3.4'], 'upper bound')] + #[DataSet(['3.0 - 3.4'], 'hyphen range')] + #[DataSet(['3.* || 2025.*'], 'calendar alternative')] + #[DataSet(['2025.*'], 'calendar wildcard')] + #[DataSet(['2025.1.5'], 'calendar release')] + #[DataSet(['>=2024'], 'calendar lower bound')] + public function keepsBoundedOrCalendarFilter(string $constraint): void + { + $get = new DLoadGetSpy(); + + $this->download($get, constraint: $constraint, stability: 'beta'); + + Assert::same($get->calls[0]->getArgument('software'), ['rr:' . $constraint . '@beta']); + } + + public function returnsDLoadExitCode(): void + { + Assert::same($this->download(new DLoadGetSpy(exitCode: 1)), 1); + } + + public function forwardsDLoadOutput(): void + { + $output = new BufferedOutput(); + + (new DLoadDownloader(new DLoadGetSpy())) + ->download('protoc-gen-php-grpc', '2025.*', 'stable', 'linux', 'amd64', $this->dir, true, $output); + + Assert::same($output->fetch(), 'dload: protoc-gen-php-grpc:2025.*@stable' . \PHP_EOL); + } + + /** + * DLoad's own `get` command is used unless another one is given. + */ + public function usesDLoadGetCommandByDefault(): void + { + // DLoad validates the stability before it resolves or downloads anything + Expect::exception(\InvalidArgumentException::class)->withMessageContaining('Unknown stability level: unknown'); + + (new DLoadDownloader())->download( + software: 'rr', + constraint: '*', + stability: 'unknown', + os: 'linux', + arch: 'amd64', + location: $this->dir, + force: false, + output: new BufferedOutput(), + ); + } + + private function download( + DLoadGetSpy $get, + string $constraint = '*', + string $stability = 'stable', + bool $force = false, + ): int { + return (new DLoadDownloader($get))->download( + software: 'rr', + constraint: $constraint, + stability: $stability, + os: 'linux', + arch: 'arm64', + location: $this->dir, + force: $force, + output: new BufferedOutput(), + ); + } +} diff --git a/tests/Unit/Environment/EnvironmentTest.php b/tests/Unit/Internal/Environment/EnvironmentTest.php similarity index 58% rename from tests/Unit/Environment/EnvironmentTest.php rename to tests/Unit/Internal/Environment/EnvironmentTest.php index b7c8987..820872e 100644 --- a/tests/Unit/Environment/EnvironmentTest.php +++ b/tests/Unit/Internal/Environment/EnvironmentTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Environment; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Environment; -use Spiral\RoadRunner\Console\Environment\Environment; +use Spiral\RoadRunner\Console\Internal\Environment\Environment; use Testo\Assert; use Testo\Lifecycle\AfterTest; use Testo\Test; @@ -20,14 +20,6 @@ public function cleanUp(): void unset($_ENV[self::KEY], $_SERVER[self::KEY]); } - public function explicitVariablesWin(): void - { - $_ENV[self::KEY] = 'env'; - $_SERVER[self::KEY] = 'server'; - - Assert::same(Environment::get(self::KEY, null, [self::KEY => 'explicit']), 'explicit'); - } - public function envWinsOverServer(): void { $_ENV[self::KEY] = 'env'; @@ -43,14 +35,15 @@ public function fallsBackToServer(): void Assert::same(Environment::get(self::KEY), 'server'); } - public function returnsDefaultWhenMissing(): void + public function returnsNullWhenMissing(): void { - Assert::same(Environment::get(self::KEY, 'default'), 'default'); Assert::null(Environment::get(self::KEY)); } public function ignoresNonStringValues(): void { - Assert::same(Environment::get(self::KEY, 'default', [self::KEY => ['array']]), 'default'); + $_SERVER[self::KEY] = ['array']; + + Assert::null(Environment::get(self::KEY)); } } diff --git a/tests/Unit/Environment/StabilityTest.php b/tests/Unit/Internal/Environment/StabilityTest.php similarity index 76% rename from tests/Unit/Environment/StabilityTest.php rename to tests/Unit/Internal/Environment/StabilityTest.php index c5c90cd..d4874fc 100644 --- a/tests/Unit/Environment/StabilityTest.php +++ b/tests/Unit/Internal/Environment/StabilityTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Environment; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Environment; -use Spiral\RoadRunner\Console\Environment\Stability; +use Spiral\RoadRunner\Console\Internal\Environment\Stability; use Testo\Assert; use Testo\Data\DataSet; use Testo\Test; @@ -14,13 +14,7 @@ final class StabilityTest { public function allListsEveryStability(): void { - Assert::same(Stability::all(), [ - 'STABILITY_STABLE' => 'stable', - 'STABILITY_RC' => 'RC', - 'STABILITY_BETA' => 'beta', - 'STABILITY_ALPHA' => 'alpha', - 'STABILITY_DEV' => 'dev', - ]); + Assert::same(Stability::all(), ['stable', 'RC', 'beta', 'alpha', 'dev']); } public function weightsFollowStabilityOrder(): void diff --git a/tests/Unit/Internal/GitHub/ReleaseTest.php b/tests/Unit/Internal/GitHub/ReleaseTest.php new file mode 100644 index 0000000..5460b62 --- /dev/null +++ b/tests/Unit/Internal/GitHub/ReleaseTest.php @@ -0,0 +1,102 @@ + $tag, 'name' => 'Release']); + + Assert::same($release->name, $name); + Assert::same($release->stability, $stability); + } + + public function fallsBackToNameForInvalidTag(): void + { + $release = Release::fromApiResponse(['tag_name' => 'latest-build', 'name' => 'v2.1.0']); + + Assert::same($release->name, '2.1.0'); + } + + #[DataSet([['tag_name' => 'nightly', 'name' => 'Nightly']], 'name is not a version')] + #[DataSet([['tag_name' => 'nightly', 'name' => null]], 'no name')] + public function namesUnparsableReleaseAsDevBranch(array $response): void + { + $release = Release::fromApiResponse($response); + + Assert::same($release->name, 'dev-nightly'); + Assert::same($release->stability, 'dev'); + } + + public function keepsAssetNames(): void + { + $release = Release::fromApiResponse([ + 'tag_name' => 'v3.4.0', + 'assets' => [ + ['name' => 'roadrunner-3.4.0-linux-amd64.tar.gz', 'browser_download_url' => 'https://example.com/a'], + ['browser_download_url' => 'https://example.com/b'], + 'garbage', + ['name' => 'roadrunner-3.4.0-windows-amd64.zip'], + ], + ]); + + Assert::same($release->assets, ['roadrunner-3.4.0-linux-amd64.tar.gz', 'roadrunner-3.4.0-windows-amd64.zip']); + } + + #[DataSet([[]], 'missing')] + #[DataSet([['tag_name' => '']], 'empty')] + #[DataSet([['tag_name' => 3]], 'not a string')] + public function requiresTag(array $response): never + { + Expect::exception(\UnexpectedValueException::class)->withMessageContaining('"tag_name"'); + + Release::fromApiResponse($response); + } + + #[DataSet(['v2024.1.0', '2024.*', true])] + #[DataSet(['v2024.1.0', '^2023.3', false])] + #[DataSet(['v2024.1.0', '>=2.0 <2025', true])] + #[DataSet(['v3.1.0', '^2.0 || ^3.0', true], 'alternatives')] + #[DataSet(['v2024.1.0-rc.1', '2024.*', true], 'wildcard matches pre-releases')] + public function satisfiesComparesName(string $tag, string $constraint, bool $expected): void + { + Assert::same(Release::fromApiResponse(['tag_name' => $tag])->satisfies($constraint), $expected); + } + + #[DataSet([ + ['v2023.3.12', 'v2024.2.0', 'v1.9.0', 'v2024.10.1', 'v2.12.3'], + ['2024.10.1', '2024.2.0', '2023.3.12', '2.12.3', '1.9.0'], + ], 'stable')] + #[DataSet([ + ['v2024.1.0-alpha.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-rc.1'], + ['2024.1.0', '2024.1.0-RC1', '2024.1.0-beta1', '2024.1.0-alpha1'], + ], 'pre-releases')] + #[DataSet([ + ['v2025.1.15', 'v2.12.3', 'v3.0.0', 'v2023.3.0', 'v3.0.0-rc.1'], + ['3.0.0', '3.0.0-RC1', '2025.1.15', '2023.3.0', '2.12.3'], + ], 'calendar')] + public function newestFirstOrdersVersionsDescending(array $tags, array $expected): void + { + $releases = \array_map(static fn(string $tag): Release => Release::fromApiResponse(['tag_name' => $tag]), $tags); + + \usort($releases, Release::newestFirst(...)); + + Assert::same(\array_map(static fn(Release $release): string => $release->name, $releases), $expected); + } +} diff --git a/tests/Unit/Internal/GitHub/ReleasesClientTest.php b/tests/Unit/Internal/GitHub/ReleasesClientTest.php new file mode 100644 index 0000000..d9a85b6 --- /dev/null +++ b/tests/Unit/Internal/GitHub/ReleasesClientTest.php @@ -0,0 +1,112 @@ + [[['tag_name' => 'v3.2.0']], '; rel="next"'], + 2 => [[['tag_name' => 'v3.1.0'], 'garbage'], '; rel="prev"'], + ]; + $client = new MockHttpClient( + static function (string $method, string $url, array $options) use (&$requests, $pages): MockResponse { + $requests[] = [$method, $url, $options['normalized_headers']['accept'][0] ?? null]; + \parse_str((string) \parse_url($url, \PHP_URL_QUERY), $query); + [$body, $link] = $pages[(int) $query['page']]; + + return new MockResponse(\json_encode($body), ['response_headers' => ['link' => $link]]); + }, + ); + + $releases = (new ReleasesClient($client))->releases(); + + Assert::same(self::names($releases), ['3.2.0', '3.1.0']); + Assert::same($requests, [ + ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=1&per_page=100', 'accept: application/vnd.github.v3+json'], + ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=2&per_page=100', 'accept: application/vnd.github.v3+json'], + ]); + } + + public function stopsWithoutLinkHeader(): void + { + $calls = 0; + $client = new MockHttpClient(static function () use (&$calls): MockResponse { + ++$calls; + + return new MockResponse('[]'); + }); + + Assert::same((new ReleasesClient($client))->releases(), []); + Assert::same($calls, 1); + } + + public function usesCustomApiUrl(): void + { + $urls = []; + $client = new MockHttpClient(static function (string $method, string $url) use (&$urls): MockResponse { + $urls[] = $url; + + return new MockResponse('[]'); + }); + + (new ReleasesClient($client, 'http://127.0.0.1:8080/api/v3/'))->releases(); + + Assert::same($urls, ['http://127.0.0.1:8080/api/v3/repos/roadrunner-server/roadrunner/releases?page=1&per_page=100']); + } + + public function sendsToken(): void + { + $authorization = []; + $client = new MockHttpClient(static function (string $method, string $url, array $options) use (&$authorization): MockResponse { + $authorization[] = $options['normalized_headers']['authorization'][0] ?? null; + + return new MockResponse('[]'); + }); + + (new ReleasesClient($client))->releases(); + (new ReleasesClient($client, token: 'secret'))->releases(); + + Assert::same($authorization, [null, 'authorization: Bearer secret']); + } + + public function readsApiUrlFromEnvironment(): void + { + // The unsupported scheme makes the client fail before any connection, with the URL in the message + $_SERVER['RR_GITHUB_API_URL'] = 'ftp://github.mock/api'; + + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('ftp://github.mock/api/repos/roadrunner-server/roadrunner/releases'); + + ReleasesClient::fromEnvironment()->releases(); + } + + /** + * @param list $releases + * @return list + */ + private static function names(array $releases): array + { + return \array_map(static fn(Release $release): string => $release->name, $releases); + } +} diff --git a/tests/Unit/Internal/GitHub/TokenTest.php b/tests/Unit/Internal/GitHub/TokenTest.php new file mode 100644 index 0000000..9849e4d --- /dev/null +++ b/tests/Unit/Internal/GitHub/TokenTest.php @@ -0,0 +1,104 @@ +command); $input = $host->input(); - Assert::same($option->getName(), 'stability'); Assert::same($host->command->getDefinition()->getOption('stability')->getShortcut(), 's'); Assert::same($option->get($input, $host->io($input)), 'stable'); Assert::same($host->display(), ''); @@ -69,7 +68,6 @@ public function operatingSystemAcceptsKnownValue(): void $option = new OperatingSystemOption($host->command, 'system', 'y'); $input = $host->input(['--system' => 'darwin']); - Assert::same($option->getName(), 'system'); Assert::same($option->get($input, $host->io($input)), 'darwin'); Assert::same($host->display(), ''); } diff --git a/tests/Unit/Command/InstallationLocationOptionTest.php b/tests/Unit/Internal/Option/InstallationLocationOptionTest.php similarity index 93% rename from tests/Unit/Command/InstallationLocationOptionTest.php rename to tests/Unit/Internal/Option/InstallationLocationOptionTest.php index d2f86fa..a29ccca 100644 --- a/tests/Unit/Command/InstallationLocationOptionTest.php +++ b/tests/Unit/Internal/Option/InstallationLocationOptionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Command; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Option; -use Spiral\RoadRunner\Console\Command\InstallationLocationOption; +use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Tests\Unit\Stub\OptionHost; use Spiral\RoadRunner\Console\Tests\Unit\Stub\TempDirectory; use Testo\Assert; diff --git a/tests/Unit/Internal/Option/VersionFilterOptionTest.php b/tests/Unit/Internal/Option/VersionFilterOptionTest.php new file mode 100644 index 0000000..ccfb64a --- /dev/null +++ b/tests/Unit/Internal/Option/VersionFilterOptionTest.php @@ -0,0 +1,33 @@ +command); + $input = $host->input(); + + Assert::same($option->get($input, $host->io($input)), RoadRunnerVersion::constraint()); + } + + public function acceptsShortcut(): void + { + $host = new OptionHost(); + $option = new VersionFilterOption($host->command); + $input = $host->input(['-f' => '^3.1']); + + Assert::same($option->get($input, $host->io($input)), '^3.1'); + } +} diff --git a/tests/Unit/Repository/AssetsCollectionTest.php b/tests/Unit/Repository/AssetsCollectionTest.php deleted file mode 100644 index 95a246c..0000000 --- a/tests/Unit/Repository/AssetsCollectionTest.php +++ /dev/null @@ -1,83 +0,0 @@ -onlyRoadrunner()); - - Assert::count($names, 6); - Assert::array($names)->notContains('protoc-gen-php-grpc-2024.1.0-linux-amd64.tar.gz'); - } - - public function exceptDebPackagesIsCaseInsensitive(): void - { - $assets = (new AssetsCollection([ - Releases::asset('roadrunner-linux-amd64.deb'), - Releases::asset('roadrunner-linux-amd64.DEB'), - Releases::asset('roadrunner-linux-amd64.tar.gz'), - ]))->exceptDebPackages(); - - Assert::same(self::names($assets), ['roadrunner-linux-amd64.tar.gz']); - } - - public function filtersByOperatingSystemAndArchitecture(): void - { - $assets = self::assets() - ->onlyRoadrunner() - ->exceptDebPackages() - ->whereOperatingSystem('linux') - ->whereArchitecture('amd64'); - - Assert::same(self::names($assets), ['roadrunner-2024.1.0-linux-amd64.tar.gz']); - } - - public function filterArgumentsAreCaseInsensitive(): void - { - $assets = self::assets()->whereOperatingSystem('Darwin')->whereArchitecture('ARM64'); - - Assert::same(self::names($assets), ['roadrunner-2024.1.0-darwin-arm64.tar.gz']); - } - - public function filtersMuslBuilds(): void - { - Assert::same( - self::names(self::assets()->whereOperatingSystem('unknown-musl')), - ['roadrunner-2024.1.0-unknown-musl-amd64.zip'], - ); - } - - private static function assets(): AssetsCollection - { - return new AssetsCollection(\array_map(Releases::asset(...), self::ASSETS)); - } - - /** - * @return list - */ - private static function names(AssetsCollection $assets): array - { - return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $assets->toArray()); - } -} diff --git a/tests/Unit/Repository/CollectionTest.php b/tests/Unit/Repository/CollectionTest.php deleted file mode 100644 index d9b2584..0000000 --- a/tests/Unit/Repository/CollectionTest.php +++ /dev/null @@ -1,133 +0,0 @@ -withMessage('Unsupported iterable type string'); - - AssetsCollection::create('assets'); - } - - public function filterAndExceptAreComplementary(): void - { - $collection = self::abc(); - $isB = static fn(AssetInterface $asset): bool => $asset->getName() === 'b'; - - Assert::same(self::names($collection->filter($isB)), ['b']); - Assert::same(self::names($collection->except($isB)), ['a', 'c']); - } - - public function mapTransformsItems(): void - { - $mapped = self::abc()->map( - static fn(AssetInterface $asset): AssetInterface => Releases::asset(\strtoupper($asset->getName())), - ); - - Assert::same(self::names($mapped), ['A', 'B', 'C']); - } - - public function firstReturnsFirstMatchingItem(): void - { - $collection = self::abc(); - $notA = static fn(AssetInterface $asset): bool => $asset->getName() !== 'a'; - - Assert::same($collection->first()?->getName(), 'a'); - Assert::same($collection->first($notA)?->getName(), 'b'); - Assert::null($collection->first(static fn(): bool => false)); - Assert::null((new AssetsCollection([]))->first()); - } - - public function firstOrFallsBackToOtherwise(): void - { - $fallback = Releases::asset('fallback'); - - Assert::same(self::abc()->firstOr(static fn(): AssetInterface => $fallback)->getName(), 'a'); - Assert::same((new AssetsCollection([]))->firstOr(static fn(): AssetInterface => $fallback), $fallback); - } - - public function whenEmptyCallsBackOnlyForEmptyCollection(): void - { - $calls = 0; - $callback = static function () use (&$calls): void { - ++$calls; - }; - $collection = self::abc(); - $empty = new AssetsCollection([]); - - Assert::same($collection->whenEmpty($callback), $collection); - Assert::same($calls, 0); - Assert::same($empty->whenEmpty($callback), $empty); - Assert::same($calls, 1); - } - - public function countEmptyAndToArray(): void - { - $filtered = self::abc()->filter(static fn(AssetInterface $asset): bool => $asset->getName() !== 'a'); - - Assert::count($filtered, 2); - Assert::false($filtered->empty()); - Assert::true((new AssetsCollection([]))->empty()); - Assert::array($filtered->toArray())->isList()->hasCount(2); - } - - private static function abc(): AssetsCollection - { - return new AssetsCollection([Releases::asset('a'), Releases::asset('b'), Releases::asset('c')]); - } - - /** - * @return list - */ - private static function names(AssetsCollection $collection): array - { - return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $collection->toArray()); - } -} diff --git a/tests/Unit/Repository/GitHub/GitHubAssetTest.php b/tests/Unit/Repository/GitHub/GitHubAssetTest.php deleted file mode 100644 index 56588cf..0000000 --- a/tests/Unit/Repository/GitHub/GitHubAssetTest.php +++ /dev/null @@ -1,69 +0,0 @@ - 'roadrunner-2024.1.0-linux-amd64.tar.gz', - 'browser_download_url' => 'https://example.com/rr.tar.gz', - ]); - - Assert::same($asset->getName(), 'roadrunner-2024.1.0-linux-amd64.tar.gz'); - Assert::same($asset->getUri(), 'https://example.com/rr.tar.gz'); - } - - #[DataSet([['browser_download_url' => 'https://example.com'], '"name"'], 'missing name')] - #[DataSet([['name' => 1, 'browser_download_url' => 'https://example.com'], '"name"'], 'non-string name')] - #[DataSet([['name' => 'rr.zip'], '"browser_download_url"'], 'missing url')] - #[DataSet([['name' => 'rr.zip', 'browser_download_url' => null], '"browser_download_url"'], 'null url')] - public function fromApiResponseValidatesFields(array $data, string $field): never - { - Expect::exception(\InvalidArgumentException::class) - ->withMessageContaining($field); - - GitHubAsset::fromApiResponse(new MockHttpClient(), $data); - } - - public function downloadStreamsBodyFromUri(): void - { - $requested = null; - $client = new MockHttpClient(static function (string $method, string $url) use (&$requested): MockResponse { - $requested = $method . ' ' . $url; - - return new MockResponse(['first-', 'second']); - }); - $asset = new GitHubAsset($client, 'rr.zip', 'https://example.com/rr.zip'); - - $content = \implode('', \iterator_to_array($asset->download(), false)); - - Assert::same($content, 'first-second'); - Assert::same($requested, 'GET https://example.com/rr.zip'); - } - - public function downloadReportsProgress(): void - { - $client = new MockHttpClient(new MockResponse('0123456789', ['response_headers' => ['content-length' => '10']])); - $asset = new GitHubAsset($client, 'rr.zip', 'https://example.com/rr.zip'); - $downloaded = 0; - - \iterator_to_array($asset->download(static function (int $size) use (&$downloaded): void { - $downloaded = $size; - }), false); - - Assert::same($downloaded, 10); - } -} diff --git a/tests/Unit/Repository/GitHub/GitHubReleaseTest.php b/tests/Unit/Repository/GitHub/GitHubReleaseTest.php deleted file mode 100644 index de01f2c..0000000 --- a/tests/Unit/Repository/GitHub/GitHubReleaseTest.php +++ /dev/null @@ -1,122 +0,0 @@ -getName(), $name); - Assert::same($release->getVersion(), $tag); - Assert::same($release->getStability(), $stability); - Assert::same($release->getRepositoryName(), 'roadrunner-server/roadrunner'); - } - - #[DataSet(['v2024.1.0', '2024.*', true])] - #[DataSet(['v2024.1.0', '^2023.3', false])] - #[DataSet(['v2024.1.0', '>=2.0 <2025', true])] - #[DataSet(['v2024.1.0-rc.1', '2024.*', true], 'wildcard matches pre-releases')] - public function satisfiesComparesNormalizedName(string $tag, string $constraint, bool $expected): void - { - Assert::same(Releases::release($tag)->satisfies($constraint), $expected); - } - - public function keepsAssets(): void - { - $release = Releases::release('v2024.1.0', ['a.zip', 'b.zip']); - - Assert::same( - \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $release->getAssets()->toArray()), - ['a.zip', 'b.zip'], - ); - } - - public function fromApiResponseUsesTagName(): void - { - $release = GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), [ - 'name' => 'Release 2024.1.0', - 'tag_name' => 'v2024.1.0', - 'assets' => [ - ['name' => 'roadrunner-2024.1.0-linux-amd64.tar.gz', 'browser_download_url' => 'https://example.com/rr.tar.gz'], - ], - ]); - - Assert::same($release->getName(), '2024.1.0'); - Assert::same($release->getVersion(), 'v2024.1.0'); - Assert::same($release->getRepositoryName(), 'roadrunner-server/roadrunner'); - Assert::same($release->getAssets()->first()?->getUri(), 'https://example.com/rr.tar.gz'); - } - - public function fromApiResponseFallsBackToNameForInvalidTag(): void - { - $release = GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), [ - 'name' => 'v2.1.0', - 'tag_name' => 'latest-build', - ]); - - Assert::same($release->getName(), '2.1.0'); - Assert::same($release->getVersion(), 'latest-build'); - Assert::true($release->getAssets()->empty()); - } - - #[Skip('Bug: Release::simplifyReleaseName() strips the last two characters of the first "-" segment, so the "dev-nightly" fallback name becomes "d-nightly"')] - public function fromApiResponseNamesUnparsableReleaseAsDevBranch(): void - { - $release = GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), [ - 'name' => 'Nightly', - 'tag_name' => 'nightly', - ]); - - Assert::same($release->getName(), 'dev-nightly'); - } - - public function fromApiResponseRequiresName(): never - { - Expect::exception(\InvalidArgumentException::class) - ->withMessageContaining('"name"'); - - GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), ['tag_name' => 'v2024.1.0']); - } - - public function getConfigDownloadsRrYamlOfTheTag(): void - { - $requested = null; - $client = new MockHttpClient(static function (string $method, string $url) use (&$requested): MockResponse { - $requested = $method . ' ' . $url; - - return new MockResponse("version: '3'\n"); - }); - - $config = Releases::release('v2024.1.0', [], $client)->getConfig(); - - Assert::same($config, "version: '3'\n"); - Assert::same($requested, 'GET https://raw.githubusercontent.com/roadrunner-server/roadrunner/v2024.1.0/.rr.yaml'); - } - - private static function repository(): GitHubRepository - { - return GitHubRepository::create('roadrunner-server', 'roadrunner', new MockHttpClient()); - } -} diff --git a/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php b/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php deleted file mode 100644 index f6fb8d5..0000000 --- a/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php +++ /dev/null @@ -1,78 +0,0 @@ -getName(), 'roadrunner-server/roadrunner'); - } - - public function getReleasesFollowsPagination(): void - { - $requests = []; - $pages = [ - 1 => [[['name' => 'v2024.2.0', 'tag_name' => 'v2024.2.0']], '; rel="next"'], - 2 => [[['name' => 'v2024.1.0', 'tag_name' => 'v2024.1.0']], '; rel="prev"'], - ]; - $client = new MockHttpClient( - static function (string $method, string $url, array $options) use (&$requests, $pages): MockResponse { - $requests[] = [$method, $url, $options['normalized_headers']['accept'][0] ?? null]; - \parse_str((string) \parse_url($url, \PHP_URL_QUERY), $query); - [$body, $link] = $pages[(int) $query['page']]; - - return new MockResponse(\json_encode($body), ['response_headers' => ['link' => $link]]); - }, - ); - - $releases = GitHubRepository::create('roadrunner-server', 'roadrunner', $client)->getReleases(); - - Assert::same(Releases::versions($releases), ['v2024.2.0', 'v2024.1.0']); - Assert::same($requests, [ - ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=1&per_page=100', 'accept: application/vnd.github.v3+json'], - ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=2&per_page=100', 'accept: application/vnd.github.v3+json'], - ]); - } - - public function getReleasesUsesCustomApiUrl(): void - { - $urls = []; - $client = new MockHttpClient(static function (string $method, string $url) use (&$urls): MockResponse { - $urls[] = $url; - - return new MockResponse('[]'); - }); - - GitHubRepository::create('roadrunner-server', 'roadrunner', $client, 'http://127.0.0.1:8080/api/v3/') - ->getReleases() - ->empty(); - - Assert::same($urls, ['http://127.0.0.1:8080/api/v3/repos/roadrunner-server/roadrunner/releases?page=1&per_page=100']); - } - - public function getReleasesStopsWithoutLinkHeader(): void - { - $calls = 0; - $client = new MockHttpClient(static function () use (&$calls): MockResponse { - ++$calls; - - return new MockResponse('[]'); - }); - - $releases = GitHubRepository::create('roadrunner-server', 'roadrunner', $client)->getReleases(); - - Assert::true($releases->empty()); - Assert::same($calls, 1); - } -} diff --git a/tests/Unit/Repository/ReleasesCollectionTest.php b/tests/Unit/Repository/ReleasesCollectionTest.php deleted file mode 100644 index cca4acb..0000000 --- a/tests/Unit/Repository/ReleasesCollectionTest.php +++ /dev/null @@ -1,119 +0,0 @@ -satisfies('^2024.1'); - - Assert::same(Releases::versions($releases), ['v2024.1.0', 'v2024.2.1']); - } - - public function satisfiesAppliesEveryConstraint(): void - { - $releases = Releases::collection('v2023.3.0', 'v2024.1.0', 'v2024.2.1')->satisfies('>=2024.1', '<2024.2'); - - Assert::same(Releases::versions($releases), ['v2024.1.0']); - } - - public function pipeSeparatedConstraintsAllApply(): void - { - $releases = Releases::collection('v2023.3.0', 'v2024.1.0', 'v2024.2.1')->satisfies('>=2024.1 | <2024.2 |'); - - Assert::same(Releases::versions($releases), ['v2024.1.0']); - } - - public function notSatisfiesDropsMatchingReleases(): void - { - $releases = Releases::collection('v2023.3.0', 'v2024.1.0', 'v2024.2.1')->notSatisfies('^2024.1'); - - Assert::same(Releases::versions($releases), ['v2023.3.0']); - } - - public function withAssetsDropsEmptyReleases(): void - { - $releases = new ReleasesCollection([ - Releases::release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.tar.gz']), - Releases::release('v2024.1.1'), - ]); - - Assert::same(Releases::versions($releases->withAssets()), ['v2024.1.0']); - } - - public function sortByVersionOrdersStableReleasesDescending(): void - { - $releases = Releases::collection('v2023.3.12', 'v2024.2.0', 'v1.9.0', 'v2024.10.1', 'v2.12.3'); - - Assert::same( - Releases::versions($releases->sortByVersion()), - ['v2024.10.1', 'v2024.2.0', 'v2023.3.12', 'v2.12.3', 'v1.9.0'], - ); - } - - public function sortByVersionPutsReleaseCandidateBelowFinalRelease(): void - { - $releases = Releases::collection('v2024.1.0-rc.1', 'v2023.3.12', 'v2024.1.0'); - - Assert::same( - Releases::versions($releases->sortByVersion()), - ['v2024.1.0', 'v2024.1.0-rc.1', 'v2023.3.12'], - ); - } - - public function sortByVersionPutsPreReleasesBelowFinalRelease(): void - { - $releases = Releases::collection('v2024.1.0-alpha.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-rc.1'); - - Assert::same( - Releases::versions($releases->sortByVersion()), - ['v2024.1.0', 'v2024.1.0-rc.1', 'v2024.1.0-beta.1', 'v2024.1.0-alpha.1'], - ); - } - - public function sortByVersionPutsCalendarReleasesBetweenV2AndV3(): void - { - $releases = Releases::collection('v2025.1.15', 'v2.12.3', 'v3.0.0', 'v2023.3.0', 'v3.0.0-rc.1'); - - Assert::same( - Releases::versions($releases->sortByVersion()), - ['v3.0.0', 'v3.0.0-rc.1', 'v2025.1.15', 'v2023.3.0', 'v2.12.3'], - ); - } - - public function stableKeepsOnlyStableReleases(): void - { - $releases = Releases::collection('v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1'); - - Assert::same(Releases::versions($releases->stable()), ['v2024.1.0']); - } - - public function stabilityKeepsExactStability(): void - { - $releases = Releases::collection('v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1'); - - Assert::same(Releases::versions($releases->stability(Stability::STABILITY_BETA)), ['v2024.1.0-beta.1']); - } - - #[DataSet([Stability::STABILITY_STABLE, ['v2024.1.0']])] - #[DataSet([Stability::STABILITY_RC, ['v2024.1.0-rc.1', 'v2024.1.0']])] - #[DataSet([Stability::STABILITY_BETA, ['v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1']])] - #[DataSet([Stability::STABILITY_DEV, ['v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-alpha.1']])] - public function minimumStabilityKeepsEqualOrMoreStable(string $stability, array $expected): void - { - $releases = Releases::collection('v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-alpha.1'); - - Assert::same(Releases::versions($releases->minimumStability($stability)), $expected); - } -} diff --git a/tests/Unit/Repository/RepositoriesCollectionTest.php b/tests/Unit/Repository/RepositoriesCollectionTest.php deleted file mode 100644 index 3af9582..0000000 --- a/tests/Unit/Repository/RepositoriesCollectionTest.php +++ /dev/null @@ -1,41 +0,0 @@ -getName(), 'unknown/unknown'); - } - - public function returnsReleasesOfSingleRepository(): void - { - $collection = new RepositoriesCollection([ - new InMemoryRepository(Releases::release('v2024.1.0'), Releases::release('v2024.2.0')), - ]); - - Assert::same(Releases::versions($collection->getReleases()), ['v2024.1.0', 'v2024.2.0']); - } - - #[Skip('Bug: getReleases() collects the generator with preserved keys, so releases of a later repository overwrite earlier ones with the same index')] - public function mergesReleasesOfAllRepositories(): void - { - $collection = new RepositoriesCollection([ - new InMemoryRepository(Releases::release('v2024.1.0')), - new InMemoryRepository(Releases::release('v2.12.3')), - ]); - - Assert::same(Releases::versions($collection->getReleases()), ['v2024.1.0', 'v2.12.3']); - } -} diff --git a/tests/Unit/Repository/Version1/StaticRepositoryTest.php b/tests/Unit/Repository/Version1/StaticRepositoryTest.php deleted file mode 100644 index 52c610a..0000000 --- a/tests/Unit/Repository/Version1/StaticRepositoryTest.php +++ /dev/null @@ -1,42 +0,0 @@ -getReleases(); - - Assert::same($repository->getName(), 'spiral/roadrunner'); - Assert::same($releases->first()?->getVersion(), 'v1.9.1'); - Assert::true($releases->satisfies('^2.0')->empty()); - } - - public function exposesAssetsOfLegacyReleases(): void - { - $asset = (new StaticRepository(new MockHttpClient())) - ->getReleases() - ->first() - ?->getAssets() - ->whereOperatingSystem('linux') - ->whereArchitecture('amd64') - ->first(); - - Assert::same($asset?->getName(), 'roadrunner-1.9.1-linux-amd64.tar.gz'); - Assert::same( - $asset?->getUri(), - 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-linux-amd64.tar.gz', - ); - } -} diff --git a/tests/Unit/Stub/DLoadGetSpy.php b/tests/Unit/Stub/DLoadGetSpy.php new file mode 100644 index 0000000..f4500f1 --- /dev/null +++ b/tests/Unit/Stub/DLoadGetSpy.php @@ -0,0 +1,49 @@ + */ + public array $calls = []; + + /** @var list Contents of the `--config` file at the time of each call. */ + public array $configs = []; + + public function __construct( + private readonly int $exitCode = self::SUCCESS, + private readonly string $binary = 'rr', + ) { + parent::__construct('get'); + } + + protected function configure(): void + { + $this->setDefinition((new Get())->getDefinition()); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->calls[] = $input; + $this->configs[] = (string) @\file_get_contents((string) $input->getOption('config')); + + if ($this->exitCode === self::SUCCESS) { + \file_put_contents($input->getOption('path') . '/' . $this->binary, 'new binary'); + } + + $output->writeln('dload: ' . \implode(' ', $input->getArgument('software'))); + + return $this->exitCode; + } +} diff --git a/tests/Unit/Stub/InMemoryRepository.php b/tests/Unit/Stub/InMemoryRepository.php deleted file mode 100644 index 34e41d0..0000000 --- a/tests/Unit/Stub/InMemoryRepository.php +++ /dev/null @@ -1,31 +0,0 @@ - */ - private array $releases; - - public function __construct( - ReleaseInterface ...$releases, - ) { - $this->releases = \array_values($releases); - } - - public function getName(): string - { - return 'roadrunner-server/roadrunner'; - } - - public function getReleases(): ReleasesCollection - { - return new ReleasesCollection($this->releases); - } -} diff --git a/tests/Unit/Stub/Releases.php b/tests/Unit/Stub/Releases.php deleted file mode 100644 index f706609..0000000 --- a/tests/Unit/Stub/Releases.php +++ /dev/null @@ -1,62 +0,0 @@ - $assets Asset file names. - */ - public static function release( - string $tag, - array $assets = [], - ?HttpClientInterface $client = null, - string $repository = 'roadrunner-server/roadrunner', - ): GitHubRelease { - $client ??= new MockHttpClient(); - - return new GitHubRelease( - $client, - $tag, - $tag, - $repository, - \array_map(static fn(string $name): GitHubAsset => self::asset($name, $client), $assets), - ); - } - - public static function asset(string $name, ?HttpClientInterface $client = null): GitHubAsset - { - return new GitHubAsset($client ?? new MockHttpClient(), $name, 'https://example.com/download/' . $name); - } - - public static function collection(string ...$tags): ReleasesCollection - { - return new ReleasesCollection(\array_map(static fn(string $tag): GitHubRelease => self::release($tag), $tags)); - } - - /** - * @param iterable $releases - * @return list - */ - public static function versions(iterable $releases): array - { - $result = []; - foreach ($releases as $release) { - $result[] = $release->getVersion(); - } - - return $result; - } -} diff --git a/tests/Unit/VersionsCommandTest.php b/tests/Unit/VersionsCommandTest.php index ada0f33..d294476 100644 --- a/tests/Unit/VersionsCommandTest.php +++ b/tests/Unit/VersionsCommandTest.php @@ -4,13 +4,12 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; -use Spiral\RoadRunner\Console\Environment\RoadRunnerVersion; -use Spiral\RoadRunner\Console\Repository\ReleaseInterface; -use Spiral\RoadRunner\Console\Repository\RepositoryInterface; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\InMemoryRepository; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\Releases; +use Spiral\RoadRunner\Console\Internal\Environment\RoadRunnerVersion; +use Spiral\RoadRunner\Console\Internal\GitHub\ReleasesClient; use Spiral\RoadRunner\Console\VersionsCommand; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; use Testo\Assert; use Testo\Test; @@ -31,9 +30,9 @@ public function listsReleasesWithBinariesNewestFirst(): void $display = self::run( ['--os' => 'linux', '--arch' => 'amd64'], - Releases::release("v$major.1.0", ["roadrunner-$major.1.0-linux-amd64.tar.gz"]), - Releases::release("v$major.2.0", ["roadrunner-$major.2.0-linux-amd64.tar.gz", "roadrunner-$major.2.0-darwin-arm64.tar.gz"]), - Releases::release("v$major.3.0"), + ["v$major.1.0" => ["roadrunner-$major.1.0-linux-amd64.tar.gz"]], + ["v$major.2.0" => ["roadrunner-$major.2.0-linux-amd64.tar.gz", "roadrunner-$major.2.0-darwin-arm64.tar.gz"]], + ["v$major.3.0" => []], ); Assert::string($display) @@ -47,8 +46,8 @@ public function explainsMissingAssembly(): void $display = self::run( ['--os' => 'darwin', '--arch' => 'amd64'], - Releases::release("v$major.1.0", ["roadrunner-$major.1.0-linux-amd64.tar.gz"]), - Releases::release("v$major.2.0", ["roadrunner-$major.2.0-darwin-arm64.tar.gz"]), + ["v$major.1.0" => ["roadrunner-$major.1.0-linux-amd64.tar.gz"]], + ["v$major.2.0" => ["roadrunner-$major.2.0-darwin-arm64.tar.gz"]], ); Assert::string($display) @@ -60,7 +59,7 @@ public function marksIncompatibleVersions(): void { $display = self::run( ['--os' => 'linux', '--arch' => 'amd64'], - Releases::release('v1.9.0', ['roadrunner-1.9.0-linux-amd64.tar.gz']), + ['v1.9.0' => ['roadrunner-1.9.0-linux-amd64.tar.gz']], ); Assert::string($display)->contains('(reason: incompatible version)'); @@ -70,9 +69,9 @@ public function filtersByStabilityAndVersion(): void { $display = self::run( ['--os' => 'linux', '--arch' => 'amd64', '--stability' => 'beta', '--filter' => '^2024.1'], - Releases::release('v2024.1.0-beta.1', ['roadrunner-2024.1.0-beta.1-linux-amd64.tar.gz']), - Releases::release('v2024.1.0-alpha.1', ['roadrunner-2024.1.0-alpha.1-linux-amd64.tar.gz']), - Releases::release('v2023.3.12', ['roadrunner-2023.3.12-linux-amd64.tar.gz']), + ['v2024.1.0-beta.1' => ['roadrunner-2024.1.0-beta.1-linux-amd64.tar.gz']], + ['v2024.1.0-alpha.1' => ['roadrunner-2024.1.0-alpha.1-linux-amd64.tar.gz']], + ['v2023.3.12' => ['roadrunner-2023.3.12-linux-amd64.tar.gz']], ); Assert::string($display) @@ -81,6 +80,21 @@ public function filtersByStabilityAndVersion(): void ->notContains('2023.3.12'); } + public function acceptsAlternativeConstraints(): void + { + $display = self::run( + ['--os' => 'linux', '--arch' => 'amd64', '--filter' => '^2023.3 || ~2024.1.0'], + ['v2024.1.0' => ['roadrunner-2024.1.0-linux-amd64.tar.gz']], + ['v2023.3.12' => ['roadrunner-2023.3.12-linux-amd64.tar.gz']], + ['v2024.2.0' => ['roadrunner-2024.2.0-linux-amd64.tar.gz']], + ); + + Assert::string($display) + ->contains('2024.1.0') + ->contains('2023.3.12') + ->notContains('2024.2.0'); + } + /** * Major version of a release that satisfies the installed RoadRunner constraint. */ @@ -91,24 +105,23 @@ private static function compatibleMajor(): string /** * @param array $input + * @param array> ...$releases Asset names by release tag. */ - private static function run(array $input, ReleaseInterface ...$releases): string + private static function run(array $input, array ...$releases): string { - $repository = new InMemoryRepository(...$releases); - $command = new class($repository) extends VersionsCommand { - public function __construct( - private readonly RepositoryInterface $repository, - ) { - parent::__construct(); - } - - protected function getRepository(): RepositoryInterface - { - return $this->repository; + $body = []; + foreach ($releases as $release) { + foreach ($release as $tag => $assets) { + $body[] = [ + 'tag_name' => $tag, + 'name' => $tag, + 'assets' => \array_map(static fn(string $name): array => ['name' => $name], $assets), + ]; } - }; + } - $tester = new CommandTester($command); + $client = new ReleasesClient(new MockHttpClient(new MockResponse(\json_encode($body)))); + $tester = new CommandTester(new VersionsCommand(releases: $client)); Assert::same($tester->execute($input, ['interactive' => false]), 0); return $tester->getDisplay();