From 4add071f8622c6d54f19ee331a0d313c4f7fed98 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 11 Sep 2025 15:03:27 +0100 Subject: [PATCH 1/2] Add basic ability to pie install non-interactively --- src/Command/CommandHelper.php | 24 ++++--- .../InstallExtensionsForProjectCommand.php | 71 ++++++++++++++----- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 61987fd8..9af22ad5 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -51,14 +51,15 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class CommandHelper { - public const ARG_REQUESTED_PACKAGE_AND_VERSION = 'requested-package-and-version'; - public const OPTION_WITH_PHP_CONFIG = 'with-php-config'; - public const OPTION_WITH_PHP_PATH = 'with-php-path'; - public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path'; - public 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'; + public const ARG_REQUESTED_PACKAGE_AND_VERSION = 'requested-package-and-version'; + public const OPTION_WITH_PHP_CONFIG = 'with-php-config'; + public const OPTION_WITH_PHP_PATH = 'with-php-path'; + public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path'; + public const OPTION_WORKING_DIRECTORY = 'working-dir'; + public const OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL = 'allow-non-interactive-project-install'; + private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs'; + private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension'; + private const OPTION_FORCE = 'force'; /** @psalm-suppress UnusedConstructor */ private function __construct() @@ -125,6 +126,13 @@ public static function configureDownloadBuildInstallOptions(Command $command, bo self::configurePhpConfigOptions($command); + $command->addOption( + 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.', + ); + /** * Allows additional options for the `./configure` command to be passed here. * Note, this means you probably need to call {@see self::validateInput()} to validate the input manually... diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 2f9131c0..391063b4 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -17,6 +17,7 @@ use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; +use Php\Pie\Platform; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Util\Emoji; use Psr\Container\ContainerInterface; @@ -24,6 +25,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -123,6 +125,17 @@ public function execute(InputInterface $input, OutputInterface $output): int return $exit; } + $allowNonInteractive = $input->hasOption(CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL) && $input->getOption(CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL); + if (! Platform::isInteractive() && ! $allowNonInteractive) { + $output->writeln(sprintf( + 'Aborting! You are not running in interactive mode, and --%s was not specified.', + CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL, + )); + // @todo more details + + return Command::FAILURE; + } + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); $output->writeln(sprintf( @@ -221,27 +234,45 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac return; } - $choiceQuestion = new ChoiceQuestion( - "\nThe following packages may be suitable, which would you like to install: ", - array_merge( - ['None'], - array_map( - static function (array $match): string { - return sprintf('%s: %s', $match['name'], $match['description'] ?? 'no description available'); - }, - $matches, + if (! Platform::isInteractive() && count($matches) > 1) { + $anyErrorsHappened = true; + + // @todo Figure out if there is a way to improve this, safely + $output->writeln(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')), + )); + + return; + } + + if (Platform::isInteractive()) { + $choiceQuestion = new ChoiceQuestion( + "\nThe following packages may be suitable, which would you like to install: ", + array_merge( + ['None'], + array_map( + static function (array $match): string { + return sprintf('%s: %s', $match['name'], $match['description'] ?? 'no description available'); + }, + $matches, + ), ), - ), - 0, - ); + 0, + ); - $selectedPackageAnswer = (string) $helper->ask($input, $output, $choiceQuestion); + $selectedPackageAnswer = (string) $helper->ask($input, $output, $choiceQuestion); - if ($selectedPackageAnswer === 'None') { - $output->writeln('Okay I won\'t install anything for ' . $extension->name()); - $anyErrorsHappened = true; + if ($selectedPackageAnswer === 'None') { + $output->writeln('Okay I won\'t install anything for ' . $extension->name()); + $anyErrorsHappened = true; - return; + return; + } + $selectedPackageName = substr($selectedPackageAnswer, 0, (int) strpos($selectedPackageAnswer, ':')); + } else { + $selectedPackageName = $matches[0]['name']; } $requestInstallConstraint = ''; @@ -250,8 +281,12 @@ static function (array $match): string { } try { + $output->writeln( + sprintf('Invoking pie install of %s%s', $selectedPackageName, $requestInstallConstraint), + OutputInterface::VERBOSITY_VERBOSE, + ); $this->installSelectedPackage->withPieCli( - substr($selectedPackageAnswer, 0, (int) strpos($selectedPackageAnswer, ':')) . $requestInstallConstraint, + $selectedPackageName . $requestInstallConstraint, $input, $output, ); From a82b4a54908b0653af1bd4c3bc8e9cc3596e2422 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 15 Sep 2025 20:47:30 +0100 Subject: [PATCH 2/2] Ensure test uses --allow-non-interactive-project-install --- phpunit.xml.dist | 3 + src/Command/CommandHelper.php | 16 ++--- .../InstallExtensionsForProjectCommand.php | 6 +- ...InstallExtensionsForProjectCommandTest.php | 67 ++++++++++++++----- 4 files changed, 64 insertions(+), 28 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 430ba14c..6582e98a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,9 @@ displayDetailsOnTestsThatTriggerWarnings="true" failOnRisky="true" failOnWarning="true"> + + + test/unit diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 9af22ad5..e1c91d6e 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -51,15 +51,15 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class CommandHelper { - public const ARG_REQUESTED_PACKAGE_AND_VERSION = 'requested-package-and-version'; - public const OPTION_WITH_PHP_CONFIG = 'with-php-config'; - public const OPTION_WITH_PHP_PATH = 'with-php-path'; - public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path'; - public const OPTION_WORKING_DIRECTORY = 'working-dir'; + public const ARG_REQUESTED_PACKAGE_AND_VERSION = 'requested-package-and-version'; + public const OPTION_WITH_PHP_CONFIG = 'with-php-config'; + public const OPTION_WITH_PHP_PATH = 'with-php-path'; + public const OPTION_WITH_PHPIZE_PATH = 'with-phpize-path'; + public const OPTION_WORKING_DIRECTORY = 'working-dir'; public const OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL = 'allow-non-interactive-project-install'; - private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs'; - private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension'; - private const OPTION_FORCE = 'force'; + private const OPTION_MAKE_PARALLEL_JOBS = 'make-parallel-jobs'; + private const OPTION_SKIP_ENABLE_EXTENSION = 'skip-enable-extension'; + private const OPTION_FORCE = 'force'; /** @psalm-suppress UnusedConstructor */ private function __construct() diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 391063b4..c84f258a 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -25,20 +25,22 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Throwable; +use function array_column; use function array_keys; use function array_map; use function array_merge; use function array_walk; use function assert; use function chdir; +use function count; use function getcwd; +use function implode; use function in_array; use function is_dir; use function is_string; @@ -131,7 +133,6 @@ public function execute(InputInterface $input, OutputInterface $output): int 'Aborting! You are not running in interactive mode, and --%s was not specified.', CommandHelper::OPTION_ALLOW_NON_INTERACTIVE_PROJECT_INSTALL, )); - // @todo more details return Command::FAILURE; } @@ -270,6 +271,7 @@ static function (array $match): string { return; } + $selectedPackageName = substr($selectedPackageAnswer, 0, (int) strpos($selectedPackageAnswer, ':')); } else { $selectedPackageName = $matches[0]['name']; diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index f4d6c7b7..e05f5948 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -101,10 +101,6 @@ public function testInstallingExtensionsForPhpProject(): void new Constraint('>=', '1.2.0.0-dev'), new Constraint('<', '2.0.0.0-dev'), ]), Link::TYPE_REQUIRE, '^1.2'), -// 'ext-mismatching' => new Link('my/project', 'ext-mismatching', new MultiConstraint([ -// new Constraint('>=', '2.0.0.0-dev'), -// new Constraint('<', '3.0.0.0-dev'), -// ]), Link::TYPE_REQUIRE, '^2.0'), ]); $this->composerFactoryForProject->method('rootPackage')->willReturn($rootPackage); @@ -119,20 +115,8 @@ public function testInstallingExtensionsForPhpProject(): void $this->composerFactoryForProject->method('composer')->willReturn($composer); -// $this->installedPiePackages->method('allPiePackages')->willReturn([ -// 'mismatching' => new Package( -// $this->createMock(CompletePackageInterface::class), -// ExtensionType::PhpModule, -// ExtensionName::normaliseFromString('mismatching'), -// 'vendor/mismatching', -// '1.9.3', -// null, -// ), -// ]); - $this->findMatchingPackages->method('for')->willReturn([ ['name' => 'vendor1/foobar', 'description' => 'The official foobar implementation'], - ['name' => 'vendor2/afoobar', 'description' => 'An improved async foobar extension'], ]); $this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation'); @@ -142,16 +126,63 @@ public function testInstallingExtensionsForPhpProject(): void ->with('vendor1/foobar:^1.2'); $this->commandTester->execute( - [], + ['--allow-non-interactive-project-install' => true], ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], ); $outputString = $this->commandTester->getDisplay(); - $this->commandTester->assertCommandIsSuccessful(); + $this->commandTester->assertCommandIsSuccessful($outputString); + 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); + } + + public function testInstallingExtensionsForPhpProjectWithMultipleMatches(): void + { + $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); + $rootPackage->setRequires([ + 'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE, '*'), + 'ext-foobar' => new Link('my/project', 'ext-foobar', new MultiConstraint([ + new Constraint('>=', '1.2.0.0-dev'), + new Constraint('<', '2.0.0.0-dev'), + ]), Link::TYPE_REQUIRE, '^1.2'), + ]); + $this->composerFactoryForProject->method('rootPackage')->willReturn($rootPackage); + + $installedRepository = new InstalledArrayRepository([$rootPackage]); + + $repositoryManager = $this->createMock(RepositoryManager::class); + $repositoryManager->method('getLocalRepository')->willReturn($installedRepository); + + $composer = $this->createMock(Composer::class); + $composer->method('getPackage')->willReturn($rootPackage); + $composer->method('getRepositoryManager')->willReturn($repositoryManager); + + $this->composerFactoryForProject->method('composer')->willReturn($composer); + + $this->findMatchingPackages->method('for')->willReturn([ + ['name' => 'vendor1/foobar', 'description' => 'The official foobar implementation'], + ['name' => 'vendor2/afoobar', 'description' => 'An improved async foobar extension'], + ]); + + $this->questionHelper->method('ask')->willReturn('vendor1/foobar: The official foobar implementation'); + + $this->installSelectedPackage->expects(self::never()) + ->method('withPieCli'); + + $this->commandTester->execute( + ['--allow-non-interactive-project-install' => true], + ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], + ); + + $outputString = $this->commandTester->getDisplay(); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); 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); } public function testInstallingExtensionsForPieProject(): void