From b76b136769cc05acd54ccbe120f8047ca949bb6b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 26 Feb 2026 12:28:33 +0000 Subject: [PATCH 01/13] 435: extract fetching dependency status --- src/Command/InfoCommand.php | 33 +++--------- src/DependencyResolver/DependencyStatus.php | 36 +++++++++++++ .../FetchDependencyStatuses.php | 48 +++++++++++++++++ src/Platform/InstalledPiePackages.php | 3 +- .../DependencyStatusTest.php | 49 +++++++++++++++++ .../FetchDependencyStatusesTest.php | 53 +++++++++++++++++++ 6 files changed, 194 insertions(+), 28 deletions(-) create mode 100644 src/DependencyResolver/DependencyStatus.php create mode 100644 src/DependencyResolver/FetchDependencyStatuses.php create mode 100644 test/unit/DependencyResolver/DependencyStatusTest.php create mode 100644 test/unit/DependencyResolver/FetchDependencyStatusesTest.php diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index d096b49c..21112a61 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -5,17 +5,15 @@ namespace Php\Pie\Command; use Composer\IO\IOInterface; -use Composer\Semver\Constraint\Constraint; -use Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; +use Php\Pie\DependencyResolver\FetchDependencyStatuses; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; -use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\ThreadSafetyMode; use Php\Pie\Util\Emoji; use Psr\Container\ContainerInterface; @@ -24,7 +22,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function array_key_exists; use function count; use function in_array; use function sprintf; @@ -38,6 +35,7 @@ final class InfoCommand extends Command public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, + private readonly FetchDependencyStatuses $fetchDependencyStatuses, private readonly FindMatchingPackages $findMatchingPackages, private readonly IOInterface $io, ) { @@ -127,30 +125,11 @@ public function execute(InputInterface $input, OutputInterface $output): int )); $this->io->write("\nDependencies:"); - $requires = $package->composerPackage()->getRequires(); - - if (count($requires) > 0) { - /** @var array> $platformConstraints */ - $platformConstraints = []; - $composerPlatform = new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), null); - foreach ($composerPlatform->getPackages() as $platformPackage) { - $platformConstraints[$platformPackage->getName()][] = new Constraint('==', $platformPackage->getVersion()); - } - foreach ($requires as $requireName => $requireLink) { - $packageStatus = sprintf(' %s: %s %%s', $requireName, $requireLink->getConstraint()->getPrettyString()); - if (! array_key_exists($requireName, $platformConstraints)) { - $this->io->write(sprintf($packageStatus, Emoji::PROHIBITED . ' (not installed)')); - continue; - } - - foreach ($platformConstraints[$requireName] as $constraint) { - if ($requireLink->getConstraint()->matches($constraint)) { - $this->io->write(sprintf($packageStatus, Emoji::GREEN_CHECKMARK)); - } else { - $this->io->write(sprintf($packageStatus, Emoji::PROHIBITED . ' (your version is ' . $constraint->getVersion() . ')')); - } - } + $dependencyStatuses = ($this->fetchDependencyStatuses)($targetPlatform, $composer, $package->composerPackage()); + if (count($dependencyStatuses) > 0) { + foreach ($dependencyStatuses as $dependencyStatus) { + $this->io->write(' ' . $dependencyStatus->asPrettyString()); } } else { $this->io->write(' No dependencies.'); diff --git a/src/DependencyResolver/DependencyStatus.php b/src/DependencyResolver/DependencyStatus.php new file mode 100644 index 00000000..1a556e8a --- /dev/null +++ b/src/DependencyResolver/DependencyStatus.php @@ -0,0 +1,36 @@ +name, $this->requireConstraint->getPrettyString()); + if ($this->installedVersion === null) { + return sprintf($statusTemplate, Emoji::PROHIBITED . ' (not installed)'); + } + + if (! $this->requireConstraint->matches($this->installedVersion)) { + return sprintf($statusTemplate, Emoji::PROHIBITED . ' (your version is ' . $this->installedVersion->getVersion() . ')'); + } + + return sprintf($statusTemplate, Emoji::GREEN_CHECKMARK); + } +} diff --git a/src/DependencyResolver/FetchDependencyStatuses.php b/src/DependencyResolver/FetchDependencyStatuses.php new file mode 100644 index 00000000..4269173f --- /dev/null +++ b/src/DependencyResolver/FetchDependencyStatuses.php @@ -0,0 +1,48 @@ + */ + public function __invoke(TargetPlatform $targetPlatform, Composer $composer, CompletePackageInterface $package): array + { + $requires = $package->getRequires(); + + if (count($requires) <= 0) { + return []; + } + + /** @var array $platformConstraints */ + $platformConstraints = []; + $composerPlatform = new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), null); + foreach ($composerPlatform->getPackages() as $platformPackage) { + $platformConstraints[$platformPackage->getName()] = new Constraint('==', $platformPackage->getVersion()); + } + + $checkedPackages = []; + + foreach ($requires as $requireName => $requireLink) { + $checkedPackages[] = new DependencyStatus( + $requireName, + $requireLink->getConstraint(), + array_key_exists($requireName, $platformConstraints) ? $platformConstraints[$requireName] : null, + ); + } + + return $checkedPackages; + } +} diff --git a/src/Platform/InstalledPiePackages.php b/src/Platform/InstalledPiePackages.php index efc2353d..00f30254 100644 --- a/src/Platform/InstalledPiePackages.php +++ b/src/Platform/InstalledPiePackages.php @@ -8,6 +8,7 @@ use Composer\Package\BasePackage; use Composer\Package\CompletePackageInterface; use Php\Pie\DependencyResolver\Package; +use Php\Pie\ExtensionName; use function array_combine; use function array_filter; @@ -38,7 +39,7 @@ static function (CompletePackageInterface $package): Package { ->getLocalRepository() ->getPackages(), static function (BasePackage $basePackage): bool { - return $basePackage instanceof CompletePackageInterface; + return $basePackage instanceof CompletePackageInterface && ExtensionName::isValidExtensionName($basePackage->getName()); }, ), ); diff --git a/test/unit/DependencyResolver/DependencyStatusTest.php b/test/unit/DependencyResolver/DependencyStatusTest.php new file mode 100644 index 00000000..e1012fc6 --- /dev/null +++ b/test/unit/DependencyResolver/DependencyStatusTest.php @@ -0,0 +1,49 @@ + 2.0.0 ' . Emoji::PROHIBITED . ' (not installed)', + (new DependencyStatus('foo', new Constraint('>', '2.0.0'), null))->asPrettyString(), + ); + } + + public function testAsPrettyStringWhenInstalledAndMatchesAllConstraint(): void + { + self::assertSame( + 'foo: * ' . Emoji::GREEN_CHECKMARK, + (new DependencyStatus('foo', new MatchAllConstraint(), new Constraint('=', '1.0.0.0')))->asPrettyString(), + ); + } + + public function testAsPrettyStringWhenInstalledAndMatchesSemverConstraint(): void + { + self::assertSame( + 'foo: ^1.0 ' . Emoji::GREEN_CHECKMARK, + (new DependencyStatus('foo', (new VersionParser())->parseConstraints('^1.0'), new Constraint('=', '1.0.0.0')))->asPrettyString(), + ); + } + + public function testAsPrettyStringWhenInstalledButMismatchingVersion(): void + { + self::assertSame( + 'foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (your version is 1.2.3.0)', + (new DependencyStatus('foo', new Constraint('>', '2.0.0'), new Constraint('=', '1.2.3.0')))->asPrettyString(), + ); + } +} diff --git a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php new file mode 100644 index 00000000..42e9f8eb --- /dev/null +++ b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php @@ -0,0 +1,53 @@ +createMock(Composer::class), $package)); + } + + public function testRequiresReturnsListOfStatuses(): void + { + $php = PhpBinaryPath::fromCurrentProcess(); + + $package = new CompletePackage('vendor/foo', '1.2.3.0', '1.2.3'); + $package->setRequires([ + 'ext-core' => new Link('__root__', 'ext-core', new Constraint('=', $php->version() . '.0')), + 'ext-nonsense_extension' => new Link('__root__', 'ext-nonsense_extension', new Constraint('=', '*')), + 'ext-standard' => new Link('__root__', 'ext-standard', new Constraint('<', '1.0.0.0')), + ]); + + $deps = (new FetchDependencyStatuses())( + TargetPlatform::fromPhpBinaryPath($php, null, null), + Factory::create($this->createMock(IOInterface::class)), + $package, + ); + + self::assertIsList($deps); + self::assertCount(3, $deps); + + self::assertSame('ext-core: == 8.4.17.0 ✅', $deps[0]->asPrettyString()); + self::assertSame('ext-nonsense_extension: == * 🚫 (not installed)', $deps[1]->asPrettyString()); + self::assertSame('ext-standard: < 1.0.0.0 🚫 (your version is 8.4.17.0)', $deps[2]->asPrettyString()); + } +} From e87299846dde0329053cabf812b1d37efc3c7169 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 2 Mar 2026 11:21:49 +0000 Subject: [PATCH 02/13] 435: move PackageManager to Platform namespace --- src/Command/BuildCommand.php | 2 +- src/Command/InstallCommand.php | 2 +- src/{SelfManage/BuildTools => Platform}/PackageManager.php | 2 +- src/SelfManage/BuildTools/BinaryBuildToolFinder.php | 1 + src/SelfManage/BuildTools/CheckAllBuildTools.php | 1 + src/SelfManage/BuildTools/PhpizeBuildToolFinder.php | 1 + .../BuildTools => Platform}/PackageManagerTest.php | 4 ++-- test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php | 2 +- test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php | 2 +- 9 files changed, 10 insertions(+), 7 deletions(-) rename src/{SelfManage/BuildTools => Platform}/PackageManager.php (98%) rename test/unit/{SelfManage/BuildTools => Platform}/PackageManagerTest.php (92%) diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index f37aa0b0..2cfe6faa 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -15,8 +15,8 @@ use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; +use Php\Pie\Platform\PackageManager; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; -use Php\Pie\SelfManage\BuildTools\PackageManager; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index d7dbd16e..71167e78 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -15,9 +15,9 @@ use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; -use Php\Pie\SelfManage\BuildTools\PackageManager; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/SelfManage/BuildTools/PackageManager.php b/src/Platform/PackageManager.php similarity index 98% rename from src/SelfManage/BuildTools/PackageManager.php rename to src/Platform/PackageManager.php index da9cac15..561a23a4 100644 --- a/src/SelfManage/BuildTools/PackageManager.php +++ b/src/Platform/PackageManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Php\Pie\SelfManage\BuildTools; +namespace Php\Pie\Platform; use Php\Pie\File\Sudo; use Php\Pie\Platform; diff --git a/src/SelfManage/BuildTools/BinaryBuildToolFinder.php b/src/SelfManage/BuildTools/BinaryBuildToolFinder.php index 786af850..d5a0f9aa 100644 --- a/src/SelfManage/BuildTools/BinaryBuildToolFinder.php +++ b/src/SelfManage/BuildTools/BinaryBuildToolFinder.php @@ -4,6 +4,7 @@ namespace Php\Pie\SelfManage\BuildTools; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Process\ExecutableFinder; diff --git a/src/SelfManage/BuildTools/CheckAllBuildTools.php b/src/SelfManage/BuildTools/CheckAllBuildTools.php index c8bd73bd..715a25f4 100644 --- a/src/SelfManage/BuildTools/CheckAllBuildTools.php +++ b/src/SelfManage/BuildTools/CheckAllBuildTools.php @@ -5,6 +5,7 @@ namespace Php\Pie\SelfManage\BuildTools; use Composer\IO\IOInterface; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Throwable; diff --git a/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php b/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php index aae61d68..ec10cf3a 100644 --- a/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php +++ b/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php @@ -4,6 +4,7 @@ namespace Php\Pie\SelfManage\BuildTools; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use RuntimeException; diff --git a/test/unit/SelfManage/BuildTools/PackageManagerTest.php b/test/unit/Platform/PackageManagerTest.php similarity index 92% rename from test/unit/SelfManage/BuildTools/PackageManagerTest.php rename to test/unit/Platform/PackageManagerTest.php index f66d490f..87c72d92 100644 --- a/test/unit/SelfManage/BuildTools/PackageManagerTest.php +++ b/test/unit/Platform/PackageManagerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Php\PieUnitTest\SelfManage\BuildTools; +namespace Php\PieUnitTest\Platform; -use Php\Pie\SelfManage\BuildTools\PackageManager; +use Php\Pie\Platform\PackageManager; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; diff --git a/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php b/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php index c5b63096..f1c52f9d 100644 --- a/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php +++ b/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php @@ -4,10 +4,10 @@ namespace Php\PieUnitTest\SelfManage\BuildTools; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Platform\TargetPlatform; use Php\Pie\SelfManage\BuildTools\BinaryBuildToolFinder; -use Php\Pie\SelfManage\BuildTools\PackageManager; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php b/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php index 07a479f4..c8e89f40 100644 --- a/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php +++ b/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php @@ -8,12 +8,12 @@ use Php\Pie\Platform\Architecture; use Php\Pie\Platform\OperatingSystem; use Php\Pie\Platform\OperatingSystemFamily; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Platform\ThreadSafetyMode; use Php\Pie\SelfManage\BuildTools\BinaryBuildToolFinder; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; -use Php\Pie\SelfManage\BuildTools\PackageManager; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Output\OutputInterface; From be8c76c4caf22e672a79b47fcc9a5c2ae67e4917 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 3 Mar 2026 08:18:05 +0000 Subject: [PATCH 03/13] 435: add ->satisfied() helper method to DependencyStatus --- src/DependencyResolver/DependencyStatus.php | 5 +++ .../DependencyStatusTest.php | 36 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/DependencyResolver/DependencyStatus.php b/src/DependencyResolver/DependencyStatus.php index 1a556e8a..7df5c063 100644 --- a/src/DependencyResolver/DependencyStatus.php +++ b/src/DependencyResolver/DependencyStatus.php @@ -33,4 +33,9 @@ public function asPrettyString(): string return sprintf($statusTemplate, Emoji::GREEN_CHECKMARK); } + + public function satisfied(): bool + { + return $this->installedVersion !== null && $this->requireConstraint->matches($this->installedVersion); + } } diff --git a/test/unit/DependencyResolver/DependencyStatusTest.php b/test/unit/DependencyResolver/DependencyStatusTest.php index e1012fc6..5010d468 100644 --- a/test/unit/DependencyResolver/DependencyStatusTest.php +++ b/test/unit/DependencyResolver/DependencyStatusTest.php @@ -15,35 +15,31 @@ #[CoversClass(DependencyStatus::class)] final class DependencyStatusTest extends TestCase { - public function testAsPrettyStringWhenNotInstalled(): void + public function testDependencyNotInstalled(): void { - self::assertSame( - 'foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (not installed)', - (new DependencyStatus('foo', new Constraint('>', '2.0.0'), null))->asPrettyString(), - ); + $dependencyStatus = new DependencyStatus('foo', new Constraint('>', '2.0.0'), null); + self::assertSame('foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (not installed)', $dependencyStatus->asPrettyString()); + self::assertFalse($dependencyStatus->satisfied()); } - public function testAsPrettyStringWhenInstalledAndMatchesAllConstraint(): void + public function testDependencyInstalledAndMatchesAllConstraint(): void { - self::assertSame( - 'foo: * ' . Emoji::GREEN_CHECKMARK, - (new DependencyStatus('foo', new MatchAllConstraint(), new Constraint('=', '1.0.0.0')))->asPrettyString(), - ); + $dependencyStatus = new DependencyStatus('foo', new MatchAllConstraint(), new Constraint('=', '1.0.0.0')); + self::assertSame('foo: * ' . Emoji::GREEN_CHECKMARK, $dependencyStatus->asPrettyString()); + self::assertTrue($dependencyStatus->satisfied()); } - public function testAsPrettyStringWhenInstalledAndMatchesSemverConstraint(): void + public function testDependencyInstalledAndMatchesSemverConstraint(): void { - self::assertSame( - 'foo: ^1.0 ' . Emoji::GREEN_CHECKMARK, - (new DependencyStatus('foo', (new VersionParser())->parseConstraints('^1.0'), new Constraint('=', '1.0.0.0')))->asPrettyString(), - ); + $dependencyStatus = new DependencyStatus('foo', (new VersionParser())->parseConstraints('^1.0'), new Constraint('=', '1.0.0.0')); + self::assertSame('foo: ^1.0 ' . Emoji::GREEN_CHECKMARK, $dependencyStatus->asPrettyString()); + self::assertTrue($dependencyStatus->satisfied()); } - public function testAsPrettyStringWhenInstalledButMismatchingVersion(): void + public function testDependencyInstalledButMismatchingVersion(): void { - self::assertSame( - 'foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (your version is 1.2.3.0)', - (new DependencyStatus('foo', new Constraint('>', '2.0.0'), new Constraint('=', '1.2.3.0')))->asPrettyString(), - ); + $dependencyStatus = new DependencyStatus('foo', new Constraint('>', '2.0.0'), new Constraint('=', '1.2.3.0')); + self::assertSame('foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (your version is 1.2.3.0)', $dependencyStatus->asPrettyString()); + self::assertFalse($dependencyStatus->satisfied()); } } From a41a1ab678cadc60cdedcf867faf9be9c546629d Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 3 Mar 2026 08:19:08 +0000 Subject: [PATCH 04/13] Add .noai to disable Jetbrains AI --- .noai | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .noai diff --git a/.noai b/.noai new file mode 100644 index 00000000..e69de29b From 299903fd9d2bff0ae8ad362c3783cf416c39c5a6 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 3 Mar 2026 12:24:48 +0000 Subject: [PATCH 05/13] 435: added service to pre-scan for missing dependencies before installing an extension --- src/Command/InstallCommand.php | 13 ++ .../PhpBinaryPathBasedPlatformRepository.php | 4 + src/Container.php | 8 + .../PrescanSystemDependencies.php | 147 ++++++++++++++++++ test/end-to-end/Dockerfile | 27 ++++ .../PrescanSystemDependenciesTest.php | 43 +++++ 6 files changed, 242 insertions(+) create mode 100644 src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php create mode 100644 test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 71167e78..6ca24df0 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -11,6 +11,7 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; +use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; @@ -23,6 +24,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function sprintf; @@ -35,6 +37,7 @@ final class InstallCommand extends Command public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, + private readonly PrescanSystemDependencies $prescanSystemDependencies, private readonly ComposerIntegrationHandler $composerIntegrationHandler, private readonly InvokeSubCommand $invokeSubCommand, private readonly FindMatchingPackages $findMatchingPackages, @@ -102,6 +105,16 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); + // @todo flag to disable this check + try { + ($this->prescanSystemDependencies)($composer, $targetPlatform, $requestedNameAndVersion); + } catch (Throwable $anything) { + $this->io->writeError( + 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), + verbosity: IOInterface::VERBOSE, + ); + } + try { $package = ($this->dependencyResolver)( $composer, diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index e29f941b..f7fc2a40 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -146,6 +146,10 @@ private function detectLibraryWithPkgConfig(string $alias, string $library): voi $this->addPackage($lib); } + /** + * Instructions for PIE to install these libraries, if they are missing, should be added + * into {@see \Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies} + */ private function addLibrariesUsingPkgConfig(): void { $this->detectLibraryWithPkgConfig('curl', 'libcurl'); diff --git a/src/Container.php b/src/Container.php index a262c6cc..34cb9eff 100644 --- a/src/Container.php +++ b/src/Container.php @@ -39,6 +39,7 @@ use Php\Pie\Installing\UninstallUsingUnlink; use Php\Pie\Installing\UnixInstall; use Php\Pie\Installing\WindowsInstall; +use Php\Pie\Platform\PackageManager; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; use Psr\Container\ContainerInterface; use Symfony\Component\Console\ConsoleEvents; @@ -209,6 +210,13 @@ static function (): CheckAllBuildTools { $container->alias(Ini\RemoveIniEntryWithFileGetContents::class, Ini\RemoveIniEntry::class); + $container->singleton( + PackageManager::class, + static function (): PackageManager|null { + return PackageManager::detect(); + }, + ); + return $container; } diff --git a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php new file mode 100644 index 00000000..b77b8dbb --- /dev/null +++ b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php @@ -0,0 +1,147 @@ +> */ + private readonly array $libraries; + + public function __construct( + private readonly DependencyResolver $dependencyResolver, + private readonly FetchDependencyStatuses $fetchDependencyStatuses, + private readonly IOInterface $io, + private readonly PackageManager|null $packageManager, + ) { + /** + * Checks for the existence of these libraries should be added into + * {@see \Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository::addLibrariesUsingPkgConfig()} + */ + $this->libraries = [ + 'sodium' => [ + PackageManager::Apt->value => 'libsodium-dev', + PackageManager::Apk->value => 'libsodium-dev', + PackageManager::Dnf->value => 'pkgconfig(libsodium)', + PackageManager::Yum->value => 'pkgconfig(libsodium)', + ], + 'jpeg' => [ + PackageManager::Apt->value => 'libjpeg-dev', + PackageManager::Apk->value => 'libjpeg-turbo-dev', + PackageManager::Dnf->value => 'pkgconfig(libjpeg)', + PackageManager::Yum->value => 'pkgconfig(libjpeg)', + ], + ]; + } + + public function __invoke(Composer $composer, TargetPlatform $targetPlatform, RequestedPackageAndVersion $requestedNameAndVersion): void + { + if ($this->packageManager === null) { + $this->io->writeError('Skipping pre-scan of system dependencies, as a supported package manager could not be detected.', verbosity: IOInterface::VERBOSE); + + return; + } + + $this->io->write(sprintf('Checking system dependencies are present for extension %s', $requestedNameAndVersion->prettyNameAndVersion()), verbosity: IOInterface::VERBOSE); + + $package = ($this->dependencyResolver)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + true, + ); + + $unmetDependencies = array_filter( + ($this->fetchDependencyStatuses)($targetPlatform, $composer, $package->composerPackage()), + static function (DependencyStatus $dependencyStatus): bool { + return ! $dependencyStatus->satisfied(); + }, + ); + + if (! count($unmetDependencies)) { + $this->io->write('All system dependencies are already installed.', verbosity: IOInterface::VERBOSE); + + return; + } + + $this->io->write( + sprintf('Extension %s has unmet dependencies: %s', $requestedNameAndVersion->prettyNameAndVersion(), implode(', ', array_map(static fn (DependencyStatus $status): string => $status->name, $unmetDependencies))), + verbosity: IOInterface::VERBOSE, + ); + + $packageManagerPackages = array_values(array_unique(array_filter(array_map( + fn (DependencyStatus $unmetDependency): string|null => $this->packageManagerPackageForDependency($unmetDependency, $this->packageManager), + $unmetDependencies, + )))); + + if (! count($packageManagerPackages)) { + $this->io->writeError('No system dependencies could be installed automatically by PIE.', verbosity: IOInterface::VERBOSE); + + return; + } + + $proposedInstallCommand = implode(' ', $this->packageManager->installCommand($packageManagerPackages)); + $this->io->write(sprintf('Installing missing system dependencies: %s', $proposedInstallCommand)); + + try { + $this->packageManager->install($packageManagerPackages); + } catch (Throwable $anything) { + $this->io->writeError(sprintf('Failed to install missing system dependencies: %s', $anything->getMessage())); + } + } + + private function packageManagerPackageForDependency(DependencyStatus $unmetDependency, PackageManager $packageManager): string|null + { + $depName = str_replace('lib-', '', $unmetDependency->name); + + if (! array_key_exists($depName, $this->libraries)) { + $this->io->writeError( + sprintf('Could not automatically install %s, as PIE does not have the package manager definition.', $unmetDependency->name), + verbosity: IOInterface::VERBOSE, + ); + + return null; + } + + if (! array_key_exists($packageManager->value, $this->libraries[$depName])) { + $this->io->writeError( + sprintf('Could not automatically install "%s", as PIE does not have a definition for "%s"', $unmetDependency->name, $packageManager->value), + verbosity: IOInterface::VERBOSE, + ); + + return null; + } + + $packageManagerPackage = $this->libraries[$depName][$packageManager->value]; + + // Note: ideally, we should also parse the version constraint. This initial iteration will ignore that, to be improved later. + $this->io->write( + sprintf('Adding %s package %s to be installed for %s', $packageManager->value, $packageManagerPackage, $unmetDependency->name), + verbosity: IOInterface::VERBOSE, + ); + + return $packageManagerPackage; + } +} diff --git a/test/end-to-end/Dockerfile b/test/end-to-end/Dockerfile index 50b3a374..abd5c11f 100644 --- a/test/end-to-end/Dockerfile +++ b/test/end-to-end/Dockerfile @@ -31,3 +31,30 @@ RUN apt-get remove --allow-remove-essential -y apt USER linuxbrew RUN pie install --auto-install-build-tools -v asgrim/example-pie-extension RUN pie show + +FROM ubuntu AS test_pie_installs_system_deps_on_ubuntu +RUN apt-get update && apt install -y unzip curl wget gcc make autoconf libtool bison re2c pkg-config libzip-dev libssl-dev libonig-dev +RUN mkdir -p /opt/php \ + && mkdir -p /tmp/php \ + && cd /tmp/php \ + && wget -O php.tgz https://www.php.net/distributions/php-8.4.17.tar.gz \ + && tar zxf php.tgz \ + && rm php.tgz \ + && cd * \ + && ./buildconf --force \ + && ./configure --prefix=/opt/php --disable-all --enable-phar --enable-filter --enable-mbstring --with-openssl --with-iconv --with-zip \ + && make -j$(nproc) \ + && make install +ENV PATH="$PATH:/opt/php/bin" +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +RUN pie install -v php/sodium + +FROM alpine AS test_pie_installs_system_deps_on_alpine +RUN apk add php php-phar php-mbstring php-iconv php-openssl bzip2-dev libbz2 build-base autoconf bison re2c libtool php84-dev +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +RUN pie install -v php/sodium + +FROM fedora AS test_pie_installs_system_deps_on_fedora +RUN dnf install -y php php-pecl-zip unzip gcc make autoconf bison re2c libtool php-devel +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +RUN pie install -v php/sodium diff --git a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php new file mode 100644 index 00000000..a0342a1f --- /dev/null +++ b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php @@ -0,0 +1,43 @@ + Date: Wed, 4 Mar 2026 11:32:02 +0000 Subject: [PATCH 06/13] Fix filtering of InstalledPiePackages to exclude non-extensions Note: baseline widened because of https://github.com/phpstan/phpstan/discussions/14228 --- phpstan-baseline.neon | 6 ++++++ src/Platform/InstalledPiePackages.php | 9 ++++++++- .../unit/Platform/InstalledPiePackagesTest.php | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 785a6d83..3f1ae163 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -288,6 +288,12 @@ parameters: count: 1 path: src/Platform.php + - + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(Composer\\Package\\BasePackage\)\: mixed\)\|null, Closure\(Composer\\Package\\CompletePackageInterface\)\: Php\\Pie\\DependencyResolver\\Package given\.$#' + identifier: argument.type + count: 1 + path: src/Platform/InstalledPiePackages.php + - message: '#^Call to function array_key_exists\(\) with 1 and array\{non\-falsy\-string, non\-empty\-string, non\-empty\-string\} will always evaluate to true\.$#' identifier: function.alreadyNarrowedType diff --git a/src/Platform/InstalledPiePackages.php b/src/Platform/InstalledPiePackages.php index 00f30254..4eb5346f 100644 --- a/src/Platform/InstalledPiePackages.php +++ b/src/Platform/InstalledPiePackages.php @@ -7,6 +7,7 @@ use Composer\Composer; use Composer\Package\BasePackage; use Composer\Package\CompletePackageInterface; +use InvalidArgumentException; use Php\Pie\DependencyResolver\Package; use Php\Pie\ExtensionName; @@ -39,7 +40,13 @@ static function (CompletePackageInterface $package): Package { ->getLocalRepository() ->getPackages(), static function (BasePackage $basePackage): bool { - return $basePackage instanceof CompletePackageInterface && ExtensionName::isValidExtensionName($basePackage->getName()); + try { + ExtensionName::determineFromComposerPackage($basePackage); + } catch (InvalidArgumentException) { + return false; + } + + return $basePackage instanceof CompletePackageInterface; }, ), ); diff --git a/test/unit/Platform/InstalledPiePackagesTest.php b/test/unit/Platform/InstalledPiePackagesTest.php index 22e6357a..b273cd2a 100644 --- a/test/unit/Platform/InstalledPiePackagesTest.php +++ b/test/unit/Platform/InstalledPiePackagesTest.php @@ -39,4 +39,22 @@ public function testAllPiePackages(): void self::assertSame('bar2', $packages['bar2']->extensionName()->name()); self::assertSame('foo/bar2', $packages['bar2']->name()); } + + public function testInvalidExtensionNamesAreFilteredOut(): void + { + $localRepo = $this->createMock(InstalledRepositoryInterface::class); + $localRepo->method('getPackages')->willReturn([ + new CompletePackage('foo/invalid-extension-name', '1.2.3.0', '1.2.3'), + new CompletePackage('invalid-extension-name', '1.2.3.0', '1.2.3'), + new CompletePackage('invalid_extension_name', '1.2.3.0', '1.2.3'), + ]); + + $repoManager = $this->createMock(RepositoryManager::class); + $repoManager->method('getLocalRepository')->willReturn($localRepo); + + $composer = $this->createMock(Composer::class); + $composer->method('getRepositoryManager')->willReturn($repoManager); + + self::assertCount(0, (new InstalledPiePackages())->allPiePackages($composer)); + } } From 076fddf53e8ec300e62d84bd724128887cfed5f1 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 5 Mar 2026 08:38:31 +0000 Subject: [PATCH 07/13] 435: add phpstan-phpunit extension --- composer.json | 1 + composer.lock | 58 ++++++++++++++++++- phpstan-baseline.neon | 12 ---- phpstan.neon | 1 + ...InstallExtensionsForProjectCommandTest.php | 20 +++---- .../FetchDependencyStatusesTest.php | 1 - .../Platform/TargetPhp/PhpBinaryPathTest.php | 2 - 7 files changed, 68 insertions(+), 27 deletions(-) diff --git a/composer.json b/composer.json index 9bb4f3d6..2f09d921 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "bnf/phpstan-psr-container": "^1.1", "doctrine/coding-standard": "^14.0.0", "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^10.5.63" }, diff --git a/composer.lock b/composer.lock index 654bc2d6..06d3fa8a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6392877b0b53e6d18d3d156173dcbba3", + "content-hash": "1138a5a4004fa55c3068062f3a2adc43", "packages": [ { "name": "composer/ca-bundle", @@ -3362,6 +3362,62 @@ ], "time": "2026-01-30T17:12:46+00:00" }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" + }, { "name": "phpstan/phpstan-webmozart-assert", "version": "2.0.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3f1ae163..484f95bd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -360,18 +360,6 @@ parameters: count: 1 path: test/integration/Command/InstallCommandTest.php - - - message: '#^Parameter \#1 \$originalClassName of method PHPUnit\\Framework\\TestCase\:\:createMock\(\) expects class\-string\, string given\.$#' - identifier: argument.type - count: 1 - path: test/integration/Command/InstallExtensionsForProjectCommandTest.php - - - - message: '#^Unable to resolve the template type RealInstanceType in call to method PHPUnit\\Framework\\TestCase\:\:createMock\(\)$#' - identifier: argument.templateType - count: 1 - path: test/integration/Command/InstallExtensionsForProjectCommandTest.php - - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' identifier: function.alreadyNarrowedType diff --git a/phpstan.neon b/phpstan.neon index 9d3ff539..80f87e1d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ includes: - phpstan-baseline.neon - vendor/bnf/phpstan-psr-container/extension.neon - vendor/phpstan/phpstan-webmozart-assert/extension.neon + - vendor/phpstan/phpstan-phpunit/extension.neon parameters: level: 10 diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index cad216ca..ad9f6cfa 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -58,17 +58,15 @@ public function setUp(): void $container->method('get')->willReturnCallback( /** @param class-string $service */ function (string $service): mixed { - switch ($service) { - case QuieterConsoleIO::class: - return new QuieterConsoleIO( - new ArrayInput([]), - new BufferedOutput(), - new MinimalHelperSet(['question' => new QuestionHelper()]), - ); - - default: - return $this->createMock($service); - } + /** @var class-string $service */ + return match ($service) { + QuieterConsoleIO::class => new QuieterConsoleIO( + new ArrayInput([]), + new BufferedOutput(), + new MinimalHelperSet(['question' => new QuestionHelper()]), + ), + default => $this->createMock($service), + }; }, ); diff --git a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php index 42e9f8eb..03c21bdc 100644 --- a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php +++ b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php @@ -43,7 +43,6 @@ public function testRequiresReturnsListOfStatuses(): void $package, ); - self::assertIsList($deps); self::assertCount(3, $deps); self::assertSame('ext-core: == 8.4.17.0 ✅', $deps[0]->asPrettyString()); diff --git a/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php b/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php index eeeda371..4bd9a668 100644 --- a/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php +++ b/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php @@ -400,10 +400,8 @@ public function testDifferentVersionsOfPhp(string $phpPath): void $php = PhpBinaryPath::fromPhpBinaryPath($phpPath); self::assertArrayHasKey('Core', $php->extensions()); self::assertNotEmpty($php->extensionPath()); - self::assertInstanceOf(OperatingSystem::class, $php->operatingSystem()); self::assertNotEmpty($php->version()); self::assertNotEmpty($php->majorMinorVersion()); - self::assertInstanceOf(Architecture::class, $php->machineType()); self::assertGreaterThan(0, $php->phpIntSize()); self::assertNotEmpty($php->phpinfo()); } From e63b9c75ac8a83a7e8f434ba21b1f89061f31ab6 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 5 Mar 2026 09:27:10 +0000 Subject: [PATCH 08/13] 435: separate definition of system dependencies into configurable class --- .../PhpBinaryPathBasedPlatformRepository.php | 2 +- src/Container.php | 8 ++++ .../PrescanSystemDependencies.php | 30 +++------------ .../SystemDependenciesDefinition.php | 37 +++++++++++++++++++ 4 files changed, 51 insertions(+), 26 deletions(-) create mode 100644 src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index f7fc2a40..2e60dd95 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -148,7 +148,7 @@ private function detectLibraryWithPkgConfig(string $alias, string $library): voi /** * Instructions for PIE to install these libraries, if they are missing, should be added - * into {@see \Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies} + * into {@see \Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition::default()} */ private function addLibrariesUsingPkgConfig(): void { diff --git a/src/Container.php b/src/Container.php index 34cb9eff..076e64f8 100644 --- a/src/Container.php +++ b/src/Container.php @@ -28,6 +28,7 @@ use Php\Pie\Command\UninstallCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; use Php\Pie\ComposerIntegration\QuieterConsoleIO; +use Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\ResolveDependencyWithComposer; use Php\Pie\Downloading\GithubPackageReleaseAssets; @@ -217,6 +218,13 @@ static function (): PackageManager|null { }, ); + $container->singleton( + SystemDependenciesDefinition::class, + static function (): SystemDependenciesDefinition { + return SystemDependenciesDefinition::default(); + }, + ); + return $container; } diff --git a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php index b77b8dbb..63f1f6c6 100644 --- a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php +++ b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php @@ -27,33 +27,13 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ class PrescanSystemDependencies { - /** @var array> */ - private readonly array $libraries; - public function __construct( private readonly DependencyResolver $dependencyResolver, private readonly FetchDependencyStatuses $fetchDependencyStatuses, - private readonly IOInterface $io, + private readonly SystemDependenciesDefinition $systemDependenciesDefinition, private readonly PackageManager|null $packageManager, + private readonly IOInterface $io, ) { - /** - * Checks for the existence of these libraries should be added into - * {@see \Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository::addLibrariesUsingPkgConfig()} - */ - $this->libraries = [ - 'sodium' => [ - PackageManager::Apt->value => 'libsodium-dev', - PackageManager::Apk->value => 'libsodium-dev', - PackageManager::Dnf->value => 'pkgconfig(libsodium)', - PackageManager::Yum->value => 'pkgconfig(libsodium)', - ], - 'jpeg' => [ - PackageManager::Apt->value => 'libjpeg-dev', - PackageManager::Apk->value => 'libjpeg-turbo-dev', - PackageManager::Dnf->value => 'pkgconfig(libjpeg)', - PackageManager::Yum->value => 'pkgconfig(libjpeg)', - ], - ]; } public function __invoke(Composer $composer, TargetPlatform $targetPlatform, RequestedPackageAndVersion $requestedNameAndVersion): void @@ -116,7 +96,7 @@ private function packageManagerPackageForDependency(DependencyStatus $unmetDepen { $depName = str_replace('lib-', '', $unmetDependency->name); - if (! array_key_exists($depName, $this->libraries)) { + if (! array_key_exists($depName, $this->systemDependenciesDefinition->definition)) { $this->io->writeError( sprintf('Could not automatically install %s, as PIE does not have the package manager definition.', $unmetDependency->name), verbosity: IOInterface::VERBOSE, @@ -125,7 +105,7 @@ private function packageManagerPackageForDependency(DependencyStatus $unmetDepen return null; } - if (! array_key_exists($packageManager->value, $this->libraries[$depName])) { + if (! array_key_exists($packageManager->value, $this->systemDependenciesDefinition->definition[$depName])) { $this->io->writeError( sprintf('Could not automatically install "%s", as PIE does not have a definition for "%s"', $unmetDependency->name, $packageManager->value), verbosity: IOInterface::VERBOSE, @@ -134,7 +114,7 @@ private function packageManagerPackageForDependency(DependencyStatus $unmetDepen return null; } - $packageManagerPackage = $this->libraries[$depName][$packageManager->value]; + $packageManagerPackage = $this->systemDependenciesDefinition->definition[$depName][$packageManager->value]; // Note: ideally, we should also parse the version constraint. This initial iteration will ignore that, to be improved later. $this->io->write( diff --git a/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php b/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php new file mode 100644 index 00000000..38bc6e84 --- /dev/null +++ b/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php @@ -0,0 +1,37 @@ +> $definition */ + public function __construct(public readonly array $definition) + { + } + + /** + * Checks for the existence of these libraries should be added into + * {@see \Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository::addLibrariesUsingPkgConfig()} + */ + public static function default(): self + { + return new self([ + 'sodium' => [ + PackageManager::Apt->value => 'libsodium-dev', + PackageManager::Apk->value => 'libsodium-dev', + PackageManager::Dnf->value => 'pkgconfig(libsodium)', + PackageManager::Yum->value => 'pkgconfig(libsodium)', + ], + 'jpeg' => [ + PackageManager::Apt->value => 'libjpeg-dev', + PackageManager::Apk->value => 'libjpeg-turbo-dev', + PackageManager::Dnf->value => 'pkgconfig(libjpeg)', + PackageManager::Yum->value => 'pkgconfig(libjpeg)', + ], + ]); + } +} From 0c48a668a5fca73d4196eb64f6a60ef5bfe965d4 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 5 Mar 2026 11:28:12 +0000 Subject: [PATCH 09/13] 435: add tests for PrescanSystemDependencies --- .../PrescanSystemDependencies.php | 2 +- .../PrescanSystemDependenciesTest.php | 225 +++++++++++++++++- 2 files changed, 219 insertions(+), 8 deletions(-) diff --git a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php index 63f1f6c6..4490652f 100644 --- a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php +++ b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php @@ -98,7 +98,7 @@ private function packageManagerPackageForDependency(DependencyStatus $unmetDepen if (! array_key_exists($depName, $this->systemDependenciesDefinition->definition)) { $this->io->writeError( - sprintf('Could not automatically install %s, as PIE does not have the package manager definition.', $unmetDependency->name), + sprintf('Could not automatically install "%s", as PIE does not have the package manager definition.', $unmetDependency->name), verbosity: IOInterface::VERBOSE, ); diff --git a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php index a0342a1f..46a1cbad 100644 --- a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php +++ b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php @@ -4,40 +4,251 @@ namespace Php\PieUnitTest\DependencyResolver\DependencyInstaller; +use Composer\Composer; +use Composer\IO\BufferIO; +use Composer\Package\CompletePackage; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\VersionParser; use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; +use Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition; +use Php\Pie\DependencyResolver\DependencyResolver; +use Php\Pie\DependencyResolver\DependencyStatus; +use Php\Pie\DependencyResolver\FetchDependencyStatuses; +use Php\Pie\DependencyResolver\Package; +use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\Platform\PackageManager; +use Php\Pie\Platform\TargetPlatform; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\StreamOutput; #[CoversClass(PrescanSystemDependencies::class)] final class PrescanSystemDependenciesTest extends TestCase { + private readonly DependencyResolver&MockObject $dependencyResolver; + private readonly FetchDependencyStatuses&MockObject $fetchDependencyStatuses; + private readonly BufferIO $io; + private readonly Composer&MockObject $composer; + private readonly TargetPlatform&MockObject $targetPlatform; + + public function setUp(): void + { + parent::setUp(); + + $this->dependencyResolver = $this->createMock(DependencyResolver::class); + $this->fetchDependencyStatuses = $this->createMock(FetchDependencyStatuses::class); + $this->io = new BufferIO(verbosity: StreamOutput::VERBOSITY_VERBOSE); + $this->composer = $this->createMock(Composer::class); + $this->targetPlatform = $this->createMock(TargetPlatform::class); + } + public function testNoPackageManager(): void { - self::markTestIncomplete('todo'); // @todo + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([]), + null, + $this->io, + ); + + ($scanner)($this->composer, $this->targetPlatform, new RequestedPackageAndVersion('foo/foo', null)); + + self::assertStringContainsString( + 'Skipping pre-scan of system dependencies, as a supported package manager could not be detected.', + $this->io->getOutput(), + ); } - public function testAllDependenciesSatisifed(): void + public function testAllDependenciesSatisfied(): void { - self::markTestIncomplete('todo'); // @todo + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-foo', $versionParser->parseConstraints('^1.0'), new Constraint('=', '1.0.0.0')), + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^2.0'), new Constraint('=', '2.5.1.0')), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request); + + self::assertStringContainsString( + 'All system dependencies are already installed.', + $this->io->getOutput(), + ); } public function testMissingDependencyThatDoesNotHaveAnyPackageManagerDefinition(): void { - self::markTestIncomplete('todo'); // @todo + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Could not automatically install "lib-bar", as PIE does not have the package manager definition.', $outputString); + self::assertStringContainsString('No system dependencies could be installed automatically by PIE.', $outputString); } public function testMissingDependencyThatDoesNotHaveMyPackageManagerDefinition(): void { - self::markTestIncomplete('todo'); // @todo + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apt->value => 'libbar-dev', + PackageManager::Apk->value => 'libbar-dev', + ], + ]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Could not automatically install "lib-bar", as PIE does not have a definition for "test"', $outputString); + self::assertStringContainsString('No system dependencies could be installed automatically by PIE.', $outputString); } public function testMissingDependenciesFailToInstall(): void { - self::markTestIncomplete('todo'); // @todo + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apk->value => 'hopefully-this-package-does-not-exist-in-apk', + PackageManager::Test->value => 'libbar-dev', + ], + ]), + PackageManager::Apk, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Failed to install missing system dependencies', $outputString); } public function testMissingDependenciesAreSuccessfullyInstalled(): void { - self::markTestIncomplete('todo'); // @todo + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apt->value => 'libbar-dev', + PackageManager::Apk->value => 'libbar-dev', + PackageManager::Test->value => 'libbar-dev', + ], + ]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Adding test package libbar-dev to be installed for lib-bar', $outputString); + self::assertStringContainsString('Installing missing system dependencies: echo "fake installing libbar-dev"', $outputString); } } From 4d3ec06e978e524ffb665ea66ff2ce6d71281f1a Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 5 Mar 2026 11:55:54 +0000 Subject: [PATCH 10/13] 435: added flags to control system dependency scanning behaviour --- src/Command/CommandHelper.php | 31 ++++++++++ src/Command/InstallCommand.php | 22 ++++--- .../PrescanSystemDependencies.php | 29 +++++++++- test/end-to-end/Dockerfile | 6 +- .../PrescanSystemDependenciesTest.php | 57 ++++++++++++++++--- .../FetchDependencyStatusesTest.php | 4 +- 6 files changed, 126 insertions(+), 23 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 62f08e7c..5fb3757f 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -63,6 +63,8 @@ final class CommandHelper private const OPTION_NO_CACHE = 'no-cache'; private const OPTION_AUTO_INSTALL_BUILD_TOOLS = 'auto-install-build-tools'; private const OPTION_SUPPRESS_BUILD_TOOLS_CHECK = 'no-build-tools-check'; + private const OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES = 'auto-install-system-dependencies'; + private const OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK = 'no-system-dependencies-check'; private function __construct() { @@ -154,6 +156,19 @@ public static function configureDownloadBuildInstallOptions(Command $command, bo 'Do not perform the check to see if build tools are present on the system.', ); + $command->addOption( + self::OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES, + null, + InputOption::VALUE_NONE, + 'If system dependencies missing, automatically install them, instead of prompting.', + ); + $command->addOption( + self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK, + null, + InputOption::VALUE_NONE, + 'Do not perform the check to see if system dependencies are present on the system.', + ); + /** * 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... @@ -267,6 +282,22 @@ public static function shouldCheckForBuildTools(InputInterface $input): bool || ! $input->getOption(self::OPTION_SUPPRESS_BUILD_TOOLS_CHECK); } + public static function autoInstallSystemDependencies(InputInterface $input): bool + { + return $input->hasOption(self::OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES) + && $input->getOption(self::OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES); + } + + public static function shouldCheckSystemDependencies(InputInterface $input): bool + { + if (Platform::isWindows()) { + return false; + } + + return ! $input->hasOption(self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK) + || ! $input->getOption(self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK); + } + public static function requestedNameAndVersionPair(InputInterface $input): RequestedPackageAndVersion { $requestedPackageString = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 6ca24df0..c74c8f7d 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -105,14 +105,20 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - // @todo flag to disable this check - try { - ($this->prescanSystemDependencies)($composer, $targetPlatform, $requestedNameAndVersion); - } catch (Throwable $anything) { - $this->io->writeError( - 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), - verbosity: IOInterface::VERBOSE, - ); + if (CommandHelper::shouldCheckSystemDependencies($input)) { + try { + ($this->prescanSystemDependencies)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + CommandHelper::autoInstallSystemDependencies($input), + ); + } catch (Throwable $anything) { + $this->io->writeError( + 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), + verbosity: IOInterface::VERBOSE, + ); + } } try { diff --git a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php index 4490652f..4b3a303e 100644 --- a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php +++ b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php @@ -36,8 +36,12 @@ public function __construct( ) { } - public function __invoke(Composer $composer, TargetPlatform $targetPlatform, RequestedPackageAndVersion $requestedNameAndVersion): void - { + public function __invoke( + Composer $composer, + TargetPlatform $targetPlatform, + RequestedPackageAndVersion $requestedNameAndVersion, + bool $autoInstallIfMissing, + ): void { if ($this->packageManager === null) { $this->io->writeError('Skipping pre-scan of system dependencies, as a supported package manager could not be detected.', verbosity: IOInterface::VERBOSE); @@ -83,10 +87,29 @@ static function (DependencyStatus $dependencyStatus): bool { } $proposedInstallCommand = implode(' ', $this->packageManager->installCommand($packageManagerPackages)); - $this->io->write(sprintf('Installing missing system dependencies: %s', $proposedInstallCommand)); + + if (! $this->io->isInteractive() && ! $autoInstallIfMissing) { + $this->io->writeError('You are not running in interactive mode, and you did not provide the --auto-install-system-dependencies flag.'); + $this->io->writeError('You may need to run: ' . $proposedInstallCommand . ''); + $this->io->writeError(''); + + return; + } + + $this->io->write(sprintf('Need to install missing system dependencies: %s', $proposedInstallCommand)); + + if ($this->io->isInteractive() && ! $autoInstallIfMissing) { + if (! $this->io->askConfirmation('Would you like to install them now?', false)) { + $this->io->write('Ok, but things might not work. Just so you know.'); + + return; + } + } try { $this->packageManager->install($packageManagerPackages); + + $this->io->write('Missing system dependencies have been installed.'); } catch (Throwable $anything) { $this->io->writeError(sprintf('Failed to install missing system dependencies: %s', $anything->getMessage())); } diff --git a/test/end-to-end/Dockerfile b/test/end-to-end/Dockerfile index abd5c11f..09e941ee 100644 --- a/test/end-to-end/Dockerfile +++ b/test/end-to-end/Dockerfile @@ -47,14 +47,14 @@ RUN mkdir -p /opt/php \ && make install ENV PATH="$PATH:/opt/php/bin" COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie -RUN pie install -v php/sodium +RUN pie install -v --auto-install-system-dependencies php/sodium FROM alpine AS test_pie_installs_system_deps_on_alpine RUN apk add php php-phar php-mbstring php-iconv php-openssl bzip2-dev libbz2 build-base autoconf bison re2c libtool php84-dev COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie -RUN pie install -v php/sodium +RUN pie install -v --auto-install-system-dependencies php/sodium FROM fedora AS test_pie_installs_system_deps_on_fedora RUN dnf install -y php php-pecl-zip unzip gcc make autoconf bison re2c libtool php-devel COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie -RUN pie install -v php/sodium +RUN pie install -v --auto-install-system-dependencies php/sodium diff --git a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php index 46a1cbad..8f292c0e 100644 --- a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php +++ b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php @@ -53,7 +53,7 @@ public function testNoPackageManager(): void $this->io, ); - ($scanner)($this->composer, $this->targetPlatform, new RequestedPackageAndVersion('foo/foo', null)); + ($scanner)($this->composer, $this->targetPlatform, new RequestedPackageAndVersion('foo/foo', null), true); self::assertStringContainsString( 'Skipping pre-scan of system dependencies, as a supported package manager could not be detected.', @@ -89,7 +89,7 @@ public function testAllDependenciesSatisfied(): void new DependencyStatus('lib-bar', $versionParser->parseConstraints('^2.0'), new Constraint('=', '2.5.1.0')), ]); - ($scanner)($this->composer, $this->targetPlatform, $request); + ($scanner)($this->composer, $this->targetPlatform, $request, true); self::assertStringContainsString( 'All system dependencies are already installed.', @@ -124,7 +124,7 @@ public function testMissingDependencyThatDoesNotHaveAnyPackageManagerDefinition( new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), ]); - ($scanner)($this->composer, $this->targetPlatform, $request); + ($scanner)($this->composer, $this->targetPlatform, $request, true); $outputString = $this->io->getOutput(); self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); @@ -164,7 +164,7 @@ public function testMissingDependencyThatDoesNotHaveMyPackageManagerDefinition() new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), ]); - ($scanner)($this->composer, $this->targetPlatform, $request); + ($scanner)($this->composer, $this->targetPlatform, $request, true); $outputString = $this->io->getOutput(); self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); @@ -204,7 +204,7 @@ public function testMissingDependenciesFailToInstall(): void new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), ]); - ($scanner)($this->composer, $this->targetPlatform, $request); + ($scanner)($this->composer, $this->targetPlatform, $request, true); $outputString = $this->io->getOutput(); self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); @@ -244,11 +244,54 @@ public function testMissingDependenciesAreSuccessfullyInstalled(): void new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), ]); - ($scanner)($this->composer, $this->targetPlatform, $request); + ($scanner)($this->composer, $this->targetPlatform, $request, true); $outputString = $this->io->getOutput(); self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); self::assertStringContainsString('Adding test package libbar-dev to be installed for lib-bar', $outputString); - self::assertStringContainsString('Installing missing system dependencies: echo "fake installing libbar-dev"', $outputString); + self::assertStringContainsString('Need to install missing system dependencies: echo "fake installing libbar-dev"', $outputString); + } + + public function testMissingDependenciesAreNotInstalledWhenShouldNotAutoInstallAndNonInteractive(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apt->value => 'libbar-dev', + PackageManager::Apk->value => 'libbar-dev', + PackageManager::Test->value => 'libbar-dev', + ], + ]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, false); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Adding test package libbar-dev to be installed for lib-bar', $outputString); + self::assertStringContainsString('You are not running in interactive mode, and you did not provide the --auto-install-system-dependencies flag.', $outputString); + self::assertStringContainsString('You may need to run: echo "fake installing libbar-dev"', $outputString); + self::assertStringNotContainsString('Need to install missing system dependencies', $outputString); } } diff --git a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php index 03c21bdc..bcd6a9c6 100644 --- a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php +++ b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php @@ -45,8 +45,8 @@ public function testRequiresReturnsListOfStatuses(): void self::assertCount(3, $deps); - self::assertSame('ext-core: == 8.4.17.0 ✅', $deps[0]->asPrettyString()); + self::assertSame('ext-core: == ' . $php->version() . '.0 ✅', $deps[0]->asPrettyString()); self::assertSame('ext-nonsense_extension: == * 🚫 (not installed)', $deps[1]->asPrettyString()); - self::assertSame('ext-standard: < 1.0.0.0 🚫 (your version is 8.4.17.0)', $deps[2]->asPrettyString()); + self::assertSame('ext-standard: < 1.0.0.0 🚫 (your version is ' . $php->version() . '.0)', $deps[2]->asPrettyString()); } } From 606137190cfc3ee74cb6b4f15103c7342bfd3f60 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 5 Mar 2026 11:57:21 +0000 Subject: [PATCH 11/13] 435: check system deps in pie build command --- src/Command/BuildCommand.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 2cfe6faa..062eac76 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -11,6 +11,7 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; +use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; @@ -22,6 +23,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function sprintf; @@ -34,6 +36,7 @@ final class BuildCommand extends Command public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, + private readonly PrescanSystemDependencies $prescanSystemDependencies, private readonly ComposerIntegrationHandler $composerIntegrationHandler, private readonly FindMatchingPackages $findMatchingPackages, private readonly IOInterface $io, @@ -88,6 +91,22 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); + if (CommandHelper::shouldCheckSystemDependencies($input)) { + try { + ($this->prescanSystemDependencies)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + CommandHelper::autoInstallSystemDependencies($input), + ); + } catch (Throwable $anything) { + $this->io->writeError( + 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), + verbosity: IOInterface::VERBOSE, + ); + } + } + try { $package = ($this->dependencyResolver)( $composer, From 3fb0c2443b95faac41e0e5291d639266705f83db Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 6 Mar 2026 08:25:50 +0000 Subject: [PATCH 12/13] 435: Added more system deps definitions --- .../SystemDependenciesDefinition.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php b/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php index 38bc6e84..d29adcef 100644 --- a/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php +++ b/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php @@ -32,6 +32,30 @@ public static function default(): self PackageManager::Dnf->value => 'pkgconfig(libjpeg)', PackageManager::Yum->value => 'pkgconfig(libjpeg)', ], + 'zip' => [ + PackageManager::Apt->value => 'libzip-dev', + PackageManager::Apk->value => 'libzip-dev', + PackageManager::Dnf->value => 'pkgconfig(libzip)', + PackageManager::Yum->value => 'pkgconfig(libzip)', + ], + 'xslt' => [ + PackageManager::Apt->value => 'libxslt1-dev', + PackageManager::Apk->value => 'libxslt-dev', + PackageManager::Dnf->value => 'pkgconfig(libxslt)', + PackageManager::Yum->value => 'pkgconfig(libxslt)', + ], + 'ffi' => [ + PackageManager::Apt->value => 'libffi-dev', + PackageManager::Apk->value => 'libffi-dev', + PackageManager::Dnf->value => 'pkgconfig(libffi)', + PackageManager::Yum->value => 'pkgconfig(libffi)', + ], + 'curl' => [ + PackageManager::Apt->value => 'libcurl4-openssl-dev', + PackageManager::Apk->value => 'curl-dev', + PackageManager::Dnf->value => 'pkgconfig(libcurl)', + PackageManager::Yum->value => 'pkgconfig(libcurl)', + ], ]); } } From e7053cb3e6432f97dc2c6cd84477ae2148ab9c0d Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 6 Mar 2026 09:23:36 +0000 Subject: [PATCH 13/13] 435: documentation improvements for 1.4.0 features --- README.md | 33 ++++++++++++--------- docs/extension-maintainers.md | 54 ++++++++++++++++++++++++++++++++--- docs/usage.md | 25 ++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 64dfe8b8..bc232dc8 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ ## What is PIE? -PIE is a new installer for PHP extensions, intended to eventually replace PECL. -It is distributed as a [PHAR](https://www.php.net/manual/en/intro.phar.php), -just like Composer, and works in a similar way to Composer, but it installs PHP -extensions (PHP Modules or Zend Extensions) to your PHP installation, rather -than pulling PHP packages into your project or library. +PIE is the official installer for PHP extensions, which replaces +[PECL](https://pecl.php.net/) (which is now deprecated). PIE is distributed as a +[PHAR](https://www.php.net/manual/en/intro.phar.php), just like Composer, and +works in a similar way to Composer, but it installs PHP extensions (PHP Modules +or Zend Extensions) to your PHP installation, rather than pulling PHP packages +into your project or library. # Using PIE - what do I need to get started? @@ -15,12 +16,8 @@ than pulling PHP packages into your project or library. You will need PHP 8.1 or newer to run PIE, but PIE can install an extension to any other installed PHP version. -On Linux, you will need a build toolchain installed. On Debian/Ubuntu type -systems, you could run something like: - -```shell -sudo apt install gcc make autoconf libtool bison re2c pkg-config php-dev -``` +On Linux/OSX, if any build tools needed are missing, PIE will ask if you would +like to automatically install them first (this is a new feature in 1.4.0). On Windows, you do not need any build toolchain installed, since PHP extensions for Windows are distributed as pre-compiled packages containing the extension @@ -38,7 +35,9 @@ Further installation details can be found in the [usage](./docs/usage.md) docs. This documentation assumes you have moved `pie.phar` into your `$PATH`, e.g. `/usr/local/bin/pie` on non-Windows systems or created an alias in your shell RC file. -## Installing a single extension using PIE +## Using PIE + +### Installing a single extension using PIE You can install an extension using the `install` command. For example, to install the `example_pie_extension` extension, you would run: @@ -57,7 +56,7 @@ You must now add "extension=example_pie_extension" to your php.ini $ ``` -## Installing all extensions for a PHP project +### Installing all extensions for a PHP project When in your PHP project, you can install any missing top-level extensions: @@ -87,6 +86,12 @@ The following packages may be suitable, which would you like to install: Finished checking extensions. ``` +> [!TIP] +> If you are running PIE in a non-interactive shell (for example, CI, a +> container), pass the `--allow-non-interactive-project-install` flag to run +> this command. It may still fail if more than one PIE package provides a +> particular extension. + ## Extensions that support PIE A list of extensions that support PIE can be found on @@ -105,6 +110,6 @@ A list of extensions that support PIE can be found on If you are an extension maintainer wanting to add PIE support to your extension, please read [extension-maintainers](./docs/extension-maintainers.md). -## More documentation... +# More documentation... The full documentation for PIE can be found in [usage](./docs/usage.md) docs. diff --git a/docs/extension-maintainers.md b/docs/extension-maintainers.md index 0507754e..40e8b4c3 100644 --- a/docs/extension-maintainers.md +++ b/docs/extension-maintainers.md @@ -345,11 +345,16 @@ The list of accepted OS families: "windows", "bsd", "darwin", "solaris", "linux" #### Extension dependencies -Extension authors may define some dependencies in `require`, but practically, +Extension authors may define some dependencies in `require`, but typically, most extensions would not need to define dependencies, except for the PHP -versions supported by the extension. Dependencies on other extensions may be -defined, for example `ext-json`. However, dependencies on a regular PHP package -(such as `monolog/monolog`) SHOULD NOT be specified in your `require` section. +versions supported by the extension, and system libraries. + +Dependencies on a regular PHP package (such as `monolog/monolog`) SHOULD NOT be +specified in your extension's `require` section. + +##### Dependencies on other extensions + +Dependencies on other extensions may be defined, for example `ext-json`. It is worth noting that if your extension does define a dependency on another dependency, and this is not available, someone installing your extension would @@ -360,6 +365,47 @@ Cannot use myvendor/myextension's latest version 1.2.3 as it requires ext-something * which is missing from your platform. ``` +##### System Library Dependencies + +In PIE 1.4.0, the ability for extension authors to define system library +dependencies was added, and in some cases, automatically install them. + +The following libraries are supported at the moment. **If you would like to add +a library, please [open a discussion](https://github.com/php/pie/discussions) +in the first instance.** Don't just open a PR without discussing first please! + +We are adding libraries and improving this feature over time. If the automatic +install of a system dependency that is supported below in your package manager +is NOT working, then please [report a bug](https://github.com/php/pie/issues). + +| Library | Checked by PIE | Auto-installs in | +|---------------|----------------|--------------------| +| lib-curl | ✅ | apt, apk, dnf, yum | +| lib-enchant | ✅ | ❌ | +| lib-enchant-2 | ✅ | ❌ | +| lib-sodium | ✅ | apt, apk, dnf, yum | +| lib-ffi | ✅ | apt, apk, dnf, yum | +| lib-xslt | ✅ | apt, apk, dnf, yum | +| lib-zip | ✅ | apt, apk, dnf, yum | +| lib-png | ✅ | ❌ | +| lib-avif | ✅ | ❌ | +| lib-webp | ✅ | ❌ | +| lib-jpeg | ✅ | apt, apk, dnf, yum | +| lib-xpm | ✅ | ❌ | +| lib-freetype2 | ✅ | ❌ | +| lib-gdlib | ✅ | ❌ | +| lib-gmp | ✅ | ❌ | +| lib-sasl | ✅ | ❌ | +| lib-onig | ✅ | ❌ | +| lib-odbc | ✅ | ❌ | +| lib-capstone | ✅ | ❌ | +| lib-pcre | ✅ | ❌ | +| lib-edit | ✅ | ❌ | +| lib-snmp | ✅ | ❌ | +| lib-argon2 | ✅ | ❌ | +| lib-uriparser | ✅ | ❌ | +| lib-exslt | ✅ | ❌ | + #### Checking the extension will work First up, you can use `composer validate` to check your `composer.json` is diff --git a/docs/usage.md b/docs/usage.md index 174c2ff0..8f797cdd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -281,6 +281,31 @@ pie install example/some-extension --with-some-library-name=/path/to/the/lib pie install example/some-extension --with-some-library-name=/path/to/the/lib --enable-some-functionality ``` +### Build tools check + +PIE will attempt to check the presence of build tools (such as gcc, make, etc.) +before running. If any are missing, an interactive prompt will ask if you would +like to install the missing tools. If you are running in non-interactive mode +(for example, in a CI pipeline, container build, etc), PIE will **not** +install these tools automatically. If you would like to install the build tools +in a non-interactive terminal, pass the `--auto-install-build-tools` and the +prompt will be skipped. + +To skip the build tools check entirely, pass the `--no-build-tools-check` flag. + +### System library dependencies check + +PIE will attempt to check the presence of system library dependencies before +installing an extension. If any are missing, an interactive prompt will ask if +you would like to install the missing tools. If you are running in +non-interactive mode (for example, in a CI pipeline, container build, etc), PIE +will **not** install these dependencies automatically. If you would like to +install the system dependencies in a non-interactive terminal, pass the +`--auto-install-system-dependencies` and the prompt will be skipped. + +To skip the dependencies check entirely, pass the +`--no-system-dependencies-check` flag. + ### Configuring the INI file PIE will automatically try to enable the extension by adding `extension=...` or