From 4fed7bc3af2318e789d4748e39eac02e76dc62b1 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 7 Jul 2026 19:29:16 +0100 Subject: [PATCH 1/4] 561: add feature definition for pie upgrade command --- features/upgrade-extensions.feature | 7 ++ .../pie.json | 0 .../pie.lock | 0 test/assets/pie-upgrade-lock/pie.json | 5 + test/assets/pie-upgrade-lock/pie.lock | 70 +++++++++++ test/behaviour/CliContext.php | 119 ++++++++++++++---- 6 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 features/upgrade-extensions.feature rename test/assets/{pie-lock => pie-install-from-lock}/pie.json (100%) rename test/assets/{pie-lock => pie-install-from-lock}/pie.lock (100%) create mode 100644 test/assets/pie-upgrade-lock/pie.json create mode 100644 test/assets/pie-upgrade-lock/pie.lock diff --git a/features/upgrade-extensions.feature b/features/upgrade-extensions.feature new file mode 100644 index 00000000..e7e7ed45 --- /dev/null +++ b/features/upgrade-extensions.feature @@ -0,0 +1,7 @@ +Feature: PIE extensions can be upgraded with PIE + + # pie upgrade + Example: I can upgrade existing PIE extensions + Given I have installed PIE extensions that have upgrades available + When I run a command to upgrade my extensions + Then the extensions should have been upgraded to the latest versions diff --git a/test/assets/pie-lock/pie.json b/test/assets/pie-install-from-lock/pie.json similarity index 100% rename from test/assets/pie-lock/pie.json rename to test/assets/pie-install-from-lock/pie.json diff --git a/test/assets/pie-lock/pie.lock b/test/assets/pie-install-from-lock/pie.lock similarity index 100% rename from test/assets/pie-lock/pie.lock rename to test/assets/pie-install-from-lock/pie.lock diff --git a/test/assets/pie-upgrade-lock/pie.json b/test/assets/pie-upgrade-lock/pie.json new file mode 100644 index 00000000..13512013 --- /dev/null +++ b/test/assets/pie-upgrade-lock/pie.json @@ -0,0 +1,5 @@ +{ + "require": { + "asgrim/example-pie-extension": "^2.0" + } +} diff --git a/test/assets/pie-upgrade-lock/pie.lock b/test/assets/pie-upgrade-lock/pie.lock new file mode 100644 index 00000000..e56070f4 --- /dev/null +++ b/test/assets/pie-upgrade-lock/pie.lock @@ -0,0 +1,70 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "52fcc35c994bb87556f462b69bab5d92", + "packages": [ + { + "name": "asgrim/example-pie-extension", + "version": "2.0.7", + "source": { + "type": "git", + "url": "https://github.com/asgrim/example-pie-extension.git", + "reference": "a84a5fefc4065bedfd7750e7e2737b29f762700f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/asgrim/example-pie-extension/zipball/a84a5fefc4065bedfd7750e7e2737b29f762700f", + "reference": "a84a5fefc4065bedfd7750e7e2737b29f762700f", + "shasum": "" + }, + "require": { + "php": ">=7.4,^8.0" + }, + "replace": { + "ext-example_pie_extension": "*" + }, + "type": "php-ext", + "notification-url": "https://packagist.org/downloads/", + "php-ext": { + "priority": 74, + "extension-name": "ext-example_pie_extension", + "configure-options": [ + { + "name": "enable-example-pie-extension", + "description": "whether to enable example-pie-extension support" + }, + { + "name": "with-hello-name", + "description": "Name ot use when saying hello", + "needs-value": true + } + ], + "download-url-method": [ + "pre-packaged-binary", + "composer-default" + ] + }, + "license": [ + "MIT" + ], + "description": "Example PIE extension", + "support": { + "issues": "https://github.com/asgrim/example-pie-extension/issues", + "source": "https://github.com/asgrim/example-pie-extension/tree/2.0.7" + }, + "time": "2026-02-03T13:51:24+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index f6a9d7ad..38cfbf53 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -9,6 +9,7 @@ use Behat\Step\Given; use Behat\Step\Then; use Behat\Step\When; +use Composer\Semver\VersionParser; use Composer\Util\Platform; use RuntimeException; use Safe\Exceptions\PcreException; @@ -476,9 +477,10 @@ public function iShouldSeeAllTheExtensionsAreNowInstalled(): void $this->runPieCommand(['show']); $this->assertCommandSuccessful(); + $pieShowOutput = $this->output; - Assert::contains($this->output, 'asgrim/example-pie-extension'); - Assert::contains($this->output, 'phpredis/phpredis'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'phpredis/phpredis'); } #[Then('I should see information on how to select packages for install')] @@ -550,11 +552,9 @@ public function iShouldSeeItHasFailedVerification(): void Assert::contains($this->errorOutput, '❌ Failed to verify that this PIE binary is the authentic release'); } - #[Given('I have a lock file')] - public function iHaveALockfile(): void + private function copyPieJsonAndLock(string $asset): void { - $this->runPieCommand(['install', 'xdebug/xdebug:3.5.3', 'derickr/quickhash']); - + // Find existing pie.json $this->runPieCommand(['show', '-v']); Assert::notNull($this->output); preg_match('#Using pie\.json: (.+)#', $this->output, $pieJsonRegexMatches); @@ -562,20 +562,89 @@ public function iHaveALockfile(): void $this->pieJsonFilename = $pieJsonRegexMatches[1]; $this->pieLockFilename = str_replace('pie.json', 'pie.lock', $this->pieJsonFilename); + // Make a backup of the pie.json/.lock $this->pieJsonContentBackup = file_get_contents($this->pieJsonFilename); $this->pieLockContentBackup = file_get_contents($this->pieLockFilename); - $testPieLockPath = realpath(__DIR__ . '/../assets/pie-lock'); + // Copy the new ones over + $testPieLockPath = realpath(__DIR__ . '/../assets/' . $asset); copy($testPieLockPath . '/pie.json', $this->pieJsonFilename); copy($testPieLockPath . '/pie.lock', $this->pieLockFilename); } + private function restorePieJsonAndLock(): void + { + file_put_contents($this->pieJsonFilename, $this->pieJsonContentBackup); + file_put_contents($this->pieLockFilename, $this->pieLockContentBackup); + $this->runPieCommand(['install', '--from-lock']); + } + + #[Given('I have installed PIE extensions that have upgrades available')] + public function iHaveInstalledPieExtensionsThatHaveUpgradesAvailable(): void + { + $this->runPieCommand(['install', 'asgrim/example-pie-extension:2.0.7']); + $this->copyPieJsonAndLock('pie-upgrade-lock'); + } + + #[Given('I have a lock file')] + public function iHaveALockfile(): void + { + $this->runPieCommand(['install', 'xdebug/xdebug:3.5.3', 'derickr/quickhash']); + $this->copyPieJsonAndLock('pie-install-from-lock'); + } + #[When('I run a command to install from the lockfile')] public function iRunACommandToInstallFromTheLockfile(): void { $this->runPieCommand(['install', '-v', '--from-lock']); } + #[When('I run a command to upgrade my extensions')] + public function iRunACommandToUpgradeMyExtensions(): void + { + $this->runPieCommand(['upgrade', '-v']); + } + + /** @return array */ + private static function installedExtensionPackagesAndVersions(string $pieShowOutput): array + { + if (! preg_match_all('#([a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+):([^ ]+)#', $pieShowOutput, $matches)) { + throw new RuntimeException('no packages found in pie show'); + } + + return array_combine($matches[1], $matches[2]); + } + + private static function assertPackageVersionInstalledInPieShowOutput(string $pieShowOutput, string $expectedPackage, string|null $expectedVersion = null): void + { + $installedExtensionPackagesAndVersions = self::installedExtensionPackagesAndVersions($pieShowOutput); + Assert::keyExists($installedExtensionPackagesAndVersions, $expectedPackage); + + if ($expectedVersion === null) { + return; + } + + $versionParser = new VersionParser(); + $installedConstraint = $versionParser->parseConstraints($installedExtensionPackagesAndVersions[$expectedPackage]); + $expectedConstraint = $versionParser->parseConstraints($expectedVersion); + Assert::true( + $expectedConstraint->matches($installedConstraint), + sprintf( + 'Installed version %s does not match expected constraint %s', + $installedConstraint->getPrettyString(), + $expectedConstraint->getPrettyString(), + ), + ); + } + + private static function assertPackageNotInstalledInPieShowOutput(string $pieShowOutput, string $notExpectedPackage): void + { + Assert::keyNotExists( + self::installedExtensionPackagesAndVersions($pieShowOutput), + $notExpectedPackage, + ); + } + #[Then('the extensions should have been updated to the lock')] public function theExtensionsShouldHaveBeenUpdatedToTheLock(): void { @@ -586,31 +655,37 @@ public function theExtensionsShouldHaveBeenUpdatedToTheLock(): void $this->runPieCommand(['show']); $this->assertCommandSuccessful(); - - if (! preg_match_all('#([a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+):([^ ]+)#', (string) $this->output, $matches)) { - throw new RuntimeException('no packages found in pie show'); - } - - $installedExtensionPackagesAndVersions = array_combine($matches[1], $matches[2]); + $pieShowOutput = $this->output; // `xdebug` should be downgraded to 3.5.2 Assert::contains($pieInstallOutput, 'PIE package xdebug/xdebug (xdebug) is at 3.5.3 but the lock requires 3.5.2, scheduling for reinstall'); Assert::contains($pieInstallOutput, 'Extension xdebug/xdebug:3.5.2 is enabled and loaded'); - Assert::keyExists($installedExtensionPackagesAndVersions, 'xdebug/xdebug'); - Assert::same($installedExtensionPackagesAndVersions['xdebug/xdebug'], '3.5.2'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'xdebug/xdebug', '3.5.2'); // `quickhash` should have been removed (not in lockfile) Assert::contains($pieInstallOutput, 'Removed extension derickr/quickhash:'); - Assert::keyNotExists($installedExtensionPackagesAndVersions, 'derickr/quickhash'); + self::assertPackageNotInstalledInPieShowOutput($pieShowOutput, 'derickr/quickhash'); // `example_pie_extension` 2.0.9 should have been installed (was not previously installed) Assert::contains($pieInstallOutput, 'Extension asgrim/example-pie-extension:2.0.9 is enabled and loaded'); - Assert::keyExists($installedExtensionPackagesAndVersions, 'asgrim/example-pie-extension'); - Assert::same($installedExtensionPackagesAndVersions['asgrim/example-pie-extension'], '2.0.9'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension', '2.0.9'); - // restore the pie.json/lock contents, and re-install from the lock - file_put_contents($this->pieJsonFilename, $this->pieJsonContentBackup); - file_put_contents($this->pieLockFilename, $this->pieLockContentBackup); - $this->runPieCommand(['install', '--from-lock']); + $this->restorePieJsonAndLock(); + } + + #[Then('the extensions should have been upgraded to the latest versions')] + public function theExtensionsShouldHaveBeenUpgradedToTheLatestVersions(): void + { + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + $pieShowOutput = $this->output; + + // xdebug/xdebug should still exist (upgrade should NOT uninstall it) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'xdebug/xdebug'); + + // asgrim/example-pie-extension should be newer than 2.0.7 (min 2.0.9 at time of writing) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension', '^2.0.9'); + + $this->restorePieJsonAndLock(); } } From 5ed63c0fac7c3491df3c0152d32c7e5812d9d7b6 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 7 Jul 2026 21:50:52 +0100 Subject: [PATCH 2/4] 561: implemented pie upgrade command --- bin/pie | 2 + src/Command/UpgradeCommand.php | 103 ++++++++++++++ .../ComposerIntegrationHandler.php | 4 +- .../OverrideDownloadUrlInstallListener.php | 5 +- .../RemoveUnrelatedInstallOperations.php | 9 +- .../PiePackageInstaller.php | 129 ++++++++++-------- src/Container.php | 2 + test/assets/pie-upgrade-lock/pie.json | 1 + test/assets/pie-upgrade-lock/pie.lock | 45 ++++++ .../ComposerIntegrationHandlerTest.php | 38 +++++- ...OverrideDownloadUrlInstallListenerTest.php | 69 ++++++++++ .../RemoveUnrelatedInstallOperationsTest.php | 57 ++++++++ 12 files changed, 401 insertions(+), 63 deletions(-) create mode 100644 src/Command/UpgradeCommand.php diff --git a/bin/pie b/bin/pie index 155b1ca4..b85fce44 100755 --- a/bin/pie +++ b/bin/pie @@ -17,6 +17,7 @@ use Php\Pie\Command\SelfUpdateCommand; use Php\Pie\Command\SelfVerifyCommand; use Php\Pie\Command\ShowCommand; use Php\Pie\Command\UninstallCommand; +use Php\Pie\Command\UpgradeCommand; use Php\Pie\Util\PieVersion; use Symfony\Component\Console\Application; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; @@ -48,6 +49,7 @@ $application->setCommandLoader(new ContainerCommandLoader( 'download' => DownloadCommand::class, 'build' => BuildCommand::class, 'install' => InstallCommand::class, + 'upgrade' => UpgradeCommand::class, 'info' => InfoCommand::class, 'show' => ShowCommand::class, 'repository:list' => RepositoryListCommand::class, diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php new file mode 100644 index 00000000..312b0094 --- /dev/null +++ b/src/Command/UpgradeCommand.php @@ -0,0 +1,103 @@ +io->write('This command may need elevated privileges, and may prompt you for your password.'); + } + + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); + + $forceInstallPackageVersion = CommandHelper::determineForceInstallingPackageVersion($input); + CommandHelper::applyNoCacheOptionIfSet($input, $this->io); + + if (CommandHelper::shouldCheckForBuildTools($input)) { + $this->checkBuildTools->check( + $this->io, + PackageManager::detect(), + $targetPlatform, + CommandHelper::autoInstallBuildTools($input), + ); + } + + if (! file_exists(PieComposerFactory::getLockFile(Platform::getPieJsonFilename($targetPlatform)))) { + $this->io->writeError('No PIE extensions are currently installed, so there is nothing to upgrade.'); + + return Command::INVALID; + } + + $composer = PieComposerFactory::createPieComposer( + $this->container, + new PieComposerRequest( + $this->io, + $targetPlatform, + [], + PieOperation::Install, + // @todo re-use each already-installed package's existing configure options here, rather than rebuilding with the defaults + [], + CommandHelper::determineAttemptToSetupIniFile($input), + installAllPackages: true, + ), + ); + + try { + $this->composerIntegrationHandler->runInstall( + [], + $composer, + $targetPlatform, + $forceInstallPackageVersion, + true, + ); + } catch (ComposerRunFailed $composerRunFailed) { + $this->io->writeError('' . $composerRunFailed->getMessage() . ''); + + return $composerRunFailed->getCode(); + } + + return Command::SUCCESS; + } +} diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 26e80f91..c37f6bb6 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -197,7 +197,9 @@ public function runInstall( $localRepository->removePackage($localRepoPackage); } - $extensionNames = $installFromLock + // When no specific packages were resolved (e.g. `--from-lock`, or `upgrade`), treat every + // package tracked in the lock file as being installed/updated by this operation. + $extensionNames = $resolvedRequestedPackages === [] ? array_values(array_map(ExtensionName::determineFromComposerPackage(...), $composer->getLocker()->getLockedRepository()->getPackages())) : ResolvedPackageRequest::extensionNames($resolvedRequestedPackages); diff --git a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php index 8ad19d12..086413f7 100644 --- a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php +++ b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php @@ -7,6 +7,7 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\Installer\InstallerEvent; use Composer\Installer\InstallerEvents; use Composer\IO\IOInterface; @@ -56,11 +57,11 @@ public function __invoke(InstallerEvent $installerEvent): void array_walk( $operations, function (OperationInterface $operation): void { - if (! $operation instanceof InstallOperation) { + if (! $operation instanceof InstallOperation && ! $operation instanceof UpdateOperation) { return; } - $composerPackage = $operation->getPackage(); + $composerPackage = $operation instanceof UpdateOperation ? $operation->getTargetPackage() : $operation->getPackage(); if (! $composerPackage instanceof CompletePackageInterface) { return; } diff --git a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php index 43850e7b..2b840237 100644 --- a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php +++ b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php @@ -9,6 +9,7 @@ use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; use Composer\DependencyResolver\Operation\UninstallOperation; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Transaction; use Composer\Installer\InstallerEvent; use Composer\Installer\InstallerEvents; @@ -46,7 +47,7 @@ public function __invoke(InstallerEvent $installerEvent): void $newOperations = array_filter( $installerEvent->getTransaction()?->getOperations() ?? [], function (OperationInterface $operation) use ($pieOutput): bool { - if (! $operation instanceof InstallOperation && ! $operation instanceof UninstallOperation) { + if (! $operation instanceof InstallOperation && ! $operation instanceof UninstallOperation && ! $operation instanceof UpdateOperation) { $pieOutput->writeError( sprintf( 'Unexpected operation during installer: %s', @@ -58,13 +59,15 @@ function (OperationInterface $operation) use ($pieOutput): bool { return false; } - $isRequestedPiePackage = $this->composerRequest->isFor($operation->getPackage()->getName()); + $operationPackageName = $operation instanceof UpdateOperation ? $operation->getTargetPackage()->getName() : $operation->getPackage()->getName(); + + $isRequestedPiePackage = $this->composerRequest->isFor($operationPackageName); if (! $isRequestedPiePackage) { $pieOutput->writeError( sprintf( 'Filtering package %s from install operations, as it was not the requested package', - $operation->getPackage()->getName(), + $operationPackageName, ), verbosity: IOInterface::VERY_VERBOSE, ); diff --git a/src/ComposerIntegration/PiePackageInstaller.php b/src/ComposerIntegration/PiePackageInstaller.php index 8fb583e8..b0e76dee 100644 --- a/src/ComposerIntegration/PiePackageInstaller.php +++ b/src/ComposerIntegration/PiePackageInstaller.php @@ -39,40 +39,38 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa $composerPackage = $package; return parent::install($repo, $composerPackage) - ?->then(function () use ($composerPackage) { - $io = $this->composerRequest->pieOutput; - - if (! $this->composerRequest->isFor($composerPackage->getName())) { - $io->write( - sprintf( - 'Skipping %s install request from Composer as it was not the expected PIE package(s) %s', - $composerPackage->getName(), - implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), - ), - verbosity: IOInterface::VERY_VERBOSE, + ?->then($this->onlyForRequestedPiePackage( + $composerPackage, + 'install', + function (CompletePackageInterface $composerPackage): void { + ($this->installAndBuildProcess)( + $this->composer, + $this->composerRequest, + $composerPackage, + $this->getInstallPath($composerPackage), ); + }, + )); + } - return null; - } - - if (! $composerPackage instanceof CompletePackageInterface) { - $io->writeError(sprintf( - 'Not using PIE to install %s as it was not a Complete Package', - $composerPackage->getName(), - )); - - return null; - } - - ($this->installAndBuildProcess)( - $this->composer, - $this->composerRequest, - $composerPackage, - $this->getInstallPath($composerPackage), - ); - - return null; - }); + /** @inheritDoc */ + public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target) + { + $composerPackage = $target; + + return parent::update($repo, $initial, $target) + ?->then($this->onlyForRequestedPiePackage( + $composerPackage, + 'update', + function (CompletePackageInterface $composerPackage): void { + ($this->installAndBuildProcess)( + $this->composer, + $this->composerRequest, + $composerPackage, + $this->getInstallPath($composerPackage), + ); + }, + )); } /** @inheritDoc */ @@ -81,37 +79,56 @@ public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $ $composerPackage = $package; return parent::uninstall($repo, $composerPackage) - ?->then(function () use ($composerPackage) { - $io = $this->composerRequest->pieOutput; - - if (! $this->composerRequest->isFor($composerPackage->getName())) { - $io->write( - sprintf( - 'Skipping %s uninstall request from Composer as it was not the expected PIE package(s) %s', - $composerPackage->getName(), - implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), - ), - verbosity: IOInterface::VERY_VERBOSE, + ?->then($this->onlyForRequestedPiePackage( + $composerPackage, + 'uninstall', + function (CompletePackageInterface $composerPackage): void { + ($this->uninstallProcess)( + $this->composerRequest, + $composerPackage, ); + }, + )); + } - return null; - } + /** + * Wraps the given callback so it only runs when Composer's operation is for a PIE package we actually + * requested, and that package has full metadata available. + * + * @return callable(): null + */ + private function onlyForRequestedPiePackage(PackageInterface $composerPackage, string $verb, callable $onVerified): callable + { + return function () use ($composerPackage, $verb, $onVerified) { + $io = $this->composerRequest->pieOutput; - if (! $composerPackage instanceof CompletePackageInterface) { - $io->writeError(sprintf( - 'Not using PIE to install %s as it was not a Complete Package', + if (! $this->composerRequest->isFor($composerPackage->getName())) { + $io->write( + sprintf( + 'Skipping %s %s request from Composer as it was not the expected PIE package(s) %s', $composerPackage->getName(), - )); + $verb, + implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), + ), + verbosity: IOInterface::VERY_VERBOSE, + ); - return null; - } + return null; + } - ($this->uninstallProcess)( - $this->composerRequest, - $composerPackage, - ); + if (! $composerPackage instanceof CompletePackageInterface) { + $io->writeError(sprintf( + 'Not using PIE to %s %s as it was not a Complete Package', + $verb, + $composerPackage->getName(), + )); return null; - }); + } + + $onVerified($composerPackage); + + return null; + }; } } diff --git a/src/Container.php b/src/Container.php index aa9e3ade..bf547bf3 100644 --- a/src/Container.php +++ b/src/Container.php @@ -26,6 +26,7 @@ use Php\Pie\Command\SelfVerifyCommand; use Php\Pie\Command\ShowCommand; use Php\Pie\Command\UninstallCommand; +use Php\Pie\Command\UpgradeCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition; @@ -114,6 +115,7 @@ static function (ConsoleCommandEvent $event) use (&$displayedBanner, $io): void $container->singleton(DownloadCommand::class); $container->singleton(BuildCommand::class); $container->singleton(InstallCommand::class); + $container->singleton(UpgradeCommand::class); $container->singleton(InfoCommand::class); $container->singleton(ShowCommand::class); $container->singleton(RepositoryListCommand::class); diff --git a/test/assets/pie-upgrade-lock/pie.json b/test/assets/pie-upgrade-lock/pie.json index 13512013..b4ecacbd 100644 --- a/test/assets/pie-upgrade-lock/pie.json +++ b/test/assets/pie-upgrade-lock/pie.json @@ -1,5 +1,6 @@ { "require": { + "xdebug/xdebug": "^3.5", "asgrim/example-pie-extension": "^2.0" } } diff --git a/test/assets/pie-upgrade-lock/pie.lock b/test/assets/pie-upgrade-lock/pie.lock index e56070f4..1b00d4aa 100644 --- a/test/assets/pie-upgrade-lock/pie.lock +++ b/test/assets/pie-upgrade-lock/pie.lock @@ -56,6 +56,51 @@ "source": "https://github.com/asgrim/example-pie-extension/tree/2.0.7" }, "time": "2026-02-03T13:51:24+00:00" + }, + { + "name": "xdebug/xdebug", + "version": "3.5.3", + "source": { + "type": "git", + "url": "https://github.com/xdebug/xdebug.git", + "reference": "127bbcb980400752221cfaa54bdc1420e6ef3c12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/xdebug/xdebug/zipball/127bbcb980400752221cfaa54bdc1420e6ef3c12", + "reference": "127bbcb980400752221cfaa54bdc1420e6ef3c12", + "shasum": "" + }, + "require": { + "php": ">=8.0,<8.6" + }, + "type": "php-ext-zend", + "notification-url": "https://packagist.org/downloads/", + "php-ext": { + "priority": 90 + }, + "license": [ + "Xdebug-1.03" + ], + "description": "Xdebug is a debugging and productivity extension for PHP", + "support": { + "source": "https://github.com/xdebug/xdebug/tree/3.5.3" + }, + "funding": [ + { + "url": "https://xdebug.org/support", + "type": "custom" + }, + { + "url": "https://github.com/derickr", + "type": "github" + }, + { + "url": "https://www.patreon.com/derickr", + "type": "patreon" + } + ], + "time": "2026-06-08T14:39:07+00:00" } ], "packages-dev": [], diff --git a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php index b6836c99..4e19e13f 100644 --- a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php +++ b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php @@ -146,6 +146,42 @@ public function testRunInstallReinstallsVerifiedPackageWhenVersionDiffers(): voi self::assertStringNotContainsString('is already installed and verified', $output); } + public function testRunInstallWithNoResolvedPackagesTreatsLockedPackagesAsExtensionNames(): void + { + PieJsonEditor::fromTargetPlatform($this->targetPlatform) + ->ensureExists() + ->addRequire(self::PACKAGE_NAME, self::VERSION_CURRENT); + $this->setUpInstalledJson(self::VERSION_CURRENT); + $this->setUpLockFile(); + + $composer = PieComposerFactory::createPieComposer( + Container::testFactory($this->capturedOutput), + new PieComposerRequest( + new NullIO(), + $this->targetPlatform, + [], + PieOperation::Install, + [], + false, + installAllPackages: true, + ), + ); + + $this->handler->runInstall( + [], + $composer, + $this->targetPlatform, + false, + false, + ); + + $output = $this->capturedOutput->fetch(); + self::assertStringContainsString( + self::PACKAGE_NAME . ' (' . self::EXTENSION_NAME . ') is already installed and verified', + $output, + ); + } + public function testRunUninstallRemovesPackageFromPieJson(): void { PieJsonEditor::fromTargetPlatform($this->targetPlatform) @@ -212,7 +248,7 @@ private function setUpInstalledJson(string $version): void private function setUpLockFile(): void { - copy(__DIR__ . '/../../assets/pie-lock/pie.lock', Platform::getPieWorkingDirectory($this->targetPlatform) . '/pie.lock'); + copy(__DIR__ . '/../../assets/pie-install-from-lock/pie.lock', Platform::getPieWorkingDirectory($this->targetPlatform) . '/pie.lock'); } /** @param non-empty-string $version */ diff --git a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php index e2e64fa8..022b05b1 100644 --- a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php +++ b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php @@ -5,6 +5,7 @@ namespace Php\PieUnitTest\ComposerIntegration\Listeners; use Composer\Composer; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Transaction; use Composer\EventDispatcher\EventDispatcher; use Composer\Installer\InstallerEvent; @@ -32,6 +33,8 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use function array_map; + #[CoversClass(OverrideDownloadUrlInstallListener::class)] final class OverrideDownloadUrlInstallListenerTest extends TestCase { @@ -551,6 +554,72 @@ public function testPrePackagedBinaryMethodIsIgnoredWhenConfigureOptionsArePasse $listener($installerEvent); } + public function testDistUrlIsUpdatedForWindowsInstallersOnUpdateOperations(): void + { + $initialPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); + $initialPackage->setDistUrl('https://example.com/git-archive-zip-url'); + + $targetPackage = new CompletePackage('foo/bar', '1.3.0.0', '1.3.0'); + $targetPackage->setDistUrl('https://example.com/git-archive-zip-url'); + + $installerEvent = new InstallerEvent( + InstallerEvents::PRE_OPERATIONS_EXEC, + $this->composer, + $this->io, + false, + true, + new Transaction([$initialPackage], [$targetPackage]), + ); + + self::assertSame( + [UpdateOperation::class], + array_map( + static fn (object $operation): string => $operation::class, + $installerEvent->getTransaction()?->getOperations() ?? [], + ), + ); + + $packageReleaseAssets = $this->createMock(PackageReleaseAssets::class); + $packageReleaseAssets + ->expects(self::once()) + ->method('findMatchingReleaseAssetUrl') + ->willReturn('https://example.com/windows-download-url'); + + $this->container + ->method('get') + ->with(PackageReleaseAssets::class) + ->willReturn($packageReleaseAssets); + + (new OverrideDownloadUrlInstallListener( + $this->composer, + $this->io, + $this->container, + new PieComposerRequest( + $this->createMock(IOInterface::class), + new TargetPlatform( + OperatingSystem::Windows, + OperatingSystemFamily::Linux, + PhpBinaryPath::fromCurrentProcess(), + Architecture::x86_64, + ThreadSafetyMode::NonThreadSafe, + 1, + WindowsCompiler::VC15, + null, + ), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], + PieOperation::Install, + [], + false, + ), + ))($installerEvent); + + self::assertSame( + 'https://example.com/windows-download-url', + $targetPackage->getDistUrl(), + ); + self::assertSame(DownloadUrlMethod::WindowsBinaryDownload, DownloadUrlMethod::fromComposerPackage($targetPackage)); + } + public function testNoSelectedDownloadUrlMethodWillThrowException(): void { $composerPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); diff --git a/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php b/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php index b7fe028b..1a82c685 100644 --- a/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php +++ b/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php @@ -7,6 +7,7 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Transaction; use Composer\EventDispatcher\EventDispatcher; use Composer\Installer\InstallerEvent; @@ -127,4 +128,60 @@ public function testUnrelatedInstallOperationsAreRemoved(): void ), ); } + + public function testUnrelatedUpdateOperationsAreRemoved(): void + { + $keepInitial = new CompletePackage('bat/baz', '3.4.5.0', '3.4.5'); + $keepTarget = new CompletePackage('bat/baz', '3.5.0.0', '3.5.0'); + $discardInitial = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); + $discardTarget = new CompletePackage('foo/bar', '1.3.0.0', '1.3.0'); + + $installerEvent = new InstallerEvent( + InstallerEvents::PRE_OPERATIONS_EXEC, + $this->composer, + $this->createMock(IOInterface::class), + false, + true, + new Transaction([$keepInitial, $discardInitial], [$keepTarget, $discardTarget]), + ); + + self::assertSame( + [UpdateOperation::class, UpdateOperation::class], + array_map( + static fn (object $operation): string => $operation::class, + $installerEvent->getTransaction()?->getOperations() ?? [], + ), + ); + + (new RemoveUnrelatedInstallOperations( + new PieComposerRequest( + $this->createMock(IOInterface::class), + new TargetPlatform( + OperatingSystem::Windows, + OperatingSystemFamily::Linux, + PhpBinaryPath::fromCurrentProcess(), + Architecture::x86_64, + ThreadSafetyMode::NonThreadSafe, + 1, + WindowsCompiler::VC15, + null, + ), + [new RequestedPackageAndVersion('bat/baz', '^3.2')], + PieOperation::Install, + [], + false, + ), + ))($installerEvent); + + self::assertSame( + ['bat/baz'], + array_map( + static fn (UpdateOperation $operation): string => $operation->getTargetPackage()->getName(), + array_filter( + $installerEvent->getTransaction()?->getOperations() ?? [], + static fn (OperationInterface $operation): bool => $operation instanceof UpdateOperation, + ), + ), + ); + } } From e2df4e6f698800afb8190d7aaf60d7b0144ea9fb Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 9 Jul 2026 20:25:29 +0100 Subject: [PATCH 3/4] 561: ensure configure options carry across on pie upgrade --- features/upgrade-extensions.feature | 6 ++++++ src/Command/UpgradeCommand.php | 25 ++++++++++++++++++++++-- test/behaviour/CliContext.php | 30 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/features/upgrade-extensions.feature b/features/upgrade-extensions.feature index e7e7ed45..db301584 100644 --- a/features/upgrade-extensions.feature +++ b/features/upgrade-extensions.feature @@ -5,3 +5,9 @@ Feature: PIE extensions can be upgraded with PIE Given I have installed PIE extensions that have upgrades available When I run a command to upgrade my extensions Then the extensions should have been upgraded to the latest versions + + # pie upgrade + Example: I can upgrade existing PIE extensions that had been built with configure options + Given I have installed PIE extensions with configure options that have upgrades available + When I run a command to upgrade my extensions + Then the extension has been upgraded with the previous configure options diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php index 312b0094..c67aee22 100644 --- a/src/Command/UpgradeCommand.php +++ b/src/Command/UpgradeCommand.php @@ -11,6 +11,7 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\Platform; +use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; @@ -19,7 +20,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Webmozart\Assert\Assert; +use function explode; use function file_exists; #[AsCommand( @@ -33,6 +36,7 @@ public function __construct( private readonly ComposerIntegrationHandler $composerIntegrationHandler, private readonly IOInterface $io, private readonly CheckAllBuildTools $checkBuildTools, + private readonly InstalledPiePackages $installedPiePackages, ) { parent::__construct(); } @@ -70,6 +74,24 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::INVALID; } + $existingComposer = PieComposerFactory::createPieComposer( + $this->container, + PieComposerRequest::noOperation($this->io, $targetPlatform), + ); + + $configureOptions = []; + foreach ($this->installedPiePackages->allPiePackages($existingComposer)->packages() as $installedPackage) { + $existingConfigureOptions = $installedPackage->installedJsonMetadata()->configureOptions(); + if ($existingConfigureOptions === null) { + continue; + } + + $existingConfigureOptionsList = explode(' ', $existingConfigureOptions); + Assert::allStringNotEmpty($existingConfigureOptionsList); + + $configureOptions[$installedPackage->name()] = $existingConfigureOptionsList; + } + $composer = PieComposerFactory::createPieComposer( $this->container, new PieComposerRequest( @@ -77,8 +99,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, [], PieOperation::Install, - // @todo re-use each already-installed package's existing configure options here, rather than rebuilding with the defaults - [], + $configureOptions, CommandHelper::determineAttemptToSetupIniFile($input), installAllPackages: true, ), diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 38cfbf53..8cd18056 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -29,6 +29,7 @@ use function sprintf; use function str_contains; use function str_replace; +use function trim; class CliContext implements Context { @@ -586,6 +587,13 @@ public function iHaveInstalledPieExtensionsThatHaveUpgradesAvailable(): void $this->copyPieJsonAndLock('pie-upgrade-lock'); } + #[Given('I have installed PIE extensions with configure options that have upgrades available')] + public function iHaveInstalledPieExtensionsThatHaveConfigureOptions(): void + { + $this->runPieCommand(['install', 'asgrim/example-pie-extension:2.0.7', '--with-hello-name=UpgradeTest']); + $this->copyPieJsonAndLock('pie-upgrade-lock'); + } + #[Given('I have a lock file')] public function iHaveALockfile(): void { @@ -688,4 +696,26 @@ public function theExtensionsShouldHaveBeenUpgradedToTheLatestVersions(): void $this->restorePieJsonAndLock(); } + + #[Then('the extension has been upgraded with the previous configure options')] + public function theExtensionsShouldHaveBeenUpgradedWithThePreviousConfigureOptions(): void + { + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + $pieShowOutput = $this->output; + + // xdebug/xdebug should still exist (upgrade should NOT uninstall it) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'xdebug/xdebug'); + + // asgrim/example-pie-extension should be newer than 2.0.7 (min 2.0.9 at time of writing) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension', '^2.0.9'); + + $exampleTest = (new Process([self::PHP_BINARY, '-r', 'example_pie_extension_test();'])) + ->mustRun() + ->getOutput(); + + Assert::same(trim($exampleTest), 'Hello, UpgradeTest!'); + + $this->restorePieJsonAndLock(); + } } From 6624355b60cda3d2f48cad70d3d2b51f273ca596 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 9 Jul 2026 20:57:03 +0100 Subject: [PATCH 4/4] 561: ensure CompletePackage details match so Composer does not try to upgrade in the testRunInstallSkipsAlreadyVerifiedPackageAtSameVersion test --- .../ComposerIntegration/ComposerIntegrationHandlerTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php index 4e19e13f..862045d7 100644 --- a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php +++ b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php @@ -216,14 +216,19 @@ private function extensionBinaryPath(): string /** @param non-empty-string $version */ private function makeComposerPackage(string $version): CompletePackage { + $sha = '963c8d70c57c23fa2098e499a0ebffabb64748b3'; $extensionBinaryPath = $this->extensionBinaryPath(); $package = new CompletePackage(self::PACKAGE_NAME, $version . '.0', $version); $package->setType('php-ext'); $package->setPhpExt(['extension-name' => 'ext-' . self::EXTENSION_NAME]); $package->setDistType('zip'); - $package->setDistUrl('https://github.com/asgrim/example-pie-extension/archive/refs/tags/' . $version . '.zip'); $package->setInstallationSource('dist'); + $package->setDistUrl('https://api.github.com/repos/asgrim/example-pie-extension/zipball/' . $sha); + $package->setDistReference($sha); + $package->setSourceType('git'); + $package->setSourceUrl('https://github.com/asgrim/example-pie-extension.git'); + $package->setSourceReference($sha); $package->setExtra([ InstalledJsonMetadata::KEY_TARGET_PLATFORM_PHP_VERSION => $this->targetPlatform->phpBinaryPath->version(), InstalledJsonMetadata::KEY_BUILT_BINARY => $extensionBinaryPath,