diff --git a/features/install-multiple-extensions.feature b/features/install-multiple-extensions.feature new file mode 100644 index 00000000..f9bd778b --- /dev/null +++ b/features/install-multiple-extensions.feature @@ -0,0 +1,18 @@ +Feature: Multiple extensions can be installed with PIE + + # pie install + Example: Multiple extensions can be installed at once + When I run a command to install multiple extensions + Then all extensions should have been installed + And the output should show the installation summary + + # pie install --skip-enable-extension + Example: Multiple extensions can be installed without enabling + When I run a command to install multiple extensions without enabling them + Then all extensions should have been installed + And the extensions should not be enabled + + Example: Installation continues when one extension fails + When I run a command to install multiple extensions where one fails + Then the successful extensions should have been installed + And the output should show which extensions failed diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 54b834a9..65da7668 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -202,7 +202,8 @@ public static function determineTargetPlatformFromInputs(InputInterface $input, $phpBinaryPath = PhpBinaryPath::fromPhpBinaryPath($withPhpPath); } - $makeParallelJobs = null; /** `null` means {@see TargetPlatform} will try to auto-detect */ + $makeParallelJobs = null; + /** `null` means {@see TargetPlatform} will try to auto-detect */ if ($input->hasOption(self::OPTION_MAKE_PARALLEL_JOBS)) { $makeParallelJobsOptions = (int) $input->getOption(self::OPTION_MAKE_PARALLEL_JOBS); if ($makeParallelJobsOptions > 0) { @@ -276,33 +277,65 @@ public static function determinePhpizePathFromInputs(InputInterface $input): Php return null; } - public static function requestedNameAndVersionPair(InputInterface $input): RequestedPackageAndVersion + /** @return list */ + public static function requestedNameAndVersionPairs(InputInterface $input): array { - $requestedPackageString = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); + $requestedPackages = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); + + if (! is_array($requestedPackages)) { + // Backwards compatibility: accept a single string instead of array + if (! is_string($requestedPackages) || $requestedPackages === '') { + throw new InvalidArgumentException('No package was requested for installation'); + } - if (! is_string($requestedPackageString) || $requestedPackageString === '') { + $requestedPackages = [$requestedPackages]; + } + + if (count($requestedPackages) === 0) { throw new InvalidArgumentException('No package was requested for installation'); } - $nameAndVersionPairs = (new VersionParser()) - ->parseNameVersionPairs([$requestedPackageString]); - $requestedNameAndVersionPair = reset($nameAndVersionPairs); + $versionParser = new VersionParser(); + $results = []; - if (! is_array($requestedNameAndVersionPair)) { - throw new InvalidArgumentException('Failed to parse the name/version pair'); - } + foreach ($requestedPackages as $requestedPackageString) { + if (! is_string($requestedPackageString) || $requestedPackageString === '') { + continue; + } + + $nameAndVersionPairs = $versionParser->parseNameVersionPairs([$requestedPackageString]); + $requestedNameAndVersionPair = reset($nameAndVersionPairs); - if (! array_key_exists('version', $requestedNameAndVersionPair)) { - $requestedNameAndVersionPair['version'] = null; + if (! is_array($requestedNameAndVersionPair)) { + throw new InvalidArgumentException('Failed to parse the name/version pair: ' . $requestedPackageString); + } + + if (! array_key_exists('version', $requestedNameAndVersionPair)) { + $requestedNameAndVersionPair['version'] = null; + } + + Assert::stringNotEmpty($requestedNameAndVersionPair['name']); + Assert::nullOrStringNotEmpty($requestedNameAndVersionPair['version']); + + $results[] = new RequestedPackageAndVersion( + $requestedNameAndVersionPair['name'], + $requestedNameAndVersionPair['version'], + ); } - Assert::stringNotEmpty($requestedNameAndVersionPair['name']); - Assert::nullOrStringNotEmpty($requestedNameAndVersionPair['version']); + return $results; + } - return new RequestedPackageAndVersion( - $requestedNameAndVersionPair['name'], - $requestedNameAndVersionPair['version'], - ); + public static function requestedNameAndVersionPair(InputInterface $input): RequestedPackageAndVersion + { + $pairs = self::requestedNameAndVersionPairs($input); + + if (count($pairs) === 0) { + throw new InvalidArgumentException('No package was requested for installation'); + } + + // If multiple packages provided, return only the first one for backwards compatibility + return $pairs[0]; } public static function bindConfigureOptionsFromPackage(Command $command, Package $package, InputInterface $input): void diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 33e93d31..c7958fbb 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -13,6 +13,7 @@ use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; +use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Platform\TargetPlatform; @@ -21,9 +22,12 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use function count; +use function is_array; use function sprintf; #[AsCommand( @@ -48,12 +52,19 @@ public function configure(): void { parent::configure(); - CommandHelper::configureDownloadBuildInstallOptions($this); + $this->addArgument( + CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION, + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'The PIE package name(s) and version constraint(s) to install. Can specify multiple packages separated by space, e.g., xdebug/xdebug redis/redis:^5.0', + ); + + CommandHelper::configureDownloadBuildInstallOptions($this, false); } public function execute(InputInterface $input, OutputInterface $output): int { - if (! $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) { + $requestedPackages = $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION); + if (! is_array($requestedPackages) || count($requestedPackages) === 0) { return ($this->invokeSubCommand)( $this, ['command' => 'install-extensions-for-project'], @@ -66,8 +77,10 @@ public function execute(InputInterface $input, OutputInterface $output): int } $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); + + // Parse all packages to install try { - $requestedNameAndVersion = CommandHelper::requestedNameAndVersionPair($input); + $requestedPackagesList = CommandHelper::requestedNameAndVersionPairs($input); } catch (InvalidPackageName $invalidPackageName) { return CommandHelper::handlePackageNotFound( $invalidPackageName, @@ -90,6 +103,122 @@ public function execute(InputInterface $input, OutputInterface $output): int ); } + $totalPackages = count($requestedPackagesList); + + // If only one package, use the original detailed output mode + if ($totalPackages === 1) { + return $this->installSinglePackage( + $requestedPackagesList[0], + $targetPlatform, + $forceInstallPackageVersion, + $input, + true, // verbose mode + ); + } + + // Multiple packages installation mode + $this->io->write(sprintf( + 'Installing %d extensions...', + $totalPackages, + )); + $this->io->write(''); + + $successCount = 0; + $failedPackages = []; + + foreach ($requestedPackagesList as $index => $requestedNameAndVersion) { + $packageNumber = $index + 1; + $this->io->write(sprintf( + '[%d/%d] Processing %s...', + $packageNumber, + $totalPackages, + $requestedNameAndVersion->package, + )); + + try { + $installResult = $this->installSinglePackage( + $requestedNameAndVersion, + $targetPlatform, + $forceInstallPackageVersion, + $input, + false, // quiet mode + ); + + if ($installResult === Command::SUCCESS) { + $successCount++; + $this->io->write(sprintf( + '✓ Successfully installed %s', + $requestedNameAndVersion->package, + )); + } else { + $failedPackages[] = $requestedNameAndVersion->package; + $this->io->writeError(sprintf( + '✗ Failed to install %s', + $requestedNameAndVersion->package, + )); + } + } catch (UnableToResolveRequirement $unableToResolveRequirement) { + $failedPackages[] = $requestedNameAndVersion->package; + $this->io->writeError(sprintf( + '✗ Could not resolve %s: %s', + $requestedNameAndVersion->package, + $unableToResolveRequirement->getMessage(), + )); + } catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) { + $failedPackages[] = $requestedNameAndVersion->package; + $this->io->writeError(sprintf( + '✗ Skipped %s: %s', + $requestedNameAndVersion->package, + $bundledPhpExtensionRefusal->getMessage(), + )); + } catch (ComposerRunFailed $composerRunFailed) { + $failedPackages[] = $requestedNameAndVersion->package; + $this->io->writeError(sprintf( + '✗ Installation failed for %s: %s', + $requestedNameAndVersion->package, + $composerRunFailed->getMessage(), + )); + } + + $this->io->write(''); + } + + // Output summary + $this->io->write('====================================='); + $this->io->write(sprintf( + 'Installation Summary: %d succeeded, %d failed out of %d total', + $successCount, + count($failedPackages), + $totalPackages, + )); + + if (count($failedPackages) > 0) { + $this->io->write('Failed packages:'); + foreach ($failedPackages as $failedPackage) { + $this->io->write(sprintf(' - %s', $failedPackage)); + } + + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + /** + * Install a single extension package + * + * @throws UnableToResolveRequirement + * @throws BundledPhpExtensionRefusal + * @throws ComposerRunFailed + * @throws InvalidPackageName + */ + private function installSinglePackage( + RequestedPackageAndVersion $requestedNameAndVersion, + TargetPlatform $targetPlatform, + bool $forceInstallPackageVersion, + InputInterface $input, + bool $verboseOutput = true, + ): int { $composer = PieComposerFactory::createPieComposer( $this->container, new PieComposerRequest( @@ -97,38 +226,37 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $requestedNameAndVersion, PieOperation::Resolve, - [], // Configure options are not needed for resolve only + [], null, - false, // setting up INI not needed for resolve step + false, ), ); - try { - $package = ($this->dependencyResolver)( - $composer, - $targetPlatform, - $requestedNameAndVersion, - $forceInstallPackageVersion, - ); - } catch (UnableToResolveRequirement $unableToResolveRequirement) { - return CommandHelper::handlePackageNotFound( - $unableToResolveRequirement, - $this->findMatchingPackages, - $this->io, - $targetPlatform, - $this->container, - ); - } catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) { - $this->io->writeError(''); - $this->io->writeError('' . $bundledPhpExtensionRefusal->getMessage() . ''); + $package = ($this->dependencyResolver)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + $forceInstallPackageVersion, + ); - return self::INVALID; + if ($verboseOutput) { + $this->io->write(sprintf( + 'Found package: %s which provides %s', + $package->prettyNameAndVersion(), + $package->extensionName()->nameWithExtPrefix(), + )); + } else { + $this->io->write( + sprintf( + ' Found package: %s which provides %s', + $package->prettyNameAndVersion(), + $package->extensionName()->nameWithExtPrefix(), + ), + verbosity: IOInterface::VERBOSE, + ); } - $this->io->write(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); - - // Now we know what package we have, we can validate the configure options for the command and re-create the - // Composer instance with the populated configure options + // Validate configure options CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); @@ -145,20 +273,14 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - try { - $this->composerIntegrationHandler->runInstall( - $package, - $composer, - $targetPlatform, - $requestedNameAndVersion, - $forceInstallPackageVersion, - true, - ); - } catch (ComposerRunFailed $composerRunFailed) { - $this->io->writeError('' . $composerRunFailed->getMessage() . ''); - - return $composerRunFailed->getCode(); - } + $this->composerIntegrationHandler->runInstall( + $package, + $composer, + $targetPlatform, + $requestedNameAndVersion, + $forceInstallPackageVersion, + true, + ); return Command::SUCCESS; } diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 6a13b6b3..8c620e80 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -9,6 +9,7 @@ use Composer\Installer; use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\ExtensionName; use Php\Pie\Platform; use Php\Pie\Platform\TargetPlatform; use Psr\Container\ContainerInterface; @@ -53,7 +54,6 @@ public function runInstall( } // Write the new requirement to pie.json; because we later essentially just do a `composer install` using that file - $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); $originalPieJsonContent = $pieJsonEditor->addRequire( $requestedPackageAndVersion->package, @@ -68,33 +68,15 @@ public function runInstall( $composer->getRepositoryManager()->getLocalRepository()->removePackage($pkg); } - $composerInstaller = PieComposerInstaller::createWithPhpBinary( - $targetPlatform->phpBinaryPath, + $composerInstaller = $this->createConfiguredInstaller( + $targetPlatform, $package->extensionName(), - $this->arrayCollectionIo, $composer, + $requestedPackageAndVersion->package, + $forceInstallPackageVersion, ); - $composerInstaller - ->setAllowedTypes(['php-ext', 'php-ext-zend']) - ->setInstall(true) - ->setIgnoredTypes([]) - ->setDryRun(false) - ->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($forceInstallPackageVersion)) - ->setDownloadOnly(false); - if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { - $composerInstaller->setUpdate(true); - $composerInstaller->setUpdateAllowList([$requestedPackageAndVersion->package]); - } - - $resultCode = $composerInstaller->run(); - - if ($resultCode !== Installer::ERROR_NONE) { - // Revert composer.json change - $pieJsonEditor->revert($originalPieJsonContent); - - throw ComposerRunFailed::fromExitCode($resultCode); - } + $this->executeComposerInstaller($composerInstaller, $pieJsonEditor, $originalPieJsonContent); if (! $runCleanup) { return; @@ -110,19 +92,42 @@ public function runUninstall( RequestedPackageAndVersion $requestedPackageAndVersionToRemove, ): void { // Write the new requirement to pie.json; because we later essentially just do a `composer install` using that file - $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); $originalPieJsonContent = $pieJsonEditor->removeRequire($requestedPackageAndVersionToRemove->package); // Refresh the Composer instance so it re-reads the updated pie.json $composer = PieComposerFactory::recreatePieComposer($this->container, $composer); + $composerInstaller = $this->createConfiguredInstaller( + $targetPlatform, + $packageToRemove->extensionName(), + $composer, + $requestedPackageAndVersionToRemove->package, + false, + ); + + $this->executeComposerInstaller($composerInstaller, $pieJsonEditor, $originalPieJsonContent); + } + + /** + * Creates and configures a PieComposerInstaller instance with common settings. + */ + private function createConfiguredInstaller( + TargetPlatform $targetPlatform, + ExtensionName $extensionName, + Composer $composer, + string $packageName, + bool $forceInstallPackageVersion, + ): PieComposerInstaller { + $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); + $composerInstaller = PieComposerInstaller::createWithPhpBinary( $targetPlatform->phpBinaryPath, - $packageToRemove->extensionName(), + $extensionName, $this->arrayCollectionIo, $composer, ); + $composerInstaller ->setAllowedTypes(['php-ext', 'php-ext-zend']) ->setInstall(true) @@ -130,11 +135,30 @@ public function runUninstall( ->setDryRun(false) ->setDownloadOnly(false); + if ($forceInstallPackageVersion) { + $composerInstaller->setPlatformRequirementFilter( + PlatformRequirementFilterFactory::fromBoolOrList(true), + ); + } + if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { $composerInstaller->setUpdate(true); - $composerInstaller->setUpdateAllowList([$requestedPackageAndVersionToRemove->package]); + $composerInstaller->setUpdateAllowList([$packageName]); } + return $composerInstaller; + } + + /** + * Executes the Composer installer and handles errors with automatic revert. + * + * @throws ComposerRunFailed + */ + private function executeComposerInstaller( + PieComposerInstaller $composerInstaller, + PieJsonEditor $pieJsonEditor, + string $originalPieJsonContent, + ): void { $resultCode = $composerInstaller->run(); if ($resultCode !== Installer::ERROR_NONE) { diff --git a/test/integration/Command/InstallCommandTest.php b/test/integration/Command/InstallCommandTest.php index dd39e533..ea633c7a 100644 --- a/test/integration/Command/InstallCommandTest.php +++ b/test/integration/Command/InstallCommandTest.php @@ -77,7 +77,7 @@ public function testInstallCommandWillInstallCompatibleExtensionNonWindows(strin $this->commandTester->execute( [ - 'requested-package-and-version' => self::TEST_PACKAGE, + 'requested-package-and-version' => [self::TEST_PACKAGE], '--with-php-config' => $phpConfigPath, '--skip-enable-extension' => true, ], @@ -114,7 +114,7 @@ public function testInstallCommandWillInstallCompatibleExtensionNonWindows(strin public function testInstallCommandWillInstallCompatibleExtensionWindows(): void { $this->commandTester->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE, + 'requested-package-and-version' => [self::TEST_PACKAGE], '--skip-enable-extension' => true, ]); diff --git a/test/integration/Command/ShowCommandTest.php b/test/integration/Command/ShowCommandTest.php index c1a4948f..501a7d0f 100644 --- a/test/integration/Command/ShowCommandTest.php +++ b/test/integration/Command/ShowCommandTest.php @@ -65,7 +65,7 @@ public function testExecuteWithAvailableConstrainedUpdates(): void $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); $installCommand->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE . ':2.0.2', + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], '--with-php-config' => $phpConfig, ]); $installCommand->assertCommandIsSuccessful(); @@ -112,7 +112,7 @@ public function testExecuteWithAvailableUnconstrainedUpdates(): void $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); $installCommand->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE . ':2.0.2', + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], '--with-php-config' => $phpConfig, ]); $installCommand->assertCommandIsSuccessful(); @@ -159,7 +159,7 @@ public function testExecuteWithOnlyUnconstrainedUpdates(): void $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); $installCommand->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE . ':2.0.2', + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], '--with-php-config' => $phpConfig, ]); $installCommand->assertCommandIsSuccessful(); diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php index 04c1f1ee..551095f9 100644 --- a/test/unit/Command/CommandHelperTest.php +++ b/test/unit/Command/CommandHelperTest.php @@ -88,6 +88,81 @@ public function testInvalidRequestedNameAndVersionPairThrowsExceptionWhenNoPacka CommandHelper::requestedNameAndVersionPair($input); } + public function testRequestedNameAndVersionPairsWithMultiplePackages(): void + { + $input = $this->createMock(InputInterface::class); + + $input->expects(self::once()) + ->method('getArgument') + ->with('requested-package-and-version') + ->willReturn(['php/ext1', 'php/ext2:^1.0', 'php/ext3:@alpha']); + + $result = CommandHelper::requestedNameAndVersionPairs($input); + + self::assertCount(3, $result); + self::assertEquals(new RequestedPackageAndVersion('php/ext1', null), $result[0]); + self::assertEquals(new RequestedPackageAndVersion('php/ext2', '^1.0'), $result[1]); + self::assertEquals(new RequestedPackageAndVersion('php/ext3', '@alpha'), $result[2]); + } + + public function testRequestedNameAndVersionPairsWithSinglePackage(): void + { + $input = $this->createMock(InputInterface::class); + + $input->expects(self::once()) + ->method('getArgument') + ->with('requested-package-and-version') + ->willReturn(['php/ext1:^2.0']); + + $result = CommandHelper::requestedNameAndVersionPairs($input); + + self::assertCount(1, $result); + self::assertEquals(new RequestedPackageAndVersion('php/ext1', '^2.0'), $result[0]); + } + + public function testRequestedNameAndVersionPairsBackwardsCompatibleWithString(): void + { + $input = $this->createMock(InputInterface::class); + + $input->expects(self::once()) + ->method('getArgument') + ->with('requested-package-and-version') + ->willReturn('php/ext1:^1.0'); + + $result = CommandHelper::requestedNameAndVersionPairs($input); + + self::assertCount(1, $result); + self::assertEquals(new RequestedPackageAndVersion('php/ext1', '^1.0'), $result[0]); + } + + public function testRequestedNameAndVersionPairsThrowsExceptionWhenEmpty(): void + { + $input = $this->createMock(InputInterface::class); + + $input->expects(self::once()) + ->method('getArgument') + ->with('requested-package-and-version') + ->willReturn([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No package was requested for installation'); + CommandHelper::requestedNameAndVersionPairs($input); + } + + public function testRequestedNameAndVersionPairUsesFirstFromMultiple(): void + { + $input = $this->createMock(InputInterface::class); + + $input->expects(self::once()) + ->method('getArgument') + ->with('requested-package-and-version') + ->willReturn(['php/ext1', 'php/ext2']); + + $result = CommandHelper::requestedNameAndVersionPair($input); + + self::assertEquals(new RequestedPackageAndVersion('php/ext1', null), $result); + } + public function testBindingConfigurationOptionsFromPackage(): void { self::markTestIncomplete(__METHOD__);