diff --git a/README.md b/README.md
index 1e0ac57..61cfbe6 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 current directory (`--location` picks another one):
+
+```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 `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
- `get-binary` (or `get`) - allows to install the latest version of the RoadRunner compatible with
@@ -70,19 +89,24 @@ 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. 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.
### 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 `--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.
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/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",
diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php
index 51300b7..65f46a8 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;
/**
@@ -46,13 +46,14 @@ 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]
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);
@@ -67,7 +68,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 +85,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 726e2c9..ea0c226 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;
@@ -43,6 +44,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
+ $this->getName() === 'get' or $this->setAliases(['get']);
$this->os = new OperatingSystemOption($this);
$this->arch = new ArchitectureOption($this);
@@ -80,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),
@@ -180,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
new file mode 100644
index 0000000..425a6f2
--- /dev/null
+++ b/src/GetGrpcToolsCommand.php
@@ -0,0 +1,239 @@
+ */
+ private \Closure $runtimes;
+
+ /**
+ * @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 $runtimes = null)
+ {
+ $this->downloader = $downloader ?? new DLoadDownloader();
+ $this->runtimes = $runtimes ?? static fn(): array => ProtobufRuntime::detect(ProtobufRuntime::projectDir(\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' => $this->option($input, 'buf'),
+ 'protoc' => $protoc,
+ 'protoc-gen-php-grpc' => $this->option($input, 'plugin'),
+ ];
+
+ $output->writeln(' Versions:');
+ foreach ($tools as $tool => $constraint) {
+ $output->writeln(\sprintf(' - %-20s %s', $tool . ':', $constraint));
+ }
+ $output->writeln('');
+
+ $failed = [];
+ foreach ($tools as $tool => $constraint) {
+ // 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,
+ stability: $stability,
+ os: $os,
+ arch: $arch,
+ location: $target,
+ force: false,
+ 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,
+ );
+ }
+
+ /**
+ * @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
+ {
+ /** @var mixed $value */
+ $value = $input->getOption($name);
+ if (\is_string($value) && $value !== '') {
+ return $value;
+ }
+
+ /** @var non-empty-string|null */
+ return $this->getDefinition()->getOption($name)->getDefault();
+ }
+
+ /**
+ * @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 = $this->option($input, 'protoc');
+ if ($option !== null) {
+ return [$option, null];
+ }
+
+ $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=.',
+ 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: keeping an installed protoc or installing the latest. 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();
+
+ 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/Downloader/DLoadDownloader.php b/src/Internal/Downloader/DLoadDownloader.php
index 78c5741..1cec9a5 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..c66cf46
--- /dev/null
+++ b/src/Internal/Environment/ProtobufRuntime.php
@@ -0,0 +1,209 @@
+ Every runtime found, the one in use first.
+ */
+ public static function detect(string $projectDir): array
+ {
+ return \array_values(\array_filter([self::fromExtension(), self::fromPackage($projectDir)]));
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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::vendorDir($projectDir) . '/composer/installed.json';
+
+ return match (true) {
+ \is_file($file) => self::fromInstalledFile($file),
+ \is_file(self::manifest($projectDir)) => null,
+ default => self::fromInstalledVersions(),
+ };
+ }
+
+ 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.
+ * 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.
+ */
+ 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 === 21 => '^21.0 || ~3.21.0',
+ $minor > 20 => \sprintf('^%d.0', $minor),
+ $major === 3 => \sprintf('~3.%d.0', $minor),
+ default => null,
+ };
+ }
+
+ /**
+ * Reads both the Composer 2 `{"packages": [...]}` layout and the Composer 1 bare list of packages.
+ */
+ private static function fromInstalledFile(string $file): ?self
+ {
+ /** @var mixed $installed */
+ $installed = \json_decode((string) @\file_get_contents($file), true);
+ /** @var mixed $packages */
+ $packages = \is_array($installed) ? $installed['packages'] ?? $installed : 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;
+ }
+
+ /**
+ * 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 === '') {
+ /** @var mixed $composer */
+ $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';
+ }
+
+ $user = self::userDir();
+ if ($user !== null && \preg_match('#^~[/\\\\]#', $vendor) === 1) {
+ $vendor = $user . \substr($vendor, 1);
+ }
+
+ return self::isAbsolute($vendor) ? $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 manifest(string $projectDir): string
+ {
+ $file = self::composerFile();
+
+ return self::isAbsolute($file) ? $file : $projectDir . '/' . $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/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;
+ }
+}
diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php
new file mode 100644
index 0000000..4b8a56a
--- /dev/null
+++ b/tests/Acceptance/GrpcToolsTest.php
@@ -0,0 +1,170 @@
+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');
+ 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
+ {
+ self::skipWithExtension();
+
+ $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 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())->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']);
+ }
+
+ /**
+ * 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.json', \json_encode([
+ 'packages' => [['name' => 'google/protobuf', 'version' => $version]],
+ ]));
+ }
+}
diff --git a/tests/Acceptance/Server/releases-buf.json b/tests/Acceptance/Server/releases-buf.json
new file mode 100644
index 0000000..b86f6cb
--- /dev/null
+++ b/tests/Acceptance/Server/releases-buf.json
@@ -0,0 +1,194 @@
+[
+ {
+ "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-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-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-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-protobuf.json b/tests/Acceptance/Server/releases-protobuf.json
new file mode 100644
index 0000000..400c160
--- /dev/null
+++ b/tests/Acceptance/Server/releases-protobuf.json
@@ -0,0 +1,61 @@
+[
+ {
+ "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": "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,
+ "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..7c2f868 100644
--- a/tests/Acceptance/Server/releases.json
+++ b/tests/Acceptance/Server/releases.json
@@ -23,8 +23,10 @@
"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",
"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..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+\(7\)\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);
}
diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php
index afb1dc0..76f97e3 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
@@ -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');
diff --git a/tests/Unit/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php
index 28d22a5..f737a8a 100644
--- a/tests/Unit/GetBinaryCommandTest.php
+++ b/tests/Unit/GetBinaryCommandTest.php
@@ -48,9 +48,16 @@ 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');
}
+ 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()))
diff --git a/tests/Unit/GetGrpcToolsCommandTest.php b/tests/Unit/GetGrpcToolsCommandTest.php
new file mode 100644
index 0000000..2bd2129
--- /dev/null
+++ b/tests/Unit/GetGrpcToolsCommandTest.php
@@ -0,0 +1,267 @@
+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::false($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 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();
+ $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']],
+ );
+ }
+
+ 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 acceptsAnyProtocWithWarning(?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('keeping an installed protoc or installing the latest')
+ ->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 leavesExistingBinariesToDLoadWithoutAsking(string $os, string $extension): void
+ {
+ 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'));
+
+ // 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:^33.0@stable'], ['protoc-gen-php-grpc:3.*@stable']],
+ );
+ foreach ($get->calls as $call) {
+ Assert::false($call->getOption('force'));
+ }
+ Assert::string($tester->getDisplay())->notContains('already exists')->contains('gRPC tools are ready');
+ }
+
+ /**
+ * @param array $input
+ * @return array
+ */
+ private function input(array $input = []): array
+ {
+ return $input + [
+ '--location' => $this->dir,
+ '--os' => 'darwin',
+ '--arch' => 'arm64',
+ '--plugin' => '3.*',
+ ];
+ }
+
+ /**
+ * @param ProtobufRuntime|list|\Closure|null $runtimes
+ */
+ private function tester(DLoadGetSpy $get, ProtobufRuntime|array|\Closure|null $runtimes): CommandTester
+ {
+ $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), runtimes: $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..4c233aa
--- /dev/null
+++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php
@@ -0,0 +1,308 @@
+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', [['name' => 'google/protobuf', 'version' => 'v5.36.2']]);
+
+ $runtime = ProtobufRuntime::fromPackage($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', [['name' => 'google/protobuf', 'version' => '4.26.0']]);
+ $this->installed('lib/vendor', [['name' => 'google/protobuf', 'version' => '4.33.1']]);
+
+ Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.33.1');
+ }
+
+ public function honoursComposerVendorDirVariable(): void
+ {
+ $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]);
+ $_SERVER['COMPOSER_VENDOR_DIR'] = 'deps';
+
+ try {
+ $runtime = ProtobufRuntime::fromPackage($this->dir);
+ } finally {
+ unset($_SERVER['COMPOSER_VENDOR_DIR']);
+ }
+
+ 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', [['name' => 'google/protobuf', 'version' => '4.33.1']]);
+ $_SERVER['COMPOSER'] = 'app.json';
+
+ 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', [['name' => 'google/protobuf', 'version' => '4.33.1']]);
+ $_SERVER['HOME'] = $this->dir;
+
+ Assert::same(ProtobufRuntime::fromPackage($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');
+ }
+
+ 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', $packages);
+
+ Assert::null(ProtobufRuntime::fromPackage($this->dir));
+ }
+
+ public function findsNothingInProjectWithoutVendor(): void
+ {
+ Assert::null(ProtobufRuntime::fromPackage($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', [['name' => 'google/protobuf', 'version' => '5.36.2']]);
+ InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]);
+
+ 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)[0] ?? null)?->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'));
+ }
+
+ /**
+ * 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', [['name' => 'roadrunner/worker', 'version' => '3.8.0']]);
+ InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]);
+
+ Assert::same(ProtobufRuntime::detect($this->dir), []);
+ }
+
+ /**
+ * A loaded extension defines the protobuf classes itself, so the package is never used.
+ */
+ public function prefersExtensionOverPackage(): void
+ {
+ $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]);
+
+ $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');
+
+ $runtime = ProtobufRuntime::fromExtension();
+
+ Assert::same($runtime?->version, $version === false ? null : $version);
+ }
+
+ #[BeforeTest]
+ 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);
+ InstalledVersions::reload(['versions' => []]);
+ }
+
+ #[AfterTest]
+ protected function restore(): void
+ {
+ $_SERVER = $this->server;
+ TempDirectory::remove($this->dir);
+ InstalledVersions::reload(null);
+ (new \ReflectionClass(InstalledVersions::class))->setStaticPropertyValue('canGetVendors', $this->canGetVendors);
+ }
+
+ private function installed(string $vendor, array $packages): void
+ {
+ \mkdir($this->dir . '/' . $vendor . '/composer', 0777, true);
+ \file_put_contents(
+ $this->dir . '/' . $vendor . '/composer/installed.json',
+ \json_encode(['packages' => $packages, 'dev' => true, 'dev-package-names' => []]),
+ );
+ }
+}
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;
}
}