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())); + } +}