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/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 27e976ca..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,9 +39,13 @@ 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; use function reset; +use function Safe\chdir; +use function Safe\getcwd; use function sprintf; use function str_starts_with; use function strtolower; @@ -56,8 +61,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'; + 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'; private const OPTION_FORCE = 'force'; @@ -141,7 +147,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( @@ -510,4 +523,55 @@ 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; + } + + /** @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 0e999619..bd33fd1d 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -4,25 +4,28 @@ namespace Php\Pie\Command; +use Composer\Composer; use Composer\IO\IOInterface; use Composer\IO\NullIO; use Composer\Package\Link; -use Composer\Package\Version\VersionParser; -use OutOfRangeException; +use Composer\Package\RootPackageInterface; 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\Util\Emoji; +use Php\Pie\Platform\PiePackageList; +use Php\Pie\Platform\TargetPlatform; use Psr\Container\ContainerInterface; use Safe\Exceptions\DirException; use Safe\Exceptions\FilesystemException; @@ -33,22 +36,13 @@ use Throwable; use Webmozart\Assert\Assert; -use function array_column; -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 is_dir; -use function Safe\chdir; use function Safe\getcwd; use function Safe\realpath; use function sprintf; -use function strtolower; use const PHP_EOL; @@ -62,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, @@ -78,72 +73,48 @@ 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 { - $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, - ); - } + try { + $cwd = realpath(getcwd()); + } catch (FilesystemException | DirException $e) { + $this->io->writeError(sprintf( + 'Failed to determine current working directory: %s', + $e->getMessage(), + )); - CommandHelper::applyNoCacheOptionIfSet($input, $this->io); + $restoreWorkingDir(); - $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); + return Command::FAILURE; + } - 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(), - )); - - $restoreWorkingDir(); - - return Command::FAILURE; - } - - $exit = ($this->installPiePackageFromPath)( - $this, - $cwd, - $rootPackage, - PieJsonEditor::fromTargetPlatform(CommandHelper::determineTargetPlatformFromInputs($input, new NullIO())), - $input, - $this->io, - ); + $exit = ($this->installPiePackageFromPath)( + $this, + $cwd, + $rootPackage, + PieJsonEditor::fromTargetPlatform(CommandHelper::determineTargetPlatformFromInputs($input, new NullIO())), + $input, + $this->io, + ); - $restoreWorkingDir(); + $restoreWorkingDir(); - return $exit; - } + return $exit; + } + + private function handlePhpProject(InputInterface $input, RootPackageInterface $rootPackage, callable $restoreWorkingDir): int + { + $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 (! 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,152 +138,126 @@ public function execute(InputInterface $input, OutputInterface $output): int array_walk( $extensionsRequired, - function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform): void { - $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(); + function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform, $extensionToPackageSelections): void { + if ( + $this->handleSingleExtensionRequiredByPhpProject( + $input, + $pieComposer, + $link, + $installedPiePackages, + $targetPlatform, + $phpEnabledExtensions, + $extensionToPackageSelections, + ) + ) { + return; } - $piePackageVersionMatchesLinkConstraint = null; - if ($piePackageVersion !== null) { - $piePackageVersionMatchesLinkConstraint = $link - ->getConstraint() - ->matches( - (new VersionParser())->parseConstraints($piePackageVersion), - ); - } + $anyErrorsHappened = true; + }, + ); - 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', - $link->getDescription(), - $link->getTarget(), - $linkRequiresConstraint, - Emoji::WARNING, - $piePackageVersion, - $link->getConstraint()->getPrettyString(), - )); - - return; - } - - $this->io->write(sprintf( - '%s: %s:%s %s Already installed', - $link->getDescription(), - $link->getTarget(), - $linkRequiresConstraint, - Emoji::GREEN_CHECKMARK, - )); + $this->io->write(PHP_EOL . 'Finished checking extensions.'); - return; - } + $restoreWorkingDir(); - $this->io->write(sprintf( - '%s: %s:%s %s Missing', - $link->getDescription(), - $link->getTarget(), - $linkRequiresConstraint, - Emoji::PROHIBITED, - )); + return $anyErrorsHappened ? self::FAILURE : self::SUCCESS; + } - try { - $matches = $this->findMatchingPackages->byProvider($pieComposer, $extension); - } catch (OutOfRangeException) { - $anyErrorsHappened = true; + /** + * 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()); + $piePackagesForExtension = $installedPiePackages + ->findByPhpFormattedExtensionName($extension->phpFormattedExtensionName()) + ->onlyVerifiedFor($targetPlatform); + + // Check if the extension is already installed, if it is, return early. + if (($this->checkExtensionStatus)($link, $piePackagesForExtension, $phpEnabledExtensions)) { + return true; + } - $this->io->writeError(sprintf( - 'No packages were found for %s', - $extension->nameWithExtPrefix(), - )); + try { + $requestedPackageAndVersion = ($this->selectPackageForExtension)( + $extension, + $link->getPrettyConstraint(), + $extensionToPackageSelections, + $pieComposer, + Platform::isInteractive(), + ); + } catch (NoMatchingPackagesFound $e) { + $this->io->write($e->getMessage()); + + 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, + ); - return; - } + $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), + )); - if (! Platform::isInteractive() && count($matches) > 1) { - $anyErrorsHappened = true; + return false; + } - // @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')), - )); + // Interactive user did not select a package to install + if ($requestedPackageAndVersion === null) { + return false; + } - return; - } + 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, + ); - 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 ($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)); - - $selectedPackageName = $matches[$matchesKey]['name']; - } else { - $selectedPackageName = $matches[0]['name']; - } + return true; + } catch (Throwable $t) { + $this->io->writeError('' . $t->getMessage() . ''); - assert($selectedPackageName !== ''); - $requestedPackageAndVersion = new RequestedPackageAndVersion( - $selectedPackageName, - $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( - ExtensionName::normaliseFromString($link->getTarget()), - $requestedPackageAndVersion, - $this, - $input, - ), - 'Non-zero exit code %s whilst installing ' . $requestedPackageAndVersion->package, - ); - } catch (Throwable $t) { - $anyErrorsHappened = true; - - $this->io->writeError('' . $t->getMessage() . ''); - } - }, - ); + return false; + } + } - $this->io->write(PHP_EOL . 'Finished checking extensions.'); + public function execute(InputInterface $input, OutputInterface $output): int + { + $restoreWorkingDir = CommandHelper::handleWorkingDirectory($input, $this->io); + CommandHelper::applyNoCacheOptionIfSet($input, $this->io); - $restoreWorkingDir(); + $rootPackage = $this->composerFactoryForProject->rootPackage($this->io); - return $anyErrorsHappened ? self::FAILURE : self::SUCCESS; + if (ExtensionType::isValid($rootPackage->getType())) { + return $this->handlePieProject($input, $rootPackage, $restoreWorkingDir); + } + + return $this->handlePhpProject($input, $rootPackage, $restoreWorkingDir); } } 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/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'; 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 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); + } +}