From 7a30ede747170c7356d9382d2f96c6f13216d464 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 2 Jun 2026 11:25:18 +0100 Subject: [PATCH 01/10] 624: update install in PHP project feature to use proposed --select params for package selection --- features/install-in-php-project.feature | 12 ++++++-- test/assets/example-php-project/composer.json | 1 + test/assets/example-php-project/composer.lock | 5 ++-- test/behaviour/CliContext.php | 29 +++++++++++++++---- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/features/install-in-php-project.feature b/features/install-in-php-project.feature index 3d3abb8c..25774394 100644 --- a/features/install-in-php-project.feature +++ b/features/install-in-php-project.feature @@ -1,7 +1,13 @@ Feature: Extensions for a PHP project can be installed with PIE - # pie install - Example: PIE running in a PHP project suggests missing dependencies + # pie install --select = ... + Example: PIE running in a PHP project automatically installs missing dependencies Given I am in a PHP project that has missing extensions - When I run a command to install the extensions + When I run a command to install the extensions with package selections Then I should see all the extensions are now installed + + # pie install + Example: PIE running in a PHP project without package selections will fail + Given I am in a PHP project that has missing extensions + When I run a command to install the extensions without package selections + Then I should see information on how to select packages for install diff --git a/test/assets/example-php-project/composer.json b/test/assets/example-php-project/composer.json index d4a25d50..82b7be0b 100644 --- a/test/assets/example-php-project/composer.json +++ b/test/assets/example-php-project/composer.json @@ -4,6 +4,7 @@ "type": "project", "require": { "php": "^8.0", + "ext-redis": "*", "ext-example_pie_extension": "^2.0" } } diff --git a/test/assets/example-php-project/composer.lock b/test/assets/example-php-project/composer.lock index de41cd27..9714929b 100644 --- a/test/assets/example-php-project/composer.lock +++ b/test/assets/example-php-project/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2e7e51dfd351f870a1a657e3e49836ad", + "content-hash": "ab8ec4f8353850311910af01c9c97085", "packages": [], "packages-dev": [], "aliases": [], @@ -14,8 +14,9 @@ "prefer-lowest": false, "platform": { "php": "^8.0", + "ext-redis": "*", "ext-example_pie_extension": "^2.0" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index fb8b1b59..fc4d51b7 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -399,22 +399,40 @@ public function iAmInAPHPProjectThatHasMissingExtensions(): void $this->workingDirectory = realpath(__DIR__ . '/../assets/example-php-project'); } - #[When('I run a command to install the extensions')] + #[When('I run a command to install the extensions with package selections')] public function iRunACommandToInstallTheExtensions(): void { - $this->runPieCommand(['install', '--allow-non-interactive-project-install']); - - $this->assertCommandSuccessful(); + $this->runPieCommand([ + 'install', + '--select', + 'example_pie_extension=asgrim/example-pie-extension', + '--select', + 'redis=phpredis/phpredis', + ]); } #[Then('I should see all the extensions are now installed')] public function iShouldSeeAllTheExtensionsAreNowInstalled(): void { $this->workingDirectory = null; + $this->assertCommandSuccessful(); $this->runPieCommand(['show']); $this->assertCommandSuccessful(); - Assert::contains($this->output, 'example_pie_extension'); + + Assert::contains($this->output, 'asgrim/example-pie-extension'); + Assert::contains($this->output, 'phpredis/phpredis'); + } + + #[Then('I should see information on how to select packages for install')] + public function iShouldSeeInformationOnHowToSelectPackagesForInstall(): void + { + Assert::same($this->exitCode, 1); + + Assert::notNull($this->errorOutput); + Assert::contains($this->errorOutput, 'No package selections were made for ext-redis; you MUST specify a package selection in non-interactive mode'); + Assert::contains($this->errorOutput, '--select=example_pie_extension=asgrim/example-pie-extension'); + Assert::contains($this->errorOutput, '--select=redis=phpredis/phpredis'); } #[Given('I am in a PIE project')] @@ -424,6 +442,7 @@ public function iAmInAPIEProject(): void } #[When('I run a command to install the extension')] + #[When('I run a command to install the extensions without package selections')] public function iRunACommandToInstallTheExtension(): void { $this->theExtension = 'example_pie_extension'; From cd165e7d06ee2895c3a3df5cd7669fe0f944a33f Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 3 Jun 2026 11:52:15 +0100 Subject: [PATCH 02/10] 624: add --select option for package selection for non-interactive pie installs --- src/Command/CommandHelper.php | 10 +- .../InstallExtensionsForProjectCommand.php | 157 +++++++++++------- 2 files changed, 104 insertions(+), 63 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 27e976ca..6d3d298f 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -58,6 +58,7 @@ final class CommandHelper public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path'; public const OPTION_WORKING_DIRECTORY = 'working-dir'; public const OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL = 'allow-non-interactive-project-install'; + public const OPTION_PACKAGE_SELECTION = 'select'; private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs'; private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension'; private const OPTION_FORCE = 'force'; @@ -141,7 +142,14 @@ public static function configureDownloadBuildInstallOptions(Command $command, bo self::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL, null, InputOption::VALUE_NONE, - 'When installing a PHP project, allow non-interactive project installations. Only used in certain contexts.', + 'Deprecated and ignored. Will emit a warning if used.', + ); + + $command->addOption( + self::OPTION_PACKAGE_SELECTION, + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Select a PIE package for a given extension name, e.g. `--select=foo=myvendor/foo` to resolve the `ext-foo` extension to `myvendor/foo` PIE package.', ); $command->addOption( diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 0e999619..294a3e39 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -33,7 +33,6 @@ use Throwable; use Webmozart\Assert\Assert; -use function array_column; use function array_key_exists; use function array_keys; use function array_map; @@ -41,9 +40,12 @@ use function array_walk; use function assert; use function count; +use function explode; use function implode; use function in_array; +use function is_array; use function is_dir; +use function is_string; use function Safe\chdir; use function Safe\getcwd; use function Safe\realpath; @@ -132,18 +134,28 @@ public function execute(InputInterface $input, OutputInterface $output): int return $exit; } + /** @var array $extensionToPackageSelections */ + $extensionToPackageSelections = []; + $selectionOptions = $input->getOption(CommandHelper::OPTION_PACKAGE_SELECTION); + assert(is_array($selectionOptions)); + + foreach ($selectionOptions as $selection) { + assert(is_string($selection) && $selection !== ''); + [$extNameString, $packageSelectionString] = explode('=', $selection); + Assert::stringNotEmpty($packageSelectionString); + $extensionToPackageSelections[ExtensionName::normaliseFromString($extNameString)->name()] = new RequestedPackageAndVersion($packageSelectionString, null); + } + + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); + $allowNonInteractive = $input->hasOption(CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL) && $input->getOption(CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL); - if (! Platform::isInteractive() && ! $allowNonInteractive) { + if ($allowNonInteractive) { $this->io->writeError(sprintf( - 'Aborting! You are not running in interactive mode, and --%s was not specified.', + 'The --%s is now deprecated and has no effect.', CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL, )); - - return Command::FAILURE; } - $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); - $this->io->write(sprintf( 'Checking extensions for your project %s (path: %s)', $rootPackage->getPrettyName(), @@ -167,7 +179,7 @@ public function execute(InputInterface $input, OutputInterface $output): int array_walk( $extensionsRequired, - function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform): void { + function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform, $extensionToPackageSelections): void { $extension = ExtensionName::normaliseFromString($link->getTarget()); $linkRequiresConstraint = $link->getPrettyConstraint(); @@ -195,7 +207,7 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac $this->io->write(sprintf( '%s: %s:%s %s Version %s is installed, but does not meet the version requirement %s', $link->getDescription(), - $link->getTarget(), + $extension->nameWithExtPrefix(), $linkRequiresConstraint, Emoji::WARNING, $piePackageVersion, @@ -208,7 +220,7 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac $this->io->write(sprintf( '%s: %s:%s %s Already installed', $link->getDescription(), - $link->getTarget(), + $extension->nameWithExtPrefix(), $linkRequiresConstraint, Emoji::GREEN_CHECKMARK, )); @@ -219,73 +231,94 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac $this->io->write(sprintf( '%s: %s:%s %s Missing', $link->getDescription(), - $link->getTarget(), + $extension->nameWithExtPrefix(), $linkRequiresConstraint, Emoji::PROHIBITED, )); - try { - $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); - } catch (OutOfRangeException) { - $anyErrorsHappened = true; + // If a `--select` was made, use it as it was explicitly requested + if (array_key_exists($extension->name(), $extensionToPackageSelections)) { + $requestedPackageAndVersion = new RequestedPackageAndVersion( + $extensionToPackageSelections[$extension->name()]->package, + $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, + ); + } else { + try { + $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); + } catch (OutOfRangeException) { + $matches = []; + } - $this->io->writeError(sprintf( - 'No packages were found for %s', - $extension->nameWithExtPrefix(), - )); + if (Platform::isInteractive()) { + if (! count($matches)) { + $this->io->write(sprintf( + 'PIE could not find any potential matches for %s; if you know which package to use, specify --select=vendor/package in the `pie install` options.', + $extension->nameWithExtPrefix(), + )); + $anyErrorsHappened = true; + + return; + } + + // If we're in interactive mode, prompt the user to select which package they want + $selectedPackageAnswer = (int) $this->io->select( + "\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', + ); - return; - } + if ($selectedPackageAnswer === 0) { + $this->io->write('Okay I won\'t install anything for ' . $extension->name()); + $anyErrorsHappened = true; - if (! Platform::isInteractive() && count($matches) > 1) { - $anyErrorsHappened = true; + return; + } - // @todo Figure out if there is a way to improve this, safely - $this->io->writeError(sprintf( - "Multiple packages were found for %s:\n %s\n\nThis means you cannot `pie install` this project interactively for now.", - $extension->nameWithExtPrefix(), - implode("\n ", array_column($matches, 'name')), - )); + $matchesKey = $selectedPackageAnswer - 1; + assert(array_key_exists($matchesKey, $matches)); - return; - } + assert($matches[$matchesKey]['name'] !== ''); + $requestedPackageAndVersion = new RequestedPackageAndVersion( + $matches[$matchesKey]['name'], + $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, + ); + } else { + // In non-interactive mode, the user MUST specify a --select definition + $anyErrorsHappened = true; - if (Platform::isInteractive()) { - $selectedPackageAnswer = (int) $this->io->select( - "\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', - ); + if (! count($matches)) { + $this->io->writeError(sprintf( + 'No package selections were made for %s; and PIE could not find any potential packages; you must specify --select=vendor/package to resolve the missing dependency', + $extension->nameWithExtPrefix(), + )); - if ($selectedPackageAnswer === 0) { - $this->io->write('Okay I won\'t install anything for ' . $extension->name()); - $anyErrorsHappened = true; + return; + } - return; - } + // @todo https://github.com/php/pie/issues/592 + $options = array_map( + static fn (array $match) => sprintf(' --select=%s=%s', $extension->name(), $match['name']), + $matches, + ); - $matchesKey = $selectedPackageAnswer - 1; - assert(array_key_exists($matchesKey, $matches)); + $this->io->writeError(sprintf( + 'No package selections were made for %s; you MUST specify a package selection in non-interactive mode, by adding one of the following parameters to the `pie install` command:%s', + $extension->nameWithExtPrefix(), + "\n" . implode("\n", $options), + )); - $selectedPackageName = $matches[$matchesKey]['name']; - } else { - $selectedPackageName = $matches[0]['name']; + return; + } } - assert($selectedPackageName !== ''); - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $selectedPackageName, - $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, - ); - try { $this->io->write( sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()), @@ -294,7 +327,7 @@ static function (array $match): string { Assert::same( 0, $this->installSelectedPackage->withSubCommand( - ExtensionName::normaliseFromString($link->getTarget()), + $extension, $requestedPackageAndVersion, $this, $input, From e521b2189d8a3828d04b432759f040e03b4985df Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 4 Jun 2026 09:04:51 +0100 Subject: [PATCH 03/10] 624: extract working directory handling to CommandHelper --- phpstan-baseline.neon | 2 +- src/Command/CommandHelper.php | 39 ++++++++++++++++++- .../InstallExtensionsForProjectCommand.php | 23 +---------- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3cd4a1ca..5ec0f102 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -16,7 +16,7 @@ parameters: message: '#^Cannot cast mixed to string\.$#' identifier: cast.string count: 1 - path: src/Command/InstallExtensionsForProjectCommand.php + path: src/Command/CommandHelper.php - message: '#^Cannot cast mixed to string\.$#' diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 6d3d298f..7912a52b 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -39,8 +39,11 @@ use function assert; use function count; use function is_array; +use function is_dir; use function is_string; use function reset; +use function Safe\chdir; +use function Safe\getcwd; use function sprintf; use function str_starts_with; use function strtolower; @@ -56,9 +59,9 @@ final class CommandHelper 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'; - public const OPTION_WORKING_DIRECTORY = 'working-dir'; public const OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL = 'allow-non-interactive-project-install'; public const OPTION_PACKAGE_SELECTION = 'select'; + private const OPTION_WORKING_DIRECTORY = 'working-dir'; private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs'; private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension'; private const OPTION_FORCE = 'force'; @@ -518,4 +521,38 @@ public static function applyNoCacheOptionIfSet(InputInterface $input, IOInterfac $io->writeError('Disabling cache usage', verbosity: IOInterface::DEBUG); Platform::putEnv('COMPOSER_CACHE_DIR', Platform::isWindows() ? 'nul' : '/dev/null'); } + + /** + * If the working directory option is set in the `$input`, change the working directory, and return a callable + * that will restore the working directory. + * + * @return callable(): void + */ + public static function handleWorkingDirectory(InputInterface $input, IOInterface $io): callable + { + $workingDirOption = (string) $input->getOption(self::OPTION_WORKING_DIRECTORY); + + // No working directory option used, or isn't a real path; this (and the returned callable) should be a no-op + if ($workingDirOption === '' || ! is_dir($workingDirOption)) { + return static function (): void { + }; + } + + $currentWorkingDir = getcwd(); + $restoreWorkingDir = static function () use ($currentWorkingDir, $io): void { + chdir($currentWorkingDir); + $io->write( + sprintf('Restored working directory to: %s', $currentWorkingDir), + verbosity: IOInterface::VERBOSE, + ); + }; + + chdir($workingDirOption); + $io->write( + sprintf('Changed working directory to: %s', $workingDirOption), + verbosity: IOInterface::VERBOSE, + ); + + return $restoreWorkingDir; + } } diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 294a3e39..20b89673 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -44,9 +44,7 @@ use function implode; use function in_array; use function is_array; -use function is_dir; use function is_string; -use function Safe\chdir; use function Safe\getcwd; use function Safe\realpath; use function sprintf; @@ -82,26 +80,7 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { - $workingDirOption = (string) $input->getOption(CommandHelper::OPTION_WORKING_DIRECTORY); - $restoreWorkingDir = static function (): void { - }; - if ($workingDirOption !== '' && is_dir($workingDirOption)) { - $currentWorkingDir = getcwd(); - $restoreWorkingDir = function () use ($currentWorkingDir): void { - chdir($currentWorkingDir); - $this->io->write( - sprintf('Restored working directory to: %s', $currentWorkingDir), - verbosity: IOInterface::VERBOSE, - ); - }; - - chdir($workingDirOption); - $this->io->write( - sprintf('Changed working directory to: %s', $workingDirOption), - verbosity: IOInterface::VERBOSE, - ); - } - + $restoreWorkingDir = CommandHelper::handleWorkingDirectory($input, $this->io); CommandHelper::applyNoCacheOptionIfSet($input, $this->io); $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); From 4a3f41bfeeceb61cd1d79c515c77bbeda524d9d8 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 4 Jun 2026 09:38:53 +0100 Subject: [PATCH 04/10] 624: split PIE project installer into private method --- .../InstallExtensionsForProjectCommand.php | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 20b89673..9c1ce7f2 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -7,6 +7,7 @@ use Composer\IO\IOInterface; use Composer\IO\NullIO; use Composer\Package\Link; +use Composer\Package\RootPackageInterface; use Composer\Package\Version\VersionParser; use OutOfRangeException; use Php\Pie\ComposerIntegration\PieComposerFactory; @@ -78,39 +79,44 @@ public function configure(): void CommandHelper::configureDownloadBuildInstallOptions($this, false); } - public function execute(InputInterface $input, OutputInterface $output): int + private function handlePieProject(InputInterface $input, RootPackageInterface $rootPackage, callable $restoreWorkingDir): int { - $restoreWorkingDir = CommandHelper::handleWorkingDirectory($input, $this->io); - CommandHelper::applyNoCacheOptionIfSet($input, $this->io); + try { + $cwd = realpath(getcwd()); + } catch (FilesystemException | DirException $e) { + $this->io->writeError(sprintf( + 'Failed to determine current working directory: %s', + $e->getMessage(), + )); - $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); + $restoreWorkingDir(); - if (ExtensionType::isValid($rootPackage->getType())) { - try { - $cwd = realpath(getcwd()); - } catch (FilesystemException | DirException $e) { - $this->io->writeError(sprintf( - 'Failed to determine current working directory: %s', - $e->getMessage(), - )); + return Command::FAILURE; + } - $restoreWorkingDir(); + $exit = ($this->installPiePackageFromPath)( + $this, + $cwd, + $rootPackage, + PieJsonEditor::fromTargetPlatform(CommandHelper::determineTargetPlatformFromInputs($input, new NullIO())), + $input, + $this->io, + ); - return Command::FAILURE; - } + $restoreWorkingDir(); - $exit = ($this->installPiePackageFromPath)( - $this, - $cwd, - $rootPackage, - PieJsonEditor::fromTargetPlatform(CommandHelper::determineTargetPlatformFromInputs($input, new NullIO())), - $input, - $this->io, - ); + return $exit; + } - $restoreWorkingDir(); + public function execute(InputInterface $input, OutputInterface $output): int + { + $restoreWorkingDir = CommandHelper::handleWorkingDirectory($input, $this->io); + CommandHelper::applyNoCacheOptionIfSet($input, $this->io); - return $exit; + $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); + + if (ExtensionType::isValid($rootPackage->getType())) { + return $this->handlePieProject($input, $rootPackage, $restoreWorkingDir); } /** @var array $extensionToPackageSelections */ From 407ad3ebd7268e5e5fdf2646ab8535cea451c162 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 4 Jun 2026 09:41:35 +0100 Subject: [PATCH 05/10] 624: split PHP project install into private method --- .../InstallExtensionsForProjectCommand.php | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 9c1ce7f2..98f7c217 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -108,17 +108,8 @@ private function handlePieProject(InputInterface $input, RootPackageInterface $r return $exit; } - public function execute(InputInterface $input, OutputInterface $output): int + private function handlePhpProject(InputInterface $input, RootPackageInterface $rootPackage, callable $restoreWorkingDir): int { - $restoreWorkingDir = CommandHelper::handleWorkingDirectory($input, $this->io); - CommandHelper::applyNoCacheOptionIfSet($input, $this->io); - - $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); - - if (ExtensionType::isValid($rootPackage->getType())) { - return $this->handlePieProject($input, $rootPackage, $restoreWorkingDir); - } - /** @var array $extensionToPackageSelections */ $extensionToPackageSelections = []; $selectionOptions = $input->getOption(CommandHelper::OPTION_PACKAGE_SELECTION); @@ -333,4 +324,18 @@ static function (array $match): string { return $anyErrorsHappened ? self::FAILURE : self::SUCCESS; } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $restoreWorkingDir = CommandHelper::handleWorkingDirectory($input, $this->io); + CommandHelper::applyNoCacheOptionIfSet($input, $this->io); + + $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); + + if (ExtensionType::isValid($rootPackage->getType())) { + return $this->handlePieProject($input, $rootPackage, $restoreWorkingDir); + } + + return $this->handlePhpProject($input, $rootPackage, $restoreWorkingDir); + } } From e676c15b94934cf8b63fc0aa94be1a4ed61053dd Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 4 Jun 2026 09:52:46 +0100 Subject: [PATCH 06/10] 624: move package selections parser into CommandHelper --- src/Command/CommandHelper.php | 21 ++++++++++++++++- .../InstallExtensionsForProjectCommand.php | 23 ++++--------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 7912a52b..3d0f0254 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -20,6 +20,7 @@ use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\DependencyResolver\UnableToResolveRequirement; +use Php\Pie\ExtensionName; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Platform as PiePlatform; use Php\Pie\Platform\OperatingSystem; @@ -38,6 +39,7 @@ use function array_map; use function assert; use function count; +use function explode; use function is_array; use function is_dir; use function is_string; @@ -60,7 +62,7 @@ final class CommandHelper public const OPTION_WITH_PHP_PATH = 'with-php-path'; public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path'; public const OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL = 'allow-non-interactive-project-install'; - public const OPTION_PACKAGE_SELECTION = 'select'; + private const OPTION_PACKAGE_SELECTION = 'select'; private const OPTION_WORKING_DIRECTORY = 'working-dir'; private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs'; private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension'; @@ -555,4 +557,21 @@ public static function handleWorkingDirectory(InputInterface $input, IOInterface return $restoreWorkingDir; } + + /** @return array */ + public static function determineExtensionToPackageSelections(InputInterface $input): array + { + $extensionToPackageSelections = []; + $selectionOptions = $input->getOption(self::OPTION_PACKAGE_SELECTION); + assert(is_array($selectionOptions)); + + foreach ($selectionOptions as $selection) { + assert(is_string($selection) && $selection !== ''); + [$extNameString, $packageSelectionString] = explode('=', $selection); + Assert::stringNotEmpty($packageSelectionString); + $extensionToPackageSelections[ExtensionName::normaliseFromString($extNameString)->name()] = (new RequestedPackageAndVersion($packageSelectionString, null))->package; + } + + return $extensionToPackageSelections; + } } diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 98f7c217..74f3f75b 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -41,11 +41,8 @@ use function array_walk; use function assert; use function count; -use function explode; use function implode; use function in_array; -use function is_array; -use function is_string; use function Safe\getcwd; use function Safe\realpath; use function sprintf; @@ -110,19 +107,8 @@ private function handlePieProject(InputInterface $input, RootPackageInterface $r private function handlePhpProject(InputInterface $input, RootPackageInterface $rootPackage, callable $restoreWorkingDir): int { - /** @var array $extensionToPackageSelections */ - $extensionToPackageSelections = []; - $selectionOptions = $input->getOption(CommandHelper::OPTION_PACKAGE_SELECTION); - assert(is_array($selectionOptions)); - - foreach ($selectionOptions as $selection) { - assert(is_string($selection) && $selection !== ''); - [$extNameString, $packageSelectionString] = explode('=', $selection); - Assert::stringNotEmpty($packageSelectionString); - $extensionToPackageSelections[ExtensionName::normaliseFromString($extNameString)->name()] = new RequestedPackageAndVersion($packageSelectionString, null); - } - - $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); + $extensionToPackageSelections = CommandHelper::determineExtensionToPackageSelections($input); + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); $allowNonInteractive = $input->hasOption(CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL) && $input->getOption(CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL); if ($allowNonInteractive) { @@ -181,13 +167,12 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac if (in_array(strtolower($extension->name()), $phpEnabledExtensions)) { if ($piePackageVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { $this->io->write(sprintf( - '%s: %s:%s %s Version %s is installed, but does not meet the version requirement %s', + '%s: %s:%s %s Version %s is installed, but does not meet the version requirement', $link->getDescription(), $extension->nameWithExtPrefix(), $linkRequiresConstraint, Emoji::WARNING, $piePackageVersion, - $link->getConstraint()->getPrettyString(), )); return; @@ -215,7 +200,7 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac // If a `--select` was made, use it as it was explicitly requested if (array_key_exists($extension->name(), $extensionToPackageSelections)) { $requestedPackageAndVersion = new RequestedPackageAndVersion( - $extensionToPackageSelections[$extension->name()]->package, + $extensionToPackageSelections[$extension->name()], $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, ); } else { From b1c7f8a5a5a3314ff01d37a51802bf662e430e57 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 4 Jun 2026 10:00:27 +0100 Subject: [PATCH 07/10] 624: use return-early for non-interactive mode with no package selections made --- .../InstallExtensionsForProjectCommand.php | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 74f3f75b..d3a1466c 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -210,48 +210,7 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac $matches = []; } - if (Platform::isInteractive()) { - if (! count($matches)) { - $this->io->write(sprintf( - 'PIE could not find any potential matches for %s; if you know which package to use, specify --select=vendor/package in the `pie install` options.', - $extension->nameWithExtPrefix(), - )); - $anyErrorsHappened = true; - - return; - } - - // If we're in interactive mode, prompt the user to select which package they want - $selectedPackageAnswer = (int) $this->io->select( - "\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', - ); - - if ($selectedPackageAnswer === 0) { - $this->io->write('Okay I won\'t install anything for ' . $extension->name()); - $anyErrorsHappened = true; - - return; - } - - $matchesKey = $selectedPackageAnswer - 1; - assert(array_key_exists($matchesKey, $matches)); - - assert($matches[$matchesKey]['name'] !== ''); - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $matches[$matchesKey]['name'], - $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, - ); - } else { + if (! Platform::isInteractive()) { // In non-interactive mode, the user MUST specify a --select definition $anyErrorsHappened = true; @@ -278,6 +237,47 @@ static function (array $match): string { return; } + + if (! count($matches)) { + $this->io->write(sprintf( + 'PIE could not find any potential matches for %s; if you know which package to use, specify --select=vendor/package in the `pie install` options.', + $extension->nameWithExtPrefix(), + )); + $anyErrorsHappened = true; + + return; + } + + // If we're in interactive mode, prompt the user to select which package they want + $selectedPackageAnswer = (int) $this->io->select( + "\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', + ); + + if ($selectedPackageAnswer === 0) { + $this->io->write('Okay I won\'t install anything for ' . $extension->name()); + $anyErrorsHappened = true; + + return; + } + + $matchesKey = $selectedPackageAnswer - 1; + assert(array_key_exists($matchesKey, $matches)); + + assert($matches[$matchesKey]['name'] !== ''); + $requestedPackageAndVersion = new RequestedPackageAndVersion( + $matches[$matchesKey]['name'], + $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, + ); } try { From b858b83c2d9991bed870b639d0315ac4b9d58dac Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 4 Jun 2026 10:14:33 +0100 Subject: [PATCH 08/10] 624: split handleSingleExtensionRequiredByPhpProject into separate method --- .../InstallExtensionsForProjectCommand.php | 331 ++++++++++-------- 1 file changed, 183 insertions(+), 148 deletions(-) diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index d3a1466c..415e9fa0 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -4,6 +4,7 @@ namespace Php\Pie\Command; +use Composer\Composer; use Composer\IO\IOInterface; use Composer\IO\NullIO; use Composer\Package\Link; @@ -23,6 +24,8 @@ use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Php\Pie\Platform; use Php\Pie\Platform\InstalledPiePackages; +use Php\Pie\Platform\PiePackageList; +use Php\Pie\Platform\TargetPlatform; use Php\Pie\Util\Emoji; use Psr\Container\ContainerInterface; use Safe\Exceptions\DirException; @@ -142,172 +145,204 @@ private function handlePhpProject(InputInterface $input, RootPackageInterface $r array_walk( $extensionsRequired, function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform, $extensionToPackageSelections): void { - $extension = ExtensionName::normaliseFromString($link->getTarget()); - $linkRequiresConstraint = $link->getPrettyConstraint(); + if ( + $this->handleSingleExtensionRequiredByPhpProject( + $input, + $pieComposer, + $link, + $installedPiePackages, + $targetPlatform, + $phpEnabledExtensions, + $extensionToPackageSelections, + ) + ) { + return; + } - $piePackagesForExtension = $installedPiePackages - ->findByPhpFormattedExtensionName($extension->phpFormattedExtensionName()) - ->onlyVerifiedFor($targetPlatform); + $anyErrorsHappened = true; + }, + ); - $piePackageVersion = null; + $this->io->write(PHP_EOL . 'Finished checking extensions.'); - if (count($piePackagesForExtension) === 1) { - $piePackageVersion = $piePackagesForExtension->onlyOne()->version(); - } + $restoreWorkingDir(); - $piePackageVersionMatchesLinkConstraint = null; - if ($piePackageVersion !== null) { - $piePackageVersionMatchesLinkConstraint = $link - ->getConstraint() - ->matches( - (new VersionParser())->parseConstraints($piePackageVersion), - ); - } + return $anyErrorsHappened ? self::FAILURE : self::SUCCESS; + } - if (in_array(strtolower($extension->name()), $phpEnabledExtensions)) { - if ($piePackageVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { - $this->io->write(sprintf( - '%s: %s:%s %s Version %s is installed, but does not meet the version requirement', - $link->getDescription(), - $extension->nameWithExtPrefix(), - $linkRequiresConstraint, - Emoji::WARNING, - $piePackageVersion, - )); - - return; - } - - $this->io->write(sprintf( - '%s: %s:%s %s Already installed', - $link->getDescription(), - $extension->nameWithExtPrefix(), - $linkRequiresConstraint, - Emoji::GREEN_CHECKMARK, - )); + /** + * Returns false if any error happened whilst trying to install the extension; returns true if was already + * installed, or it was successfully installed if needed. + * + * @param list $phpEnabledExtensions + * @param array $extensionToPackageSelections + */ + private function handleSingleExtensionRequiredByPhpProject( + InputInterface $input, + Composer $pieComposer, + Link $link, + PiePackageList $installedPiePackages, + TargetPlatform $targetPlatform, + array $phpEnabledExtensions, + array $extensionToPackageSelections, + ): bool { + $extension = ExtensionName::normaliseFromString($link->getTarget()); + $linkRequiresConstraint = $link->getPrettyConstraint(); + + $piePackagesForExtension = $installedPiePackages + ->findByPhpFormattedExtensionName($extension->phpFormattedExtensionName()) + ->onlyVerifiedFor($targetPlatform); + + $piePackageVersion = null; + + if (count($piePackagesForExtension) === 1) { + $piePackageVersion = $piePackagesForExtension->onlyOne()->version(); + } - return; - } + $piePackageVersionMatchesLinkConstraint = null; + if ($piePackageVersion !== null) { + $piePackageVersionMatchesLinkConstraint = $link + ->getConstraint() + ->matches( + (new VersionParser())->parseConstraints($piePackageVersion), + ); + } + // Extension is already installed; but check if it matches the constraint + if (in_array(strtolower($extension->name()), $phpEnabledExtensions)) { + if ($piePackageVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { $this->io->write(sprintf( - '%s: %s:%s %s Missing', + '%s: %s:%s %s Version %s is installed, but does not meet the version requirement', $link->getDescription(), $extension->nameWithExtPrefix(), $linkRequiresConstraint, - Emoji::PROHIBITED, + Emoji::WARNING, + $piePackageVersion, )); - // If a `--select` was made, use it as it was explicitly requested - if (array_key_exists($extension->name(), $extensionToPackageSelections)) { - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $extensionToPackageSelections[$extension->name()], - $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, - ); - } else { - try { - $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); - } catch (OutOfRangeException) { - $matches = []; - } - - if (! Platform::isInteractive()) { - // In non-interactive mode, the user MUST specify a --select definition - $anyErrorsHappened = true; - - if (! count($matches)) { - $this->io->writeError(sprintf( - 'No package selections were made for %s; and PIE could not find any potential packages; you must specify --select=vendor/package to resolve the missing dependency', - $extension->nameWithExtPrefix(), - )); - - return; - } - - // @todo https://github.com/php/pie/issues/592 - $options = array_map( - static fn (array $match) => sprintf(' --select=%s=%s', $extension->name(), $match['name']), - $matches, - ); - - $this->io->writeError(sprintf( - 'No package selections were made for %s; you MUST specify a package selection in non-interactive mode, by adding one of the following parameters to the `pie install` command:%s', - $extension->nameWithExtPrefix(), - "\n" . implode("\n", $options), - )); - - return; - } - - if (! count($matches)) { - $this->io->write(sprintf( - 'PIE could not find any potential matches for %s; if you know which package to use, specify --select=vendor/package in the `pie install` options.', - $extension->nameWithExtPrefix(), - )); - $anyErrorsHappened = true; - - return; - } - - // If we're in interactive mode, prompt the user to select which package they want - $selectedPackageAnswer = (int) $this->io->select( - "\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', - ); - - if ($selectedPackageAnswer === 0) { - $this->io->write('Okay I won\'t install anything for ' . $extension->name()); - $anyErrorsHappened = true; - - return; - } - - $matchesKey = $selectedPackageAnswer - 1; - assert(array_key_exists($matchesKey, $matches)); - - assert($matches[$matchesKey]['name'] !== ''); - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $matches[$matchesKey]['name'], - $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, - ); - } + return true; + } - try { - $this->io->write( - sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()), - verbosity: IOInterface::VERBOSE, - ); - Assert::same( - 0, - $this->installSelectedPackage->withSubCommand( - $extension, - $requestedPackageAndVersion, - $this, - $input, - ), - 'Non-zero exit code %s whilst installing ' . $requestedPackageAndVersion->package, - ); - } catch (Throwable $t) { - $anyErrorsHappened = true; - - $this->io->writeError('' . $t->getMessage() . ''); + $this->io->write(sprintf( + '%s: %s:%s %s Already installed', + $link->getDescription(), + $extension->nameWithExtPrefix(), + $linkRequiresConstraint, + Emoji::GREEN_CHECKMARK, + )); + + return true; + } + + $this->io->write(sprintf( + '%s: %s:%s %s Missing', + $link->getDescription(), + $extension->nameWithExtPrefix(), + $linkRequiresConstraint, + Emoji::PROHIBITED, + )); + + // If a `--select` was made, use it as it was explicitly requested + if (array_key_exists($extension->name(), $extensionToPackageSelections)) { + $requestedPackageAndVersion = new RequestedPackageAndVersion( + $extensionToPackageSelections[$extension->name()], + $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, + ); + } else { + try { + $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); + } catch (OutOfRangeException) { + $matches = []; + } + + if (! Platform::isInteractive()) { + // In non-interactive mode, the user MUST specify a --select definition + if (! count($matches)) { + $this->io->writeError(sprintf( + 'No package selections were made for %s; and PIE could not find any potential packages; you must specify --select=vendor/package to resolve the missing dependency', + $extension->nameWithExtPrefix(), + )); + + return false; } - }, - ); - $this->io->write(PHP_EOL . 'Finished checking extensions.'); + // @todo https://github.com/php/pie/issues/592 + $options = array_map( + static fn (array $match) => sprintf(' --select=%s=%s', $extension->name(), $match['name']), + $matches, + ); - $restoreWorkingDir(); + $this->io->writeError(sprintf( + 'No package selections were made for %s; you MUST specify a package selection in non-interactive mode, by adding one of the following parameters to the `pie install` command:%s', + $extension->nameWithExtPrefix(), + "\n" . implode("\n", $options), + )); - return $anyErrorsHappened ? self::FAILURE : self::SUCCESS; + return false; + } + + if (! count($matches)) { + $this->io->write(sprintf( + 'PIE could not find any potential matches for %s; if you know which package to use, specify --select=vendor/package in the `pie install` options.', + $extension->nameWithExtPrefix(), + )); + + return false; + } + + // If we're in interactive mode, prompt the user to select which package they want + $selectedPackageAnswer = (int) $this->io->select( + "\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', + ); + + if ($selectedPackageAnswer === 0) { + $this->io->write('Okay I won\'t install anything for ' . $extension->name()); + + return false; + } + + $matchesKey = $selectedPackageAnswer - 1; + assert(array_key_exists($matchesKey, $matches)); + + assert($matches[$matchesKey]['name'] !== ''); + $requestedPackageAndVersion = new RequestedPackageAndVersion( + $matches[$matchesKey]['name'], + $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, + ); + } + + try { + $this->io->write( + sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()), + verbosity: IOInterface::VERBOSE, + ); + Assert::same( + 0, + $this->installSelectedPackage->withSubCommand( + $extension, + $requestedPackageAndVersion, + $this, + $input, + ), + 'Non-zero exit code %s whilst installing ' . $requestedPackageAndVersion->package, + ); + + return true; + } catch (Throwable $t) { + $this->io->writeError('' . $t->getMessage() . ''); + + return false; + } } public function execute(InputInterface $input, OutputInterface $output): int From 96cda33be8b072d58800ec0c6bc67ab1b706c529 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 16 Jun 2026 11:19:05 +0100 Subject: [PATCH 09/10] 624: move extension checking and package selection into InstallForPhpProject for better testing --- .../InstallExtensionsForProjectCommand.php | 166 ++++------------- .../CheckExtensionStatus.php | 90 +++++++++ .../NoMatchingPackagesFound.php | 22 +++ .../PackageSelectionRequired.php | 30 +++ .../SelectPackageForExtension.php | 109 +++++++++++ ...InstallExtensionsForProjectCommandTest.php | 13 +- .../CheckExtensionStatusTest.php | 109 +++++++++++ .../SelectPackageForExtensionTest.php | 174 ++++++++++++++++++ 8 files changed, 577 insertions(+), 136 deletions(-) create mode 100644 src/Installing/InstallForPhpProject/CheckExtensionStatus.php create mode 100644 src/Installing/InstallForPhpProject/NoMatchingPackagesFound.php create mode 100644 src/Installing/InstallForPhpProject/PackageSelectionRequired.php create mode 100644 src/Installing/InstallForPhpProject/SelectPackageForExtension.php create mode 100644 test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php create mode 100644 test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 415e9fa0..bd33fd1d 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -9,24 +9,23 @@ use Composer\IO\NullIO; use Composer\Package\Link; use Composer\Package\RootPackageInterface; -use Composer\Package\Version\VersionParser; -use OutOfRangeException; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieJsonEditor; -use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; +use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject; use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired; -use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; +use Php\Pie\Installing\InstallForPhpProject\NoMatchingPackagesFound; +use Php\Pie\Installing\InstallForPhpProject\PackageSelectionRequired; +use Php\Pie\Installing\InstallForPhpProject\SelectPackageForExtension; use Php\Pie\Platform; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\PiePackageList; use Php\Pie\Platform\TargetPlatform; -use Php\Pie\Util\Emoji; use Psr\Container\ContainerInterface; use Safe\Exceptions\DirException; use Safe\Exceptions\FilesystemException; @@ -37,19 +36,13 @@ use Throwable; use Webmozart\Assert\Assert; -use function array_key_exists; use function array_keys; use function array_map; -use function array_merge; use function array_walk; -use function assert; -use function count; use function implode; -use function in_array; use function Safe\getcwd; use function Safe\realpath; use function sprintf; -use function strtolower; use const PHP_EOL; @@ -63,7 +56,8 @@ public function __construct( private readonly ComposerFactoryForProject $composerFactoryForProject, private readonly DetermineExtensionsRequired $determineExtensionsRequired, private readonly InstalledPiePackages $installedPiePackages, - private readonly FindMatchingPackages $findMatchingPackages, + private readonly CheckExtensionStatus $checkExtensionStatus, + private readonly SelectPackageForExtension $selectPackageForExtension, private readonly InstallSelectedPackage $installSelectedPackage, private readonly InstallPiePackageFromPath $installPiePackageFromPath, private readonly ContainerInterface $container, @@ -186,139 +180,47 @@ private function handleSingleExtensionRequiredByPhpProject( array $phpEnabledExtensions, array $extensionToPackageSelections, ): bool { - $extension = ExtensionName::normaliseFromString($link->getTarget()); - $linkRequiresConstraint = $link->getPrettyConstraint(); - + $extension = ExtensionName::normaliseFromString($link->getTarget()); $piePackagesForExtension = $installedPiePackages ->findByPhpFormattedExtensionName($extension->phpFormattedExtensionName()) ->onlyVerifiedFor($targetPlatform); - $piePackageVersion = null; - - if (count($piePackagesForExtension) === 1) { - $piePackageVersion = $piePackagesForExtension->onlyOne()->version(); - } - - $piePackageVersionMatchesLinkConstraint = null; - if ($piePackageVersion !== null) { - $piePackageVersionMatchesLinkConstraint = $link - ->getConstraint() - ->matches( - (new VersionParser())->parseConstraints($piePackageVersion), - ); - } - - // Extension is already installed; but check if it matches the constraint - if (in_array(strtolower($extension->name()), $phpEnabledExtensions)) { - if ($piePackageVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { - $this->io->write(sprintf( - '%s: %s:%s %s Version %s is installed, but does not meet the version requirement', - $link->getDescription(), - $extension->nameWithExtPrefix(), - $linkRequiresConstraint, - Emoji::WARNING, - $piePackageVersion, - )); - - return true; - } - - $this->io->write(sprintf( - '%s: %s:%s %s Already installed', - $link->getDescription(), - $extension->nameWithExtPrefix(), - $linkRequiresConstraint, - Emoji::GREEN_CHECKMARK, - )); - + // Check if the extension is already installed, if it is, return early. + if (($this->checkExtensionStatus)($link, $piePackagesForExtension, $phpEnabledExtensions)) { return true; } - $this->io->write(sprintf( - '%s: %s:%s %s Missing', - $link->getDescription(), - $extension->nameWithExtPrefix(), - $linkRequiresConstraint, - Emoji::PROHIBITED, - )); - - // If a `--select` was made, use it as it was explicitly requested - if (array_key_exists($extension->name(), $extensionToPackageSelections)) { - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $extensionToPackageSelections[$extension->name()], - $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, + try { + $requestedPackageAndVersion = ($this->selectPackageForExtension)( + $extension, + $link->getPrettyConstraint(), + $extensionToPackageSelections, + $pieComposer, + Platform::isInteractive(), ); - } else { - try { - $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); - } catch (OutOfRangeException) { - $matches = []; - } - - if (! Platform::isInteractive()) { - // In non-interactive mode, the user MUST specify a --select definition - if (! count($matches)) { - $this->io->writeError(sprintf( - 'No package selections were made for %s; and PIE could not find any potential packages; you must specify --select=vendor/package to resolve the missing dependency', - $extension->nameWithExtPrefix(), - )); - - return false; - } + } catch (NoMatchingPackagesFound $e) { + $this->io->write($e->getMessage()); - // @todo https://github.com/php/pie/issues/592 - $options = array_map( - static fn (array $match) => sprintf(' --select=%s=%s', $extension->name(), $match['name']), - $matches, - ); - - $this->io->writeError(sprintf( - 'No package selections were made for %s; you MUST specify a package selection in non-interactive mode, by adding one of the following parameters to the `pie install` command:%s', - $extension->nameWithExtPrefix(), - "\n" . implode("\n", $options), - )); - - return false; - } - - if (! count($matches)) { - $this->io->write(sprintf( - 'PIE could not find any potential matches for %s; if you know which package to use, specify --select=vendor/package in the `pie install` options.', - $extension->nameWithExtPrefix(), - )); - - return false; - } - - // If we're in interactive mode, prompt the user to select which package they want - $selectedPackageAnswer = (int) $this->io->select( - "\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', + return false; + } catch (PackageSelectionRequired $e) { + // @todo https://github.com/php/pie/issues/592 + $options = array_map( + static fn (array $match) => sprintf(' --select=%s=%s', $e->extensionName->name(), $match['name']), + $e->matches, ); - if ($selectedPackageAnswer === 0) { - $this->io->write('Okay I won\'t install anything for ' . $extension->name()); - - return false; - } + $this->io->writeError(sprintf( + 'No package selections were made for %s; you MUST specify a package selection in non-interactive mode, by adding one of the following parameters to the `pie install` command:%s', + $e->extensionName->nameWithExtPrefix(), + "\n" . implode("\n", $options), + )); - $matchesKey = $selectedPackageAnswer - 1; - assert(array_key_exists($matchesKey, $matches)); + return false; + } - assert($matches[$matchesKey]['name'] !== ''); - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $matches[$matchesKey]['name'], - $linkRequiresConstraint === '*' || $linkRequiresConstraint === '' ? null : $linkRequiresConstraint, - ); + // Interactive user did not select a package to install + if ($requestedPackageAndVersion === null) { + return false; } try { diff --git a/src/Installing/InstallForPhpProject/CheckExtensionStatus.php b/src/Installing/InstallForPhpProject/CheckExtensionStatus.php new file mode 100644 index 00000000..0494b32f --- /dev/null +++ b/src/Installing/InstallForPhpProject/CheckExtensionStatus.php @@ -0,0 +1,90 @@ + $phpEnabledExtensions + */ + public function __invoke( + Link $link, + PiePackageList $piePackagesForExtension, + array $phpEnabledExtensions, + ): bool { + $extension = ExtensionName::normaliseFromString($link->getTarget()); + $linkRequiresConstraint = $link->getPrettyConstraint(); + + $piePackageVersion = null; + + if (count($piePackagesForExtension) === 1) { + $piePackageVersion = $piePackagesForExtension->onlyOne()->version(); + } + + $piePackageVersionMatchesLinkConstraint = null; + if ($piePackageVersion !== null) { + $piePackageVersionMatchesLinkConstraint = $link + ->getConstraint() + ->matches( + (new VersionParser())->parseConstraints($piePackageVersion), + ); + } + + if (in_array(strtolower($extension->name()), $phpEnabledExtensions)) { + if ($piePackageVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { + $this->io->write(sprintf( + '%s: %s:%s %s Version %s is installed, but does not meet the version requirement', + $link->getDescription(), + $extension->nameWithExtPrefix(), + $linkRequiresConstraint, + Emoji::WARNING, + $piePackageVersion, + )); + + return true; + } + + $this->io->write(sprintf( + '%s: %s:%s %s Already installed', + $link->getDescription(), + $extension->nameWithExtPrefix(), + $linkRequiresConstraint, + Emoji::GREEN_CHECKMARK, + )); + + return true; + } + + $this->io->write(sprintf( + '%s: %s:%s %s Missing', + $link->getDescription(), + $extension->nameWithExtPrefix(), + $linkRequiresConstraint, + Emoji::PROHIBITED, + )); + + return false; + } +} diff --git a/src/Installing/InstallForPhpProject/NoMatchingPackagesFound.php b/src/Installing/InstallForPhpProject/NoMatchingPackagesFound.php new file mode 100644 index 00000000..32bfd51f --- /dev/null +++ b/src/Installing/InstallForPhpProject/NoMatchingPackagesFound.php @@ -0,0 +1,22 @@ +nameWithExtPrefix(), + )); + } +} diff --git a/src/Installing/InstallForPhpProject/PackageSelectionRequired.php b/src/Installing/InstallForPhpProject/PackageSelectionRequired.php new file mode 100644 index 00000000..5aa01903 --- /dev/null +++ b/src/Installing/InstallForPhpProject/PackageSelectionRequired.php @@ -0,0 +1,30 @@ +nameWithExtPrefix()); + } + + /** @param MatchingPackages $matches */ + public static function forExtensionWithMatches(ExtensionName $extensionName, array $matches): self + { + return new self($extensionName, $matches); + } +} diff --git a/src/Installing/InstallForPhpProject/SelectPackageForExtension.php b/src/Installing/InstallForPhpProject/SelectPackageForExtension.php new file mode 100644 index 00000000..944e7747 --- /dev/null +++ b/src/Installing/InstallForPhpProject/SelectPackageForExtension.php @@ -0,0 +1,109 @@ + $extensionToPackageSelections + * + * @throws NoMatchingPackagesFound + * @throws PackageSelectionRequired + */ + public function __invoke( + ExtensionName $extension, + string $linkRequiresConstraint, + array $extensionToPackageSelections, + Composer $pieComposer, + bool $isInteractive, + ): RequestedPackageAndVersion|null { + if (array_key_exists($extension->name(), $extensionToPackageSelections)) { + return new RequestedPackageAndVersion( + $extensionToPackageSelections[$extension->name()], + $this->normaliseConstraint($linkRequiresConstraint), + ); + } + + try { + $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); + } catch (OutOfRangeException) { + $matches = []; + } + + if (! count($matches)) { + throw NoMatchingPackagesFound::forExtension($extension); + } + + if (! $isInteractive) { + throw PackageSelectionRequired::forExtensionWithMatches($extension, $matches); + } + + $selectedPackageAnswer = (int) $this->io->select( + "\nThe following packages may be suitable, which would you like to install: ", + array_merge( + ['None'], + array_map( + static fn (array $match): string => sprintf('%s: %s', $match['name'], $match['description'] ?? 'no description available'), + $matches, + ), + ), + '0', + ); + + if ($selectedPackageAnswer === 0) { + $this->io->write('Okay I won\'t install anything for ' . $extension->name()); + + return null; + } + + $matchesKey = $selectedPackageAnswer - 1; + assert(array_key_exists($matchesKey, $matches)); + assert($matches[$matchesKey]['name'] !== ''); + + return new RequestedPackageAndVersion( + $matches[$matchesKey]['name'], + $this->normaliseConstraint($linkRequiresConstraint), + ); + } + + /** @return non-empty-string|null */ + private function normaliseConstraint(string $linkRequiresConstraint): string|null + { + if ($linkRequiresConstraint === '*' || $linkRequiresConstraint === '') { + return null; + } + + return $linkRequiresConstraint; + } +} diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index bad81fb6..b13e47e1 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -20,11 +20,13 @@ use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; +use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject; use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; +use Php\Pie\Installing\InstallForPhpProject\SelectPackageForExtension; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\PiePackageList; use PHPUnit\Framework\Attributes\CoversClass; @@ -83,7 +85,8 @@ function (string $service): mixed { $this->composerFactoryForProject, new DetermineExtensionsRequired(), $this->installedPiePackages, - $this->findMatchingPackages, + new CheckExtensionStatus(Container::testBuffer()), + new SelectPackageForExtension($this->findMatchingPackages, Container::testBuffer()), $this->installSelectedPackage, $this->installPiePackage, $container, @@ -139,7 +142,7 @@ public function testInstallingExtensionsForPhpProject(): void $this->installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); $this->commandTester->execute( - ['--allow-non-interactive-project-install' => true], + ['--select' => ['foobar=vendor1/foobar']], ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], ); @@ -188,7 +191,7 @@ public function testInstallingExtensionsForPhpProjectWithMultipleMatches(): void $this->installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); $this->commandTester->execute( - ['--allow-non-interactive-project-install' => true], + [], ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], ); @@ -198,7 +201,9 @@ public function testInstallingExtensionsForPhpProjectWithMultipleMatches(): void self::assertStringContainsString('Checking extensions for your project my/project', $outputString); self::assertStringContainsString('requires: ext-standard:* ✅ Already installed', $outputString); self::assertStringContainsString('requires: ext-foobar:^1.2 🚫 Missing', $outputString); - self::assertStringContainsString('Multiple packages were found for ext-foobar', $outputString); + self::assertStringContainsString('No package selections were made for ext-foobar; you MUST specify a package selection', $outputString); + self::assertStringContainsString('--select=foobar=vendor1/foobar', $outputString); + self::assertStringContainsString('--select=foobar=vendor2/afoobar', $outputString); } public function testInstallingExtensionsForPieProject(): void diff --git a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php new file mode 100644 index 00000000..0aef1b83 --- /dev/null +++ b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php @@ -0,0 +1,109 @@ +io = new BufferIO(); + $this->checkExtensionStatus = new CheckExtensionStatus($this->io); + } + + private function makePackage(string $version): Package + { + $composerPackage = new CompletePackage('vendor/foobar', $version . '.0', $version); + $composerPackage->setType(ExtensionType::PhpModule->value); + + return new Package( + $composerPackage, + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'vendor/foobar', + $version, + null, + ); + } + + public function testAlreadyInstalledWithNoVersionInfo(): void + { + $link = new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['foobar'], + ); + + self::assertTrue($result); + self::assertStringContainsString('Already installed', $this->io->getOutput()); + self::assertStringContainsString(Emoji::GREEN_CHECKMARK, $this->io->getOutput()); + } + + public function testAlreadyInstalledWithMatchingVersion(): void + { + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^1.0'), Link::TYPE_REQUIRE, '^1.0'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([$this->makePackage('1.2.0')]), + ['foobar'], + ); + + self::assertTrue($result); + self::assertStringContainsString('Already installed', $this->io->getOutput()); + self::assertStringContainsString(Emoji::GREEN_CHECKMARK, $this->io->getOutput()); + } + + public function testVersionMismatchWarning(): void + { + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([$this->makePackage('1.2.0')]), + ['foobar'], + ); + + self::assertTrue($result); + self::assertStringContainsString('Version 1.2.0 is installed, but does not meet the version requirement', $this->io->getOutput()); + self::assertStringContainsString(Emoji::WARNING, $this->io->getOutput()); + } + + public function testMissingExtension(): void + { + $link = new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + [], + ); + + self::assertFalse($result); + self::assertStringContainsString('Missing', $this->io->getOutput()); + self::assertStringContainsString(Emoji::PROHIBITED, $this->io->getOutput()); + } +} diff --git a/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php b/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php new file mode 100644 index 00000000..766b300d --- /dev/null +++ b/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php @@ -0,0 +1,174 @@ + */ + private array $matches = [ + ['name' => 'vendor/foobar', 'description' => 'The best foobar extension'], + ['name' => 'other/foobar', 'description' => 'Another foobar option'], + ]; + + public function setUp(): void + { + parent::setUp(); + + $this->findMatchingPackages = $this->createMock(FindMatchingPackages::class); + $this->io = new BufferIO(); + $this->selectPackageForExtension = new SelectPackageForExtension($this->findMatchingPackages, $this->io); + $this->pieComposer = $this->createMock(Composer::class); + $this->extension = ExtensionName::normaliseFromString('foobar'); + } + + public function testExplicitSelectOptionIsUsedWithoutLookup(): void + { + $this->findMatchingPackages->expects(self::never())->method('byProvider'); + + $result = ($this->selectPackageForExtension)( + $this->extension, + '^1.0', + ['foobar' => 'vendor/foobar'], + $this->pieComposer, + false, + ); + + self::assertNotNull($result); + self::assertSame('vendor/foobar', $result->package); + self::assertSame('^1.0', $result->version); + } + + public function testExplicitSelectWithWildcardConstraintNormalisesToNull(): void + { + $this->findMatchingPackages->expects(self::never())->method('byProvider'); + + $result = ($this->selectPackageForExtension)( + $this->extension, + '*', + ['foobar' => 'vendor/foobar'], + $this->pieComposer, + false, + ); + + self::assertNotNull($result); + self::assertSame('vendor/foobar', $result->package); + self::assertNull($result->version); + } + + public function testNonInteractiveWithNoMatchesThrowsNoMatchingPackagesFound(): void + { + $this->findMatchingPackages->method('byProvider')->willThrowException(new OutOfRangeException()); + + $this->expectException(NoMatchingPackagesFound::class); + + ($this->selectPackageForExtension)( + $this->extension, + '^1.0', + [], + $this->pieComposer, + false, + ); + } + + public function testNonInteractiveWithMatchesThrowsPackageSelectionRequired(): void + { + $this->findMatchingPackages->method('byProvider')->willReturn($this->matches); + + $exception = null; + try { + ($this->selectPackageForExtension)( + $this->extension, + '^1.0', + [], + $this->pieComposer, + false, + ); + } catch (PackageSelectionRequired $e) { + $exception = $e; + } + + self::assertNotNull($exception); + self::assertSame('foobar', $exception->extensionName->name()); + self::assertSame($this->matches, $exception->matches); + } + + public function testInteractiveWithNoMatchesThrowsNoMatchingPackagesFound(): void + { + $this->findMatchingPackages->method('byProvider')->willThrowException(new OutOfRangeException()); + + $this->expectException(NoMatchingPackagesFound::class); + + ($this->selectPackageForExtension)( + $this->extension, + '^1.0', + [], + $this->pieComposer, + true, + ); + } + + public function testInteractiveUserSelectsPackageWillReturnSelectedPackage(): void + { + $this->findMatchingPackages->method('byProvider')->willReturn($this->matches); + + $io = $this->createMock(IOInterface::class); + $io->method('select')->willReturn('1'); + + $service = new SelectPackageForExtension($this->findMatchingPackages, $io); + + $result = $service( + $this->extension, + '^1.0', + [], + $this->pieComposer, + true, + ); + + self::assertNotNull($result); + self::assertSame('vendor/foobar', $result->package); + self::assertSame('^1.0', $result->version); + } + + public function testInteractiveUserSelectsNothingReturnsNull(): void + { + $this->findMatchingPackages->method('byProvider')->willReturn($this->matches); + + $io = $this->createMock(IOInterface::class); + $io->method('select')->willReturn('0'); + $io->expects(self::once())->method('write')->with(self::stringContains('won\'t install anything')); + + $service = new SelectPackageForExtension($this->findMatchingPackages, $io); + + $result = $service( + $this->extension, + '^1.0', + [], + $this->pieComposer, + true, + ); + + self::assertNull($result); + } +} From 0cec266ca60bf2536438987695c605484da054cd Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 16 Jun 2026 12:06:18 +0100 Subject: [PATCH 10/10] 624: alpine uses php8.5 now --- test/end-to-end/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/end-to-end/Dockerfile b/test/end-to-end/Dockerfile index a827bb98..375978f6 100644 --- a/test/end-to-end/Dockerfile +++ b/test/end-to-end/Dockerfile @@ -50,7 +50,7 @@ COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie RUN pie install -v --auto-install-system-dependencies php/sodium FROM alpine AS test_pie_installs_system_deps_on_alpine -RUN apk add php php-phar php-mbstring php-iconv php-openssl bzip2-dev libbz2 build-base autoconf bison re2c libtool php84-dev +RUN apk add php php-phar php-mbstring php-iconv php-openssl bzip2-dev libbz2 build-base autoconf bison re2c libtool php85-dev COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie RUN pie install -v --auto-install-system-dependencies php/sodium