From dacea1ab05dadba239ce9c78b345e8d4b23e423d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 14:06:20 +0400 Subject: [PATCH 01/12] feat!: download binaries with DLoad `get-binary` and `download-protoc-binary` keep their options and output, but release lookup, asset selection, download and extraction are done by DLoad (`internal/dload` ^1.19.0); the overwrite prompt and `.rr.yaml` generation stay in the CLI. `--filter` and `--stability` become a DLoad constraint, and `--refresh` is always on so the version cache never hides a fresh release. An empty bundled `resources/dload.xml`, shipped in the dist archive, is passed as `--config`, so a project's `./dload.xml` can not override the requested version. `RR_GITHUB_API_URL` keeps its meaning: a GitHub Enterprise Server API URL `scheme://host[:port]/api/v3` becomes a temporary config that points the `rr` and `protoc-gen-php-grpc` entries at `scheme://host[:port]/roadrunner-server/roadrunner`. `GITHUB_TOKEN` goes only to the public GitHub, as DLoad does. A kept binary now exits with 1, and an unknown `--os` or `--arch` fails before any request. The acceptance tests run `rr get` against a fake GitHub Enterprise Server. BREAKING CHANGE: for `get-binary` and `download-protoc-binary`, `RR_GITHUB_API_URL` must be `https://api.github.com` or end with `/api/v3`, and the token for a GitHub Enterprise Server is read from `DLOAD_TOKEN_` (e.g. `DLOAD_TOKEN_GHE_EXAMPLE_COM`) instead of `GITHUB_TOKEN`. `symfony/console` and `symfony/http-client` 6.4 or newer are required. Assisted-By: Claude Opus 5.5 --- .gitattributes | 4 + README.md | 2 +- composer.json | 5 +- resources/dload.xml | 3 + src/DownloadProtocBinaryCommand.php | 181 ++------------- src/Downloader/DLoadDownloader.php | 197 ++++++++++++++++ src/GetBinaryCommand.php | 197 +++------------- tests/Acceptance/AuthenticationTest.php | 39 ++++ tests/Acceptance/ExistingBinaryTest.php | 2 - tests/Acceptance/FailureTest.php | 2 - tests/Acceptance/Server/router.php | 23 +- tests/Acceptance/Support/FakeGitHub.php | 96 ++++---- tests/Acceptance/VersionSelectionTest.php | 18 +- .../Unit/DownloadProtocBinaryCommandTest.php | 97 +++++++- tests/Unit/Downloader/DLoadDownloaderTest.php | 210 ++++++++++++++++++ tests/Unit/GetBinaryCommandTest.php | 170 +++++++------- tests/Unit/Stub/DLoadGetSpy.php | 49 ++++ 17 files changed, 819 insertions(+), 476 deletions(-) create mode 100644 resources/dload.xml create mode 100644 src/Downloader/DLoadDownloader.php create mode 100644 tests/Acceptance/AuthenticationTest.php create mode 100644 tests/Unit/Downloader/DLoadDownloaderTest.php create mode 100644 tests/Unit/Stub/DLoadGetSpy.php 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..a6de439 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,4 @@ 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), `--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`). `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. For these two commands `RR_GITHUB_API_URL` 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`), and `GITHUB_TOKEN` is sent only to the public GitHub: the token for a GitHub Enterprise Server goes into `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`). diff --git a/composer.json b/composer.json index e2de837..6d5d053 100644 --- a/composer.json +++ b/composer.json @@ -42,10 +42,11 @@ "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/resources/dload.xml b/resources/dload.xml new file mode 100644 index 0000000..a2ffa03 --- /dev/null +++ b/resources/dload.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php index 939717f..84af473 100644 --- a/src/DownloadProtocBinaryCommand.php +++ b/src/DownloadProtocBinaryCommand.php @@ -4,18 +4,13 @@ namespace Spiral\RoadRunner\Console; -use Spiral\RoadRunner\Console\Archive\ArchiveInterface; -use Spiral\RoadRunner\Console\Archive\Factory; use Spiral\RoadRunner\Console\Command\ArchitectureOption; use Spiral\RoadRunner\Console\Command\InstallationLocationOption; use Spiral\RoadRunner\Console\Command\OperatingSystemOption; use Spiral\RoadRunner\Console\Command\StabilityOption; use Spiral\RoadRunner\Console\Command\VersionFilterOption; -use Spiral\RoadRunner\Console\Repository\AssetInterface; -use Spiral\RoadRunner\Console\Repository\ReleaseInterface; -use Spiral\RoadRunner\Console\Repository\ReleasesCollection; -use Spiral\RoadRunner\Console\Repository\RepositoryInterface; -use Symfony\Component\Console\Helper\ProgressBar; +use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Environment\OperatingSystem; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\StyleInterface; @@ -25,16 +20,16 @@ */ final class DownloadProtocBinaryCommand extends Command { - private const ERROR_ENVIRONMENT = 'Could not find any available protoc-gen-php-grpc binary version which meets criterion (--%s=%s --%s=%s --%s=%s). Available: %s'; - private OperatingSystemOption $os; private ArchitectureOption $arch; private VersionFilterOption $version; private StabilityOption $stability; private InstallationLocationOption $location; + private DLoadDownloader $downloader; - public function __construct(?string $name = null) + public function __construct(?string $name = null, ?DLoadDownloader $downloader = null) { + $this->downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'download-protoc-binary'); $this->os = new OperatingSystemOption($this); @@ -56,91 +51,39 @@ public function execute(InputInterface $input, OutputInterface $output): int $io = $this->io($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 +95,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/Downloader/DLoadDownloader.php b/src/Downloader/DLoadDownloader.php new file mode 100644 index 0000000..b3d21d7 --- /dev/null +++ b/src/Downloader/DLoadDownloader.php @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + 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; "*" means any version. + * @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. + * It also rejects "*", which is the same as no constraint. + */ + private static function versionSuffix(string $constraint, string $stability): string + { + $constraint = \trim($constraint); + + if ($constraint === '' || $constraint === '*') { + return ''; + } + + return ':' . (\str_contains($constraint, '@') ? $constraint : $constraint . '@' . $stability); + } + + /** + * @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/GetBinaryCommand.php b/src/GetBinaryCommand.php index 95a59a0..54b2ef3 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -11,8 +11,6 @@ namespace Spiral\RoadRunner\Console; -use Spiral\RoadRunner\Console\Archive\ArchiveInterface; -use Spiral\RoadRunner\Console\Archive\Factory; use Spiral\RoadRunner\Console\Command\ArchitectureOption; use Spiral\RoadRunner\Console\Command\InstallationLocationOption; use Spiral\RoadRunner\Console\Command\OperatingSystemOption; @@ -20,11 +18,8 @@ use Spiral\RoadRunner\Console\Command\VersionFilterOption; use Spiral\RoadRunner\Console\Configuration\Generator; use Spiral\RoadRunner\Console\Configuration\Plugins; -use Spiral\RoadRunner\Console\Repository\AssetInterface; -use Spiral\RoadRunner\Console\Repository\ReleaseInterface; -use Spiral\RoadRunner\Console\Repository\ReleasesCollection; -use Spiral\RoadRunner\Console\Repository\RepositoryInterface; -use Symfony\Component\Console\Helper\ProgressBar; +use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Environment\OperatingSystem; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -32,21 +27,16 @@ class GetBinaryCommand extends Command { - /** - * @var string - */ - private const ERROR_ENVIRONMENT = - 'Could not find any available RoadRunner binary version which meets criterion (--%s=%s --%s=%s --%s=%s). ' . - 'Available: %s'; - private OperatingSystemOption $os; private ArchitectureOption $arch; private VersionFilterOption $version; private StabilityOption $stability; private InstallationLocationOption $location; + private DLoadDownloader $downloader; - public function __construct(?string $name = null) + public function __construct(?string $name = null, ?DLoadDownloader $downloader = null) { + $this->downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'get-binary'); $this->os = new OperatingSystemOption($this); @@ -72,46 +62,47 @@ public function execute(InputInterface $input, OutputInterface $output): int $io = $this->io($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 +112,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,46 +146,6 @@ 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 { $to .= '/.rr.yaml'; @@ -226,9 +177,9 @@ private function installConfig(string $to, InputInterface $in, StyleInterface $i 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 +191,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/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/router.php b/tests/Acceptance/Server/router.php index 89bc072..f31e715 100644 --- a/tests/Acceptance/Server/router.php +++ b/tests/Acceptance/Server/router.php @@ -3,14 +3,16 @@ /** * 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. * - * 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 +25,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'); @@ -90,7 +93,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 +107,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..ecf32bc 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,44 @@ public static function downloads(): array return $names; } + /** + * @return string `http://127.0.0.1:` + */ + private static function startServer(string $scenario): string + { + self::start(); + $port = self::freePort(); + + $env = Rr::baseEnvironment(); + $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..050e431 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; /** @@ -43,7 +42,6 @@ public function installsNewestReleaseMatchingFilter(string $filter, string $vers #[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 +50,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 +114,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/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php index c89f13a..d93c16f 100644 --- a/tests/Unit/DownloadProtocBinaryCommandTest.php +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -4,16 +4,34 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; +use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; use Spiral\RoadRunner\Console\DownloadProtocBinaryCommand; +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/Downloader/DLoadDownloaderTest.php b/tests/Unit/Downloader/DLoadDownloaderTest.php new file mode 100644 index 0000000..d0ba6b2 --- /dev/null +++ b/tests/Unit/Downloader/DLoadDownloaderTest.php @@ -0,0 +1,210 @@ +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')] + #[DataSet(['*', 'beta', 'rr'], 'any version')] + #[DataSet(['', 'stable', 'rr'], 'empty constraint')] + 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); + } + + 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/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php index 779a89a..36161fc 100644 --- a/tests/Unit/GetBinaryCommandTest.php +++ b/tests/Unit/GetBinaryCommandTest.php @@ -4,18 +4,13 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; +use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; 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\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/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; + } +} From 50fe8a22a776b5b6ea26a162a0959418e6c90d3d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 14:07:16 +0400 Subject: [PATCH 02/12] feat: make the wildcard version filter install RoadRunner 3.0 or newer RoadRunner went from calendar releases back to 3.x and DLoad orders versions numerically, so `-f '*'` installed 2025.1.x for both `get-binary` and `download-protoc-binary`, which share the release stream. `*` and an empty filter now mean `^3.0` with the requested stability; `2025.*` still selects the calendar releases. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/Downloader/DLoadDownloader.php | 18 ++++++++++------ tests/Acceptance/Server/releases.json | 1 + tests/Acceptance/VersionSelectionTest.php | 21 +++++++++++++++++++ tests/Unit/Downloader/DLoadDownloaderTest.php | 19 +++++++++++++++-- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a6de439..19059ec 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,4 @@ 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`). `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. For these two commands `RR_GITHUB_API_URL` 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`), and `GITHUB_TOKEN` is sent only to the public GitHub: the token for a GitHub Enterprise Server goes into `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`). +The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` `*` means any 3.x release, use `2025.*` for the calendar releases), `--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`). `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. For these two commands `RR_GITHUB_API_URL` 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`), and `GITHUB_TOKEN` is sent only to the public GitHub: the token for a GitHub Enterprise Server goes into `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`). diff --git a/src/Downloader/DLoadDownloader.php b/src/Downloader/DLoadDownloader.php index b3d21d7..b414058 100644 --- a/src/Downloader/DLoadDownloader.php +++ b/src/Downloader/DLoadDownloader.php @@ -37,6 +37,12 @@ final class DLoadDownloader private const PUBLIC_API_URL = 'https://api.github.com'; + /** + * What `*` stands for. RoadRunner went from calendar versions back to 3.x, and DLoad orders versions + * numerically, so without a bound `2025.1.x` would win over every 3.x release; 2025 is `2025.*`. + */ + private const ANY_VERSION = '^3.0'; + /** * DLoad serves the API of a GitHub Enterprise Server at `scheme://host[:port]/api/v3`, so no other path can be mapped. */ @@ -80,7 +86,7 @@ public function __construct(?Command $get = null, ?string $githubApiUrl = null) /** * @param non-empty-string $software DLoad software alias, e.g. "rr" or "protoc-gen-php-grpc". - * @param string $constraint Composer version constraint; "*" means any version. + * @param string $constraint Composer version constraint; "*" or empty means any 3.x version. * @param string $stability Minimum stability, e.g. "stable" or "beta". * @param bool $force Overwrite the binary if it already exists in the location. * @@ -122,17 +128,17 @@ public function download( /** * DLoad ignores `--stability` once a version is given: the stability has to be a part of the constraint. - * It also rejects "*", which is the same as no constraint. */ private static function versionSuffix(string $constraint, string $stability): string { - $constraint = \trim($constraint); + [$version, $explicitStability] = \explode('@', \trim($constraint), 2) + [1 => null]; + $version = \trim($version); - if ($constraint === '' || $constraint === '*') { - return ''; + if ($version === '' || $version === '*') { + $version = self::ANY_VERSION; } - return ':' . (\str_contains($constraint, '@') ? $constraint : $constraint . '@' . $stability); + return ':' . $version . '@' . ($explicitStability ?? $stability); } /** 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/VersionSelectionTest.php b/tests/Acceptance/VersionSelectionTest.php index 050e431..23954e6 100644 --- a/tests/Acceptance/VersionSelectionTest.php +++ b/tests/Acceptance/VersionSelectionTest.php @@ -40,6 +40,27 @@ 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"); + } + + public function treatsWildcardFilterAsAnyV3ProtocPlugin(): void + { + $result = $this->rr()->run(['download-protoc-binary', '--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'); + } + #[DataSet(['stable', '3.*', '3.4.0'])] #[DataSet(['RC', '3.*', '3.5.0-rc.1'])] public function honoursMinimumStability(string $stability, string $filter, string $version): void diff --git a/tests/Unit/Downloader/DLoadDownloaderTest.php b/tests/Unit/Downloader/DLoadDownloaderTest.php index d0ba6b2..c811322 100644 --- a/tests/Unit/Downloader/DLoadDownloaderTest.php +++ b/tests/Unit/Downloader/DLoadDownloaderTest.php @@ -143,8 +143,6 @@ public function readsGitHubApiUrlFromEnvironment(): void #[DataSet(['3.*', 'stable', 'rr:3.*@stable'])] #[DataSet([' 2025.1.* ', 'rc', 'rr:2025.1.*@rc'], 'trimmed')] #[DataSet(['3.*@beta', 'stable', 'rr:3.*@beta'], 'explicit stability')] - #[DataSet(['*', 'beta', 'rr'], 'any version')] - #[DataSet(['', 'stable', 'rr'], 'empty constraint')] public function putsStabilityIntoVersionConstraint(string $constraint, string $stability, string $software): void { $get = new DLoadGetSpy(); @@ -155,6 +153,23 @@ public function putsStabilityIntoVersionConstraint(string $constraint, string $s 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@stable'])] + #[DataSet([' * ', 'beta', 'rr:^3.0@beta'], 'trimmed')] + #[DataSet(['', 'rc', 'rr:^3.0@rc'], 'empty constraint')] + #[DataSet(['*@beta', 'stable', 'rr:^3.0@beta'], 'explicit stability')] + #[DataSet(['@alpha', 'stable', 'rr:^3.0@alpha'], 'stability only')] + public function treatsWildcardAsAnyV3Release(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]); + } + public function returnsDLoadExitCode(): void { Assert::same($this->download(new DLoadGetSpy(exitCode: 1)), 1); From 14df91f469843768a78d8b43b37faa408eefc141 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 14:08:18 +0400 Subject: [PATCH 03/12] test: cover rr versions against the fake GitHub The tests pin the output of `rr versions` before its GitHub client is replaced. Assisted-By: Claude Opus 5.5 --- tests/Acceptance/VersionsTest.php | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/Acceptance/VersionsTest.php diff --git a/tests/Acceptance/VersionsTest.php b/tests/Acceptance/VersionsTest.php new file mode 100644 index 0000000..ac5923a --- /dev/null +++ b/tests/Acceptance/VersionsTest.php @@ -0,0 +1,111 @@ +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 sendsGitHubToken(): void + { + $result = $this->rr()->withEnv('GITHUB_TOKEN', 'token')->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\array_column(FakeGitHub::releaseRequests(), 'authorized'), [true]); + } + + 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); + } + + /** + * @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]; + } +} From 4eaf4260554cbf890c283e575eb9e86da70dbf27 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 12:46:12 +0400 Subject: [PATCH 04/12] refactor!: remove the archive and release repository classes DLoad downloads and unpacks the binaries, so only `rr versions` still reads the GitHub releases. It now uses a small internal client that lists release names and asset file names, and passes `--filter` to composer/semver as a whole, so `||` alternatives work. A release whose tag and name are not versions is named `dev-` instead of `d-`. BREAKING CHANGE: the `Spiral\RoadRunner\Console\Archive` and `Spiral\RoadRunner\Console\Repository` namespaces are removed, `Version1\StaticRepository` included. `Command::getRepository()` and `Command::confirm()` are removed, and `VersionFilterOption::find()` and `VersionFilterOption::choices()` with them. `VersionsCommand` is final. Assisted-By: Claude Opus 5.5 --- src/Archive/Archive.php | 35 - src/Archive/ArchiveInterface.php | 21 - src/Archive/Factory.php | 126 -- src/Archive/FactoryInterface.php | 30 - src/Archive/PharArchive.php | 21 - src/Archive/PharAwareArchive.php | 49 - src/Archive/TarPharArchive.php | 21 - src/Archive/ZipPharArchive.php | 23 - src/Command.php | 50 - src/Command/VersionFilterOption.php | 44 - src/Internal/GitHub/Release.php | 97 ++ src/Internal/GitHub/ReleasesClient.php | 97 ++ src/Repository/Asset.php | 36 - src/Repository/AssetInterface.php | 24 - src/Repository/AssetsCollection.php | 62 - src/Repository/Collection.php | 164 --- src/Repository/GitHub/GitHubAsset.php | 75 - src/Repository/GitHub/GitHubRelease.php | 106 -- src/Repository/GitHub/GitHubRepository.php | 145 -- src/Repository/Release.php | 104 -- src/Repository/ReleaseInterface.php | 49 - src/Repository/ReleasesCollection.php | 142 -- src/Repository/RepositoriesCollection.php | 44 - src/Repository/RepositoryInterface.php | 19 - src/Repository/Version1/StaticRepository.php | 1260 ----------------- src/VersionsCommand.php | 128 +- tests/Unit/Archive/FactoryTest.php | 140 -- tests/Unit/Archive/PharAwareArchiveTest.php | 94 -- .../Unit/Command/VersionFilterOptionTest.php | 43 +- tests/Unit/CommandTest.php | 53 - tests/Unit/Internal/GitHub/ReleaseTest.php | 102 ++ .../GitHub/ReleasesClientTest.php} | 67 +- .../Unit/Repository/AssetsCollectionTest.php | 83 -- tests/Unit/Repository/CollectionTest.php | 133 -- .../Repository/GitHub/GitHubAssetTest.php | 69 - .../Repository/GitHub/GitHubReleaseTest.php | 122 -- .../Repository/ReleasesCollectionTest.php | 119 -- .../Repository/RepositoriesCollectionTest.php | 41 - .../Version1/StaticRepositoryTest.php | 42 - tests/Unit/Stub/InMemoryRepository.php | 31 - tests/Unit/Stub/Releases.php | 62 - tests/Unit/VersionsCommandTest.php | 67 +- 42 files changed, 433 insertions(+), 3807 deletions(-) delete mode 100644 src/Archive/Archive.php delete mode 100644 src/Archive/ArchiveInterface.php delete mode 100644 src/Archive/Factory.php delete mode 100644 src/Archive/FactoryInterface.php delete mode 100644 src/Archive/PharArchive.php delete mode 100644 src/Archive/PharAwareArchive.php delete mode 100644 src/Archive/TarPharArchive.php delete mode 100644 src/Archive/ZipPharArchive.php create mode 100644 src/Internal/GitHub/Release.php create mode 100644 src/Internal/GitHub/ReleasesClient.php delete mode 100644 src/Repository/Asset.php delete mode 100644 src/Repository/AssetInterface.php delete mode 100644 src/Repository/AssetsCollection.php delete mode 100644 src/Repository/Collection.php delete mode 100644 src/Repository/GitHub/GitHubAsset.php delete mode 100644 src/Repository/GitHub/GitHubRelease.php delete mode 100644 src/Repository/GitHub/GitHubRepository.php delete mode 100644 src/Repository/Release.php delete mode 100644 src/Repository/ReleaseInterface.php delete mode 100644 src/Repository/ReleasesCollection.php delete mode 100644 src/Repository/RepositoriesCollection.php delete mode 100644 src/Repository/RepositoryInterface.php delete mode 100644 src/Repository/Version1/StaticRepository.php delete mode 100644 tests/Unit/Archive/FactoryTest.php delete mode 100644 tests/Unit/Archive/PharAwareArchiveTest.php delete mode 100644 tests/Unit/CommandTest.php create mode 100644 tests/Unit/Internal/GitHub/ReleaseTest.php rename tests/Unit/{Repository/GitHub/GitHubRepositoryTest.php => Internal/GitHub/ReleasesClientTest.php} (50%) delete mode 100644 tests/Unit/Repository/AssetsCollectionTest.php delete mode 100644 tests/Unit/Repository/CollectionTest.php delete mode 100644 tests/Unit/Repository/GitHub/GitHubAssetTest.php delete mode 100644 tests/Unit/Repository/GitHub/GitHubReleaseTest.php delete mode 100644 tests/Unit/Repository/ReleasesCollectionTest.php delete mode 100644 tests/Unit/Repository/RepositoriesCollectionTest.php delete mode 100644 tests/Unit/Repository/Version1/StaticRepositoryTest.php delete mode 100644 tests/Unit/Stub/InMemoryRepository.php delete mode 100644 tests/Unit/Stub/Releases.php 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 index 27e726a..931a1c5 100644 --- a/src/Command.php +++ b/src/Command.php @@ -11,66 +11,16 @@ namespace Spiral\RoadRunner\Console; -use Spiral\RoadRunner\Console\Environment\Environment; -use Spiral\RoadRunner\Console\Repository\GitHub\GitHubRepository; -use Spiral\RoadRunner\Console\Repository\RepositoriesCollection; -use Spiral\RoadRunner\Console\Repository\RepositoryInterface; use Symfony\Component\Console\Command\Command as BaseCommand; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\HttpClient\HttpClient; abstract class Command extends BaseCommand { - /** - * @var string - */ - private const ENV_GITHUB_TOKEN = 'GITHUB_TOKEN'; - - /** - * Not `GITHUB_API_URL`: GitHub Actions sets that one in every job. - * - * @var string - */ - private const ENV_GITHUB_API_URL = 'RR_GITHUB_API_URL'; - - protected function getRepository(): RepositoryInterface - { - $token = Environment::get(self::ENV_GITHUB_TOKEN); - $apiUrl = Environment::get(self::ENV_GITHUB_API_URL); - - $client = HttpClient::create([ - 'headers' => \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/VersionFilterOption.php b/src/Command/VersionFilterOption.php index 1143fa8..58a5e4c 100644 --- a/src/Command/VersionFilterOption.php +++ b/src/Command/VersionFilterOption.php @@ -12,12 +12,7 @@ namespace Spiral\RoadRunner\Console\Command; use Spiral\RoadRunner\Console\Environment\RoadRunnerVersion; -use Spiral\RoadRunner\Console\Repository\ReleaseInterface; -use Spiral\RoadRunner\Console\Repository\ReleasesCollection; -use Spiral\RoadRunner\Console\Repository\RepositoryInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Style\StyleInterface; class VersionFilterOption extends Option { @@ -26,33 +21,6 @@ public function __construct(Command $command, string $name = 'filter', string $s parent::__construct($command, $name, $short); } - public function choices(ReleasesCollection $releases): string - { - $versions = \array_map( - static fn(ReleaseInterface $release): string => $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 { @@ -64,16 +32,4 @@ 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/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..2c5d85e --- /dev/null +++ b/src/Internal/GitHub/ReleasesClient.php @@ -0,0 +1,97 @@ +apiUrl = \rtrim($apiUrl, '/'); + } + + /** + * Takes the API URL from RR_GITHUB_API_URL and the token from GITHUB_TOKEN, the token is sent to any API URL. + */ + public static function fromEnvironment(): self + { + $token = Environment::get(self::ENV_GITHUB_TOKEN); + $apiUrl = Environment::get(self::ENV_GITHUB_API_URL); + + $client = HttpClient::create([ + 'headers' => \array_filter([ + 'authorization' => $token !== null && $token !== '' ? 'token ' . $token : null, + ]), + ]); + + return new self($client, $apiUrl !== null && $apiUrl !== '' ? $apiUrl : self::DEFAULT_API_URL); + } + + /** + * @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' => ['accept' => 'application/vnd.github.v3+json'], + '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/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..f3267c1 100644 --- a/src/VersionsCommand.php +++ b/src/VersionsCommand.php @@ -17,20 +17,25 @@ 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\GitHub\Release; +use Spiral\RoadRunner\Console\Internal\GitHub\ReleasesClient; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\StyleInterface; -class VersionsCommand extends Command +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); @@ -57,110 +62,77 @@ public function execute(InputInterface $input, OutputInterface $output): int { $io = $this->io($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/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 index 1a0b115..1987917 100644 --- a/tests/Unit/Command/VersionFilterOptionTest.php +++ b/tests/Unit/Command/VersionFilterOptionTest.php @@ -6,11 +6,8 @@ use Spiral\RoadRunner\Console\Command\VersionFilterOption; use Spiral\RoadRunner\Console\Environment\RoadRunnerVersion; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\InMemoryRepository; use Spiral\RoadRunner\Console\Tests\Unit\Stub\OptionHost; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\Releases; use Testo\Assert; -use Testo\Expect; use Testo\Test; #[Test] @@ -25,45 +22,13 @@ public function defaultsToInstalledRoadRunnerConstraint(): void Assert::same($option->get($input, $host->io($input)), RoadRunnerVersion::constraint()); } - public function findReturnsMatchingReleasesNewestFirst(): void + public function acceptsShortcut(): 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'), - ); + $input = $host->input(['-f' => '^3.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', - ); + Assert::same($option->getName(), 'filter'); + Assert::same($option->get($input, $host->io($input)), '^3.1'); } } 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/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/Repository/GitHub/GitHubRepositoryTest.php b/tests/Unit/Internal/GitHub/ReleasesClientTest.php similarity index 50% rename from tests/Unit/Repository/GitHub/GitHubRepositoryTest.php rename to tests/Unit/Internal/GitHub/ReleasesClientTest.php index f6fb8d5..0fd9d6a 100644 --- a/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php +++ b/tests/Unit/Internal/GitHub/ReleasesClientTest.php @@ -2,29 +2,32 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Repository\GitHub; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\GitHub; -use Spiral\RoadRunner\Console\Repository\GitHub\GitHubRepository; -use Spiral\RoadRunner\Console\Tests\Unit\Stub\Releases; +use Spiral\RoadRunner\Console\Internal\GitHub\Release; +use Spiral\RoadRunner\Console\Internal\GitHub\ReleasesClient; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Testo\Assert; +use Testo\Expect; +use Testo\Lifecycle\AfterTest; use Testo\Test; #[Test] -final class GitHubRepositoryTest +final class ReleasesClientTest { - public function nameIsOwnerAndRepository(): void + #[AfterTest] + public function cleanUp(): void { - Assert::same(GitHubRepository::create('roadrunner-server', 'roadrunner')->getName(), 'roadrunner-server/roadrunner'); + unset($_SERVER['RR_GITHUB_API_URL'], $_SERVER['GITHUB_TOKEN']); } - public function getReleasesFollowsPagination(): void + public function followsPagination(): 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"'], + 1 => [[['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 { @@ -36,16 +39,29 @@ static function (string $method, string $url, array $options) use (&$requests, $ }, ); - $releases = GitHubRepository::create('roadrunner-server', 'roadrunner', $client)->getReleases(); + $releases = (new ReleasesClient($client))->releases(); - Assert::same(Releases::versions($releases), ['v2024.2.0', 'v2024.1.0']); + 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 getReleasesUsesCustomApiUrl(): void + 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 { @@ -54,25 +70,28 @@ public function getReleasesUsesCustomApiUrl(): void return new MockResponse('[]'); }); - GitHubRepository::create('roadrunner-server', 'roadrunner', $client, 'http://127.0.0.1:8080/api/v3/') - ->getReleases() - ->empty(); + (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 getReleasesStopsWithoutLinkHeader(): void + public function readsApiUrlFromEnvironment(): void { - $calls = 0; - $client = new MockHttpClient(static function () use (&$calls): MockResponse { - ++$calls; + // 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'; - return new MockResponse('[]'); - }); + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('ftp://github.mock/api/repos/roadrunner-server/roadrunner/releases'); - $releases = GitHubRepository::create('roadrunner-server', 'roadrunner', $client)->getReleases(); + ReleasesClient::fromEnvironment()->releases(); + } - Assert::true($releases->empty()); - Assert::same($calls, 1); + /** + * @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/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/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/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..d655f5f 100644 --- a/tests/Unit/VersionsCommandTest.php +++ b/tests/Unit/VersionsCommandTest.php @@ -5,12 +5,11 @@ 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\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(); From ee9f83d9dc03d4af28858dace11649def6b1069b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 12:50:55 +0400 Subject: [PATCH 05/12] refactor!: move the implementation into the Internal namespace Only the console commands and the platform constants are meant for other code: the Temporal SDK reads `Environment\OperatingSystem` and `Environment\Architecture`. Everything else is marked `@internal` and lives under `Spiral\RoadRunner\Console\Internal`, so it can change in a minor release. BREAKING CHANGE: the `Command`, `Configuration` and `Downloader` namespaces move to `Spiral\RoadRunner\Console\Internal\Option`, `Internal\Configuration` and `Internal\Downloader`, and `Environment\Stability`, `Environment\RoadRunnerVersion` and `Environment\Environment` to `Internal\Environment`; all of them are internal now. The abstract `Spiral\RoadRunner\Console\Command`, `Command\OptionInterface`, `Environment\Enum` and both `Environment\*\Factory` classes are removed: the commands extend the Symfony command directly. The commands and `Configuration\Generator` are final. `Environment\Environment::get()` takes only the variable name, `OperatingSystem::createFromGlobals()` takes no arguments, `Stability::all()` returns a list, and `RoadRunnerVersion::PACKAGE_NAME` and `DEFAULT_CONSTRAINT` are private. Assisted-By: Claude Opus 5.5 --- psalm.xml | 2 - src/Command.php | 26 -------- src/Command/Option.php | 66 ------------------- src/Command/OptionInterface.php | 22 ------- src/Command/VersionFilterOption.php | 35 ---------- src/Configuration/Presets.php | 17 ----- src/DownloadProtocBinaryCommand.php | 16 +++-- src/Environment/Architecture.php | 39 ++++++----- src/Environment/Architecture/Factory.php | 63 ------------------ src/Environment/Enum.php | 43 ------------ src/Environment/Environment.php | 31 --------- src/Environment/OperatingSystem.php | 58 +++++++--------- src/Environment/OperatingSystem/Factory.php | 57 ---------------- src/GetBinaryCommand.php | 22 ++++--- .../Configuration/Generator.php | 23 ++++--- src/{ => Internal}/Configuration/Plugins.php | 23 ++++--- src/Internal/Configuration/Presets.php | 20 ++++++ .../Configuration/Section/AbstractSection.php | 5 +- .../Configuration/Section/Amqp.php | 5 +- .../Configuration/Section/Beanstalk.php | 5 +- .../Configuration/Section/Boltdb.php | 5 +- .../Configuration/Section/Broadcast.php | 5 +- .../Configuration/Section/Endure.php | 5 +- .../Configuration/Section/Fileserver.php | 5 +- .../Configuration/Section/Grpc.php | 5 +- .../Configuration/Section/Http.php | 5 +- .../Configuration/Section/Jobs.php | 5 +- .../Configuration/Section/Kv.php | 5 +- .../Configuration/Section/Logs.php | 5 +- .../Configuration/Section/Metrics.php | 5 +- .../Configuration/Section/Nats.php | 5 +- .../Configuration/Section/Otel.php | 5 +- .../Configuration/Section/Redis.php | 5 +- .../Configuration/Section/Reload.php | 5 +- .../Configuration/Section/Rpc.php | 5 +- .../Section/SectionInterface.php | 5 +- .../Configuration/Section/Server.php | 5 +- .../Configuration/Section/Service.php | 5 +- .../Configuration/Section/Sqs.php | 5 +- .../Configuration/Section/Status.php | 5 +- .../Configuration/Section/Tcp.php | 5 +- .../Configuration/Section/Temporal.php | 5 +- .../Configuration/Section/Version.php | 5 +- .../Configuration/Section/Websockets.php | 5 +- .../Downloader/DLoadDownloader.php | 6 +- src/Internal/Environment/Environment.php | 29 ++++++++ .../Environment/RoadRunnerVersion.php | 9 ++- src/{ => Internal}/Environment/Stability.php | 51 ++++---------- src/Internal/GitHub/ReleasesClient.php | 2 +- .../Option}/ArchitectureOption.php | 13 ++-- .../Option}/InstallationLocationOption.php | 7 +- .../Option}/OperatingSystemOption.php | 13 ++-- src/Internal/Option/Option.php | 45 +++++++++++++ .../Option}/StabilityOption.php | 19 ++---- src/Internal/Option/VersionFilterOption.php | 48 ++++++++++++++ src/MakeConfigCommand.php | 12 ++-- src/VersionsCommand.php | 24 +++---- .../Unit/DownloadProtocBinaryCommandTest.php | 2 +- tests/Unit/Environment/EnumTest.php | 29 -------- tests/Unit/GetBinaryCommandTest.php | 2 +- .../Configuration/GeneratorTest.php | 6 +- .../Configuration/PluginsTest.php | 24 +++---- .../Downloader/DLoadDownloaderTest.php | 4 +- .../Environment/EnvironmentTest.php | 19 ++---- .../Environment/StabilityTest.php | 12 +--- .../Option}/EnvironmentOptionsTest.php | 10 ++- .../InstallationLocationOptionTest.php | 4 +- .../Option}/VersionFilterOptionTest.php | 7 +- tests/Unit/VersionsCommandTest.php | 2 +- 69 files changed, 446 insertions(+), 651 deletions(-) delete mode 100644 src/Command.php delete mode 100644 src/Command/Option.php delete mode 100644 src/Command/OptionInterface.php delete mode 100644 src/Command/VersionFilterOption.php delete mode 100644 src/Configuration/Presets.php delete mode 100644 src/Environment/Architecture/Factory.php delete mode 100644 src/Environment/Enum.php delete mode 100644 src/Environment/Environment.php delete mode 100644 src/Environment/OperatingSystem/Factory.php rename src/{ => Internal}/Configuration/Generator.php (66%) rename src/{ => Internal}/Configuration/Plugins.php (81%) create mode 100644 src/Internal/Configuration/Presets.php rename src/{ => Internal}/Configuration/Section/AbstractSection.php (73%) rename src/{ => Internal}/Configuration/Section/Amqp.php (86%) rename src/{ => Internal}/Configuration/Section/Beanstalk.php (83%) rename src/{ => Internal}/Configuration/Section/Boltdb.php (81%) rename src/{ => Internal}/Configuration/Section/Broadcast.php (96%) rename src/{ => Internal}/Configuration/Section/Endure.php (84%) rename src/{ => Internal}/Configuration/Section/Fileserver.php (94%) rename src/{ => Internal}/Configuration/Section/Grpc.php (95%) rename src/{ => Internal}/Configuration/Section/Http.php (91%) rename src/{ => Internal}/Configuration/Section/Jobs.php (87%) rename src/{ => Internal}/Configuration/Section/Kv.php (91%) rename src/{ => Internal}/Configuration/Section/Logs.php (96%) rename src/{ => Internal}/Configuration/Section/Metrics.php (82%) rename src/{ => Internal}/Configuration/Section/Nats.php (81%) rename src/{ => Internal}/Configuration/Section/Otel.php (88%) rename src/{ => Internal}/Configuration/Section/Redis.php (94%) rename src/{ => Internal}/Configuration/Section/Reload.php (91%) rename src/{ => Internal}/Configuration/Section/Rpc.php (82%) rename src/{ => Internal}/Configuration/Section/SectionInterface.php (76%) rename src/{ => Internal}/Configuration/Section/Server.php (83%) rename src/{ => Internal}/Configuration/Section/Service.php (93%) rename src/{ => Internal}/Configuration/Section/Sqs.php (86%) rename src/{ => Internal}/Configuration/Section/Status.php (83%) rename src/{ => Internal}/Configuration/Section/Tcp.php (94%) rename src/{ => Internal}/Configuration/Section/Temporal.php (95%) rename src/{ => Internal}/Configuration/Section/Version.php (82%) rename src/{ => Internal}/Configuration/Section/Websockets.php (84%) rename src/{ => Internal}/Downloader/DLoadDownloader.php (97%) create mode 100644 src/Internal/Environment/Environment.php rename src/{ => Internal}/Environment/RoadRunnerVersion.php (86%) rename src/{ => Internal}/Environment/Stability.php (55%) rename src/{Command => Internal/Option}/ArchitectureOption.php (81%) rename src/{Command => Internal/Option}/InstallationLocationOption.php (90%) rename src/{Command => Internal/Option}/OperatingSystemOption.php (78%) create mode 100644 src/Internal/Option/Option.php rename src/{Command => Internal/Option}/StabilityOption.php (76%) create mode 100644 src/Internal/Option/VersionFilterOption.php delete mode 100644 tests/Unit/Environment/EnumTest.php rename tests/Unit/{ => Internal}/Configuration/GeneratorTest.php (87%) rename tests/Unit/{ => Internal}/Configuration/PluginsTest.php (69%) rename tests/Unit/{ => Internal}/Downloader/DLoadDownloaderTest.php (98%) rename tests/Unit/{ => Internal}/Environment/EnvironmentTest.php (58%) rename tests/Unit/{ => Internal}/Environment/StabilityTest.php (76%) rename tests/Unit/{Command => Internal/Option}/EnvironmentOptionsTest.php (90%) rename tests/Unit/{Command => Internal/Option}/InstallationLocationOptionTest.php (93%) rename tests/Unit/{Command => Internal/Option}/VersionFilterOptionTest.php (76%) diff --git a/psalm.xml b/psalm.xml index faa1d08..03e1a49 100644 --- a/psalm.xml +++ b/psalm.xml @@ -15,8 +15,6 @@ > - - diff --git a/src/Command.php b/src/Command.php deleted file mode 100644 index 931a1c5..0000000 --- a/src/Command.php +++ /dev/null @@ -1,26 +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 @@ -io($input, $output); + $io = new SymfonyStyle($input, $output); $target = $this->location->get($input, $io); $os = $this->os->get($input, $io); 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 @@ -io($input, $output); + $io = new SymfonyStyle($input, $output); $target = $this->location->get($input, $io); $os = $this->os->get($input, $io); 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/Configuration/Plugins.php b/src/Internal/Configuration/Plugins.php similarity index 81% rename from src/Configuration/Plugins.php rename to src/Internal/Configuration/Plugins.php index e63cdac..069739b 100644 --- a/src/Configuration/Plugins.php +++ b/src/Internal/Configuration/Plugins.php @@ -2,19 +2,22 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Configuration; - -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; +namespace Spiral\RoadRunner\Console\Internal\Configuration; + +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 Spiral\Tokenizer\ClassLocator; use Symfony\Component\Finder\Finder; +/** + * @internal + */ final class 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 @@ + 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/ReleasesClient.php b/src/Internal/GitHub/ReleasesClient.php index 2c5d85e..d7ffdc4 100644 --- a/src/Internal/GitHub/ReleasesClient.php +++ b/src/Internal/GitHub/ReleasesClient.php @@ -11,7 +11,7 @@ namespace Spiral\RoadRunner\Console\Internal\GitHub; -use Spiral\RoadRunner\Console\Environment\Environment; +use Spiral\RoadRunner\Console\Internal\Environment\Environment; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; 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..15ec1c3 100644 --- a/src/MakeConfigCommand.php +++ b/src/MakeConfigCommand.php @@ -4,14 +4,16 @@ 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 +final class MakeConfigCommand extends Command { private InstallationLocationOption $location; @@ -28,7 +30,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/VersionsCommand.php b/src/VersionsCommand.php index f3267c1..5bc09ef 100644 --- a/src/VersionsCommand.php +++ b/src/VersionsCommand.php @@ -11,16 +11,18 @@ 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\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\SymfonyStyle; final class VersionsCommand extends Command { @@ -42,13 +44,7 @@ public function __construct( $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] @@ -60,7 +56,7 @@ 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); diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php index d93c16f..afb1dc0 100644 --- a/tests/Unit/DownloadProtocBinaryCommandTest.php +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -4,8 +4,8 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; -use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; 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; 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 36161fc..28d22a5 100644 --- a/tests/Unit/GetBinaryCommandTest.php +++ b/tests/Unit/GetBinaryCommandTest.php @@ -4,8 +4,8 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; -use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; use Spiral\RoadRunner\Console\GetBinaryCommand; +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; 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/Downloader/DLoadDownloaderTest.php b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php similarity index 98% rename from tests/Unit/Downloader/DLoadDownloaderTest.php rename to tests/Unit/Internal/Downloader/DLoadDownloaderTest.php index c811322..dd65800 100644 --- a/tests/Unit/Downloader/DLoadDownloaderTest.php +++ b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Downloader; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Downloader; -use Spiral\RoadRunner\Console\Downloader\DLoadDownloader; +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\Output\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/Command/EnvironmentOptionsTest.php b/tests/Unit/Internal/Option/EnvironmentOptionsTest.php similarity index 90% rename from tests/Unit/Command/EnvironmentOptionsTest.php rename to tests/Unit/Internal/Option/EnvironmentOptionsTest.php index ee4d51b..9b07fc3 100644 --- a/tests/Unit/Command/EnvironmentOptionsTest.php +++ b/tests/Unit/Internal/Option/EnvironmentOptionsTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Command; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Option; -use Spiral\RoadRunner\Console\Command\ArchitectureOption; -use Spiral\RoadRunner\Console\Command\OperatingSystemOption; -use Spiral\RoadRunner\Console\Command\StabilityOption; +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\Tests\Unit\Stub\OptionHost; use Testo\Assert; use Testo\Test; @@ -23,7 +23,6 @@ public function stabilityDefaultsToStable(): void $option = new StabilityOption($host->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/Command/VersionFilterOptionTest.php b/tests/Unit/Internal/Option/VersionFilterOptionTest.php similarity index 76% rename from tests/Unit/Command/VersionFilterOptionTest.php rename to tests/Unit/Internal/Option/VersionFilterOptionTest.php index 1987917..ccfb64a 100644 --- a/tests/Unit/Command/VersionFilterOptionTest.php +++ b/tests/Unit/Internal/Option/VersionFilterOptionTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Spiral\RoadRunner\Console\Tests\Unit\Command; +namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Option; -use Spiral\RoadRunner\Console\Command\VersionFilterOption; -use Spiral\RoadRunner\Console\Environment\RoadRunnerVersion; +use Spiral\RoadRunner\Console\Internal\Environment\RoadRunnerVersion; +use Spiral\RoadRunner\Console\Internal\Option\VersionFilterOption; use Spiral\RoadRunner\Console\Tests\Unit\Stub\OptionHost; use Testo\Assert; use Testo\Test; @@ -28,7 +28,6 @@ public function acceptsShortcut(): void $option = new VersionFilterOption($host->command); $input = $host->input(['-f' => '^3.1']); - Assert::same($option->getName(), 'filter'); Assert::same($option->get($input, $host->io($input)), '^3.1'); } } diff --git a/tests/Unit/VersionsCommandTest.php b/tests/Unit/VersionsCommandTest.php index d655f5f..d294476 100644 --- a/tests/Unit/VersionsCommandTest.php +++ b/tests/Unit/VersionsCommandTest.php @@ -4,7 +4,7 @@ namespace Spiral\RoadRunner\Console\Tests\Unit; -use Spiral\RoadRunner\Console\Environment\RoadRunnerVersion; +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; From 47364b75264280365aedce186a12a6c33215e70e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 12:52:24 +0400 Subject: [PATCH 06/12] refactor: list the configuration sections without spiral/tokenizer build: drop the spiral/tokenizer and ext-json requirements Scanning the `Section` directory with spiral/tokenizer on every `make-config` run found the same fixed set of classes; `PluginsTest` still checks that every section is listed. JSON is always enabled since PHP 8.0. Assisted-By: Claude Opus 5.5 --- composer.json | 2 - src/Internal/Configuration/Plugins.php | 122 ++++++++++++------------- 2 files changed, 60 insertions(+), 64 deletions(-) diff --git a/composer.json b/composer.json index 6d5d053..03816db 100644 --- a/composer.json +++ b/composer.json @@ -39,12 +39,10 @@ ], "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": "^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" diff --git a/src/Internal/Configuration/Plugins.php b/src/Internal/Configuration/Plugins.php index 069739b..7898a3d 100644 --- a/src/Internal/Configuration/Plugins.php +++ b/src/Internal/Configuration/Plugins.php @@ -4,16 +4,32 @@ namespace Spiral\RoadRunner\Console\Internal\Configuration; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Amqp; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Beanstalk; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Boltdb; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Broadcast; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Endure; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Fileserver; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Grpc; 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\Logs; use Spiral\RoadRunner\Console\Internal\Configuration\Section\Metrics; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Nats; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Otel; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Redis; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Reload; 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\Service; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Sqs; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Status; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Tcp; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Temporal; use Spiral\RoadRunner\Console\Internal\Configuration\Section\Version; -use Spiral\Tokenizer\ClassLocator; -use Symfony\Component\Finder\Finder; +use Spiral\RoadRunner\Console\Internal\Configuration\Section\Websockets; /** * @internal @@ -21,42 +37,57 @@ final class Plugins { /** - * @psalm-var list> + * Every section a plugin can be requested by. * - * Default plugins in a class-string format. + * @var list> */ - private array $defaultPlugins = [ - Version::class, - Rpc::class, - Server::class, + 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 string[] - * - * Requested plugins in a shortname format. + * @var list> */ - private array $requestedPlugins; + private const DEFAULT = [ + Version::class, + Rpc::class, + Server::class, + Http::class, + Jobs::class, + Kv::class, + Metrics::class, + ]; /** - * @psalm-var list> - * - * All plugins. + * @param string[] $requestedPlugins Plugin short names. */ - private array $available; - - /** - * @param string[] $plugins - */ - private function __construct(array $plugins) - { - $this->available = $this->getAvailable(); - $this->requestedPlugins = $plugins; - } + private function __construct( + private readonly array $requestedPlugins, + ) {} /** * @param string[] $plugins Plugin short names. @@ -68,15 +99,9 @@ public static function fromPlugins(array $plugins): self public static function fromPreset(string $preset): self { - $plugins = []; - switch ($preset) { - case Presets::WEB_PRESET_NANE: - $plugins = Presets::WEB_PLUGINS; - } + $plugins = $preset === Presets::WEB_PRESET_NANE ? Presets::WEB_PLUGINS : []; - return new self(\array_map(static function (string $plugin) { - return $plugin::getShortName(); - }, $plugins)); + return new self(\array_map(static fn(string $plugin): string => $plugin::getShortName(), $plugins)); } /** @@ -85,11 +110,11 @@ public static function fromPreset(string $preset): self public function getPlugins(): array { if ($this->requestedPlugins === []) { - return $this->defaultPlugins; + return self::DEFAULT; } $plugins = []; - foreach ($this->available as $plugin) { + foreach (self::AVAILABLE as $plugin) { if (\in_array($plugin::getShortName(), $this->requestedPlugins, true)) { $plugins[] = $plugin; } @@ -97,31 +122,4 @@ public function getPlugins(): array 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(); - } } From 42d357c2f45b7c8466093eb848db6fffcde340b9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 12:52:53 +0400 Subject: [PATCH 07/12] fix: do not write .rr.yaml when its generation fails `get-binary` printed the error and then wrote the undefined result to `.rr.yaml`; `make-config` already stopped there. Assisted-By: Claude Opus 5.5 --- src/GetBinaryCommand.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/GetBinaryCommand.php b/src/GetBinaryCommand.php index d484bb5..87d2f92 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -148,16 +148,16 @@ protected function configure(): void /** * @throws \Throwable */ - 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(); @@ -171,12 +171,11 @@ 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(string $binary, StyleInterface $io): bool From 31310dbec39a1e55da216f6ba079a35695b575bb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 13:48:48 +0400 Subject: [PATCH 08/12] fix: pick the rr versions token like DLoad does `versions` sent `GITHUB_TOKEN` to whatever `RR_GITHUB_API_URL` named, while `get-binary` keeps it on the public GitHub. Now both follow DLoad's rules: the public GitHub gets `DLOAD_TOKEN_GITHUB_COM` or `GITHUB_TOKEN`, another server only its own `DLOAD_TOKEN_`, and plain HTTP carries a token only to a loopback host. DLoad keeps these rules in internal classes, so they are repeated in `Internal\GitHub\Token`. Symfony HttpClient already drops the `Authorization` header on a redirect to another scheme, host or port; an acceptance test pins that. Assisted-By: Claude Opus 5.5 --- README.md | 10 +- src/Internal/GitHub/ReleasesClient.php | 21 ++-- src/Internal/GitHub/Token.php | 118 ++++++++++++++++++ tests/Acceptance/Server/router.php | 9 +- tests/Acceptance/Support/FakeGitHub.php | 8 +- tests/Acceptance/VersionsTest.php | 34 ++++- .../Internal/GitHub/ReleasesClientTest.php | 15 +++ tests/Unit/Internal/GitHub/TokenTest.php | 104 +++++++++++++++ 8 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 src/Internal/GitHub/Token.php create mode 100644 tests/Unit/Internal/GitHub/TokenTest.php diff --git a/README.md b/README.md index 19059ec..d7f56b6 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,12 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur ### Common options -The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` `*` means any 3.x release, use `2025.*` for the calendar releases), `--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`). `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. For these two commands `RR_GITHUB_API_URL` 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`), and `GITHUB_TOKEN` is sent only to the public GitHub: the token for a GitHub Enterprise Server goes into `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`). +The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` `*` means any 3.x release, use `2025.*` for the calendar releases), `--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. + +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/src/Internal/GitHub/ReleasesClient.php b/src/Internal/GitHub/ReleasesClient.php index d7ffdc4..161ab14 100644 --- a/src/Internal/GitHub/ReleasesClient.php +++ b/src/Internal/GitHub/ReleasesClient.php @@ -24,7 +24,6 @@ final class ReleasesClient { private const DEFAULT_API_URL = 'https://api.github.com'; - private const ENV_GITHUB_TOKEN = 'GITHUB_TOKEN'; /** * Not `GITHUB_API_URL`: GitHub Actions sets that one in every job. @@ -42,29 +41,26 @@ final class ReleasesClient /** * @param string $apiUrl Base URL of the GitHub REST API: GitHub Enterprise, a mirror or a mock server. + * @param string|null $token Sent with every request; the HTTP client drops it on a redirect to another + * scheme, host or port. */ public function __construct( private readonly HttpClientInterface $client, string $apiUrl = self::DEFAULT_API_URL, + private readonly ?string $token = null, ) { $this->apiUrl = \rtrim($apiUrl, '/'); } /** - * Takes the API URL from RR_GITHUB_API_URL and the token from GITHUB_TOKEN, the token is sent to any API URL. + * 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 { - $token = Environment::get(self::ENV_GITHUB_TOKEN); $apiUrl = Environment::get(self::ENV_GITHUB_API_URL); + $apiUrl = $apiUrl !== null && $apiUrl !== '' ? $apiUrl : self::DEFAULT_API_URL; - $client = HttpClient::create([ - 'headers' => \array_filter([ - 'authorization' => $token !== null && $token !== '' ? 'token ' . $token : null, - ]), - ]); - - return new self($client, $apiUrl !== null && $apiUrl !== '' ? $apiUrl : self::DEFAULT_API_URL); + return new self(HttpClient::create(), $apiUrl, Token::forApiUrl($apiUrl)); } /** @@ -78,7 +74,10 @@ public function releases(): array do { $response = $this->client->request('GET', $this->apiUrl . '/repos/' . self::REPOSITORY . '/releases', [ - 'headers' => ['accept' => 'application/vnd.github.v3+json'], + '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], ]); 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/tests/Acceptance/Server/router.php b/tests/Acceptance/Server/router.php index f31e715..f272446 100644 --- a/tests/Acceptance/Server/router.php +++ b/tests/Acceptance/Server/router.php @@ -8,7 +8,8 @@ * - `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. * * Like GitHub Enterprise Server, the API is served under `/api/v3`: release lists at * `/api/v3/repos/roadrunner-server/roadrunner/releases`, assets at @@ -79,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.)', diff --git a/tests/Acceptance/Support/FakeGitHub.php b/tests/Acceptance/Support/FakeGitHub.php index ecf32bc..d750ce3 100644 --- a/tests/Acceptance/Support/FakeGitHub.php +++ b/tests/Acceptance/Support/FakeGitHub.php @@ -108,9 +108,13 @@ public static function downloads(): array private static function startServer(string $scenario): string { self::start(); - $port = self::freePort(); - $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; diff --git a/tests/Acceptance/VersionsTest.php b/tests/Acceptance/VersionsTest.php index ac5923a..efd0786 100644 --- a/tests/Acceptance/VersionsTest.php +++ b/tests/Acceptance/VersionsTest.php @@ -76,14 +76,39 @@ public function followsPagination(): void Assert::same(\array_column(\array_column(FakeGitHub::releaseRequests(), 'query'), 'page'), ['1', '2', '3', '4']); } - public function sendsGitHubToken(): void + public function keepsGitHubTokenFromEnterpriseServer(): void { - $result = $this->rr()->withEnv('GITHUB_TOKEN', 'token')->run(self::ARGS); + $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); @@ -99,6 +124,11 @@ public function failsWhenGitHubIsUnreachable(): void 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. */ diff --git a/tests/Unit/Internal/GitHub/ReleasesClientTest.php b/tests/Unit/Internal/GitHub/ReleasesClientTest.php index 0fd9d6a..d9a85b6 100644 --- a/tests/Unit/Internal/GitHub/ReleasesClientTest.php +++ b/tests/Unit/Internal/GitHub/ReleasesClientTest.php @@ -75,6 +75,21 @@ public function usesCustomApiUrl(): void 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 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 @@ + Date: Mon, 12 Oct 2026 12:53:15 +0400 Subject: [PATCH 09/12] chore(psalm): report unused code The commands are used only by `bin/rr`, which psalm does not analyse, so they are marked `@psalm-api`. Assisted-By: Claude Opus 5.5 --- psalm.xml | 2 +- src/DownloadProtocBinaryCommand.php | 2 ++ src/GetBinaryCommand.php | 3 +++ src/MakeConfigCommand.php | 3 +++ src/VersionsCommand.php | 3 +++ 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/psalm.xml b/psalm.xml index 03e1a49..ea1d20b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,7 +8,7 @@ resolveFromConfigFile="true" findUnusedPsalmSuppress="true" findUnusedBaselineEntry="true" - findUnusedCode="false" + findUnusedCode="true" findUnusedVariablesAndParams="true" ensureArrayStringOffsetsExist="true" addParamDefaultToDocblockType="true" diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php index 3bb6c6b..51300b7 100644 --- a/src/DownloadProtocBinaryCommand.php +++ b/src/DownloadProtocBinaryCommand.php @@ -19,6 +19,8 @@ /** * protoc-gen-php-grpc file download command. + * + * @psalm-api */ final class DownloadProtocBinaryCommand extends Command { diff --git a/src/GetBinaryCommand.php b/src/GetBinaryCommand.php index 87d2f92..726e2c9 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -27,6 +27,9 @@ use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; +/** + * @psalm-api + */ final class GetBinaryCommand extends Command { private OperatingSystemOption $os; diff --git a/src/MakeConfigCommand.php b/src/MakeConfigCommand.php index 15ec1c3..18fdd34 100644 --- a/src/MakeConfigCommand.php +++ b/src/MakeConfigCommand.php @@ -13,6 +13,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +/** + * @psalm-api + */ final class MakeConfigCommand extends Command { private InstallationLocationOption $location; diff --git a/src/VersionsCommand.php b/src/VersionsCommand.php index 5bc09ef..ce8eceb 100644 --- a/src/VersionsCommand.php +++ b/src/VersionsCommand.php @@ -24,6 +24,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +/** + * @psalm-api + */ final class VersionsCommand extends Command { private OperatingSystemOption $os; From 21fe9765599703dbdf9f61494aeb14a201f8d31a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 14:15:31 +0400 Subject: [PATCH 10/12] feat: keep version filters on the RoadRunner 3.x line DLoad orders versions numerically, so the calendar releases (2025.x) rank above 3.x, and a filter without an upper bound, like `>=3.0` or `!=3.1.0`, installed 2025.1.x while `rr versions` lists 3.x first. Each `||` alternative without an upper bound now gets `<2000`, unless its lower bound is a calendar version, and `*` means `>=3.0 <2000`, so a future 4.x is included. The calendar releases are installed only when named, like `2025.*` or `>=2025`. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/Internal/Downloader/DLoadDownloader.php | 29 +++++++++-- tests/Acceptance/VersionSelectionTest.php | 22 ++++++++- .../Downloader/DLoadDownloaderTest.php | 48 ++++++++++++++++--- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d7f56b6..ccf9420 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur ### Common options -The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` `*` means any 3.x release, use `2025.*` for the calendar releases), `--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. +The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` a filter without an upper bound stays on 3.x and later semver lines, `*` included, 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. 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. diff --git a/src/Internal/Downloader/DLoadDownloader.php b/src/Internal/Downloader/DLoadDownloader.php index fb8b69f..78c5741 100644 --- a/src/Internal/Downloader/DLoadDownloader.php +++ b/src/Internal/Downloader/DLoadDownloader.php @@ -38,10 +38,15 @@ final class DLoadDownloader private const PUBLIC_API_URL = 'https://api.github.com'; /** - * What `*` stands for. RoadRunner went from calendar versions back to 3.x, and DLoad orders versions - * numerically, so without a bound `2025.1.x` would win over every 3.x release; 2025 is `2025.*`. + * RoadRunner went from calendar versions back to 3.x, and DLoad orders versions numerically, + * so without this bound `2025.1.x` would win over every 3.x release. */ - private const ANY_VERSION = '^3.0'; + private const CALENDAR_BOUND = '<2000'; + + /** + * What `*` stands for: 3.x and the later semver lines. + */ + private const ANY_VERSION = '>=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. @@ -86,7 +91,7 @@ public function __construct(?Command $get = null, ?string $githubApiUrl = null) /** * @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 any 3.x version. + * @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. * @@ -138,9 +143,25 @@ private static function versionSuffix(string $constraint, string $stability): st $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. */ diff --git a/tests/Acceptance/VersionSelectionTest.php b/tests/Acceptance/VersionSelectionTest.php index 23954e6..0df1d58 100644 --- a/tests/Acceptance/VersionSelectionTest.php +++ b/tests/Acceptance/VersionSelectionTest.php @@ -32,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']); @@ -53,14 +54,31 @@ public function treatsWildcardFilterAsAnyV3Release(string $stability, string $ve Assert::same(\trim($this->dir->read('rr')), "fake rr $version linux amd64"); } - public function treatsWildcardFilterAsAnyV3ProtocPlugin(): void + #[DataSet(['*'])] + #[DataSet(['>=3.0'])] + public function keepsProtocPluginFilterBelowCalendarReleases(string $filter): void { - $result = $this->rr()->run(['download-protoc-binary', '--filter=*', '--os=linux', '--arch=amd64']); + $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'])] public function honoursMinimumStability(string $stability, string $filter, string $version): void diff --git a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php index dd65800..56ac0a9 100644 --- a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php +++ b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php @@ -156,12 +156,12 @@ public function putsStabilityIntoVersionConstraint(string $constraint, string $s /** * Calendar releases (2025.x) rank above 3.x numerically, so "any version" must not reach DLoad unbounded. */ - #[DataSet(['*', 'stable', 'rr:^3.0@stable'])] - #[DataSet([' * ', 'beta', 'rr:^3.0@beta'], 'trimmed')] - #[DataSet(['', 'rc', 'rr:^3.0@rc'], 'empty constraint')] - #[DataSet(['*@beta', 'stable', 'rr:^3.0@beta'], 'explicit stability')] - #[DataSet(['@alpha', 'stable', 'rr:^3.0@alpha'], 'stability only')] - public function treatsWildcardAsAnyV3Release(string $constraint, string $stability, string $software): void + #[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(); @@ -170,6 +170,42 @@ public function treatsWildcardAsAnyV3Release(string $constraint, string $stabili 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); From d3c4988881bd921c4e7f654e796f6af52e6a2115 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 14:21:45 +0400 Subject: [PATCH 11/12] docs: describe musl selection and DLoad side effects DLoad picks the libc itself, so an explicit `--os=linux` on Alpine now installs the musl build, and `--os=unknown-musl` still forces it from any host. The registry location and `vendor/bin/dload` are new to users of the CLI. Assisted-By: Claude Opus 5.5 --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ccf9420..da4b6b1 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,11 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur ### Common options -The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` a filter without an upper bound stays on 3.x and later semver lines, `*` included, 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. +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. From 07614a05388f6f9e400131a888f7a02c40029e12 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 12 Oct 2026 14:21:46 +0400 Subject: [PATCH 12/12] docs: point the empty DLoad config at the moved downloader class Assisted-By: Claude Opus 5.5 --- resources/dload.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/dload.xml b/resources/dload.xml index a2ffa03..4b55865 100644 --- a/resources/dload.xml +++ b/resources/dload.xml @@ -1,3 +1,3 @@ - +