Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions features/install-multiple-extensions.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Feature: Multiple extensions can be installed with PIE

# pie install <ext1> <ext2> <ext3>
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 <ext1> <ext2> --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
69 changes: 51 additions & 18 deletions src/Command/CommandHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -276,33 +277,65 @@ public static function determinePhpizePathFromInputs(InputInterface $input): Php
return null;
}

public static function requestedNameAndVersionPair(InputInterface $input): RequestedPackageAndVersion
/** @return list<RequestedPackageAndVersion> */
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
Expand Down
206 changes: 164 additions & 42 deletions src/Command/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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'],
Expand All @@ -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,
Expand All @@ -90,45 +103,160 @@ 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(
'<info>Installing %d extensions...</info>',
$totalPackages,
));
$this->io->write('');

$successCount = 0;
$failedPackages = [];

foreach ($requestedPackagesList as $index => $requestedNameAndVersion) {
$packageNumber = $index + 1;
$this->io->write(sprintf(
'<comment>[%d/%d]</comment> Processing <info>%s</info>...',
$packageNumber,
$totalPackages,
$requestedNameAndVersion->package,
));

try {
$installResult = $this->installSinglePackage(
$requestedNameAndVersion,
$targetPlatform,
$forceInstallPackageVersion,
$input,
false, // quiet mode
);

if ($installResult === Command::SUCCESS) {
$successCount++;
$this->io->write(sprintf(
'<info>✓ Successfully installed %s</info>',
$requestedNameAndVersion->package,
));
} else {
$failedPackages[] = $requestedNameAndVersion->package;
$this->io->writeError(sprintf(
'<error>✗ Failed to install %s</error>',
$requestedNameAndVersion->package,
));
}
} catch (UnableToResolveRequirement $unableToResolveRequirement) {
$failedPackages[] = $requestedNameAndVersion->package;
$this->io->writeError(sprintf(
'<error>✗ Could not resolve %s: %s</error>',
$requestedNameAndVersion->package,
$unableToResolveRequirement->getMessage(),
));
} catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) {
$failedPackages[] = $requestedNameAndVersion->package;
$this->io->writeError(sprintf(
'<comment>✗ Skipped %s: %s</comment>',
$requestedNameAndVersion->package,
$bundledPhpExtensionRefusal->getMessage(),
));
} catch (ComposerRunFailed $composerRunFailed) {
$failedPackages[] = $requestedNameAndVersion->package;
$this->io->writeError(sprintf(
'<error>✗ Installation failed for %s: %s</error>',
$requestedNameAndVersion->package,
$composerRunFailed->getMessage(),
));
}

$this->io->write('');
}

// Output summary
$this->io->write('<info>=====================================</info>');
$this->io->write(sprintf(
'<info>Installation Summary:</info> %d succeeded, %d failed out of %d total',
$successCount,
count($failedPackages),
$totalPackages,
));

if (count($failedPackages) > 0) {
$this->io->write('<error>Failed packages:</error>');
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(
$this->io,
$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('<comment>' . $bundledPhpExtensionRefusal->getMessage() . '</comment>');
$package = ($this->dependencyResolver)(
$composer,
$targetPlatform,
$requestedNameAndVersion,
$forceInstallPackageVersion,
);

return self::INVALID;
if ($verboseOutput) {
$this->io->write(sprintf(
'<info>Found package:</info> %s which provides <info>%s</info>',
$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('<info>Found package:</info> %s which provides <info>%s</info>', $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);

Expand All @@ -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('<error>' . $composerRunFailed->getMessage() . '</error>');

return $composerRunFailed->getCode();
}
$this->composerIntegrationHandler->runInstall(
$package,
$composer,
$targetPlatform,
$requestedNameAndVersion,
$forceInstallPackageVersion,
true,
);

return Command::SUCCESS;
}
Expand Down
Loading
Loading