From 8ce58921e8968f3902ed692a34709549b7a2033e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 28 Oct 2026 11:46:51 +0400 Subject: [PATCH 01/16] feat: add get-grpc-tools command docs: deprecate download-protoc-binary in favour of get-grpc-tools `buf generate` needs buf, protoc and protoc-gen-php-grpc, and protoc has to match the protobuf runtime: since protobuf 21 the PHP runtime minor is the protoc major. The command derives the protoc constraint from google/protobuf installed in the project (read from the project's own installed.php, so a global or PHAR installation of this tool still sees it), then from ext-protobuf, and falls back to the latest protoc with a warning. `get` becomes an explicit alias of get-binary: as an abbreviation it would now be ambiguous. Wildcards and unbounded constraints get the calendar bound only for the RoadRunner software, so `--buf=*` is not turned into `>=3.0`. Assisted-By: Claude Opus 5.5 --- README.md | 33 ++- bin/rr | 3 + src/DownloadProtocBinaryCommand.php | 2 +- src/GetBinaryCommand.php | 2 + src/GetGrpcToolsCommand.php | 236 ++++++++++++++++++ src/Internal/Downloader/DLoadDownloader.php | 40 ++- src/Internal/Environment/ProtobufRuntime.php | 126 ++++++++++ tests/Acceptance/GrpcToolsTest.php | 111 ++++++++ tests/Acceptance/Server/releases-buf.json | 29 +++ .../Acceptance/Server/releases-protobuf.json | 52 ++++ tests/Acceptance/Server/releases.json | 1 + tests/Acceptance/Server/router.php | 53 ++-- tests/Acceptance/VersionsTest.php | 2 +- .../Unit/DownloadProtocBinaryCommandTest.php | 2 +- tests/Unit/GetBinaryCommandTest.php | 1 + tests/Unit/GetGrpcToolsCommandTest.php | 217 ++++++++++++++++ .../Downloader/DLoadDownloaderTest.php | 34 ++- .../Environment/ProtobufRuntimeTest.php | 160 ++++++++++++ tests/Unit/Stub/DLoadGetSpy.php | 19 +- 19 files changed, 1080 insertions(+), 43 deletions(-) create mode 100644 src/GetGrpcToolsCommand.php create mode 100644 src/Internal/Environment/ProtobufRuntime.php create mode 100644 tests/Acceptance/GrpcToolsTest.php create mode 100644 tests/Acceptance/Server/releases-buf.json create mode 100644 tests/Acceptance/Server/releases-protobuf.json create mode 100644 tests/Unit/GetGrpcToolsCommandTest.php create mode 100644 tests/Unit/Internal/Environment/ProtobufRuntimeTest.php diff --git a/README.md b/README.md index 1e0ac57..5a9d8eb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
-This package provides the `rr` console command for PHP projects that run on [RoadRunner](https://roadrunner.dev): it downloads the RoadRunner server binary and `protoc-gen-php-grpc` plugin built for your environment and generates a starter `.rr.yaml` configuration. +This package provides the `rr` console command for PHP projects that run on [RoadRunner](https://roadrunner.dev): it downloads the RoadRunner server binary and the gRPC code generation tools (`buf`, `protoc`, `protoc-gen-php-grpc`) built for your environment and generates a starter `.rr.yaml` configuration. > [!NOTE] > Version 3.x of `roadrunner/cli` targets RoadRunner v3: `rr get` downloads RoadRunner 3 by default. For RoadRunner 2025.x, use [`spiral/roadrunner-cli`](https://packagist.org/packages/spiral/roadrunner-cli) 2.x. @@ -54,6 +54,25 @@ Then start the server: See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configuration reference. +### gRPC tools + +Generating gRPC code with `buf generate` takes three binaries: [`buf`](https://buf.build), `protoc` (buf runs its built-in PHP generator) and `protoc-gen-php-grpc`. Download all of them into the project root: + +```bash +vendor/bin/rr get-grpc-tools +``` + +``` + google/protobuf v5.36.2 -> protoc ^36.0 + + Versions: + - buf: ^1 + - protoc: ^36.0 + - protoc-gen-php-grpc: 3.* +``` + +The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is the `google/protobuf` package installed in the project in the working directory, or else `ext-protobuf`; without either, the latest protoc is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. + ## Commands - `get-binary` (or `get`) - allows to install the latest version of the RoadRunner compatible with @@ -70,19 +89,23 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur Available presets: `web` (contains plugins `http`, `jobs`). Use `--no-config` to skip the configuration file. - `make-config` - creates the `.rr.yaml` configuration file without downloading the binary. Accepts the same `plugin` and `preset` options. -- `download-protoc-binary` - allows to install the latest version of the `protoc-gen-php-grpc` file compatible with - your environment (operating system, processor architecture, runtime, etc...). +- `get-grpc-tools` - installs `buf`, `protoc` and `protoc-gen-php-grpc`, the binaries `buf generate` needs to + generate gRPC code, see [gRPC tools](#grpc-tools). The versions default to buf 1.x, the protoc major the project's + protobuf runtime supports and `protoc-gen-php-grpc` of the RoadRunner line `get-binary` installs; `--buf`, `--protoc` + and `--plugin` override them. Binaries already in the location are kept unless the overwrite is confirmed. +- `download-protoc-binary` - **deprecated**, use `get-grpc-tools`. Installs only the `protoc-gen-php-grpc` plugin, + not protoc itself. - `versions` - displays a list of available RoadRunner binary versions. ### Common options -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. +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`; `get-grpc-tools` applies the same to `--plugin` and takes `--buf`/`--protoc` as they are), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. `get-binary`, `get-grpc-tools` 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. +Releases are fetched from the GitHub API at `RR_GITHUB_API_URL`, `https://api.github.com` by default. For `get-binary`, `get-grpc-tools` 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`), which then has to serve `bufbuild/buf` and `protocolbuffers/protobuf` for `get-grpc-tools` too; `versions` also accepts a mirror. All commands pick the API token the same way, to avoid the rate limits: diff --git a/bin/rr b/bin/rr index a1441e4..7e14585 100644 --- a/bin/rr +++ b/bin/rr @@ -12,6 +12,7 @@ declare(strict_types=1); use Spiral\RoadRunner\Console\DownloadProtocBinaryCommand; use Spiral\RoadRunner\Console\GetBinaryCommand; +use Spiral\RoadRunner\Console\GetGrpcToolsCommand; use Spiral\RoadRunner\Console\MakeConfigCommand; use Spiral\RoadRunner\Console\VersionsCommand; use Spiral\RoadRunner\Version; @@ -72,12 +73,14 @@ if (\method_exists($app, 'addCommand')) { $app->addCommand(new GetBinaryCommand()); $app->addCommand(new VersionsCommand()); $app->addCommand(new DownloadProtocBinaryCommand()); + $app->addCommand(new GetGrpcToolsCommand()); $app->addCommand(new MakeConfigCommand()); } else { // For Symfony 7.3 and lower $app->add(new GetBinaryCommand()); $app->add(new VersionsCommand()); $app->add(new DownloadProtocBinaryCommand()); + $app->add(new GetGrpcToolsCommand()); $app->add(new MakeConfigCommand()); } diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php index 51300b7..cf9faab 100644 --- a/src/DownloadProtocBinaryCommand.php +++ b/src/DownloadProtocBinaryCommand.php @@ -46,7 +46,7 @@ public function __construct(?string $name = null, ?DLoadDownloader $downloader = #[\Override] public function getDescription(): string { - return 'Install or update protoc-gen-php-grpc binary'; + return 'Install or update protoc-gen-php-grpc binary (deprecated, use get-grpc-tools)'; } #[\Override] diff --git a/src/GetBinaryCommand.php b/src/GetBinaryCommand.php index 726e2c9..4321b9f 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -43,6 +43,8 @@ public function __construct(?string $name = null, ?DLoadDownloader $downloader = { $this->downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'get-binary'); + // Not left to Symfony's abbreviation lookup: `get` also abbreviates get-grpc-tools + $name === null and $this->setAliases(['get']); $this->os = new OperatingSystemOption($this); $this->arch = new ArchitectureOption($this); diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php new file mode 100644 index 0000000..1311fe7 --- /dev/null +++ b/src/GetGrpcToolsCommand.php @@ -0,0 +1,236 @@ +downloader = $downloader ?? new DLoadDownloader(); + $this->runtime = $runtime ?? static fn(): ?ProtobufRuntime => ProtobufRuntime::detect(\getcwd() ?: '.'); + parent::__construct($name ?? 'get-grpc-tools'); + + $this->os = new OperatingSystemOption($this); + $this->arch = new ArchitectureOption($this); + $this->location = new InstallationLocationOption($this); + $this->stability = new StabilityOption($this); + } + + #[\Override] + public function getDescription(): string + { + return 'Install or update buf, protoc and protoc-gen-php-grpc to generate gRPC code'; + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $target = $this->location->get($input, $io); + $os = $this->os->get($input, $io); + $arch = $this->arch->get($input, $io); + $stability = $this->stability->get($input, $io); + + $output->writeln(''); + $output->writeln(' Environment:'); + $output->writeln(\sprintf(' - Stability: %s', $stability)); + $output->writeln(\sprintf(' - Operating System: %s', $os)); + $output->writeln(\sprintf(' - Architecture: %s', $arch)); + $output->writeln(''); + + [$protoc, $derivedFrom] = $this->protocConstraint($input, $io); + + $tools = [ + 'buf' => self::option($input, 'buf') ?? self::ANY_VERSION, + 'protoc' => $protoc, + 'protoc-gen-php-grpc' => self::option($input, 'plugin') ?? self::ANY_VERSION, + ]; + + $output->writeln(' Versions:'); + foreach ($tools as $tool => $constraint) { + $output->writeln(\sprintf(' - %-20s %s', $tool . ':', $constraint)); + } + $output->writeln(''); + + $failed = []; + foreach ($tools as $tool => $constraint) { + $binary = $target . '/' . $tool . ($os === OperatingSystem::OS_WINDOWS ? '.exe' : ''); + if (! $this->checkExisting($tool, $binary, $io)) { + continue; + } + + $code = $this->downloader->download( + software: $tool, + constraint: $constraint, + stability: $stability, + os: $os, + arch: $arch, + location: $target, + force: true, + output: $output, + ); + + if ($code === self::SUCCESS) { + continue; + } + + $failed[] = $tool; + if ($tool === 'protoc' && $derivedFrom !== null) { + $io->error(\sprintf( + 'Could not install protoc %s, the version %s %s supports. ' + . 'If no protoc release matches it, choose one with --protoc=.', + $constraint, + $derivedFrom->name, + $derivedFrom->version, + )); + } + } + + if ($failed !== []) { + $io->error('Not installed: ' . \implode(', ', $failed)); + + return self::FAILURE; + } + + $io->success('gRPC tools are ready in ' . $target); + + return self::SUCCESS; + } + + #[\Override] + protected function configure(): void + { + $this->addOption( + 'protoc', + null, + InputOption::VALUE_REQUIRED, + 'Version constraint of protoc; by default the protoc major the installed google/protobuf or ext-protobuf supports', + ); + + $this->addOption( + 'plugin', + null, + InputOption::VALUE_REQUIRED, + 'Version constraint of protoc-gen-php-grpc', + RoadRunnerVersion::constraint(), + ); + + $this->addOption( + 'buf', + null, + InputOption::VALUE_REQUIRED, + 'Version constraint of buf', + self::BUF_CONSTRAINT, + ); + } + + /** + * @return non-empty-string|null + */ + private static function option(InputInterface $input, string $name): ?string + { + /** @var mixed $value */ + $value = $input->getOption($name); + + return \is_string($value) && $value !== '' ? $value : null; + } + + /** + * @return array{non-empty-string, ProtobufRuntime|null} The constraint and the runtime it is derived from. + */ + private function protocConstraint(InputInterface $input, SymfonyStyle $io): array + { + $option = self::option($input, 'protoc'); + if ($option !== null) { + return [$option, null]; + } + + $runtime = ($this->runtime)(); + if ($runtime === null) { + $io->warning(\sprintf( + 'Neither %s nor %s is installed: installing the latest protoc. Choose a version with --protoc=.', + ProtobufRuntime::PACKAGE, + ProtobufRuntime::EXTENSION, + )); + + return [self::ANY_VERSION, null]; + } + + $constraint = $runtime->protocConstraint(); + if ($constraint === null) { + $io->warning(\sprintf( + 'Can not tell the protoc version for %s %s: installing the latest protoc. Choose a version with --protoc=.', + $runtime->name, + $runtime->version, + )); + + return [self::ANY_VERSION, null]; + } + + $io->writeln(\sprintf(' %s %s -> protoc %s', $runtime->name, $runtime->version, $constraint)); + $io->newLine(); + + return [$constraint, $runtime]; + } + + private function checkExisting(string $tool, string $binary, StyleInterface $io): bool + { + if (\is_file($binary)) { + $io->warning(\sprintf('%s binary file already exists!', $tool)); + + if (! $io->confirm('Do you want overwrite it?', false)) { + $io->note(\sprintf('Skipping %s installation...', $tool)); + + return false; + } + } + + return true; + } +} diff --git a/src/Internal/Downloader/DLoadDownloader.php b/src/Internal/Downloader/DLoadDownloader.php index 78c5741..8ec8392 100644 --- a/src/Internal/Downloader/DLoadDownloader.php +++ b/src/Internal/Downloader/DLoadDownloader.php @@ -48,13 +48,18 @@ final class DLoadDownloader */ private const ANY_VERSION = '>=3.0 ' . self::CALENDAR_BOUND; + /** + * Software released from the RoadRunner repository, the only one with calendar versions. + */ + private const ROADRUNNER_SOFTWARE = ['rr', 'protoc-gen-php-grpc']; + /** * DLoad serves the API of a GitHub Enterprise Server at `scheme://host[:port]/api/v3`, so no other path can be mapped. */ private const ENTERPRISE_API_PATH = '/api/v3'; /** - * The RoadRunner entries of DLoad's built-in registry, with the server `scheme://host[:port]` to substitute. + * The entries of DLoad's built-in registry this package downloads, with the server `scheme://host[:port]` to substitute. * * DLoad takes a self-hosted GitHub from the repository `uri` itself: `scheme://host[:port]/owner/repo`. */ @@ -70,6 +75,14 @@ final class DLoadDownloader + + + + + + + + XML; @@ -90,8 +103,9 @@ 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 3.x or a later semver line. + * @param non-empty-string $software DLoad software alias, e.g. "rr", "protoc-gen-php-grpc", "protoc" or "buf". + * @param string $constraint Composer version constraint; "*" or empty means any version, which for + * the RoadRunner software is 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. * @@ -110,7 +124,11 @@ public function download( $config = $this->createConfig(); $input = new ArrayInput([ - 'software' => [$software . self::versionSuffix($constraint, $stability)], + 'software' => [$software . self::versionSuffix( + $constraint, + $stability, + \in_array($software, self::ROADRUNNER_SOFTWARE, true), + )], '--config' => $config ?? self::CONFIG, '--path' => $location, '--os' => $os, @@ -133,20 +151,22 @@ public function download( /** * DLoad ignores `--stability` once a version is given: the stability has to be a part of the constraint. + * + * @param bool $calendar Whether the software has calendar releases an unbounded constraint must stay below. */ - private static function versionSuffix(string $constraint, string $stability): string + private static function versionSuffix(string $constraint, string $stability, bool $calendar): string { [$version, $explicitStability] = \explode('@', \trim($constraint), 2) + [1 => null]; $version = \trim($version); if ($version === '' || $version === '*') { - $version = self::ANY_VERSION; + $version = $calendar ? self::ANY_VERSION : '*'; + } elseif ($calendar) { + // 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)); } - // 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); } diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php new file mode 100644 index 0000000..167721d --- /dev/null +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -0,0 +1,126 @@ + require $file)($file); + /** @var mixed $package */ + $package = \is_array($installed) && \is_array($installed['versions'] ?? null) + ? $installed['versions'][self::PACKAGE] ?? null + : null; + /** @var mixed $version */ + $version = \is_array($package) ? $package['pretty_version'] ?? null : null; + + return \is_string($version) ? new self(self::PACKAGE, $version) : null; + } + + public static function fromInstalledVersions(): ?self + { + if (! InstalledVersions::isInstalled(self::PACKAGE)) { + return null; + } + + $version = InstalledVersions::getPrettyVersion(self::PACKAGE); + + return $version === null ? null : new self(self::PACKAGE, $version); + } + + public static function fromExtension(): ?self + { + $version = \phpversion('protobuf'); + + return $version === false ? null : new self(self::EXTENSION, $version); + } + + /** + * The protoc versions whose generated code this runtime supports: the same protoc major. + * + * Since protobuf 21 the PHP runtime minor is the protoc major (3.21.x and protoc 21.x, 4.33.x and 33.x, + * 5.36.x and 36.x), and the runtime major is bumped on its own. Up to 3.20 both shared one version. + * + * @return non-empty-string|null Null when the version is not a release number, e.g. a branch. + */ + public function protocConstraint(): ?string + { + if (\preg_match('/^v?(\d+)\.(\d+)/', \trim($this->version), $m) !== 1) { + return null; + } + + [, $major, $minor] = \array_map(\intval(...), $m); + + return match (true) { + $minor > 20 => \sprintf('^%d.0', $minor), + $major === 3 => \sprintf('~3.%d.0', $minor), + default => null, + }; + } + + private static function vendorDir(string $projectDir): string + { + $vendor = Environment::get('COMPOSER_VENDOR_DIR'); + + if ($vendor === null || $vendor === '') { + /** @var mixed $composer */ + $composer = \json_decode((string) @\file_get_contents($projectDir . '/composer.json'), true); + /** @var mixed $vendor */ + $vendor = \is_array($composer) ? $composer['config']['vendor-dir'] ?? null : null; + $vendor = \is_string($vendor) && $vendor !== '' ? $vendor : 'vendor'; + } + + $absolute = \preg_match('#^(?:[a-z]:)?[/\\\\]#i', $vendor) === 1; + + return $absolute ? $vendor : $projectDir . '/' . $vendor; + } +} diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php new file mode 100644 index 0000000..06f0fd4 --- /dev/null +++ b/tests/Acceptance/GrpcToolsTest.php @@ -0,0 +1,111 @@ +installProtobuf($runtime); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->contains("google/protobuf $runtime -> protoc $constraint"); + Assert::same(\trim($this->dir->read('protoc')), "fake protoc $protoc linux-x86_64"); + Assert::same(\trim($this->dir->read('buf')), 'fake buf 1.75.0 Linux x86_64'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 3.4.0 linux amd64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Linux-x86_64.tar.gz', + "protoc-$protoc-linux-x86_64.zip", + 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz', + ]); + } + + public function installsWindowsBinaries(): void + { + $this->installProtobuf('4.33.1'); + + $result = $this->rr()->run(['get-grpc-tools', '--os=windows', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('protoc.exe')), 'fake protoc 33.6 win64'); + Assert::same(\trim($this->dir->read('buf.exe')), 'fake buf 1.75.0 Windows x86_64'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc.exe')), 'fake rr 3.4.0 windows amd64'); + } + + public function installsLatestProtocWithoutRuntime(): void + { + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->contains('Neither google/protobuf nor ext-protobuf is installed'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 36.2 linux-x86_64'); + } + + public function takesVersionsFromOptions(): void + { + $this->installProtobuf('5.36.2'); + + $result = $this->rr()->run([...self::ARGS, '--protoc=^33.0', '--buf=*', '--plugin=2025.*']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->notContains('google/protobuf'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 33.6 linux-x86_64'); + Assert::same(\trim($this->dir->read('buf')), 'fake buf 2.0.0 Linux x86_64'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 2025.1.5 linux amd64'); + } + + public function suggestsProtocOptionWhenNoReleaseMatchesRuntime(): void + { + $this->installProtobuf('5.40.0'); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 1, (string) $result); + Assert::string($result->output())->contains('--protoc='); + Assert::false($this->dir->has('protoc')); + Assert::true($this->dir->has('buf')); + Assert::true($this->dir->has('protoc-gen-php-grpc')); + } + + public function keepsExistingBinariesWithoutInteraction(): void + { + $this->dir->write('protoc', "old protoc\n"); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->contains('protoc binary file already exists!'); + Assert::same($this->dir->read('protoc'), "old protoc\n"); + Assert::same(FakeGitHub::downloads(), ['buf-Linux-x86_64.tar.gz', 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz']); + } + + private function installProtobuf(string $version): void + { + \mkdir($this->dir->file('vendor/composer'), 0777, true); + $this->dir->write('vendor/composer/installed.php', \sprintf( + " [], 'versions' => ['google/protobuf' => ['pretty_version' => %s]]];", + \var_export($version, true), + )); + } +} diff --git a/tests/Acceptance/Server/releases-buf.json b/tests/Acceptance/Server/releases-buf.json new file mode 100644 index 0000000..7ddd9ff --- /dev/null +++ b/tests/Acceptance/Server/releases-buf.json @@ -0,0 +1,29 @@ +[ + { + "tag_name": "v2.0.0", + "prerelease": false, + "assets": [ + "buf-Linux-x86_64.tar.gz", + "buf-Windows-x86_64.zip" + ] + }, + { + "tag_name": "v1.75.0", + "prerelease": false, + "assets": [ + "buf-Darwin-arm64.tar.gz", + "buf-Linux-aarch64.tar.gz", + "buf-Linux-x86_64.tar.gz", + "buf-Windows-x86_64.zip", + "protoc-gen-buf-lint-Linux-x86_64" + ] + }, + { + "tag_name": "v1.74.0", + "prerelease": false, + "assets": [ + "buf-Linux-x86_64.tar.gz", + "buf-Windows-x86_64.zip" + ] + } +] diff --git a/tests/Acceptance/Server/releases-protobuf.json b/tests/Acceptance/Server/releases-protobuf.json new file mode 100644 index 0000000..f178053 --- /dev/null +++ b/tests/Acceptance/Server/releases-protobuf.json @@ -0,0 +1,52 @@ +[ + { + "tag_name": "v37.0-rc1", + "prerelease": true, + "assets": [ + "protobuf-37.0-rc1.tar.gz", + "protoc-37.0-rc-1-linux-x86_64.zip", + "protoc-37.0-rc-1-win64.zip" + ] + }, + { + "tag_name": "v36.2", + "prerelease": false, + "assets": [ + "protobuf-36.2.tar.gz", + "protobuf-36.2.zip", + "protoc-36.2-linux-aarch_64.zip", + "protoc-36.2-linux-x86_32.zip", + "protoc-36.2-linux-x86_64.zip", + "protoc-36.2-osx-universal_binary.zip", + "protoc-36.2-win32.zip", + "protoc-36.2-win64.zip" + ] + }, + { + "tag_name": "v36.1", + "prerelease": false, + "assets": [ + "protobuf-36.1.tar.gz", + "protoc-36.1-linux-x86_64.zip", + "protoc-36.1-win64.zip" + ] + }, + { + "tag_name": "v33.6", + "prerelease": false, + "assets": [ + "protobuf-33.6.tar.gz", + "protoc-33.6-linux-x86_64.zip", + "protoc-33.6-win64.zip" + ] + }, + { + "tag_name": "v3.20.3", + "prerelease": false, + "assets": [ + "protobuf-all-3.20.3.tar.gz", + "protoc-3.20.3-linux-x86_64.zip", + "protoc-3.20.3-win64.zip" + ] + } +] diff --git a/tests/Acceptance/Server/releases.json b/tests/Acceptance/Server/releases.json index ceac630..6b0fb69 100644 --- a/tests/Acceptance/Server/releases.json +++ b/tests/Acceptance/Server/releases.json @@ -25,6 +25,7 @@ "assets": [ "protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz", "protoc-gen-php-grpc-3.4.0-linux-arm64.tar.gz", + "protoc-gen-php-grpc-3.4.0-windows-amd64.zip", "roadrunner-3.4.0-linux-amd64.deb", "roadrunner-3.4.0-linux-arm64.deb", "roadrunner-3.4.0-linux-amd64.tar.gz", diff --git a/tests/Acceptance/Server/router.php b/tests/Acceptance/Server/router.php index f272446..0d67dab 100644 --- a/tests/Acceptance/Server/router.php +++ b/tests/Acceptance/Server/router.php @@ -5,16 +5,19 @@ * * 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); + * - `github` — releases of RoadRunner from releases.json, of protobuf from releases-protobuf.json and of buf + * from releases-buf.json (its v2.0.0 is made up), 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; * - `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 + * `/api/v3/repos///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. + * holding the text `fake rr `, `bin/protoc[.exe]` holding `fake protoc ` and + * `buf/bin/buf[.exe]` holding `fake buf `, 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. * @@ -50,23 +53,35 @@ echo $body; }; -$archive = static function (string $name): ?string { +$archive = static function (string $name, string $tag): ?string { + $files = []; $pattern = '/^(roadrunner|protoc-gen-php-grpc)-(.+)-(linux|darwin|freebsd|windows)-(amd64|arm64)\.(tar\.gz|zip)$/'; - if (\preg_match($pattern, $name, $m) !== 1) { + if (\preg_match($pattern, $name, $m) === 1) { + [, $software, $version, $os, $arch, $format] = $m; + $binary = ($software === 'roadrunner' ? 'rr' : $software) . ($os === 'windows' ? '.exe' : ''); + $root = "$software-$version-$os-$arch"; + $files = ["$root/$binary" => "fake rr $version $os $arch\n", "$root/LICENSE" => "MIT\n"]; + } elseif (\preg_match('/^protoc-(.+?)-((?:linux|osx)-[a-z0-9_]+|win32|win64)\.(zip)$/', $name, $m) === 1) { + [, $version, $platform, $format] = $m; + $binary = 'protoc' . (\str_starts_with($platform, 'win') ? '.exe' : ''); + $files = ["bin/$binary" => "fake protoc $version $platform\n", 'include/google/protobuf/empty.proto' => "\n"]; + } elseif (\preg_match('/^buf-(Linux|Darwin|Windows)-([a-z0-9_]+)\.(tar\.gz|zip)$/', $name, $m) === 1) { + [, $os, $arch, $format] = $m; + $version = \ltrim($tag, 'v'); + $binary = 'buf' . ($os === 'Windows' ? '.exe' : ''); + $files = ["buf/bin/$binary" => "fake buf $version $os $arch\n", 'buf/LICENSE' => "Apache-2.0\n"]; + } else { return null; } - [, $software, $version, $os, $arch, $format] = $m; - $binary = ($software === 'roadrunner' ? 'rr' : $software) . ($os === 'windows' ? '.exe' : ''); - $root = "$software-$version-$os-$arch"; - $dir = \sys_get_temp_dir() . '/rr-cli-fake-github'; @\mkdir($dir, 0777, true); $file = $dir . '/archive-' . \bin2hex(\random_bytes(8)) . ($format === 'zip' ? '.zip' : '.tar'); $phar = new \PharData($file); - $phar->addFromString("$root/$binary", "fake rr $version $os $arch\n"); - $phar->addFromString("$root/LICENSE", "MIT\n"); + foreach ($files as $entry => $content) { + $phar->addFromString($entry, $content); + } if ($format === 'tar.gz') { $phar->compress(\Phar::GZ); } @@ -100,8 +115,15 @@ return true; } -if (\implode('/', $segments) === 'api/v3/repos/roadrunner-server/roadrunner/releases') { - $releases = \json_decode((string) \file_get_contents(__DIR__ . '/releases.json'), true); +$repository = \preg_match('#^api/v3/repos/([^/]+/[^/]+)/releases$#', \implode('/', $segments), $m) === 1 ? $m[1] : ''; +$releaseFile = [ + 'roadrunner-server/roadrunner' => 'releases.json', + 'protocolbuffers/protobuf' => 'releases-protobuf.json', + 'bufbuild/buf' => 'releases-buf.json', +][$repository] ?? null; + +if ($releaseFile !== null) { + $releases = \json_decode((string) \file_get_contents(__DIR__ . '/' . $releaseFile), true); $perPage = $scenario === 'paged' ? 2 : \min(100, \max(1, (int) ($query['per_page'] ?? 30))); $page = \max(1, (int) ($query['page'] ?? 1)); $last = (int) \ceil(\count($releases) / $perPage); @@ -126,8 +148,9 @@ } $link = static fn(int $to, string $rel): string => \sprintf( - '<%s/repos/roadrunner-server/roadrunner/releases?per_page=%d&page=%d>; rel="%s"', + '<%s/repos/%s/releases?per_page=%d&page=%d>; rel="%s"', $api, + $repository, $perPage, $to, $rel, @@ -151,7 +174,7 @@ } if (($segments[0] ?? '') === 'download' && \count($segments) === 3 && $scenario !== 'missing-asset') { - $content = $archive($segments[2]) ?? \str_repeat("\0", 1024); + $content = $archive($segments[2], $segments[1]) ?? \str_repeat("\0", 1024); $respond(200, $content, [ 'Content-Type' => 'application/octet-stream', 'Content-Length' => (string) \strlen($content), diff --git a/tests/Acceptance/VersionsTest.php b/tests/Acceptance/VersionsTest.php index efd0786..b6641ee 100644 --- a/tests/Acceptance/VersionsTest.php +++ b/tests/Acceptance/VersionsTest.php @@ -26,7 +26,7 @@ public function listsStableReleasesNewestFirst(): void 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*3\.4\.0\s+stable\s+✓\s+\(8\)\s+✓\s*$/mu') ->matchesRegex('/^\s*2025\.1\.5\s+stable\s+.*\(reason: incompatible version\)/mu'); Assert::same(\count(FakeGitHub::releaseRequests()), 1); } diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php index afb1dc0..e414238 100644 --- a/tests/Unit/DownloadProtocBinaryCommandTest.php +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -37,7 +37,7 @@ public function describesItself(): void $command = new DownloadProtocBinaryCommand(); Assert::same($command->getName(), 'download-protoc-binary'); - Assert::same($command->getDescription(), 'Install or update protoc-gen-php-grpc binary'); + Assert::same($command->getDescription(), 'Install or update protoc-gen-php-grpc binary (deprecated, use get-grpc-tools)'); } public function definesEnvironmentOptions(): void diff --git a/tests/Unit/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php index 28d22a5..079f72e 100644 --- a/tests/Unit/GetBinaryCommandTest.php +++ b/tests/Unit/GetBinaryCommandTest.php @@ -48,6 +48,7 @@ public function describesItself(): void $command = new GetBinaryCommand(); Assert::same($command->getName(), 'get-binary'); + Assert::same($command->getAliases(), ['get']); Assert::same($command->getDescription(), 'Install or update RoadRunner binary'); } diff --git a/tests/Unit/GetGrpcToolsCommandTest.php b/tests/Unit/GetGrpcToolsCommandTest.php new file mode 100644 index 0000000..61202bf --- /dev/null +++ b/tests/Unit/GetGrpcToolsCommandTest.php @@ -0,0 +1,217 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + public function describesItself(): void + { + $command = new GetGrpcToolsCommand(); + + Assert::same($command->getName(), 'get-grpc-tools'); + Assert::string($command->getDescription())->contains('buf')->contains('protoc')->contains('protoc-gen-php-grpc'); + } + + public function definesOptions(): void + { + $definition = (new GetGrpcToolsCommand())->getDefinition(); + + Assert::array(\array_keys($definition->getOptions())) + ->sameElementsAs(['os', 'arch', 'location', 'stability', 'protoc', 'plugin', 'buf']); + Assert::same($definition->getOption('buf')->getDefault(), '^1'); + Assert::same($definition->getOption('plugin')->getDefault(), '3.*'); + Assert::null($definition->getOption('protoc')->getDefault()); + } + + public function installsAllToolsThroughDLoad(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '5.36.2')); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:^1@stable'], ['protoc:^36.0@stable'], ['protoc-gen-php-grpc:3.*@stable']], + ); + foreach ($get->calls as $call) { + Assert::same($call->getOption('path'), $this->dir); + Assert::same($call->getOption('os'), 'darwin'); + Assert::same($call->getOption('arch'), 'arm64'); + Assert::true($call->getOption('force')); + } + Assert::string($tester->getDisplay()) + ->contains('google/protobuf 5.36.2 -> protoc ^36.0') + ->contains('protoc: ^36.0') + ->contains('gRPC tools are ready'); + } + + public function derivesProtocFromExtension(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::EXTENSION, '4.33.1')); + + $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($get->calls[1]->getArgument('software'), ['protoc:^33.0@stable']); + Assert::string($tester->getDisplay())->contains('ext-protobuf 4.33.1 -> protoc ^33.0'); + } + + public function takesVersionsFromOptions(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, static fn(): never => throw new \LogicException('Must not detect the runtime')); + + $status = $tester->execute($this->input([ + '--protoc' => '^30.0', + '--plugin' => '3.0.*', + '--buf' => '1.50.*', + '--stability' => 'beta', + ]), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:1.50.*@beta'], ['protoc:^30.0@beta'], ['protoc-gen-php-grpc:3.0.*@beta']], + ); + } + + #[DataSet([null, 'Neither google/protobuf nor ext-protobuf is installed'], 'no runtime')] + #[DataSet(['dev-main', 'Can not tell the protoc version for google/protobuf dev-main'], 'branch')] + public function installsLatestProtocWithWarning(?string $version, string $warning): void + { + $get = new DLoadGetSpy(); + $runtime = $version === null ? null : new ProtobufRuntime(ProtobufRuntime::PACKAGE, $version); + $tester = $this->tester($get, $runtime); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same($get->calls[1]->getArgument('software'), ['protoc:*@stable']); + Assert::string($this->display($tester))->contains($warning)->contains('--protoc='); + } + + public function suggestsProtocOptionWhenDerivedVersionFails(): void + { + $get = new DLoadGetSpy(failing: ['protoc']); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '5.37.0')); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 1); + Assert::count($get->calls, 3); + Assert::string($this->display($tester)) + ->contains('Could not install protoc ^37.0, the version google/protobuf 5.37.0 supports') + ->contains('--protoc=') + ->contains('Not installed: protoc'); + Assert::true(\is_file($this->dir . '/buf')); + Assert::true(\is_file($this->dir . '/protoc-gen-php-grpc')); + } + + public function reportsFailureWithoutHintForExplicitVersion(): void + { + $get = new DLoadGetSpy(failing: ['buf', 'protoc']); + $tester = $this->tester($get, null); + + $status = $tester->execute($this->input(['--protoc' => '^99.0']), ['interactive' => false]); + + Assert::same($status, 1); + Assert::string($this->display($tester)) + ->notContains('Could not install protoc') + ->contains('Not installed: buf, protoc'); + } + + #[DataSet(['linux', ''])] + #[DataSet(['windows', '.exe'])] + public function keepsExistingBinariesUnlessConfirmed(string $os, string $extension): void + { + \file_put_contents($this->dir . '/protoc' . $extension, 'old binary'); + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '4.33.1')); + + $status = $tester->execute($this->input(['--os' => $os]), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:^1@stable'], ['protoc-gen-php-grpc:3.*@stable']], + ); + Assert::same(\file_get_contents($this->dir . '/protoc' . $extension), 'old binary'); + Assert::string($tester->getDisplay()) + ->contains('protoc binary file already exists!') + ->contains('Skipping protoc installation...'); + } + + public function overwritesExistingBinaryWhenConfirmed(): void + { + \file_put_contents($this->dir . '/buf', 'old binary'); + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '4.33.1')); + $tester->setInputs(['yes']); + + $status = $tester->execute($this->input(['--os' => 'linux'])); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::count($get->calls, 3); + Assert::same(\file_get_contents($this->dir . '/buf'), 'new binary'); + } + + /** + * @param array $input + * @return array + */ + private function input(array $input = []): array + { + return $input + [ + '--location' => $this->dir, + '--os' => 'darwin', + '--arch' => 'arm64', + '--plugin' => '3.*', + ]; + } + + private function tester(DLoadGetSpy $get, ProtobufRuntime|\Closure|null $runtime): CommandTester + { + $detect = $runtime instanceof \Closure ? $runtime : static fn(): ?ProtobufRuntime => $runtime; + + return new CommandTester(new GetGrpcToolsCommand(downloader: new DLoadDownloader($get), runtime: $detect)); + } + + /** + * The display with the block messages unwrapped. + */ + private function display(CommandTester $tester): string + { + return (string) \preg_replace('/\s*\n\s*(?:!\s+)?/', ' ', $tester->getDisplay()); + } +} diff --git a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php index 56ac0a9..b1404b9 100644 --- a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php +++ b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php @@ -75,10 +75,10 @@ public function usesBundledEmptyConfig(): void 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 + #[DataSet(['https://ghe.example.com/api/v3', 'https://ghe.example.com'])] + #[DataSet(['https://GHE.example.com/api/v3/', 'https://ghe.example.com'], 'trailing slash')] + #[DataSet(['http://127.0.0.1:8080/api/v3', 'http://127.0.0.1:8080'], 'scheme and port are kept')] + public function pointsRegistryAtEnterpriseServer(string $url, string $server): void { $get = new DLoadGetSpy(); @@ -94,7 +94,12 @@ public function pointsRegistryAtEnterpriseServer(string $url, string $uri): void isset($software->repository['server']), ]; } - Assert::same($repositories, ['rr' => [$uri, false], 'protoc-gen-php-grpc' => [$uri, false]]); + Assert::same($repositories, [ + 'rr' => [$server . '/roadrunner-server/roadrunner', false], + 'protoc-gen-php-grpc' => [$server . '/roadrunner-server/roadrunner', false], + 'protoc' => [$server . '/protocolbuffers/protobuf', false], + 'buf' => [$server . '/bufbuild/buf', false], + ]); Assert::false(\is_file((string) $get->calls[0]->getOption('config'))); } @@ -206,6 +211,25 @@ public function keepsBoundedOrCalendarFilter(string $constraint): void Assert::same($get->calls[0]->getArgument('software'), ['rr:' . $constraint . '@beta']); } + /** + * Only the RoadRunner repository has calendar releases to keep out. + */ + #[DataSet(['buf', '*', 'buf:*@stable'], 'wildcard')] + #[DataSet(['protoc', '', 'protoc:*@stable'], 'empty constraint')] + #[DataSet(['protoc', '*@rc', 'protoc:*@rc'], 'explicit stability')] + #[DataSet(['protoc', '>=30.0', 'protoc:>=30.0@stable'], 'lower bound')] + #[DataSet(['buf', '^1 || >=2.1', 'buf:^1 || >=2.1@stable'], 'alternatives')] + #[DataSet(['protoc-gen-php-grpc', '>=3.0', 'protoc-gen-php-grpc:>=3.0 <2000@stable'], 'RoadRunner plugin')] + public function boundsOnlyRoadRunnerSoftware(string $software, string $constraint, string $expected): void + { + $get = new DLoadGetSpy(); + + (new DLoadDownloader($get)) + ->download($software, $constraint, 'stable', 'linux', 'amd64', $this->dir, false, new BufferedOutput()); + + Assert::same($get->calls[0]->getArgument('software'), [$expected]); + } + public function returnsDLoadExitCode(): void { Assert::same($this->download(new DLoadGetSpy(exitCode: 1)), 1); diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php new file mode 100644 index 0000000..f93e094 --- /dev/null +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -0,0 +1,160 @@ +protocConstraint(), $constraint); + } + + public function readsVersionFromProjectInstallation(): void + { + $this->installed('vendor', ['google/protobuf' => ['pretty_version' => 'v5.36.2']]); + + $runtime = ProtobufRuntime::fromProject($this->dir); + + Assert::same($runtime?->name, 'google/protobuf'); + Assert::same($runtime?->version, 'v5.36.2'); + } + + public function honoursVendorDirOfProject(): void + { + \file_put_contents($this->dir . '/composer.json', '{"config": {"vendor-dir": "lib/vendor"}}'); + $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '4.26.0']]); + $this->installed('lib/vendor', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + + Assert::same(ProtobufRuntime::fromProject($this->dir)?->version, '4.33.1'); + } + + public function honoursComposerVendorDirVariable(): void + { + $this->installed('deps', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $_SERVER['COMPOSER_VENDOR_DIR'] = 'deps'; + + try { + $runtime = ProtobufRuntime::fromProject($this->dir); + } finally { + unset($_SERVER['COMPOSER_VENDOR_DIR']); + } + + Assert::same($runtime?->version, '4.33.1'); + } + + #[DataSet([['roadrunner/worker' => ['pretty_version' => '3.8.0']]], 'other packages')] + #[DataSet([['google/protobuf' => ['replaced' => ['*']]]], 'replaced package')] + public function findsNothingInProjectWithoutPackage(array $versions): void + { + $this->installed('vendor', $versions); + + Assert::null(ProtobufRuntime::fromProject($this->dir)); + } + + public function findsNothingInProjectWithoutVendor(): void + { + Assert::null(ProtobufRuntime::fromProject($this->dir)); + } + + public function readsVersionFromRunningInstallation(): void + { + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => 'v4.33.1']]]); + + $runtime = ProtobufRuntime::fromInstalledVersions(); + + Assert::same($runtime?->name, 'google/protobuf'); + Assert::same($runtime?->version, 'v4.33.1'); + } + + public function findsNothingInRunningInstallationWithoutPackage(): void + { + InstalledVersions::reload(['versions' => ['roadrunner/worker' => ['pretty_version' => 'v3.8.0']]]); + + Assert::null(ProtobufRuntime::fromInstalledVersions()); + } + + public function prefersProjectOverRunningInstallation(): void + { + $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '5.36.2']]); + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::same(ProtobufRuntime::detect($this->dir)?->version, '5.36.2'); + } + + public function fallsBackToRunningInstallation(): void + { + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::same(ProtobufRuntime::detect($this->dir)?->version, '4.26.0'); + } + + public function readsExtensionVersion(): void + { + $version = \phpversion('protobuf'); + + $runtime = ProtobufRuntime::fromExtension(); + + Assert::same($runtime?->version, $version === false ? null : $version); + } + + #[BeforeTest] + protected function isolate(): void + { + $this->dir = TempDirectory::create(); + $ref = new \ReflectionClass(InstalledVersions::class); + $this->canGetVendors = $ref->getStaticPropertyValue('canGetVendors'); + $ref->setStaticPropertyValue('canGetVendors', false); + InstalledVersions::reload(['versions' => []]); + } + + #[AfterTest] + protected function restore(): void + { + TempDirectory::remove($this->dir); + InstalledVersions::reload(null); + (new \ReflectionClass(InstalledVersions::class))->setStaticPropertyValue('canGetVendors', $this->canGetVendors); + } + + private function installed(string $vendor, array $versions): void + { + \mkdir($this->dir . '/' . $vendor . '/composer', 0777, true); + \file_put_contents( + $this->dir . '/' . $vendor . '/composer/installed.php', + ' [], 'versions' => $versions], true) . ';', + ); + } +} diff --git a/tests/Unit/Stub/DLoadGetSpy.php b/tests/Unit/Stub/DLoadGetSpy.php index f4500f1..1891e80 100644 --- a/tests/Unit/Stub/DLoadGetSpy.php +++ b/tests/Unit/Stub/DLoadGetSpy.php @@ -21,9 +21,14 @@ final class DLoadGetSpy extends Command /** @var list Contents of the `--config` file at the time of each call. */ public array $configs = []; + /** + * @param string|null $binary File name of the fake binary; the requested software name by default. + * @param list $failing Software names that fail with exit code 1 whatever `$exitCode` is. + */ public function __construct( private readonly int $exitCode = self::SUCCESS, - private readonly string $binary = 'rr', + private readonly ?string $binary = null, + private readonly array $failing = [], ) { parent::__construct('get'); } @@ -38,12 +43,16 @@ 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'); + $software = \implode(' ', $input->getArgument('software')); + $name = \explode(':', $software, 2)[0]; + $exitCode = \in_array($name, $this->failing, true) ? self::FAILURE : $this->exitCode; + + if ($exitCode === self::SUCCESS) { + \file_put_contents($input->getOption('path') . '/' . ($this->binary ?? $name), 'new binary'); } - $output->writeln('dload: ' . \implode(' ', $input->getArgument('software'))); + $output->writeln('dload: ' . $software); - return $this->exitCode; + return $exitCode; } } From a61f8b56cde01dd3c36d8f8c3c4257cae0f2cc37 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 14:05:10 +0400 Subject: [PATCH 02/16] build(deps): require DLoad 1.19.1 to select protoc on macOS and Linux arm64 Assisted-By: Claude Opus 5.5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b01e79b..7fddfa1 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "php": ">=8.2", "composer-runtime-api": "^2.0", "composer/semver": "^3.4", - "internal/dload": "^1.19.0", + "internal/dload": "^1.19.1", "roadrunner/worker": "^3.8", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/http-client": "^6.4.17 || ^7.2 || ^8.0", From 6d254f1a1fb2cdcc8786b1216a68df6baaacd859 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 14:05:43 +0400 Subject: [PATCH 03/16] refactor: share the existing-binary check between the install commands Assisted-By: Claude Opus 5.5 --- src/DownloadProtocBinaryCommand.php | 19 +-------- src/GetBinaryCommand.php | 18 +------- src/GetGrpcToolsCommand.php | 19 +-------- src/Internal/Installation/ExistingBinary.php | 44 ++++++++++++++++++++ 4 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 src/Internal/Installation/ExistingBinary.php diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php index cf9faab..ea50630 100644 --- a/src/DownloadProtocBinaryCommand.php +++ b/src/DownloadProtocBinaryCommand.php @@ -6,6 +6,7 @@ use Spiral\RoadRunner\Console\Environment\OperatingSystem; use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Internal\Installation\ExistingBinary; use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; @@ -14,7 +15,6 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** @@ -67,7 +67,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $binary = $target . ($os === OperatingSystem::OS_WINDOWS ? '/protoc-gen-php-grpc.exe' : '/protoc-gen-php-grpc'); - if (!$this->checkExisting($binary, $io)) { + if (!ExistingBinary::mayInstall($binary, 'protoc-gen-php-grpc', $io)) { $io->warning('protoc-gen-php-grpc has not been installed'); return 1; @@ -84,19 +84,4 @@ public function execute(InputInterface $input, OutputInterface $output): int output: $output, ); } - - private function checkExisting(string $binary, StyleInterface $io): bool - { - if (\is_file($binary)) { - $io->warning('protoc-gen-php-grpc binary file already exists!'); - - if (!$io->confirm('Do you want overwrite it?', false)) { - $io->note('Skipping protoc-gen-php-grpc installation...'); - - return false; - } - } - - return true; - } } diff --git a/src/GetBinaryCommand.php b/src/GetBinaryCommand.php index 4321b9f..69da690 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -15,6 +15,7 @@ use Spiral\RoadRunner\Console\Internal\Configuration\Generator; use Spiral\RoadRunner\Console\Internal\Configuration\Plugins; use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Internal\Installation\ExistingBinary; use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; @@ -82,7 +83,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $binary = $target . ($os === OperatingSystem::OS_WINDOWS ? '/rr.exe' : '/rr'); $installed = false; - if ($this->checkExisting($binary, $io)) { + if (ExistingBinary::mayInstall($binary, 'RoadRunner', $io)) { $code = $this->downloader->download( software: 'rr', constraint: $this->version->get($input, $io), @@ -182,19 +183,4 @@ private function installConfig(string $to, InputInterface $in, StyleInterface $i \file_put_contents($to, $config); } - - private function checkExisting(string $binary, StyleInterface $io): bool - { - if (\is_file($binary)) { - $io->warning('RoadRunner binary file already exists!'); - - if (! $io->confirm('Do you want overwrite it?', false)) { - $io->note('Skipping RoadRunner installation...'); - - return false; - } - } - - return true; - } } diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php index 1311fe7..6dfacbf 100644 --- a/src/GetGrpcToolsCommand.php +++ b/src/GetGrpcToolsCommand.php @@ -15,6 +15,7 @@ use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; use Spiral\RoadRunner\Console\Internal\Environment\ProtobufRuntime; use Spiral\RoadRunner\Console\Internal\Environment\RoadRunnerVersion; +use Spiral\RoadRunner\Console\Internal\Installation\ExistingBinary; use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; @@ -23,7 +24,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** @@ -101,7 +101,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $failed = []; foreach ($tools as $tool => $constraint) { $binary = $target . '/' . $tool . ($os === OperatingSystem::OS_WINDOWS ? '.exe' : ''); - if (! $this->checkExisting($tool, $binary, $io)) { + if (! ExistingBinary::mayInstall($binary, $tool, $io)) { continue; } @@ -218,19 +218,4 @@ private function protocConstraint(InputInterface $input, SymfonyStyle $io): arra return [$constraint, $runtime]; } - - private function checkExisting(string $tool, string $binary, StyleInterface $io): bool - { - if (\is_file($binary)) { - $io->warning(\sprintf('%s binary file already exists!', $tool)); - - if (! $io->confirm('Do you want overwrite it?', false)) { - $io->note(\sprintf('Skipping %s installation...', $tool)); - - return false; - } - } - - return true; - } } diff --git a/src/Internal/Installation/ExistingBinary.php b/src/Internal/Installation/ExistingBinary.php new file mode 100644 index 0000000..e25ca30 --- /dev/null +++ b/src/Internal/Installation/ExistingBinary.php @@ -0,0 +1,44 @@ +warning(\sprintf('%s binary file already exists!', $name)); + + if ($io->confirm('Do you want overwrite it?', false)) { + return true; + } + + $io->note(\sprintf('Skipping %s installation...', $name)); + + return false; + } +} From f49913b2e2e9bf15139d81d9279136de6f66e20d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 14:05:53 +0400 Subject: [PATCH 04/16] fix: keep the default versions for an empty --buf or --plugin An empty value turned into `*`, so `--plugin=` left the RoadRunner line and `--buf=` pulled buf 2.x. Assisted-By: Claude Opus 5.5 --- src/GetGrpcToolsCommand.php | 20 ++++++++++++++------ tests/Unit/GetGrpcToolsCommandTest.php | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php index 6dfacbf..40575f0 100644 --- a/src/GetGrpcToolsCommand.php +++ b/src/GetGrpcToolsCommand.php @@ -87,9 +87,9 @@ public function execute(InputInterface $input, OutputInterface $output): int [$protoc, $derivedFrom] = $this->protocConstraint($input, $io); $tools = [ - 'buf' => self::option($input, 'buf') ?? self::ANY_VERSION, + 'buf' => $this->option($input, 'buf') ?? self::ANY_VERSION, 'protoc' => $protoc, - 'protoc-gen-php-grpc' => self::option($input, 'plugin') ?? self::ANY_VERSION, + 'protoc-gen-php-grpc' => $this->option($input, 'plugin') ?? self::ANY_VERSION, ]; $output->writeln(' Versions:'); @@ -171,14 +171,22 @@ protected function configure(): void } /** - * @return non-empty-string|null + * The option value; an empty one falls back to the option default. + * + * @return non-empty-string|null Null when neither is set. */ - private static function option(InputInterface $input, string $name): ?string + private function option(InputInterface $input, string $name): ?string { /** @var mixed $value */ $value = $input->getOption($name); + if (\is_string($value) && $value !== '') { + return $value; + } + + /** @var mixed $default */ + $default = $this->getDefinition()->getOption($name)->getDefault(); - return \is_string($value) && $value !== '' ? $value : null; + return \is_string($default) && $default !== '' ? $default : null; } /** @@ -186,7 +194,7 @@ private static function option(InputInterface $input, string $name): ?string */ private function protocConstraint(InputInterface $input, SymfonyStyle $io): array { - $option = self::option($input, 'protoc'); + $option = $this->option($input, 'protoc'); if ($option !== null) { return [$option, null]; } diff --git a/tests/Unit/GetGrpcToolsCommandTest.php b/tests/Unit/GetGrpcToolsCommandTest.php index 61202bf..e4979f9 100644 --- a/tests/Unit/GetGrpcToolsCommandTest.php +++ b/tests/Unit/GetGrpcToolsCommandTest.php @@ -7,6 +7,7 @@ use Spiral\RoadRunner\Console\GetGrpcToolsCommand; use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; use Spiral\RoadRunner\Console\Internal\Environment\ProtobufRuntime; +use Spiral\RoadRunner\Console\Internal\Environment\RoadRunnerVersion; use Spiral\RoadRunner\Console\Tests\Unit\Stub\DLoadGetSpy; use Spiral\RoadRunner\Console\Tests\Unit\Stub\TempDirectory; use Symfony\Component\Console\Tester\CommandTester; @@ -106,6 +107,24 @@ public function takesVersionsFromOptions(): void ); } + public function fallsBackToDefaultsForEmptyOptions(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '5.36.2')); + + $status = $tester->execute($this->input([ + '--protoc' => '', + '--plugin' => '', + '--buf' => '', + ]), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:^1@stable'], ['protoc:^36.0@stable'], [\sprintf('protoc-gen-php-grpc:%s@stable', RoadRunnerVersion::constraint())]], + ); + } + #[DataSet([null, 'Neither google/protobuf nor ext-protobuf is installed'], 'no runtime')] #[DataSet(['dev-main', 'Can not tell the protoc version for google/protobuf dev-main'], 'branch')] public function installsLatestProtocWithWarning(?string $version, string $warning): void From a354cf83ec4df2c51ae14c78e8ad651b2dbb1e6c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 14:06:16 +0400 Subject: [PATCH 05/16] fix: prefer ext-protobuf and find the project up from the working directory A loaded ext-protobuf defines the Google\Protobuf classes, so the google/protobuf package is never autoloaded and protoc has to follow the extension. The project is found as Composer finds it: the nearest directory up with the manifest named by `COMPOSER`, so the command also works from a subdirectory such as `proto/`. The acceptance tests that rely on the project's google/protobuf skip when the extension is loaded. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/GetGrpcToolsCommand.php | 4 +- src/Internal/Environment/ProtobufRuntime.php | 67 +++++++++++++++++-- tests/Acceptance/GrpcToolsTest.php | 12 ++++ .../Environment/ProtobufRuntimeTest.php | 67 ++++++++++++++++++- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5a9d8eb..fb6f447 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ vendor/bin/rr get-grpc-tools - protoc-gen-php-grpc: 3.* ``` -The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is the `google/protobuf` package installed in the project in the working directory, or else `ext-protobuf`; without either, the latest protoc is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. +The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); without either, the latest protoc is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. ## Commands diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php index 40575f0..da5c494 100644 --- a/src/GetGrpcToolsCommand.php +++ b/src/GetGrpcToolsCommand.php @@ -47,12 +47,12 @@ final class GetGrpcToolsCommand extends Command /** * @param (\Closure(): ?ProtobufRuntime)|null $runtime Finds the protobuf runtime of the project; - * {@see ProtobufRuntime::detect()} in the working directory by default. + * {@see ProtobufRuntime::detect()} in the project found up from the working directory by default. */ public function __construct(?string $name = null, ?DLoadDownloader $downloader = null, ?\Closure $runtime = null) { $this->downloader = $downloader ?? new DLoadDownloader(); - $this->runtime = $runtime ?? static fn(): ?ProtobufRuntime => ProtobufRuntime::detect(\getcwd() ?: '.'); + $this->runtime = $runtime ?? static fn(): ?ProtobufRuntime => ProtobufRuntime::detect(ProtobufRuntime::projectDir(\getcwd() ?: '.')); parent::__construct($name ?? 'get-grpc-tools'); $this->os = new OperatingSystemOption($this); diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php index 167721d..a0d769d 100644 --- a/src/Internal/Environment/ProtobufRuntime.php +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -32,16 +32,40 @@ public function __construct( ) {} /** - * Looks for google/protobuf in the project, then in the Composer installation running this tool, then for ext-protobuf. + * Looks for ext-protobuf, then for google/protobuf in the project, then in the Composer installation running this tool. * + * The extension goes first: once loaded it defines the `Google\Protobuf` classes, so the package is never autoloaded. * The project is read from its own `installed.php`: run from a global installation or a PHAR, * {@see InstalledVersions} knows only the packages installed next to this tool. * - * @param string $projectDir Root of the project, where its `composer.json` is. + * @param string $projectDir Root of the project, see {@see self::projectDir()}. */ public static function detect(string $projectDir): ?self { - return self::fromProject($projectDir) ?? self::fromInstalledVersions() ?? self::fromExtension(); + return self::fromExtension() ?? self::fromProject($projectDir) ?? self::fromInstalledVersions(); + } + + /** + * Finds the project root as Composer does: the nearest directory up from the working one with a `composer.json` + * (or the file named by `COMPOSER`), looking neither in the user home nor in the filesystem root. + * + * @return string The working directory itself when none has the file. + */ + public static function projectDir(string $workingDir): string + { + $file = self::composerFile(); + if (self::isAbsolute($file)) { + return $workingDir; + } + + $home = \realpath(self::userDir() ?? '/'); + for ($dir = $workingDir; \dirname($dir) !== $dir && \realpath($dir) !== $home; $dir = \dirname($dir)) { + if (\is_file($dir . '/' . $file)) { + return $dir; + } + } + + return $workingDir; } public static function fromProject(string $projectDir): ?self @@ -107,20 +131,51 @@ public function protocConstraint(): ?string }; } + /** + * Resolves `vendor-dir` as Composer does, expanding a leading `~/`; `{$home}` (the Composer home) is left as is. + */ private static function vendorDir(string $projectDir): string { $vendor = Environment::get('COMPOSER_VENDOR_DIR'); if ($vendor === null || $vendor === '') { + $file = self::composerFile(); + $file = self::isAbsolute($file) ? $file : $projectDir . '/' . $file; /** @var mixed $composer */ - $composer = \json_decode((string) @\file_get_contents($projectDir . '/composer.json'), true); + $composer = \json_decode((string) @\file_get_contents($file), true); /** @var mixed $vendor */ $vendor = \is_array($composer) ? $composer['config']['vendor-dir'] ?? null : null; $vendor = \is_string($vendor) && $vendor !== '' ? $vendor : 'vendor'; } - $absolute = \preg_match('#^(?:[a-z]:)?[/\\\\]#i', $vendor) === 1; + $user = self::userDir(); + if ($user !== null && \preg_match('#^~[/\\\\]#', $vendor) === 1) { + $vendor = $user . \substr($vendor, 1); + } + + return self::isAbsolute($vendor) ? $vendor : $projectDir . '/' . $vendor; + } - return $absolute ? $vendor : $projectDir . '/' . $vendor; + /** + * @return non-empty-string The manifest named by `COMPOSER`, relative to the project unless absolute. + */ + private static function composerFile(): string + { + $file = \trim(Environment::get('COMPOSER') ?? ''); + + return $file === '' ? 'composer.json' : $file; + } + + private static function userDir(): ?string + { + $dir = Environment::get('HOME'); + $dir = $dir === null || $dir === '' ? Environment::get('USERPROFILE') : $dir; + + return $dir === null || $dir === '' ? null : $dir; + } + + private static function isAbsolute(string $path): bool + { + return \preg_match('#^(?:[a-z]:)?[/\\\\]#i', $path) === 1; } } diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php index 06f0fd4..2c87c9d 100644 --- a/tests/Acceptance/GrpcToolsTest.php +++ b/tests/Acceptance/GrpcToolsTest.php @@ -7,6 +7,7 @@ use Spiral\RoadRunner\Console\Tests\Acceptance\Support\FakeGitHub; use Spiral\RoadRunner\Console\Tests\Acceptance\Support\FakeGitHubCase; use Testo\Assert; +use Testo\Core\Exception\SkipTest; use Testo\Data\DataSet; use Testo\Test; @@ -55,6 +56,8 @@ public function installsWindowsBinaries(): void public function installsLatestProtocWithoutRuntime(): void { + self::skipWithExtension(); + $result = $this->rr()->run(self::ARGS); Assert::same($result->exitCode, 0, (string) $result); @@ -100,8 +103,17 @@ public function keepsExistingBinariesWithoutInteraction(): void Assert::same(FakeGitHub::downloads(), ['buf-Linux-x86_64.tar.gz', 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz']); } + /** + * The rr subprocess prefers a loaded ext-protobuf to the google/protobuf of the project. + */ + private static function skipWithExtension(): void + { + \extension_loaded('protobuf') and throw new SkipTest('ext-protobuf is loaded'); + } + private function installProtobuf(string $version): void { + self::skipWithExtension(); \mkdir($this->dir->file('vendor/composer'), 0777, true); $this->dir->write('vendor/composer/installed.php', \sprintf( " [], 'versions' => ['google/protobuf' => ['pretty_version' => %s]]];", diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php index f93e094..acea55e 100644 --- a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -18,6 +18,7 @@ final class ProtobufRuntimeTest { private string $dir; private ?bool $canGetVendors = null; + private array $server = []; /** * The PHP runtime minor is the protoc major since protobuf 21; up to 3.20 both shared one version. @@ -76,6 +77,53 @@ public function honoursComposerVendorDirVariable(): void Assert::same($runtime?->version, '4.33.1'); } + public function honoursComposerVariable(): void + { + \file_put_contents($this->dir . '/app.json', '{"config": {"vendor-dir": "deps"}}'); + $this->installed('deps', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $_SERVER['COMPOSER'] = 'app.json'; + + Assert::same(ProtobufRuntime::fromProject($this->dir)?->version, '4.33.1'); + } + + public function expandsUserDirInVendorDir(): void + { + \mkdir($this->dir . '/project'); + \file_put_contents($this->dir . '/project/composer.json', '{"config": {"vendor-dir": "~/deps"}}'); + $this->installed('deps', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $_SERVER['HOME'] = $this->dir; + + Assert::same(ProtobufRuntime::fromProject($this->dir . '/project')?->version, '4.33.1'); + } + + public function findsProjectUpFromWorkingDir(): void + { + \file_put_contents($this->dir . '/composer.json', '{}'); + \mkdir($this->dir . '/proto/v1', 0777, true); + + Assert::same(ProtobufRuntime::projectDir($this->dir . '/proto/v1'), $this->dir); + Assert::same(ProtobufRuntime::projectDir($this->dir), $this->dir); + } + + public function findsProjectByComposerVariable(): void + { + \file_put_contents($this->dir . '/composer.json', '{}'); + \mkdir($this->dir . '/app/proto', 0777, true); + \file_put_contents($this->dir . '/app/app.json', '{}'); + $_SERVER['COMPOSER'] = 'app.json'; + + Assert::same(ProtobufRuntime::projectDir($this->dir . '/app/proto'), $this->dir . '/app'); + } + + public function stopsLookingForProjectAtUserHome(): void + { + \file_put_contents($this->dir . '/composer.json', '{}'); + \mkdir($this->dir . '/home/proto', 0777, true); + $_SERVER['HOME'] = $this->dir . '/home'; + + Assert::same(ProtobufRuntime::projectDir($this->dir . '/home/proto'), $this->dir . '/home/proto'); + } + #[DataSet([['roadrunner/worker' => ['pretty_version' => '3.8.0']]], 'other packages')] #[DataSet([['google/protobuf' => ['replaced' => ['*']]]], 'replaced package')] public function findsNothingInProjectWithoutPackage(array $versions): void @@ -112,14 +160,26 @@ public function prefersProjectOverRunningInstallation(): void $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '5.36.2']]); InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); - Assert::same(ProtobufRuntime::detect($this->dir)?->version, '5.36.2'); + Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '5.36.2'); } public function fallsBackToRunningInstallation(): void { InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); - Assert::same(ProtobufRuntime::detect($this->dir)?->version, '4.26.0'); + Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '4.26.0'); + } + + /** + * A loaded extension defines the protobuf classes itself, so the package is never used. + */ + public function prefersExtensionOverPackage(): void + { + $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '5.36.2']]); + + $runtime = ProtobufRuntime::detect($this->dir); + + Assert::same($runtime?->name, \extension_loaded('protobuf') ? 'ext-protobuf' : 'google/protobuf'); } public function readsExtensionVersion(): void @@ -135,6 +195,8 @@ public function readsExtensionVersion(): void protected function isolate(): void { $this->dir = TempDirectory::create(); + $this->server = $_SERVER; + unset($_SERVER['COMPOSER'], $_ENV['COMPOSER']); $ref = new \ReflectionClass(InstalledVersions::class); $this->canGetVendors = $ref->getStaticPropertyValue('canGetVendors'); $ref->setStaticPropertyValue('canGetVendors', false); @@ -144,6 +206,7 @@ protected function isolate(): void #[AfterTest] protected function restore(): void { + $_SERVER = $this->server; TempDirectory::remove($this->dir); InstalledVersions::reload(null); (new \ReflectionClass(InstalledVersions::class))->setStaticPropertyValue('canGetVendors', $this->canGetVendors); From fe63d0dddcda1555986e01f3b421ada697b84039 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 14:06:23 +0400 Subject: [PATCH 06/16] test: serve the real buf release assets and cover macOS The buf fixture listed only the archives, while real releases also ship bare binaries, protoc-gen-buf-* plugins and checksums, so the asset selection among them was untested. Assisted-By: Claude Opus 5.5 --- tests/Acceptance/GrpcToolsTest.php | 22 +++ tests/Acceptance/Server/releases-buf.json | 171 +++++++++++++++++++++- tests/Acceptance/Server/releases.json | 1 + tests/Acceptance/VersionsTest.php | 2 +- 4 files changed, 192 insertions(+), 4 deletions(-) diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php index 2c87c9d..c1a75c1 100644 --- a/tests/Acceptance/GrpcToolsTest.php +++ b/tests/Acceptance/GrpcToolsTest.php @@ -52,6 +52,28 @@ public function installsWindowsBinaries(): void Assert::same(\trim($this->dir->read('protoc.exe')), 'fake protoc 33.6 win64'); Assert::same(\trim($this->dir->read('buf.exe')), 'fake buf 1.75.0 Windows x86_64'); Assert::same(\trim($this->dir->read('protoc-gen-php-grpc.exe')), 'fake rr 3.4.0 windows amd64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Windows-x86_64.zip', + 'protoc-33.6-win64.zip', + 'protoc-gen-php-grpc-3.4.0-windows-amd64.zip', + ]); + } + + public function installsMacosBinaries(): void + { + $this->installProtobuf('5.36.2'); + + $result = $this->rr()->run(['get-grpc-tools', '--os=darwin', '--arch=arm64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('buf')), 'fake buf 1.75.0 Darwin arm64'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 36.2 osx-universal_binary'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 3.4.0 darwin arm64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Darwin-arm64.tar.gz', + 'protoc-36.2-osx-universal_binary.zip', + 'protoc-gen-php-grpc-3.4.0-darwin-arm64.tar.gz', + ]); } public function installsLatestProtocWithoutRuntime(): void diff --git a/tests/Acceptance/Server/releases-buf.json b/tests/Acceptance/Server/releases-buf.json index 7ddd9ff..b86f6cb 100644 --- a/tests/Acceptance/Server/releases-buf.json +++ b/tests/Acceptance/Server/releases-buf.json @@ -3,27 +3,192 @@ "tag_name": "v2.0.0", "prerelease": false, "assets": [ + "buf-Darwin-arm64", + "buf-Darwin-arm64.tar.gz", + "buf-Darwin-x86_64", + "buf-Darwin-x86_64.tar.gz", + "buf-FreeBSD-arm64", + "buf-FreeBSD-arm64.tar.gz", + "buf-FreeBSD-x86_64", + "buf-FreeBSD-x86_64.tar.gz", + "buf-Linux-aarch64", + "buf-Linux-aarch64.tar.gz", + "buf-Linux-armv7", + "buf-Linux-armv7.tar.gz", + "buf-Linux-ppc64le", + "buf-Linux-ppc64le.tar.gz", + "buf-Linux-riscv64", + "buf-Linux-riscv64.tar.gz", + "buf-Linux-s390x", + "buf-Linux-s390x.tar.gz", + "buf-Linux-x86_64", "buf-Linux-x86_64.tar.gz", - "buf-Windows-x86_64.zip" + "buf-OpenBSD-arm64", + "buf-OpenBSD-arm64.tar.gz", + "buf-OpenBSD-x86_64", + "buf-OpenBSD-x86_64.tar.gz", + "buf-Windows-arm64.exe", + "buf-Windows-arm64.zip", + "buf-Windows-x86_64.exe", + "buf-Windows-x86_64.zip", + "protoc-gen-buf-breaking-Darwin-arm64", + "protoc-gen-buf-breaking-Darwin-x86_64", + "protoc-gen-buf-breaking-FreeBSD-arm64", + "protoc-gen-buf-breaking-FreeBSD-x86_64", + "protoc-gen-buf-breaking-Linux-aarch64", + "protoc-gen-buf-breaking-Linux-armv7", + "protoc-gen-buf-breaking-Linux-ppc64le", + "protoc-gen-buf-breaking-Linux-riscv64", + "protoc-gen-buf-breaking-Linux-s390x", + "protoc-gen-buf-breaking-Linux-x86_64", + "protoc-gen-buf-breaking-OpenBSD-arm64", + "protoc-gen-buf-breaking-OpenBSD-x86_64", + "protoc-gen-buf-breaking-Windows-arm64.exe", + "protoc-gen-buf-breaking-Windows-x86_64.exe", + "protoc-gen-buf-lint-Darwin-arm64", + "protoc-gen-buf-lint-Darwin-x86_64", + "protoc-gen-buf-lint-FreeBSD-arm64", + "protoc-gen-buf-lint-FreeBSD-x86_64", + "protoc-gen-buf-lint-Linux-aarch64", + "protoc-gen-buf-lint-Linux-armv7", + "protoc-gen-buf-lint-Linux-ppc64le", + "protoc-gen-buf-lint-Linux-riscv64", + "protoc-gen-buf-lint-Linux-s390x", + "protoc-gen-buf-lint-Linux-x86_64", + "protoc-gen-buf-lint-OpenBSD-arm64", + "protoc-gen-buf-lint-OpenBSD-x86_64", + "protoc-gen-buf-lint-Windows-arm64.exe", + "protoc-gen-buf-lint-Windows-x86_64.exe", + "sha256.txt", + "sha256.txt.minisig" ] }, { "tag_name": "v1.75.0", "prerelease": false, "assets": [ + "buf-Darwin-arm64", "buf-Darwin-arm64.tar.gz", + "buf-Darwin-x86_64", + "buf-Darwin-x86_64.tar.gz", + "buf-FreeBSD-arm64", + "buf-FreeBSD-arm64.tar.gz", + "buf-FreeBSD-x86_64", + "buf-FreeBSD-x86_64.tar.gz", + "buf-Linux-aarch64", "buf-Linux-aarch64.tar.gz", + "buf-Linux-armv7", + "buf-Linux-armv7.tar.gz", + "buf-Linux-ppc64le", + "buf-Linux-ppc64le.tar.gz", + "buf-Linux-riscv64", + "buf-Linux-riscv64.tar.gz", + "buf-Linux-s390x", + "buf-Linux-s390x.tar.gz", + "buf-Linux-x86_64", "buf-Linux-x86_64.tar.gz", + "buf-OpenBSD-arm64", + "buf-OpenBSD-arm64.tar.gz", + "buf-OpenBSD-x86_64", + "buf-OpenBSD-x86_64.tar.gz", + "buf-Windows-arm64.exe", + "buf-Windows-arm64.zip", + "buf-Windows-x86_64.exe", "buf-Windows-x86_64.zip", - "protoc-gen-buf-lint-Linux-x86_64" + "protoc-gen-buf-breaking-Darwin-arm64", + "protoc-gen-buf-breaking-Darwin-x86_64", + "protoc-gen-buf-breaking-FreeBSD-arm64", + "protoc-gen-buf-breaking-FreeBSD-x86_64", + "protoc-gen-buf-breaking-Linux-aarch64", + "protoc-gen-buf-breaking-Linux-armv7", + "protoc-gen-buf-breaking-Linux-ppc64le", + "protoc-gen-buf-breaking-Linux-riscv64", + "protoc-gen-buf-breaking-Linux-s390x", + "protoc-gen-buf-breaking-Linux-x86_64", + "protoc-gen-buf-breaking-OpenBSD-arm64", + "protoc-gen-buf-breaking-OpenBSD-x86_64", + "protoc-gen-buf-breaking-Windows-arm64.exe", + "protoc-gen-buf-breaking-Windows-x86_64.exe", + "protoc-gen-buf-lint-Darwin-arm64", + "protoc-gen-buf-lint-Darwin-x86_64", + "protoc-gen-buf-lint-FreeBSD-arm64", + "protoc-gen-buf-lint-FreeBSD-x86_64", + "protoc-gen-buf-lint-Linux-aarch64", + "protoc-gen-buf-lint-Linux-armv7", + "protoc-gen-buf-lint-Linux-ppc64le", + "protoc-gen-buf-lint-Linux-riscv64", + "protoc-gen-buf-lint-Linux-s390x", + "protoc-gen-buf-lint-Linux-x86_64", + "protoc-gen-buf-lint-OpenBSD-arm64", + "protoc-gen-buf-lint-OpenBSD-x86_64", + "protoc-gen-buf-lint-Windows-arm64.exe", + "protoc-gen-buf-lint-Windows-x86_64.exe", + "sha256.txt", + "sha256.txt.minisig" ] }, { "tag_name": "v1.74.0", "prerelease": false, "assets": [ + "buf-Darwin-arm64", + "buf-Darwin-arm64.tar.gz", + "buf-Darwin-x86_64", + "buf-Darwin-x86_64.tar.gz", + "buf-FreeBSD-arm64", + "buf-FreeBSD-arm64.tar.gz", + "buf-FreeBSD-x86_64", + "buf-FreeBSD-x86_64.tar.gz", + "buf-Linux-aarch64", + "buf-Linux-aarch64.tar.gz", + "buf-Linux-armv7", + "buf-Linux-armv7.tar.gz", + "buf-Linux-ppc64le", + "buf-Linux-ppc64le.tar.gz", + "buf-Linux-riscv64", + "buf-Linux-riscv64.tar.gz", + "buf-Linux-s390x", + "buf-Linux-s390x.tar.gz", + "buf-Linux-x86_64", "buf-Linux-x86_64.tar.gz", - "buf-Windows-x86_64.zip" + "buf-OpenBSD-arm64", + "buf-OpenBSD-arm64.tar.gz", + "buf-OpenBSD-x86_64", + "buf-OpenBSD-x86_64.tar.gz", + "buf-Windows-arm64.exe", + "buf-Windows-arm64.zip", + "buf-Windows-x86_64.exe", + "buf-Windows-x86_64.zip", + "protoc-gen-buf-breaking-Darwin-arm64", + "protoc-gen-buf-breaking-Darwin-x86_64", + "protoc-gen-buf-breaking-FreeBSD-arm64", + "protoc-gen-buf-breaking-FreeBSD-x86_64", + "protoc-gen-buf-breaking-Linux-aarch64", + "protoc-gen-buf-breaking-Linux-armv7", + "protoc-gen-buf-breaking-Linux-ppc64le", + "protoc-gen-buf-breaking-Linux-riscv64", + "protoc-gen-buf-breaking-Linux-s390x", + "protoc-gen-buf-breaking-Linux-x86_64", + "protoc-gen-buf-breaking-OpenBSD-arm64", + "protoc-gen-buf-breaking-OpenBSD-x86_64", + "protoc-gen-buf-breaking-Windows-arm64.exe", + "protoc-gen-buf-breaking-Windows-x86_64.exe", + "protoc-gen-buf-lint-Darwin-arm64", + "protoc-gen-buf-lint-Darwin-x86_64", + "protoc-gen-buf-lint-FreeBSD-arm64", + "protoc-gen-buf-lint-FreeBSD-x86_64", + "protoc-gen-buf-lint-Linux-aarch64", + "protoc-gen-buf-lint-Linux-armv7", + "protoc-gen-buf-lint-Linux-ppc64le", + "protoc-gen-buf-lint-Linux-riscv64", + "protoc-gen-buf-lint-Linux-s390x", + "protoc-gen-buf-lint-Linux-x86_64", + "protoc-gen-buf-lint-OpenBSD-arm64", + "protoc-gen-buf-lint-OpenBSD-x86_64", + "protoc-gen-buf-lint-Windows-arm64.exe", + "protoc-gen-buf-lint-Windows-x86_64.exe", + "sha256.txt", + "sha256.txt.minisig" ] } ] diff --git a/tests/Acceptance/Server/releases.json b/tests/Acceptance/Server/releases.json index 6b0fb69..7c2f868 100644 --- a/tests/Acceptance/Server/releases.json +++ b/tests/Acceptance/Server/releases.json @@ -23,6 +23,7 @@ "tag_name": "v3.4.0", "prerelease": false, "assets": [ + "protoc-gen-php-grpc-3.4.0-darwin-arm64.tar.gz", "protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz", "protoc-gen-php-grpc-3.4.0-linux-arm64.tar.gz", "protoc-gen-php-grpc-3.4.0-windows-amd64.zip", diff --git a/tests/Acceptance/VersionsTest.php b/tests/Acceptance/VersionsTest.php index b6641ee..3b752cd 100644 --- a/tests/Acceptance/VersionsTest.php +++ b/tests/Acceptance/VersionsTest.php @@ -26,7 +26,7 @@ public function listsStableReleasesNewestFirst(): void 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+\(8\)\s+✓\s*$/mu') + ->matchesRegex('/^\s*3\.4\.0\s+stable\s+✓\s+\(9\)\s+✓\s*$/mu') ->matchesRegex('/^\s*2025\.1\.5\s+stable\s+.*\(reason: incompatible version\)/mu'); Assert::same(\count(FakeGitHub::releaseRequests()), 1); } From 0fe574df4567b29b5806551478673076381502c9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 17:07:50 +0400 Subject: [PATCH 07/16] fix: ignore the running installation when the project has installed.php A project without google/protobuf got the protobuf of a global or PHAR rr installation, and so a protoc for a runtime it does not have. Assisted-By: Claude Opus 5.5 --- src/Internal/Environment/ProtobufRuntime.php | 21 +++++++++++++++---- .../Environment/ProtobufRuntimeTest.php | 13 ++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php index a0d769d..65544a0 100644 --- a/src/Internal/Environment/ProtobufRuntime.php +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -36,13 +36,16 @@ public function __construct( * * The extension goes first: once loaded it defines the `Google\Protobuf` classes, so the package is never autoloaded. * The project is read from its own `installed.php`: run from a global installation or a PHAR, - * {@see InstalledVersions} knows only the packages installed next to this tool. + * {@see InstalledVersions} knows only the packages installed next to this tool, + * so it is asked only when the project has no `installed.php`. * * @param string $projectDir Root of the project, see {@see self::projectDir()}. */ public static function detect(string $projectDir): ?self { - return self::fromExtension() ?? self::fromProject($projectDir) ?? self::fromInstalledVersions(); + return self::fromExtension() ?? (self::installedFile($projectDir) === null + ? self::fromInstalledVersions() + : self::fromProject($projectDir)); } /** @@ -70,8 +73,8 @@ public static function projectDir(string $workingDir): string public static function fromProject(string $projectDir): ?self { - $file = self::vendorDir($projectDir) . '/composer/installed.php'; - if (! \is_file($file)) { + $file = self::installedFile($projectDir); + if ($file === null) { return null; } @@ -131,6 +134,16 @@ public function protocConstraint(): ?string }; } + /** + * @return string|null The project's `installed.php`, null when there is none. + */ + private static function installedFile(string $projectDir): ?string + { + $file = self::vendorDir($projectDir) . '/composer/installed.php'; + + return \is_file($file) ? $file : null; + } + /** * Resolves `vendor-dir` as Composer does, expanding a leading `~/`; `{$home}` (the Composer home) is left as is. */ diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php index acea55e..0aae112 100644 --- a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -8,6 +8,7 @@ use Spiral\RoadRunner\Console\Internal\Environment\ProtobufRuntime; use Spiral\RoadRunner\Console\Tests\Unit\Stub\TempDirectory; use Testo\Assert; +use Testo\Core\Exception\SkipTest; use Testo\Data\DataSet; use Testo\Lifecycle\AfterTest; use Testo\Lifecycle\BeforeTest; @@ -170,6 +171,18 @@ public function fallsBackToRunningInstallation(): void Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '4.26.0'); } + /** + * The package next to this tool is not the project's runtime. + */ + public function ignoresRunningInstallationWhenProjectLacksPackage(): void + { + \extension_loaded('protobuf') and throw new SkipTest('ext-protobuf is loaded'); + $this->installed('vendor', ['roadrunner/worker' => ['pretty_version' => '3.8.0']]); + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::null(ProtobufRuntime::detect($this->dir)); + } + /** * A loaded extension defines the protobuf classes itself, so the package is never used. */ From ec2b7802b158aabbe1bf7c0e5ea3d01f422562b9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 17:07:53 +0400 Subject: [PATCH 08/16] fix: tell which tools get-grpc-tools kept instead of reporting them ready Without interaction every existing binary is kept, so a stale protoc stayed in place while the command reported success. Assisted-By: Claude Opus 5.5 --- src/GetGrpcToolsCommand.php | 11 +++++++++++ tests/Acceptance/GrpcToolsTest.php | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php index da5c494..ad72615 100644 --- a/src/GetGrpcToolsCommand.php +++ b/src/GetGrpcToolsCommand.php @@ -99,9 +99,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); $failed = []; + $kept = []; foreach ($tools as $tool => $constraint) { $binary = $target . '/' . $tool . ($os === OperatingSystem::OS_WINDOWS ? '.exe' : ''); if (! ExistingBinary::mayInstall($binary, $tool, $io)) { + $kept[] = $tool; continue; } @@ -138,6 +140,15 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } + if ($kept !== []) { + $io->warning(\sprintf( + 'Kept the installed %s, which may not match the versions above. Confirm the overwrite to update.', + \implode(', ', $kept), + )); + + return self::SUCCESS; + } + $io->success('gRPC tools are ready in ' . $target); return self::SUCCESS; diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php index c1a75c1..5115723 100644 --- a/tests/Acceptance/GrpcToolsTest.php +++ b/tests/Acceptance/GrpcToolsTest.php @@ -120,7 +120,10 @@ public function keepsExistingBinariesWithoutInteraction(): void $result = $this->rr()->run(self::ARGS); Assert::same($result->exitCode, 0, (string) $result); - Assert::string($result->output())->contains('protoc binary file already exists!'); + Assert::string($result->output()) + ->contains('protoc binary file already exists!') + ->contains('Kept the installed protoc') + ->notContains('gRPC tools are ready'); Assert::same($this->dir->read('protoc'), "old protoc\n"); Assert::same(FakeGitHub::downloads(), ['buf-Linux-x86_64.tar.gz', 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz']); } From 401799590124d0d4a1dfc2d46ee456f30a96710a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 17:07:54 +0400 Subject: [PATCH 09/16] docs: state where get-grpc-tools installs and which commands take --filter Assisted-By: Claude Opus 5.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb6f447..8565c96 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur ### gRPC tools -Generating gRPC code with `buf generate` takes three binaries: [`buf`](https://buf.build), `protoc` (buf runs its built-in PHP generator) and `protoc-gen-php-grpc`. Download all of them into the project root: +Generating gRPC code with `buf generate` takes three binaries: [`buf`](https://buf.build), `protoc` (buf runs its built-in PHP generator) and `protoc-gen-php-grpc`. Download all of them into the current directory (`--location` picks another one): ```bash vendor/bin/rr get-grpc-tools @@ -99,7 +99,7 @@ The protoc version follows the protobuf runtime of the project, since the genera ### Common options -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`; `get-grpc-tools` applies the same to `--plugin` and takes `--buf`/`--protoc` as they are), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. `get-binary`, `get-grpc-tools` 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 `--stability` (`-s`), `--os` (`-o`) and `--arch` (`-a`) options. `get-binary`, `download-protoc-binary` and `versions` take the version constraint as `--filter` (`-f`), `get-grpc-tools` as `--buf`, `--protoc` and `--plugin`. 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`; `get-grpc-tools` applies the same to `--plugin` and takes `--buf`/`--protoc` as they are. `get-binary`, `get-grpc-tools` and `download-protoc-binary` also accept `--location` (`-l`, target directory, the current one by default) and 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. From 2f1e9be0e9c64062ce11e4686ef2f5a5bc478d63 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 18:01:42 +0400 Subject: [PATCH 10/16] fix: let DLoad keep a buf or protoc that already satisfies the version fix: read the project's installed.json instead of including installed.php fix: keep an installed protoc 21, which reports itself as 3.21 fix: ignore the running installation for a project that is not installed yet refactor: declare the --buf and --plugin defaults once The overwrite prompt defaulted to "no" without interaction, so a stale protoc stayed in place in CI; DLoad already compares the installed binary's --version with the constraint. Reading installed.json avoids running PHP from a directory found up the tree just to get a version string. Assisted-By: Claude Opus 5.5 --- README.md | 5 +- src/GetGrpcToolsCommand.php | 42 ++----- src/Internal/Environment/ProtobufRuntime.php | 77 +++++++------ tests/Acceptance/GrpcToolsTest.php | 42 +++++-- .../Acceptance/Server/releases-protobuf.json | 9 ++ tests/Unit/GetGrpcToolsCommandTest.php | 41 +++---- .../Environment/ProtobufRuntimeTest.php | 107 ++++++++++++++---- 7 files changed, 200 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 8565c96..7dd0705 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ vendor/bin/rr get-grpc-tools - protoc-gen-php-grpc: 3.* ``` -The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); without either, the latest protoc is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. +The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); without either, or when its version does not tell the protoc major (e.g. a branch), a protoc already in the location stays whatever its version, or else the latest one is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. A `buf` or `protoc` already in the location stays when its `--version` satisfies the constraint and is replaced otherwise, also when it does not run on this machine; `protoc-gen-php-grpc` is downloaded on every run. ## Commands @@ -92,7 +92,8 @@ The protoc version follows the protobuf runtime of the project, since the genera - `get-grpc-tools` - installs `buf`, `protoc` and `protoc-gen-php-grpc`, the binaries `buf generate` needs to generate gRPC code, see [gRPC tools](#grpc-tools). The versions default to buf 1.x, the protoc major the project's protobuf runtime supports and `protoc-gen-php-grpc` of the RoadRunner line `get-binary` installs; `--buf`, `--protoc` - and `--plugin` override them. Binaries already in the location are kept unless the overwrite is confirmed. + and `--plugin` override them. Without asking, it keeps a binary already in the location only when its version + satisfies the constraint, see [gRPC tools](#grpc-tools). - `download-protoc-binary` - **deprecated**, use `get-grpc-tools`. Installs only the `protoc-gen-php-grpc` plugin, not protoc itself. - `versions` - displays a list of available RoadRunner binary versions. diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php index ad72615..b1ee6dc 100644 --- a/src/GetGrpcToolsCommand.php +++ b/src/GetGrpcToolsCommand.php @@ -11,11 +11,9 @@ namespace Spiral\RoadRunner\Console; -use Spiral\RoadRunner\Console\Environment\OperatingSystem; use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; use Spiral\RoadRunner\Console\Internal\Environment\ProtobufRuntime; use Spiral\RoadRunner\Console\Internal\Environment\RoadRunnerVersion; -use Spiral\RoadRunner\Console\Internal\Installation\ExistingBinary; use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; @@ -87,9 +85,9 @@ public function execute(InputInterface $input, OutputInterface $output): int [$protoc, $derivedFrom] = $this->protocConstraint($input, $io); $tools = [ - 'buf' => $this->option($input, 'buf') ?? self::ANY_VERSION, + 'buf' => $this->option($input, 'buf'), 'protoc' => $protoc, - 'protoc-gen-php-grpc' => $this->option($input, 'plugin') ?? self::ANY_VERSION, + 'protoc-gen-php-grpc' => $this->option($input, 'plugin'), ]; $output->writeln(' Versions:'); @@ -99,14 +97,9 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); $failed = []; - $kept = []; foreach ($tools as $tool => $constraint) { - $binary = $target . '/' . $tool . ($os === OperatingSystem::OS_WINDOWS ? '.exe' : ''); - if (! ExistingBinary::mayInstall($binary, $tool, $io)) { - $kept[] = $tool; - continue; - } - + // Without force DLoad keeps a binary whose `--version` satisfies the constraint and replaces any other, + // so protoc-gen-php-grpc, which has no version command, is downloaded every time $code = $this->downloader->download( software: $tool, constraint: $constraint, @@ -114,7 +107,7 @@ public function execute(InputInterface $input, OutputInterface $output): int os: $os, arch: $arch, location: $target, - force: true, + force: false, output: $output, ); @@ -140,15 +133,6 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } - if ($kept !== []) { - $io->warning(\sprintf( - 'Kept the installed %s, which may not match the versions above. Confirm the overwrite to update.', - \implode(', ', $kept), - )); - - return self::SUCCESS; - } - $io->success('gRPC tools are ready in ' . $target); return self::SUCCESS; @@ -182,9 +166,9 @@ protected function configure(): void } /** - * The option value; an empty one falls back to the option default. - * - * @return non-empty-string|null Null when neither is set. + * @param 'buf'|'plugin'|'protoc' $name + * @return ($name is 'protoc' ? non-empty-string|null : non-empty-string) The option's default when it is + * empty or not given; protoc has none. */ private function option(InputInterface $input, string $name): ?string { @@ -194,10 +178,8 @@ private function option(InputInterface $input, string $name): ?string return $value; } - /** @var mixed $default */ - $default = $this->getDefinition()->getOption($name)->getDefault(); - - return \is_string($default) && $default !== '' ? $default : null; + /** @var non-empty-string|null */ + return $this->getDefinition()->getOption($name)->getDefault(); } /** @@ -213,7 +195,7 @@ private function protocConstraint(InputInterface $input, SymfonyStyle $io): arra $runtime = ($this->runtime)(); if ($runtime === null) { $io->warning(\sprintf( - 'Neither %s nor %s is installed: installing the latest protoc. Choose a version with --protoc=.', + 'Neither %s nor %s is installed: keeping an installed protoc or installing the latest. Choose a version with --protoc=.', ProtobufRuntime::PACKAGE, ProtobufRuntime::EXTENSION, )); @@ -224,7 +206,7 @@ private function protocConstraint(InputInterface $input, SymfonyStyle $io): arra $constraint = $runtime->protocConstraint(); if ($constraint === null) { $io->warning(\sprintf( - 'Can not tell the protoc version for %s %s: installing the latest protoc. Choose a version with --protoc=.', + 'Can not tell the protoc version for %s %s: keeping an installed protoc or installing the latest. Choose a version with --protoc=.', $runtime->name, $runtime->version, )); diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php index 65544a0..55634e5 100644 --- a/src/Internal/Environment/ProtobufRuntime.php +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -32,20 +32,16 @@ public function __construct( ) {} /** - * Looks for ext-protobuf, then for google/protobuf in the project, then in the Composer installation running this tool. + * Looks for ext-protobuf, then for google/protobuf in the project, or in the Composer installation running this tool + * when there is no project. * * The extension goes first: once loaded it defines the `Google\Protobuf` classes, so the package is never autoloaded. - * The project is read from its own `installed.php`: run from a global installation or a PHAR, - * {@see InstalledVersions} knows only the packages installed next to this tool, - * so it is asked only when the project has no `installed.php`. * * @param string $projectDir Root of the project, see {@see self::projectDir()}. */ public static function detect(string $projectDir): ?self { - return self::fromExtension() ?? (self::installedFile($projectDir) === null - ? self::fromInstalledVersions() - : self::fromProject($projectDir)); + return self::fromExtension() ?? self::fromPackage($projectDir); } /** @@ -71,26 +67,23 @@ public static function projectDir(string $workingDir): string return $workingDir; } - public static function fromProject(string $projectDir): ?self + /** + * Looks for google/protobuf in the project's `installed.json`, or in the Composer installation running this tool + * when there is no project manifest at all. + * + * Run from a global installation or a PHAR, {@see InstalledVersions} knows only the packages installed next + * to this tool, so a project with its own manifest is never matched against them, even before `composer install`. + * The file is decoded rather than `installed.php` included: the project may be any directory above the working one. + */ + public static function fromPackage(string $projectDir): ?self { - $file = self::installedFile($projectDir); - if ($file === null) { - return null; - } + $file = self::vendorDir($projectDir) . '/composer/installed.json'; - /** - * @psalm-suppress UnresolvableInclude - * @var mixed $installed - */ - $installed = (static fn(string $file): mixed => require $file)($file); - /** @var mixed $package */ - $package = \is_array($installed) && \is_array($installed['versions'] ?? null) - ? $installed['versions'][self::PACKAGE] ?? null - : null; - /** @var mixed $version */ - $version = \is_array($package) ? $package['pretty_version'] ?? null : null; - - return \is_string($version) ? new self(self::PACKAGE, $version) : null; + return match (true) { + \is_file($file) => self::fromInstalledFile($file), + \is_file(self::manifest($projectDir)) => null, + default => self::fromInstalledVersions(), + }; } public static function fromInstalledVersions(): ?self @@ -116,6 +109,7 @@ public static function fromExtension(): ?self * * Since protobuf 21 the PHP runtime minor is the protoc major (3.21.x and protoc 21.x, 4.33.x and 33.x, * 5.36.x and 36.x), and the runtime major is bumped on its own. Up to 3.20 both shared one version. + * The constraint also checks an installed protoc, and protoc 21.x reports itself as `libprotoc 3.21.x`. * * @return non-empty-string|null Null when the version is not a release number, e.g. a branch. */ @@ -128,6 +122,7 @@ public function protocConstraint(): ?string [, $major, $minor] = \array_map(\intval(...), $m); return match (true) { + $minor === 21 => '^21.0 || ~3.21.0', $minor > 20 => \sprintf('^%d.0', $minor), $major === 3 => \sprintf('~3.%d.0', $minor), default => null, @@ -135,13 +130,26 @@ public function protocConstraint(): ?string } /** - * @return string|null The project's `installed.php`, null when there is none. + * Reads both the Composer 2 `{"packages": [...]}` layout and the Composer 1 bare list of packages. */ - private static function installedFile(string $projectDir): ?string + private static function fromInstalledFile(string $file): ?self { - $file = self::vendorDir($projectDir) . '/composer/installed.php'; + /** @var mixed $installed */ + $installed = \json_decode((string) @\file_get_contents($file), true); + /** @var mixed $packages */ + $packages = \is_array($installed) ? $installed['packages'] ?? $installed : null; - return \is_file($file) ? $file : null; + /** @var mixed $package */ + foreach (\is_array($packages) ? $packages : [] as $package) { + if (\is_array($package) && ($package['name'] ?? null) === self::PACKAGE) { + /** @var mixed $version */ + $version = $package['version'] ?? null; + + return \is_string($version) ? new self(self::PACKAGE, $version) : null; + } + } + + return null; } /** @@ -152,10 +160,8 @@ private static function vendorDir(string $projectDir): string $vendor = Environment::get('COMPOSER_VENDOR_DIR'); if ($vendor === null || $vendor === '') { - $file = self::composerFile(); - $file = self::isAbsolute($file) ? $file : $projectDir . '/' . $file; /** @var mixed $composer */ - $composer = \json_decode((string) @\file_get_contents($file), true); + $composer = \json_decode((string) @\file_get_contents(self::manifest($projectDir)), true); /** @var mixed $vendor */ $vendor = \is_array($composer) ? $composer['config']['vendor-dir'] ?? null : null; $vendor = \is_string($vendor) && $vendor !== '' ? $vendor : 'vendor'; @@ -179,6 +185,13 @@ private static function composerFile(): string return $file === '' ? 'composer.json' : $file; } + private static function manifest(string $projectDir): string + { + $file = self::composerFile(); + + return self::isAbsolute($file) ? $file : $projectDir . '/' . $file; + } + private static function userDir(): ?string { $dir = Environment::get('HOME'); diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php index 5115723..4b8a56a 100644 --- a/tests/Acceptance/GrpcToolsTest.php +++ b/tests/Acceptance/GrpcToolsTest.php @@ -23,6 +23,7 @@ final class GrpcToolsTest #[DataSet(['v5.36.2', '^36.0', '36.2'])] #[DataSet(['4.33.1', '^33.0', '33.6'])] + #[DataSet(['v3.21.12', '^21.0 || ~3.21.0', '21.12'], 'first split version')] #[DataSet(['v3.20.3', '~3.20.0', '3.20.3'], 'shared version scheme')] public function installsProtocOfProjectRuntime(string $runtime, string $constraint, string $protoc): void { @@ -113,18 +114,40 @@ public function suggestsProtocOptionWhenNoReleaseMatchesRuntime(): void Assert::true($this->dir->has('protoc-gen-php-grpc')); } - public function keepsExistingBinariesWithoutInteraction(): void + public function replacesExistingBinaryWithoutVersion(): void { + $this->installProtobuf('4.33.1'); $this->dir->write('protoc', "old protoc\n"); $result = $this->rr()->run(self::ARGS); Assert::same($result->exitCode, 0, (string) $result); - Assert::string($result->output()) - ->contains('protoc binary file already exists!') - ->contains('Kept the installed protoc') - ->notContains('gRPC tools are ready'); - Assert::same($this->dir->read('protoc'), "old protoc\n"); + Assert::string($result->output())->notContains('already exists')->contains('gRPC tools are ready'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 33.6 linux-x86_64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Linux-x86_64.tar.gz', + 'protoc-33.6-linux-x86_64.zip', + 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz', + ]); + } + + /** + * protoc 21.x reports the version of the 3.21.x runtime. + */ + #[DataSet(['4.33.1', '33.6'])] + #[DataSet(['3.21.12', '3.21.12'], 'protoc 21')] + public function keepsExistingBinaryOfMatchingVersion(string $runtime, string $protoc): void + { + \PHP_OS_FAMILY === 'Windows' and throw new SkipTest('A shell script stands in for the linux protoc'); + $this->installProtobuf($runtime); + $script = "#!/bin/sh\necho libprotoc $protoc\n"; + $this->dir->write('protoc', $script); + \chmod($this->dir->file('protoc'), 0755); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same($this->dir->read('protoc'), $script); Assert::same(FakeGitHub::downloads(), ['buf-Linux-x86_64.tar.gz', 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz']); } @@ -140,9 +163,8 @@ private function installProtobuf(string $version): void { self::skipWithExtension(); \mkdir($this->dir->file('vendor/composer'), 0777, true); - $this->dir->write('vendor/composer/installed.php', \sprintf( - " [], 'versions' => ['google/protobuf' => ['pretty_version' => %s]]];", - \var_export($version, true), - )); + $this->dir->write('vendor/composer/installed.json', \json_encode([ + 'packages' => [['name' => 'google/protobuf', 'version' => $version]], + ])); } } diff --git a/tests/Acceptance/Server/releases-protobuf.json b/tests/Acceptance/Server/releases-protobuf.json index f178053..400c160 100644 --- a/tests/Acceptance/Server/releases-protobuf.json +++ b/tests/Acceptance/Server/releases-protobuf.json @@ -40,6 +40,15 @@ "protoc-33.6-win64.zip" ] }, + { + "tag_name": "v21.12", + "prerelease": false, + "assets": [ + "protobuf-all-21.12.tar.gz", + "protoc-21.12-linux-x86_64.zip", + "protoc-21.12-win64.zip" + ] + }, { "tag_name": "v3.20.3", "prerelease": false, diff --git a/tests/Unit/GetGrpcToolsCommandTest.php b/tests/Unit/GetGrpcToolsCommandTest.php index e4979f9..2b44396 100644 --- a/tests/Unit/GetGrpcToolsCommandTest.php +++ b/tests/Unit/GetGrpcToolsCommandTest.php @@ -69,7 +69,7 @@ public function installsAllToolsThroughDLoad(): void Assert::same($call->getOption('path'), $this->dir); Assert::same($call->getOption('os'), 'darwin'); Assert::same($call->getOption('arch'), 'arm64'); - Assert::true($call->getOption('force')); + Assert::false($call->getOption('force')); } Assert::string($tester->getDisplay()) ->contains('google/protobuf 5.36.2 -> protoc ^36.0') @@ -127,7 +127,7 @@ public function fallsBackToDefaultsForEmptyOptions(): void #[DataSet([null, 'Neither google/protobuf nor ext-protobuf is installed'], 'no runtime')] #[DataSet(['dev-main', 'Can not tell the protoc version for google/protobuf dev-main'], 'branch')] - public function installsLatestProtocWithWarning(?string $version, string $warning): void + public function acceptsAnyProtocWithWarning(?string $version, string $warning): void { $get = new DLoadGetSpy(); $runtime = $version === null ? null : new ProtobufRuntime(ProtobufRuntime::PACKAGE, $version); @@ -137,7 +137,9 @@ public function installsLatestProtocWithWarning(?string $version, string $warnin Assert::same($status, 0, $tester->getDisplay()); Assert::same($get->calls[1]->getArgument('software'), ['protoc:*@stable']); - Assert::string($this->display($tester))->contains($warning)->contains('--protoc='); + Assert::string($this->display($tester))->contains($warning) + ->contains('keeping an installed protoc or installing the latest') + ->contains('--protoc='); } public function suggestsProtocOptionWhenDerivedVersionFails(): void @@ -172,37 +174,26 @@ public function reportsFailureWithoutHintForExplicitVersion(): void #[DataSet(['linux', ''])] #[DataSet(['windows', '.exe'])] - public function keepsExistingBinariesUnlessConfirmed(string $os, string $extension): void + public function leavesExistingBinariesToDLoadWithoutAsking(string $os, string $extension): void { - \file_put_contents($this->dir . '/protoc' . $extension, 'old binary'); + foreach (['buf', 'protoc', 'protoc-gen-php-grpc'] as $tool) { + \file_put_contents($this->dir . '/' . $tool . $extension, 'old binary'); + } $get = new DLoadGetSpy(); $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '4.33.1')); - $status = $tester->execute($this->input(['--os' => $os]), ['interactive' => false]); + // An interactive run without answers: a prompt would fail it + $status = $tester->execute($this->input(['--os' => $os])); Assert::same($status, 0, $tester->getDisplay()); Assert::same( \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), - [['buf:^1@stable'], ['protoc-gen-php-grpc:3.*@stable']], + [['buf:^1@stable'], ['protoc:^33.0@stable'], ['protoc-gen-php-grpc:3.*@stable']], ); - Assert::same(\file_get_contents($this->dir . '/protoc' . $extension), 'old binary'); - Assert::string($tester->getDisplay()) - ->contains('protoc binary file already exists!') - ->contains('Skipping protoc installation...'); - } - - public function overwritesExistingBinaryWhenConfirmed(): void - { - \file_put_contents($this->dir . '/buf', 'old binary'); - $get = new DLoadGetSpy(); - $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '4.33.1')); - $tester->setInputs(['yes']); - - $status = $tester->execute($this->input(['--os' => 'linux'])); - - Assert::same($status, 0, $tester->getDisplay()); - Assert::count($get->calls, 3); - Assert::same(\file_get_contents($this->dir . '/buf'), 'new binary'); + foreach ($get->calls as $call) { + Assert::false($call->getOption('force')); + } + Assert::string($tester->getDisplay())->notContains('already exists')->contains('gRPC tools are ready'); } /** diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php index 0aae112..50a73b2 100644 --- a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -5,6 +5,8 @@ namespace Spiral\RoadRunner\Console\Tests\Unit\Internal\Environment; use Composer\InstalledVersions; +use Internal\DLoad\Module\Version\Constraint; +use Internal\DLoad\Module\Version\Version; use Spiral\RoadRunner\Console\Internal\Environment\ProtobufRuntime; use Spiral\RoadRunner\Console\Tests\Unit\Stub\TempDirectory; use Testo\Assert; @@ -26,7 +28,8 @@ final class ProtobufRuntimeTest */ #[DataSet(['3.20.3', '~3.20.0'], 'last shared version')] #[DataSet(['3.6.1', '~3.6.0'], 'old shared version')] - #[DataSet(['3.21.12', '^21.0'], 'first split version')] + #[DataSet(['3.21.12', '^21.0 || ~3.21.0'], 'first split version')] + #[DataSet(['3.22.0', '^22.0'], 'second split version')] #[DataSet(['3.25.5', '^25.0'], 'last 3.x runtime')] #[DataSet(['4.26.0', '^26.0'], 'first 4.x runtime')] #[DataSet(['4.33.1', '^33.0'])] @@ -45,11 +48,31 @@ public function derivesProtocConstraint(string $version, ?string $constraint): v Assert::same($runtime->protocConstraint(), $constraint); } + /** + * The constraint picks the release by its tag and keeps an installed protoc by its `--version`: + * protoc 21.x reports `libprotoc 3.21.x`, protoc 22 and later `libprotoc 22.0`. + */ + #[DataSet(['3.21.12', '21.12', true], 'protoc 21 release')] + #[DataSet(['3.21.12', '3.21.12', true], 'protoc 21 binary')] + #[DataSet(['3.21.12', '3.2.0', false], 'protoc 3.2')] + #[DataSet(['3.21.12', '22.0', false], 'protoc 22')] + #[DataSet(['3.22.0', '22.5', true], 'protoc 22 release and binary')] + #[DataSet(['3.20.3', '3.20.3', true], 'shared version')] + public function protocConstraintMatchesReleaseAndBinary(string $runtime, string $protoc, bool $satisfies): void + { + $constraint = (new ProtobufRuntime(ProtobufRuntime::PACKAGE, $runtime))->protocConstraint(); + + Assert::same( + Constraint::fromConstraintString((string) $constraint)->isSatisfiedBy(Version::fromVersionString($protoc)), + $satisfies, + ); + } + public function readsVersionFromProjectInstallation(): void { - $this->installed('vendor', ['google/protobuf' => ['pretty_version' => 'v5.36.2']]); + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => 'v5.36.2']]); - $runtime = ProtobufRuntime::fromProject($this->dir); + $runtime = ProtobufRuntime::fromPackage($this->dir); Assert::same($runtime?->name, 'google/protobuf'); Assert::same($runtime?->version, 'v5.36.2'); @@ -58,19 +81,19 @@ public function readsVersionFromProjectInstallation(): void public function honoursVendorDirOfProject(): void { \file_put_contents($this->dir . '/composer.json', '{"config": {"vendor-dir": "lib/vendor"}}'); - $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '4.26.0']]); - $this->installed('lib/vendor', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '4.26.0']]); + $this->installed('lib/vendor', [['name' => 'google/protobuf', 'version' => '4.33.1']]); - Assert::same(ProtobufRuntime::fromProject($this->dir)?->version, '4.33.1'); + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.33.1'); } public function honoursComposerVendorDirVariable(): void { - $this->installed('deps', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); $_SERVER['COMPOSER_VENDOR_DIR'] = 'deps'; try { - $runtime = ProtobufRuntime::fromProject($this->dir); + $runtime = ProtobufRuntime::fromPackage($this->dir); } finally { unset($_SERVER['COMPOSER_VENDOR_DIR']); } @@ -81,20 +104,20 @@ public function honoursComposerVendorDirVariable(): void public function honoursComposerVariable(): void { \file_put_contents($this->dir . '/app.json', '{"config": {"vendor-dir": "deps"}}'); - $this->installed('deps', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); $_SERVER['COMPOSER'] = 'app.json'; - Assert::same(ProtobufRuntime::fromProject($this->dir)?->version, '4.33.1'); + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.33.1'); } public function expandsUserDirInVendorDir(): void { \mkdir($this->dir . '/project'); \file_put_contents($this->dir . '/project/composer.json', '{"config": {"vendor-dir": "~/deps"}}'); - $this->installed('deps', ['google/protobuf' => ['pretty_version' => '4.33.1']]); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); $_SERVER['HOME'] = $this->dir; - Assert::same(ProtobufRuntime::fromProject($this->dir . '/project')?->version, '4.33.1'); + Assert::same(ProtobufRuntime::fromPackage($this->dir . '/project')?->version, '4.33.1'); } public function findsProjectUpFromWorkingDir(): void @@ -125,18 +148,29 @@ public function stopsLookingForProjectAtUserHome(): void Assert::same(ProtobufRuntime::projectDir($this->dir . '/home/proto'), $this->dir . '/home/proto'); } - #[DataSet([['roadrunner/worker' => ['pretty_version' => '3.8.0']]], 'other packages')] - #[DataSet([['google/protobuf' => ['replaced' => ['*']]]], 'replaced package')] - public function findsNothingInProjectWithoutPackage(array $versions): void + public function readsComposer1Installation(): void + { + \mkdir($this->dir . '/vendor/composer', 0777, true); + \file_put_contents( + $this->dir . '/vendor/composer/installed.json', + \json_encode([['name' => 'google/protobuf', 'version' => 'v3.20.3']]), + ); + + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, 'v3.20.3'); + } + + #[DataSet([[['name' => 'roadrunner/worker', 'version' => '3.8.0']]], 'other packages')] + #[DataSet([[['name' => 'acme/protobuf', 'version' => '1.0.0', 'replace' => ['google/protobuf' => '*']]]], 'replaced package')] + public function findsNothingInProjectWithoutPackage(array $packages): void { - $this->installed('vendor', $versions); + $this->installed('vendor', $packages); - Assert::null(ProtobufRuntime::fromProject($this->dir)); + Assert::null(ProtobufRuntime::fromPackage($this->dir)); } public function findsNothingInProjectWithoutVendor(): void { - Assert::null(ProtobufRuntime::fromProject($this->dir)); + Assert::null(ProtobufRuntime::fromPackage($this->dir)); } public function readsVersionFromRunningInstallation(): void @@ -158,7 +192,7 @@ public function findsNothingInRunningInstallationWithoutPackage(): void public function prefersProjectOverRunningInstallation(): void { - $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '5.36.2']]); + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '5.36.2'); @@ -169,6 +203,31 @@ public function fallsBackToRunningInstallation(): void InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '4.26.0'); + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.26.0'); + } + + /** + * The package next to this tool is not the runtime of a project that is not installed yet. + */ + #[DataSet(['composer.json'], 'composer.json')] + #[DataSet(['app.json'], 'COMPOSER variable')] + public function ignoresRunningInstallationWhenProjectIsNotInstalled(string $manifest): void + { + \file_put_contents($this->dir . '/' . $manifest, '{}'); + $manifest === 'composer.json' or $_SERVER['COMPOSER'] = $manifest; + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::null(ProtobufRuntime::fromPackage($this->dir)); + } + + public function ignoresRunningInstallationWhenAbsoluteManifestIsNotInstalled(): void + { + \mkdir($this->dir . '/project'); + \file_put_contents($this->dir . '/app.json', '{}'); + $_SERVER['COMPOSER'] = $this->dir . '/app.json'; + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::null(ProtobufRuntime::fromPackage($this->dir . '/project')); } /** @@ -177,7 +236,7 @@ public function fallsBackToRunningInstallation(): void public function ignoresRunningInstallationWhenProjectLacksPackage(): void { \extension_loaded('protobuf') and throw new SkipTest('ext-protobuf is loaded'); - $this->installed('vendor', ['roadrunner/worker' => ['pretty_version' => '3.8.0']]); + $this->installed('vendor', [['name' => 'roadrunner/worker', 'version' => '3.8.0']]); InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); Assert::null(ProtobufRuntime::detect($this->dir)); @@ -188,7 +247,7 @@ public function ignoresRunningInstallationWhenProjectLacksPackage(): void */ public function prefersExtensionOverPackage(): void { - $this->installed('vendor', ['google/protobuf' => ['pretty_version' => '5.36.2']]); + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); $runtime = ProtobufRuntime::detect($this->dir); @@ -225,12 +284,12 @@ protected function restore(): void (new \ReflectionClass(InstalledVersions::class))->setStaticPropertyValue('canGetVendors', $this->canGetVendors); } - private function installed(string $vendor, array $versions): void + private function installed(string $vendor, array $packages): void { \mkdir($this->dir . '/' . $vendor . '/composer', 0777, true); \file_put_contents( - $this->dir . '/' . $vendor . '/composer/installed.php', - ' [], 'versions' => $versions], true) . ';', + $this->dir . '/' . $vendor . '/composer/installed.json', + \json_encode(['packages' => $packages, 'dev' => true, 'dev-package-names' => []]), ); } } From 593e40fabf48e7de0aa869fb4fda784a406b0c08 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 18:01:46 +0400 Subject: [PATCH 11/16] fix: keep the get alias when get-binary is registered under another name Assisted-By: Claude Opus 5.5 --- src/GetBinaryCommand.php | 2 +- tests/Unit/GetBinaryCommandTest.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/GetBinaryCommand.php b/src/GetBinaryCommand.php index 69da690..ea0c226 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -45,7 +45,7 @@ public function __construct(?string $name = null, ?DLoadDownloader $downloader = $this->downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'get-binary'); // Not left to Symfony's abbreviation lookup: `get` also abbreviates get-grpc-tools - $name === null and $this->setAliases(['get']); + $this->getName() === 'get' or $this->setAliases(['get']); $this->os = new OperatingSystemOption($this); $this->arch = new ArchitectureOption($this); diff --git a/tests/Unit/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php index 079f72e..f737a8a 100644 --- a/tests/Unit/GetBinaryCommandTest.php +++ b/tests/Unit/GetBinaryCommandTest.php @@ -52,6 +52,12 @@ public function describesItself(): void Assert::same($command->getDescription(), 'Install or update RoadRunner binary'); } + public function keepsGetAliasUnderAnotherName(): void + { + Assert::same((new GetBinaryCommand('install'))->getAliases(), ['get']); + Assert::same((new GetBinaryCommand('get'))->getAliases(), []); + } + public function keepsOptions(): void { Assert::array(\array_keys((new GetBinaryCommand())->getDefinition()->getOptions())) From 1e283ab2f6534db8387eaf1760c984d0e7b8ae77 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 23:53:27 +0400 Subject: [PATCH 12/16] feat: warn when ext-protobuf and the project need different protoc majors The extension is the runtime of the PHP running rr, while the project may run on a PHP without it. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/GetGrpcToolsCommand.php | 31 ++++++++++--- src/Internal/Environment/ProtobufRuntime.php | 6 ++- tests/Unit/GetGrpcToolsCommandTest.php | 46 +++++++++++++++++-- .../Environment/ProtobufRuntimeTest.php | 21 +++++++-- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7dd0705..61cfbe6 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ vendor/bin/rr get-grpc-tools - protoc-gen-php-grpc: 3.* ``` -The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); without either, or when its version does not tell the protoc major (e.g. a branch), a protoc already in the location stays whatever its version, or else the latest one is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. A `buf` or `protoc` already in the location stays when its `--version` satisfies the constraint and is replaced otherwise, also when it does not run on this machine; `protoc-gen-php-grpc` is downloaded on every run. +The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded in the PHP running `rr`, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); when the two need different protoc majors, a warning names both, as the project may run on a PHP without the extension; without either, or when its version does not tell the protoc major (e.g. a branch), a protoc already in the location stays whatever its version, or else the latest one is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. A `buf` or `protoc` already in the location stays when its `--version` satisfies the constraint and is replaced otherwise, also when it does not run on this machine; `protoc-gen-php-grpc` is downloaded on every run. ## Commands diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php index b1ee6dc..425a6f2 100644 --- a/src/GetGrpcToolsCommand.php +++ b/src/GetGrpcToolsCommand.php @@ -40,17 +40,18 @@ final class GetGrpcToolsCommand extends Command private InstallationLocationOption $location; private DLoadDownloader $downloader; - /** @var \Closure(): ?ProtobufRuntime */ - private \Closure $runtime; + /** @var \Closure(): list */ + private \Closure $runtimes; /** - * @param (\Closure(): ?ProtobufRuntime)|null $runtime Finds the protobuf runtime of the project; - * {@see ProtobufRuntime::detect()} in the project found up from the working directory by default. + * @param (\Closure(): list)|null $runtimes Finds the protobuf runtimes of the project, the one + * in use first; {@see ProtobufRuntime::detect()} in the project found up from the working directory + * by default. */ - public function __construct(?string $name = null, ?DLoadDownloader $downloader = null, ?\Closure $runtime = null) + public function __construct(?string $name = null, ?DLoadDownloader $downloader = null, ?\Closure $runtimes = null) { $this->downloader = $downloader ?? new DLoadDownloader(); - $this->runtime = $runtime ?? static fn(): ?ProtobufRuntime => ProtobufRuntime::detect(ProtobufRuntime::projectDir(\getcwd() ?: '.')); + $this->runtimes = $runtimes ?? static fn(): array => ProtobufRuntime::detect(ProtobufRuntime::projectDir(\getcwd() ?: '.')); parent::__construct($name ?? 'get-grpc-tools'); $this->os = new OperatingSystemOption($this); @@ -192,7 +193,8 @@ private function protocConstraint(InputInterface $input, SymfonyStyle $io): arra return [$option, null]; } - $runtime = ($this->runtime)(); + $runtimes = ($this->runtimes)(); + $runtime = $runtimes[0] ?? null; if ($runtime === null) { $io->warning(\sprintf( 'Neither %s nor %s is installed: keeping an installed protoc or installing the latest. Choose a version with --protoc=.', @@ -217,6 +219,21 @@ private function protocConstraint(InputInterface $input, SymfonyStyle $io): arra $io->writeln(\sprintf(' %s %s -> protoc %s', $runtime->name, $runtime->version, $constraint)); $io->newLine(); + foreach (\array_slice($runtimes, 1) as $other) { + $otherConstraint = $other->protocConstraint(); + if ($otherConstraint !== null && $otherConstraint !== $constraint) { + $io->warning(\sprintf( + '%s %s (protoc %s) is used, while the project has %s %s (protoc %s); choose with --protoc=.', + $runtime->name, + $runtime->version, + $constraint, + $other->name, + $other->version, + $otherConstraint, + )); + } + } + return [$constraint, $runtime]; } } diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php index 55634e5..c66cf46 100644 --- a/src/Internal/Environment/ProtobufRuntime.php +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -36,12 +36,14 @@ public function __construct( * when there is no project. * * The extension goes first: once loaded it defines the `Google\Protobuf` classes, so the package is never autoloaded. + * The package is still returned after it, for the project may run on a PHP without the extension. * * @param string $projectDir Root of the project, see {@see self::projectDir()}. + * @return list Every runtime found, the one in use first. */ - public static function detect(string $projectDir): ?self + public static function detect(string $projectDir): array { - return self::fromExtension() ?? self::fromPackage($projectDir); + return \array_values(\array_filter([self::fromExtension(), self::fromPackage($projectDir)])); } /** diff --git a/tests/Unit/GetGrpcToolsCommandTest.php b/tests/Unit/GetGrpcToolsCommandTest.php index 2b44396..2bd2129 100644 --- a/tests/Unit/GetGrpcToolsCommandTest.php +++ b/tests/Unit/GetGrpcToolsCommandTest.php @@ -88,6 +88,39 @@ public function derivesProtocFromExtension(): void Assert::string($tester->getDisplay())->contains('ext-protobuf 4.33.1 -> protoc ^33.0'); } + public function warnsWhenProjectPackageNeedsAnotherProtoc(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, [ + new ProtobufRuntime(ProtobufRuntime::EXTENSION, '4.29.0'), + new ProtobufRuntime(ProtobufRuntime::PACKAGE, 'v5.36.2'), + ]); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same($get->calls[1]->getArgument('software'), ['protoc:^29.0@stable']); + Assert::string($this->display($tester))->contains( + 'ext-protobuf 4.29.0 (protoc ^29.0) is used, while the project has google/protobuf v5.36.2 (protoc ^36.0); ' + . 'choose with --protoc=.', + ); + } + + public function keepsQuietWhenRuntimesNeedSameProtoc(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, [ + new ProtobufRuntime(ProtobufRuntime::EXTENSION, '4.33.1'), + new ProtobufRuntime(ProtobufRuntime::PACKAGE, 'v4.33.4'), + ]); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same($get->calls[1]->getArgument('software'), ['protoc:^33.0@stable']); + Assert::string($tester->getDisplay())->notContains('[WARNING]')->notContains('is used, while'); + } + public function takesVersionsFromOptions(): void { $get = new DLoadGetSpy(); @@ -210,11 +243,18 @@ private function input(array $input = []): array ]; } - private function tester(DLoadGetSpy $get, ProtobufRuntime|\Closure|null $runtime): CommandTester + /** + * @param ProtobufRuntime|list|\Closure|null $runtimes + */ + private function tester(DLoadGetSpy $get, ProtobufRuntime|array|\Closure|null $runtimes): CommandTester { - $detect = $runtime instanceof \Closure ? $runtime : static fn(): ?ProtobufRuntime => $runtime; + $detect = match (true) { + $runtimes instanceof \Closure => $runtimes, + $runtimes instanceof ProtobufRuntime => static fn(): array => [$runtimes], + default => static fn(): array => $runtimes ?? [], + }; - return new CommandTester(new GetGrpcToolsCommand(downloader: new DLoadDownloader($get), runtime: $detect)); + return new CommandTester(new GetGrpcToolsCommand(downloader: new DLoadDownloader($get), runtimes: $detect)); } /** diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php index 50a73b2..4c233aa 100644 --- a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -195,14 +195,14 @@ public function prefersProjectOverRunningInstallation(): void $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); - Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '5.36.2'); + Assert::same((ProtobufRuntime::detect($this->dir)[0] ?? null)?->version, \phpversion('protobuf') ?: '5.36.2'); } public function fallsBackToRunningInstallation(): void { InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); - Assert::same(ProtobufRuntime::detect($this->dir)?->version, \phpversion('protobuf') ?: '4.26.0'); + Assert::same((ProtobufRuntime::detect($this->dir)[0] ?? null)?->version, \phpversion('protobuf') ?: '4.26.0'); Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.26.0'); } @@ -239,7 +239,7 @@ public function ignoresRunningInstallationWhenProjectLacksPackage(): void $this->installed('vendor', [['name' => 'roadrunner/worker', 'version' => '3.8.0']]); InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); - Assert::null(ProtobufRuntime::detect($this->dir)); + Assert::same(ProtobufRuntime::detect($this->dir), []); } /** @@ -249,11 +249,24 @@ public function prefersExtensionOverPackage(): void { $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); - $runtime = ProtobufRuntime::detect($this->dir); + $runtime = ProtobufRuntime::detect($this->dir)[0] ?? null; Assert::same($runtime?->name, \extension_loaded('protobuf') ? 'ext-protobuf' : 'google/protobuf'); } + public function detectsBothRuntimes(): void + { + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); + + $runtimes = ProtobufRuntime::detect($this->dir); + + Assert::same( + \array_map(static fn(ProtobufRuntime $runtime): string => $runtime->name, $runtimes), + \extension_loaded('protobuf') ? ['ext-protobuf', 'google/protobuf'] : ['google/protobuf'], + ); + Assert::same(\end($runtimes)->version, '5.36.2'); + } + public function readsExtensionVersion(): void { $version = \phpversion('protobuf'); From 6dcc87ee60cbc4d0511f0e072660b501b3c36907 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 23:53:42 +0400 Subject: [PATCH 13/16] fix: match the buf binary on GitHub Enterprise as DLoad's own registry does Assisted-By: Claude Opus 5.5 --- src/Internal/Downloader/DLoadDownloader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Internal/Downloader/DLoadDownloader.php b/src/Internal/Downloader/DLoadDownloader.php index 8ec8392..1cec9a5 100644 --- a/src/Internal/Downloader/DLoadDownloader.php +++ b/src/Internal/Downloader/DLoadDownloader.php @@ -81,7 +81,7 @@ final class DLoadDownloader - + From ef0735668523307af460d21ddc24e24bb9869092 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 29 Oct 2026 23:53:42 +0400 Subject: [PATCH 14/16] feat: print a deprecation warning when download-protoc-binary runs Assisted-By: Claude Opus 5.5 --- src/DownloadProtocBinaryCommand.php | 1 + tests/Unit/DownloadProtocBinaryCommandTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php index ea50630..65f46a8 100644 --- a/src/DownloadProtocBinaryCommand.php +++ b/src/DownloadProtocBinaryCommand.php @@ -53,6 +53,7 @@ public function getDescription(): string public function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + $io->warning('download-protoc-binary is deprecated: use get-grpc-tools, which also installs protoc and buf.'); $target = $this->location->get($input, $io); $os = $this->os->get($input, $io); diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php index e414238..76f97e3 100644 --- a/tests/Unit/DownloadProtocBinaryCommandTest.php +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -65,6 +65,7 @@ public function installsBinaryThroughDLoad(int $exitCode): void Assert::same($get->calls[0]->getOption('os'), 'darwin'); Assert::same($get->calls[0]->getOption('arch'), 'arm64'); Assert::string($tester->getDisplay()) + ->contains('download-protoc-binary is deprecated: use get-grpc-tools') ->contains('Version: 2025.1.*') ->contains('Operating System: darwin') ->contains('Architecture: arm64'); From a8c3edbeb6b55f386ac0b9b39939bc53ed73f284 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 30 Oct 2026 16:05:51 +0400 Subject: [PATCH 15/16] fix: expand a leading environment variable in vendor-dir as Composer does test: isolate $_ENV as well as $_SERVER in the ProtobufRuntime tests Composer expands `$VAR` and `%VAR%` but not `${VAR}`; with an absolute COMPOSER it still resolves vendor-dir against the working directory, which the tests now pin. Assisted-By: Claude Opus 5.5 --- src/Internal/Environment/ProtobufRuntime.php | 31 ++++++++-- .../Environment/ProtobufRuntimeTest.php | 61 ++++++++++++++++++- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php index c66cf46..924355e 100644 --- a/src/Internal/Environment/ProtobufRuntime.php +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -50,6 +50,9 @@ public static function detect(string $projectDir): array * Finds the project root as Composer does: the nearest directory up from the working one with a `composer.json` * (or the file named by `COMPOSER`), looking neither in the user home nor in the filesystem root. * + * An absolute `COMPOSER` keeps the working directory: Composer resolves `vendor-dir` against it, + * not against the manifest's directory. + * * @return string The working directory itself when none has the file. */ public static function projectDir(string $workingDir): string @@ -155,7 +158,7 @@ private static function fromInstalledFile(string $file): ?self } /** - * Resolves `vendor-dir` as Composer does, expanding a leading `~/`; `{$home}` (the Composer home) is left as is. + * Resolves `vendor-dir` as Composer does; `{$home}` (the Composer home) is left as is. */ private static function vendorDir(string $projectDir): string { @@ -169,14 +172,32 @@ private static function vendorDir(string $projectDir): string $vendor = \is_string($vendor) && $vendor !== '' ? $vendor : 'vendor'; } - $user = self::userDir(); - if ($user !== null && \preg_match('#^~[/\\\\]#', $vendor) === 1) { - $vendor = $user . \substr($vendor, 1); - } + $vendor = self::expandPath($vendor); return self::isAbsolute($vendor) ? $vendor : $projectDir . '/' . $vendor; } + /** + * Expands a leading `~/`, `$VAR` or `%VAR%` (on any OS) as Composer's `Platform::expandPath()` does; + * `${VAR}` is left as is, and an unset variable expands to an empty string. + */ + private static function expandPath(string $path): string + { + if (\preg_match('#^~[/\\\\]#', $path) === 1) { + $user = self::userDir(); + + return $user === null ? $path : $user . \substr($path, 1); + } + + if (\preg_match('#^(\$|(%))(\w++)(?(2)%)(.*)#', $path, $m) !== 1) { + return $path; + } + + $value = $m[3] === 'HOME' && \PHP_OS_FAMILY === 'Windows' ? self::userDir() : Environment::get($m[3]); + + return ($value ?? '') . $m[4]; + } + /** * @return non-empty-string The manifest named by `COMPOSER`, relative to the project unless absolute. */ diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php index 4c233aa..ca147bf 100644 --- a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -22,6 +22,7 @@ final class ProtobufRuntimeTest private string $dir; private ?bool $canGetVendors = null; private array $server = []; + private array $env = []; /** * The PHP runtime minor is the protoc major since protobuf 21; up to 3.20 both shared one version. @@ -120,6 +121,58 @@ public function expandsUserDirInVendorDir(): void Assert::same(ProtobufRuntime::fromPackage($this->dir . '/project')?->version, '4.33.1'); } + #[DataSet(['$PROTOBUF_TEST_DEPS/vendor'], 'dollar')] + #[DataSet(['%PROTOBUF_TEST_DEPS%/vendor'], 'percent')] + public function expandsVariableInVendorDir(string $vendor): void + { + \mkdir($this->dir . '/project'); + \file_put_contents($this->dir . '/project/composer.json', \json_encode(['config' => ['vendor-dir' => $vendor]])); + $this->installed('deps/vendor', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $_SERVER['PROTOBUF_TEST_DEPS'] = $this->dir . '/deps'; + + Assert::same(ProtobufRuntime::fromPackage($this->dir . '/project')?->version, '4.33.1'); + } + + public function expandsVariableInComposerVendorDirVariable(): void + { + \mkdir($this->dir . '/project'); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $_SERVER['PROTOBUF_TEST_DEPS'] = $this->dir . '/deps'; + $_SERVER['COMPOSER_VENDOR_DIR'] = '$PROTOBUF_TEST_DEPS'; + + Assert::same(ProtobufRuntime::fromPackage($this->dir . '/project')?->version, '4.33.1'); + } + + /** + * Composer expands only `$VAR` and `%VAR%`. + */ + public function keepsBracedVariableInVendorDir(): void + { + \file_put_contents($this->dir . '/composer.json', '{"config": {"vendor-dir": "${PROTOBUF_TEST_DEPS}"}}'); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $this->installed('${PROTOBUF_TEST_DEPS}', [['name' => 'google/protobuf', 'version' => '4.26.0']]); + $_SERVER['PROTOBUF_TEST_DEPS'] = $this->dir . '/deps'; + + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.26.0'); + } + + /** + * Composer resolves `vendor-dir` against the working directory, not against the directory of an absolute manifest. + */ + public function resolvesAbsoluteManifestVendorDirAgainstWorkingDir(): void + { + \mkdir($this->dir . '/app/proto', 0777, true); + \file_put_contents($this->dir . '/app/app.json', '{"config": {"vendor-dir": "deps"}}'); + $this->installed('app/deps', [['name' => 'google/protobuf', 'version' => '4.26.0']]); + $this->installed('app/proto/deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $_SERVER['COMPOSER'] = $this->dir . '/app/app.json'; + + $projectDir = ProtobufRuntime::projectDir($this->dir . '/app/proto'); + + Assert::same($projectDir, $this->dir . '/app/proto'); + Assert::same(ProtobufRuntime::fromPackage($projectDir)?->version, '4.33.1'); + } + public function findsProjectUpFromWorkingDir(): void { \file_put_contents($this->dir . '/composer.json', '{}'); @@ -281,7 +334,12 @@ protected function isolate(): void { $this->dir = TempDirectory::create(); $this->server = $_SERVER; - unset($_SERVER['COMPOSER'], $_ENV['COMPOSER']); + $this->env = $_ENV; + // `Environment::get()` reads `$_ENV` first, so an inherited value there would override a fixture in `$_SERVER`. + foreach (['COMPOSER', 'COMPOSER_VENDOR_DIR', 'HOME', 'USERPROFILE', 'PROTOBUF_TEST_DEPS'] as $key) { + unset($_ENV[$key]); + } + unset($_SERVER['COMPOSER'], $_SERVER['COMPOSER_VENDOR_DIR']); $ref = new \ReflectionClass(InstalledVersions::class); $this->canGetVendors = $ref->getStaticPropertyValue('canGetVendors'); $ref->setStaticPropertyValue('canGetVendors', false); @@ -292,6 +350,7 @@ protected function isolate(): void protected function restore(): void { $_SERVER = $this->server; + $_ENV = $this->env; TempDirectory::remove($this->dir); InstalledVersions::reload(null); (new \ReflectionClass(InstalledVersions::class))->setStaticPropertyValue('canGetVendors', $this->canGetVendors); From 1c2fa3e06730cd938ca64370fbd04fcb752db940 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 30 Oct 2026 16:05:56 +0400 Subject: [PATCH 16/16] docs: tell when get-grpc-tools keeps a binary and that protoc comes without include/ Assisted-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 61cfbe6..da4fdb8 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ vendor/bin/rr get-grpc-tools - protoc-gen-php-grpc: 3.* ``` -The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded in the PHP running `rr`, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); when the two need different protoc majors, a warning names both, as the project may run on a PHP without the extension; without either, or when its version does not tell the protoc major (e.g. a branch), a protoc already in the location stays whatever its version, or else the latest one is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. A `buf` or `protoc` already in the location stays when its `--version` satisfies the constraint and is replaced otherwise, also when it does not run on this machine; `protoc-gen-php-grpc` is downloaded on every run. +The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded in the PHP running `rr`, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`, read from its `vendor/composer/installed.json`; a project that is not installed yet counts as having none); when the two need different protoc majors, a warning names both, as the project may run on a PHP without the extension; without either, or when its version does not tell the protoc major (e.g. a branch), a protoc already in the location stays whatever its version, or else the latest one is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. A `buf` or `protoc` already in the location stays only when it runs and the version it reports with `--version` satisfies the constraint; otherwise it is replaced. `protoc-gen-php-grpc` is downloaded on every run. Only the `protoc` executable is installed, without the `include/` directory of its release: `buf` supplies the well-known types (`google/protobuf/timestamp.proto` and the like) itself, while a direct `protoc` call that imports them needs `-I` pointing to them. ## Commands