diff --git a/README.md b/README.md
index 3054cb9d..14dc87ec 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,36 @@ You must now add "extension=example_pie_extension" to your php.ini
$
```
+## Installing all extensions for a project
+
+When in your PHP project, you can install any missing top-level extensions:
+
+```
+$ pie install
+🥧 PHP Installer for Extensions (PIE), 0.9.0, from The PHP Foundation
+You are running PHP 8.3.19
+Target PHP installation: 8.3.19 nts, on Linux/OSX/etc x86_64 (from /usr/bin/php8.3)
+Checking extensions for your project your-vendor/your-project
+requires: curl ✅ Already installed
+requires: intl ✅ Already installed
+requires: json ✅ Already installed
+requires: example_pie_extension ⚠️ Missing
+
+The following packages may be suitable, which would you like to install:
+ [0] None
+ [1] asgrim/example-pie-extension: Example PIE extension
+ > 1
+ > 🥧 PHP Installer for Extensions (PIE), 0.9.0, from The PHP Foundation
+ > This command may need elevated privileges, and may prompt you for your password.
+ > You are running PHP 8.3.19
+ > Target PHP installation: 8.3.19 nts, on Linux/OSX/etc x86_64 (from /usr/bin/php8.3)
+ > Found package: asgrim/example-pie-extension:2.0.2 which provides ext-example_pie_extension
+ ... (snip) ...
+ > ✅ Extension is enabled and loaded in /usr/bin/php8.3
+
+Finished checking extensions.
+```
+
## More documentation...
The full documentation for PIE can be found in [usage](./docs/usage.md) docs.
diff --git a/bin/pie b/bin/pie
index d6d71fb9..e458eb44 100755
--- a/bin/pie
+++ b/bin/pie
@@ -6,6 +6,7 @@ declare(strict_types=1);
namespace Php\Pie;
use Php\Pie\Command\BuildCommand;
+use Php\Pie\Command\InstallExtensionsForProjectCommand;
use Php\Pie\Command\DownloadCommand;
use Php\Pie\Command\InfoCommand;
use Php\Pie\Command\InstallCommand;
@@ -44,6 +45,7 @@ $application->setCommandLoader(new ContainerCommandLoader(
'repository:remove' => RepositoryRemoveCommand::class,
'uninstall' => UninstallCommand::class,
'self-update' => SelfUpdateCommand::class,
+ 'install-extensions-for-project' => InstallExtensionsForProjectCommand::class,
]
));
diff --git a/docs/usage.md b/docs/usage.md
index 0b747794..68a997ca 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -258,6 +258,39 @@ You can list the repositories for the target PHP installation with:
* `pie repository:list [--with-php-config=...]`
+## Check and install missing extensions for your project
+
+You can use `pie install` when in a PHP project working directory to check the
+extensions the project requires are present. If an extension is missing, PIE
+will try to find an installation candidate and interactively ask if you would
+like to install one. For example:
+
+```
+$ pie install
+🥧 PHP Installer for Extensions (PIE), 0.9.0, from The PHP Foundation
+You are running PHP 8.3.19
+Target PHP installation: 8.3.19 nts, on Linux/OSX/etc x86_64 (from /usr/bin/php8.3)
+Checking extensions for your project your-vendor/your-project
+requires: curl ✅ Already installed
+requires: intl ✅ Already installed
+requires: json ✅ Already installed
+requires: example_pie_extension ⚠️ Missing
+
+The following packages may be suitable, which would you like to install:
+ [0] None
+ [1] asgrim/example-pie-extension: Example PIE extension
+ > 1
+ > 🥧 PHP Installer for Extensions (PIE), 0.9.0, from The PHP Foundation
+ > This command may need elevated privileges, and may prompt you for your password.
+ > You are running PHP 8.3.19
+ > Target PHP installation: 8.3.19 nts, on Linux/OSX/etc x86_64 (from /usr/bin/php8.3)
+ > Found package: asgrim/example-pie-extension:2.0.2 which provides ext-example_pie_extension
+ ... (snip) ...
+ > ✅ Extension is enabled and loaded in /usr/bin/php8.3
+
+Finished checking extensions.
+```
+
## Comparison with PECL
Since PIE is a replacement for PECL, here is a comparison of the commands that
diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php
index 489b2474..47e4beba 100644
--- a/src/Command/CommandHelper.php
+++ b/src/Command/CommandHelper.php
@@ -38,13 +38,13 @@
/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
final class CommandHelper
{
- private const ARG_REQUESTED_PACKAGE_AND_VERSION = 'requested-package-and-version';
- private const OPTION_WITH_PHP_CONFIG = 'with-php-config';
- private const OPTION_WITH_PHP_PATH = 'with-php-path';
- private const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path';
- private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs';
- private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension';
- private const OPTION_FORCE = 'force';
+ public const ARG_REQUESTED_PACKAGE_AND_VERSION = 'requested-package-and-version';
+ public const OPTION_WITH_PHP_CONFIG = 'with-php-config';
+ public const OPTION_WITH_PHP_PATH = 'with-php-path';
+ public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path';
+ private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs';
+ private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension';
+ private const OPTION_FORCE = 'force';
/** @psalm-suppress UnusedConstructor */
private function __construct()
@@ -77,7 +77,7 @@ public static function configureDownloadBuildInstallOptions(Command $command): v
{
$command->addArgument(
self::ARG_REQUESTED_PACKAGE_AND_VERSION,
- InputArgument::REQUIRED,
+ InputArgument::OPTIONAL,
'The PIE package name and version constraint to use, in the format {vendor/package}{?:{?version-constraint}{?@stability}}, for example `xdebug/xdebug:^3.4@alpha`, `xdebug/xdebug:@alpha`, `xdebug/xdebug:^3.4`, etc.',
);
$command->addOption(
diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php
index ae405949..4f1237b4 100644
--- a/src/Command/InstallCommand.php
+++ b/src/Command/InstallCommand.php
@@ -14,9 +14,17 @@
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-
+use Webmozart\Assert\Assert;
+
+use function array_combine;
+use function array_filter;
+use function array_keys;
+use function array_map;
+use function array_merge;
+use function array_values;
use function sprintf;
#[AsCommand(
@@ -40,8 +48,29 @@ public function configure(): void
CommandHelper::configureDownloadBuildInstallOptions($this);
}
+ private function invokeInstallForProject(InputInterface $input, OutputInterface $output): int
+ {
+ $originalSuppliedOptions = array_filter($input->getOptions());
+ $installForProjectInput = new ArrayInput(array_merge(
+ ['command' => 'install-extensions-for-project'],
+ array_combine(
+ array_map(static fn ($key) => '--' . $key, array_keys($originalSuppliedOptions)),
+ array_values($originalSuppliedOptions),
+ ),
+ ));
+
+ $application = $this->getApplication();
+ Assert::notNull($application);
+
+ return $application->doRun($installForProjectInput, $output);
+ }
+
public function execute(InputInterface $input, OutputInterface $output): int
{
+ if (! $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) {
+ return $this->invokeInstallForProject($input, $output);
+ }
+
if (! TargetPlatform::isRunningAsRoot()) {
$output->writeln('This command may need elevated privileges, and may prompt you for your password.');
}
diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php
new file mode 100644
index 00000000..b96f9366
--- /dev/null
+++ b/src/Command/InstallExtensionsForProjectCommand.php
@@ -0,0 +1,204 @@
+getHelper('question');
+ assert($helper instanceof QuestionHelper);
+
+ $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output);
+
+ $rootPackage = $this->findRootPackage->forCwd($input, $output);
+
+ if (ExtensionType::isValid($rootPackage->getType())) {
+ $output->writeln('This composer.json is for an extension, installing missing packages is not supported.');
+
+ return Command::INVALID;
+ }
+
+ $output->writeln(sprintf(
+ 'Checking extensions for your project %s (path: %s)',
+ $rootPackage->getPrettyName(),
+ getcwd(),
+ ));
+
+ $rootPackageExtensionsRequired = array_filter(
+ array_merge($rootPackage->getRequires(), $rootPackage->getDevRequires()),
+ static function (Link $link) {
+ $linkTarget = $link->getTarget();
+ if (! str_starts_with($linkTarget, 'ext-')) {
+ return false;
+ }
+
+ return ExtensionName::isValidExtensionName(substr($linkTarget, strlen('ext-')));
+ },
+ );
+
+ $pieComposer = PieComposerFactory::createPieComposer(
+ $this->container,
+ PieComposerRequest::noOperation(
+ new NullOutput(),
+ $targetPlatform,
+ ),
+ );
+
+ $phpEnabledExtensions = array_keys($targetPlatform->phpBinaryPath->extensions());
+
+ $anyErrorsHappened = false;
+
+ array_walk(
+ $rootPackageExtensionsRequired,
+ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $input, $output, $helper, &$anyErrorsHappened): void {
+ $extension = ExtensionName::normaliseFromString($link->getTarget());
+
+ if (in_array($extension->name(), $phpEnabledExtensions)) {
+ $output->writeln(sprintf(
+ '%s: %s ✅ Already installed',
+ $link->getDescription(),
+ $extension->name(),
+ ));
+
+ return;
+ }
+
+ $output->writeln(sprintf(
+ '%s: %s ⚠️ Missing',
+ $link->getDescription(),
+ $extension->name(),
+ ));
+
+ try {
+ $matches = $this->findMatchingPackages->for($pieComposer, $extension);
+ } catch (OutOfRangeException) {
+ $anyErrorsHappened = true;
+
+ $message = sprintf(
+ 'No packages were found for %s',
+ $extension->nameWithExtPrefix(),
+ );
+
+ if ($output instanceof ConsoleOutputInterface) {
+ $output->getErrorOutput()->writeln($message);
+
+ return;
+ }
+
+ $output->writeln($message);
+
+ return;
+ }
+
+ $choiceQuestion = new ChoiceQuestion(
+ "\nThe following packages may be suitable, which would you like to install: ",
+ array_merge(
+ ['None'],
+ array_map(
+ static function (array $match): string {
+ return sprintf('%s: %s', $match['name'], $match['description'] ?? 'no description available');
+ },
+ $matches,
+ ),
+ ),
+ 0,
+ );
+
+ $selectedPackageAnswer = (string) $helper->ask($input, $output, $choiceQuestion);
+
+ if ($selectedPackageAnswer === 'None') {
+ $output->writeln('Okay I won\'t install anything for ' . $extension->name());
+
+ return;
+ }
+
+ try {
+ $this->installSelectedPackage->withPieCli(
+ substr($selectedPackageAnswer, 0, (int) strpos($selectedPackageAnswer, ':')),
+ $input,
+ $output,
+ );
+ } catch (Throwable $t) {
+ $anyErrorsHappened = true;
+
+ $message = '' . $t->getMessage() . '';
+
+ if ($output instanceof ConsoleOutputInterface) {
+ $output->getErrorOutput()->writeln($message);
+
+ return;
+ }
+
+ $output->writeln($message);
+ }
+ },
+ );
+
+ $output->writeln(PHP_EOL . 'Finished checking extensions.');
+
+ /**
+ * @psalm-suppress TypeDoesNotContainType
+ * @psalm-suppress RedundantCondition
+ */
+ return $anyErrorsHappened ? self::FAILURE : self::SUCCESS;
+ }
+}
diff --git a/src/Container.php b/src/Container.php
index 9d2da927..fdf86619 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -13,6 +13,7 @@
use Php\Pie\Command\DownloadCommand;
use Php\Pie\Command\InfoCommand;
use Php\Pie\Command\InstallCommand;
+use Php\Pie\Command\InstallExtensionsForProjectCommand;
use Php\Pie\Command\RepositoryAddCommand;
use Php\Pie\Command\RepositoryListCommand;
use Php\Pie\Command\RepositoryRemoveCommand;
@@ -58,6 +59,7 @@ public static function factory(): ContainerInterface
$container->singleton(RepositoryRemoveCommand::class);
$container->singleton(UninstallCommand::class);
$container->singleton(SelfUpdateCommand::class);
+ $container->singleton(InstallExtensionsForProjectCommand::class);
$container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO {
return new QuieterConsoleIO(
diff --git a/src/Installing/InstallForPhpProject/FindMatchingPackages.php b/src/Installing/InstallForPhpProject/FindMatchingPackages.php
new file mode 100644
index 00000000..dbb6c401
--- /dev/null
+++ b/src/Installing/InstallForPhpProject/FindMatchingPackages.php
@@ -0,0 +1,42 @@
+
+ */
+class FindMatchingPackages
+{
+ /** @return MatchingPackages */
+ public function for(Composer $pieComposer, ExtensionName $extension): array
+ {
+ $matches = [];
+ foreach ($pieComposer->getRepositoryManager()->getRepositories() as $repo) {
+ $matches = array_merge($matches, $repo->search($extension->name(), RepositoryInterface::SEARCH_FULLTEXT, 'php-ext'));
+ $matches = array_merge($matches, $repo->search($extension->name(), RepositoryInterface::SEARCH_FULLTEXT, 'php-ext-zend'));
+ }
+
+ if (! count($matches)) {
+ throw new OutOfRangeException('No matches found for ' . $extension->name());
+ }
+
+ usort($matches, static function (array $a, array $b): int {
+ return $b['downloads'] <=> $a['downloads'];
+ });
+
+ return $matches;
+ }
+}
diff --git a/src/Installing/InstallForPhpProject/FindRootPackage.php b/src/Installing/InstallForPhpProject/FindRootPackage.php
new file mode 100644
index 00000000..3f6ab111
--- /dev/null
+++ b/src/Installing/InstallForPhpProject/FindRootPackage.php
@@ -0,0 +1,22 @@
+getPackage();
+ }
+}
diff --git a/src/Installing/InstallForPhpProject/InstallSelectedPackage.php b/src/Installing/InstallForPhpProject/InstallSelectedPackage.php
new file mode 100644
index 00000000..8d316b35
--- /dev/null
+++ b/src/Installing/InstallForPhpProject/InstallSelectedPackage.php
@@ -0,0 +1,80 @@
+getOptions(),
+ static function (mixed $value, string|int $key): bool {
+ return $value !== null
+ && $value !== false
+ && in_array(
+ $key,
+ [
+ CommandHelper::OPTION_WITH_PHP_CONFIG,
+ CommandHelper::OPTION_WITH_PHP_PATH,
+ CommandHelper::OPTION_WITH_PHPIZE_PATH,
+ ],
+ );
+ },
+ ARRAY_FILTER_USE_BOTH,
+ );
+
+ array_walk(
+ $phpPathOptions,
+ static function (string $value, string $key) use (&$process): void {
+ $process[] = '--' . $key;
+ $process[] = $value;
+ },
+ );
+
+ Process::run(
+ $process,
+ getcwd(),
+ null,
+ static function (string $outOrErr, string $message) use ($output): void {
+ if ($output instanceof ConsoleOutputInterface && $outOrErr === \Symfony\Component\Process\Process::ERR) {
+ $output->getErrorOutput()->write(' > ' . $message);
+
+ return;
+ }
+
+ $output->write(' > ' . $message);
+ },
+ );
+ }
+}
diff --git a/test/assets/fake-pie-cli/happy.bat b/test/assets/fake-pie-cli/happy.bat
new file mode 100644
index 00000000..8cb8a887
--- /dev/null
+++ b/test/assets/fake-pie-cli/happy.bat
@@ -0,0 +1,5 @@
+@echo off
+
+echo Params passed: %*
+
+exit /b 0
diff --git a/test/assets/fake-pie-cli/happy.sh b/test/assets/fake-pie-cli/happy.sh
new file mode 100755
index 00000000..8c1777f4
--- /dev/null
+++ b/test/assets/fake-pie-cli/happy.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+echo "Params passed: ${*}"
+exit 0
diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php
new file mode 100644
index 00000000..36f9d3d3
--- /dev/null
+++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php
@@ -0,0 +1,106 @@
+createMock(ContainerInterface::class);
+ $container->method('get')->willReturnCallback(
+ /** @param class-string $service */
+ function (string $service): mixed {
+ switch ($service) {
+ case QuieterConsoleIO::class:
+ return new QuieterConsoleIO(
+ new ArrayInput([]),
+ new BufferedOutput(),
+ new MinimalHelperSet(['question' => new QuestionHelper()]),
+ );
+
+ default:
+ return $this->createMock($service);
+ }
+ },
+ );
+
+ $this->findRootpackage = $this->createMock(FindRootPackage::class);
+ $this->findMatchingPackages = $this->createMock(FindMatchingPackages::class);
+ $this->installSelectedPackage = $this->createMock(InstallSelectedPackage::class);
+ $this->questionHelper = $this->createMock(QuestionHelper::class);
+
+ $cmd = new InstallExtensionsForProjectCommand(
+ $this->findRootpackage,
+ $this->findMatchingPackages,
+ $this->installSelectedPackage,
+ $container,
+ );
+ $cmd->setHelperSet(new HelperSet([
+ 'question' => $this->questionHelper,
+ ]));
+ $this->commandTester = new CommandTester($cmd);
+ }
+
+ public function testInstallingExtensionsForProject(): void
+ {
+ $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3');
+ $rootPackage->setRequires([
+ 'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE),
+ 'ext-foobar' => new Link('my/project', 'ext-foobar', new Constraint('=', '*'), Link::TYPE_REQUIRE),
+ ]);
+ $this->findRootpackage->method('forCwd')->willReturn($rootPackage);
+
+ $this->findMatchingPackages->method('for')->willReturn([
+ ['name' => 'vendor1/foobar', 'description' => 'The official foobar implementation'],
+ ['name' => 'vendor2/afoobar', 'description' => 'An improved async foobar extension'],
+ ]);
+
+ $this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation');
+
+ $this->installSelectedPackage->expects(self::once())
+ ->method('withPieCli')
+ ->with('vendor1/foobar');
+
+ $this->commandTester->execute(
+ [],
+ ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE],
+ );
+
+ $outputString = $this->commandTester->getDisplay();
+
+ $this->commandTester->assertCommandIsSuccessful();
+ self::assertStringContainsString('Checking extensions for your project my/project', $outputString);
+ self::assertStringContainsString('requires: standard ✅ Already installed', $outputString);
+ self::assertStringContainsString('requires: foobar ⚠️ Missing', $outputString);
+ }
+}
diff --git a/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php b/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php
new file mode 100644
index 00000000..87414fc6
--- /dev/null
+++ b/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php
@@ -0,0 +1,44 @@
+ '/path/to/php/config'],
+ new InputDefinition([
+ new InputOption('with-php-config', null, InputOption::VALUE_REQUIRED),
+ ]),
+ );
+ $output = new BufferedOutput();
+
+ (new InstallSelectedPackage())->withPieCli(
+ 'foo/bar',
+ $input,
+ $output,
+ );
+
+ self::assertSame('> Params passed: install foo/bar --with-php-config /path/to/php/config', trim($output->fetch()));
+ }
+}