diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 4f1237b4..fd2c8fde 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( @@ -37,6 +29,7 @@ public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, private readonly ComposerIntegrationHandler $composerIntegrationHandler, + private readonly InvokeSubCommand $invokeSubCommand, ) { parent::__construct(); } @@ -48,27 +41,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 ($this->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..cf87564b 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -8,10 +8,12 @@ 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; 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; @@ -32,6 +34,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; @@ -50,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(); @@ -67,16 +72,28 @@ 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; + } + + return ($this->installPiePackageFromPath)( + $this, + $cwd, + $rootPackage, + PieJsonEditor::fromTargetPlatform(CommandHelper::determineTargetPlatformFromInputs($input, new NullOutput())), + $input, + $output, + ); } + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); + $output->writeln(sprintf( 'Checking extensions for your project %s (path: %s)', $rootPackage->getPrettyName(), 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(); + } }