From 256472f2b8b8b772db97904ca87c2e672d8cda3f Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 16 Apr 2025 21:21:33 +0100 Subject: [PATCH 1/2] Install an extension from the current working directory --- src/Command/CommandHelper.php | 29 ++++++++++++ src/Command/InstallCommand.php | 34 +++----------- .../InstallExtensionsForProjectCommand.php | 45 ++++++++++++++++--- 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 47e4beba..56f1fc20 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -19,13 +19,20 @@ use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Webmozart\Assert\Assert; +use function array_combine; +use function array_filter; use function array_key_exists; +use function array_keys; +use function array_map; +use function array_merge; +use function array_values; use function is_array; use function is_string; use function reset; @@ -315,4 +322,26 @@ public static function listRepositories(Composer $composer, OutputInterface $out )); } } + + /** @param array $subCommandInput */ + public static function invokeSubCommand( + Command $command, + array $subCommandInput, + InputInterface $originalCommandInput, + OutputInterface $output, + ): int { + $originalSuppliedOptions = array_filter($originalCommandInput->getOptions()); + $installForProjectInput = new ArrayInput(array_merge( + $subCommandInput, + array_combine( + array_map(static fn ($key) => '--' . $key, array_keys($originalSuppliedOptions)), + array_values($originalSuppliedOptions), + ), + )); + + $application = $command->getApplication(); + Assert::notNull($application); + + return $application->doRun($installForProjectInput, $output); + } } diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 4f1237b4..6feb95ea 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -14,17 +14,9 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Webmozart\Assert\Assert; - -use function array_combine; -use function array_filter; -use function array_keys; -use function array_map; -use function array_merge; -use function array_values; + use function sprintf; #[AsCommand( @@ -48,27 +40,15 @@ public function configure(): void CommandHelper::configureDownloadBuildInstallOptions($this); } - private function invokeInstallForProject(InputInterface $input, OutputInterface $output): int - { - $originalSuppliedOptions = array_filter($input->getOptions()); - $installForProjectInput = new ArrayInput(array_merge( - ['command' => 'install-extensions-for-project'], - array_combine( - array_map(static fn ($key) => '--' . $key, array_keys($originalSuppliedOptions)), - array_values($originalSuppliedOptions), - ), - )); - - $application = $this->getApplication(); - Assert::notNull($application); - - return $application->doRun($installForProjectInput, $output); - } - public function execute(InputInterface $input, OutputInterface $output): int { if (! $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) { - return $this->invokeInstallForProject($input, $output); + return CommandHelper::invokeSubCommand( + $this, + ['command' => 'install-extensions-for-project'], + $input, + $output, + ); } if (! TargetPlatform::isRunningAsRoot()) { diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index b96f9366..1a2a90b2 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -8,6 +8,7 @@ use OutOfRangeException; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; +use Php\Pie\ComposerIntegration\PieJsonEditor; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; @@ -32,6 +33,8 @@ use function assert; use function getcwd; use function in_array; +use function is_string; +use function realpath; use function sprintf; use function str_starts_with; use function strlen; @@ -67,16 +70,48 @@ public function execute(InputInterface $input, OutputInterface $output): int $helper = $this->getHelper('question'); assert($helper instanceof QuestionHelper); - $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); - $rootPackage = $this->findRootPackage->forCwd($input, $output); if (ExtensionType::isValid($rootPackage->getType())) { - $output->writeln('This composer.json is for an extension, installing missing packages is not supported.'); - - return Command::INVALID; + $cwd = realpath(getcwd()); + if (! is_string($cwd) || $cwd === '') { + $output->writeln('Failed to determine current working directory.'); + + return Command::FAILURE; + } + + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, new NullOutput()); + $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); + + $output->writeln(sprintf('Installing PIE extension from %s', $cwd)); + $pieJsonEditor + ->ensureExists() + ->addRepository('path', $cwd); + + try { + return CommandHelper::invokeSubCommand( + $this, + [ + 'command' => 'install', + 'requested-package-and-version' => $rootPackage->getName() . ':*@dev', + ], + $input, + $output, + ); + } finally { + $output->writeln( + sprintf( + 'Removing temporary path repository: %s', + $cwd, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + $pieJsonEditor->removeRepository($cwd); + } } + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); + $output->writeln(sprintf( 'Checking extensions for your project %s (path: %s)', $rootPackage->getPrettyName(), From 092db83eefda4bb3e3d3b213a2902510c8754c64 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 16 Apr 2025 22:32:04 +0100 Subject: [PATCH 2/2] Add component to install a PIE package from Path --- src/Command/CommandHelper.php | 29 ---- src/Command/InstallCommand.php | 3 +- .../InstallExtensionsForProjectCommand.php | 38 ++---- src/Command/InvokeSubCommand.php | 44 ++++++ .../InstallForPhpProject/FindRootPackage.php | 1 + .../InstallPiePackageFromPath.php | 53 ++++++++ .../InstallPiePackageFromPathTest.php | 125 ++++++++++++++++++ ...InstallExtensionsForProjectCommandTest.php | 40 +++++- 8 files changed, 274 insertions(+), 59 deletions(-) create mode 100644 src/Command/InvokeSubCommand.php create mode 100644 src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php create mode 100644 test/behaviour/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 56f1fc20..47e4beba 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -19,20 +19,13 @@ use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Webmozart\Assert\Assert; -use function array_combine; -use function array_filter; use function array_key_exists; -use function array_keys; -use function array_map; -use function array_merge; -use function array_values; use function is_array; use function is_string; use function reset; @@ -322,26 +315,4 @@ public static function listRepositories(Composer $composer, OutputInterface $out )); } } - - /** @param array $subCommandInput */ - public static function invokeSubCommand( - Command $command, - array $subCommandInput, - InputInterface $originalCommandInput, - OutputInterface $output, - ): int { - $originalSuppliedOptions = array_filter($originalCommandInput->getOptions()); - $installForProjectInput = new ArrayInput(array_merge( - $subCommandInput, - array_combine( - array_map(static fn ($key) => '--' . $key, array_keys($originalSuppliedOptions)), - array_values($originalSuppliedOptions), - ), - )); - - $application = $command->getApplication(); - Assert::notNull($application); - - return $application->doRun($installForProjectInput, $output); - } } diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 6feb95ea..fd2c8fde 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -29,6 +29,7 @@ public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, private readonly ComposerIntegrationHandler $composerIntegrationHandler, + private readonly InvokeSubCommand $invokeSubCommand, ) { parent::__construct(); } @@ -43,7 +44,7 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { if (! $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) { - return CommandHelper::invokeSubCommand( + return ($this->invokeSubCommand)( $this, ['command' => 'install-extensions-for-project'], $input, diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 1a2a90b2..cf87564b 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -13,6 +13,7 @@ use Php\Pie\ExtensionType; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\FindRootPackage; +use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -53,6 +54,7 @@ public function __construct( private readonly FindRootPackage $findRootPackage, private readonly FindMatchingPackages $findMatchingPackages, private readonly InstallSelectedPackage $installSelectedPackage, + private readonly InstallPiePackageFromPath $installPiePackageFromPath, private readonly ContainerInterface $container, ) { parent::__construct(); @@ -80,34 +82,14 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, new NullOutput()); - $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); - - $output->writeln(sprintf('Installing PIE extension from %s', $cwd)); - $pieJsonEditor - ->ensureExists() - ->addRepository('path', $cwd); - - try { - return CommandHelper::invokeSubCommand( - $this, - [ - 'command' => 'install', - 'requested-package-and-version' => $rootPackage->getName() . ':*@dev', - ], - $input, - $output, - ); - } finally { - $output->writeln( - sprintf( - 'Removing temporary path repository: %s', - $cwd, - ), - OutputInterface::VERBOSITY_VERBOSE, - ); - $pieJsonEditor->removeRepository($cwd); - } + return ($this->installPiePackageFromPath)( + $this, + $cwd, + $rootPackage, + PieJsonEditor::fromTargetPlatform(CommandHelper::determineTargetPlatformFromInputs($input, new NullOutput())), + $input, + $output, + ); } $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); diff --git a/src/Command/InvokeSubCommand.php b/src/Command/InvokeSubCommand.php new file mode 100644 index 00000000..b6b2546a --- /dev/null +++ b/src/Command/InvokeSubCommand.php @@ -0,0 +1,44 @@ + $subCommandInput */ + public function __invoke( + Command $command, + array $subCommandInput, + InputInterface $originalCommandInput, + OutputInterface $output, + ): int { + $originalSuppliedOptions = array_filter($originalCommandInput->getOptions()); + $installForProjectInput = new ArrayInput(array_merge( + $subCommandInput, + array_combine( + array_map(static fn ($key) => '--' . $key, array_keys($originalSuppliedOptions)), + array_values($originalSuppliedOptions), + ), + )); + + $application = $command->getApplication(); + Assert::notNull($application); + + return $application->doRun($installForProjectInput, $output); + } +} diff --git a/src/Installing/InstallForPhpProject/FindRootPackage.php b/src/Installing/InstallForPhpProject/FindRootPackage.php index 3f6ab111..3e26db98 100644 --- a/src/Installing/InstallForPhpProject/FindRootPackage.php +++ b/src/Installing/InstallForPhpProject/FindRootPackage.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ class FindRootPackage { public function forCwd(InputInterface $input, OutputInterface $output): RootPackageInterface diff --git a/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php b/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php new file mode 100644 index 00000000..73ccf367 --- /dev/null +++ b/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php @@ -0,0 +1,53 @@ +writeln(sprintf('Installing PIE extension from %s', $piePackagePath)); + $pieJsonEditor + ->ensureExists() + ->addRepository('path', $piePackagePath); + + try { + return ($this->invokeSubCommand)( + $invokeContext, + [ + 'command' => 'install', + 'requested-package-and-version' => $pieRootPackage->getName() . ':*@dev', + ], + $input, + $output, + ); + } finally { + $output->writeln( + sprintf( + 'Removing temporary path repository: %s', + $piePackagePath, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + $pieJsonEditor->removeRepository($piePackagePath); + } + } +} diff --git a/test/behaviour/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php b/test/behaviour/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php new file mode 100644 index 00000000..ec36ff6e --- /dev/null +++ b/test/behaviour/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php @@ -0,0 +1,125 @@ +command = $this->createMock(Command::class); + $this->rootPackage = new RootPackage('foo/bar', '1.2.3.0', '1.2.3'); + $this->invokeSubCommand = $this->createMock(InvokeSubCommand::class); + $this->pieJsonEditor = $this->createMock(PieJsonEditor::class); + $this->input = $this->createMock(InputInterface::class); + $this->output = new BufferedOutput(); + } + + public function testInvokeWithSuccessfulSubCommand(): void + { + $this->pieJsonEditor + ->expects(self::once()) + ->method('ensureExists') + ->willReturnSelf(); + $this->pieJsonEditor + ->expects(self::once()) + ->method('addRepository') + ->with('path', '/path/to/pie/package'); + $this->pieJsonEditor + ->expects(self::once()) + ->method('removeRepository') + ->with('/path/to/pie/package'); + + $this->invokeSubCommand->expects(self::once()) + ->method('__invoke') + ->with( + $this->command, + [ + 'command' => 'install', + 'requested-package-and-version' => 'foo/bar:*@dev', + ], + $this->input, + $this->output, + ) + ->willReturn(Command::SUCCESS); + + self::assertSame( + Command::SUCCESS, + (new InstallPiePackageFromPath($this->invokeSubCommand))( + $this->command, + '/path/to/pie/package', + $this->rootPackage, + $this->pieJsonEditor, + $this->input, + $this->output, + ), + ); + } + + public function testInvokeWithSubCommandException(): void + { + $this->pieJsonEditor + ->expects(self::once()) + ->method('ensureExists') + ->willReturnSelf(); + $this->pieJsonEditor + ->expects(self::once()) + ->method('addRepository') + ->with('path', '/path/to/pie/package'); + + // We still expect the package path to be removed even if there is an exception! + $this->pieJsonEditor + ->expects(self::once()) + ->method('removeRepository') + ->with('/path/to/pie/package'); + + $this->invokeSubCommand->expects(self::once()) + ->method('__invoke') + ->with( + $this->command, + [ + 'command' => 'install', + 'requested-package-and-version' => 'foo/bar:*@dev', + ], + $this->input, + $this->output, + ) + ->willThrowException(new RuntimeException('oh no')); + + $install = new InstallPiePackageFromPath($this->invokeSubCommand); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('oh no'); + $install( + $this->command, + '/path/to/pie/package', + $this->rootPackage, + $this->pieJsonEditor, + $this->input, + $this->output, + ); + } +} diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index 36f9d3d3..5376f8ce 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -9,20 +9,28 @@ use Composer\Semver\Constraint\Constraint; use Php\Pie\Command\InstallExtensionsForProjectCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; +use Php\Pie\ComposerIntegration\PieJsonEditor; use Php\Pie\ComposerIntegration\QuieterConsoleIO; +use Php\Pie\ExtensionType; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\FindRootPackage; +use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; +use function getcwd; + #[CoversClass(InstallExtensionsForProjectCommand::class)] final class InstallExtensionsForProjectCommandTest extends TestCase { @@ -31,6 +39,7 @@ final class InstallExtensionsForProjectCommandTest extends TestCase private FindMatchingPackages&MockObject $findMatchingPackages; private InstallSelectedPackage&MockObject $installSelectedPackage; private QuestionHelper&MockObject $questionHelper; + private InstallPiePackageFromPath&MockObject $installPiePackage; public function setUp(): void { @@ -57,12 +66,14 @@ function (string $service): mixed { $this->findRootpackage = $this->createMock(FindRootPackage::class); $this->findMatchingPackages = $this->createMock(FindMatchingPackages::class); $this->installSelectedPackage = $this->createMock(InstallSelectedPackage::class); + $this->installPiePackage = $this->createMock(InstallPiePackageFromPath::class); $this->questionHelper = $this->createMock(QuestionHelper::class); $cmd = new InstallExtensionsForProjectCommand( $this->findRootpackage, $this->findMatchingPackages, $this->installSelectedPackage, + $this->installPiePackage, $container, ); $cmd->setHelperSet(new HelperSet([ @@ -71,7 +82,7 @@ function (string $service): mixed { $this->commandTester = new CommandTester($cmd); } - public function testInstallingExtensionsForProject(): void + public function testInstallingExtensionsForPhpProject(): void { $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); $rootPackage->setRequires([ @@ -103,4 +114,31 @@ public function testInstallingExtensionsForProject(): void self::assertStringContainsString('requires: standard ✅ Already installed', $outputString); self::assertStringContainsString('requires: foobar ⚠️ Missing', $outputString); } + + public function testInstallingExtensionsForPieProject(): void + { + $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); + $rootPackage->setType(ExtensionType::PhpModule->value); + $this->findRootpackage->method('forCwd')->willReturn($rootPackage); + + $this->installPiePackage + ->expects(self::once()) + ->method('__invoke') + ->with( + self::isInstanceOf(InstallExtensionsForProjectCommand::class), + getcwd(), + $rootPackage, + self::isInstanceOf(PieJsonEditor::class), + self::isInstanceOf(InputInterface::class), + self::isInstanceOf(OutputInterface::class), + ) + ->willReturn(Command::SUCCESS); + + $this->commandTester->execute( + [], + ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], + ); + + $this->commandTester->assertCommandIsSuccessful(); + } }