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/features/upgrade-extensions.feature b/features/upgrade-extensions.feature
new file mode 100644
index 00000000..db301584
--- /dev/null
+++ b/features/upgrade-extensions.feature
@@ -0,0 +1,13 @@
+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
+
+ # 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
new file mode 100644
index 00000000..c67aee22
--- /dev/null
+++ b/src/Command/UpgradeCommand.php
@@ -0,0 +1,124 @@
+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;
+ }
+
+ $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(
+ $this->io,
+ $targetPlatform,
+ [],
+ PieOperation::Install,
+ $configureOptions,
+ 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-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..b4ecacbd
--- /dev/null
+++ b/test/assets/pie-upgrade-lock/pie.json
@@ -0,0 +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
new file mode 100644
index 00000000..1b00d4aa
--- /dev/null
+++ b/test/assets/pie-upgrade-lock/pie.lock
@@ -0,0 +1,115 @@
+{
+ "_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"
+ },
+ {
+ "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": [],
+ "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..8cd18056 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;
@@ -28,6 +29,7 @@
use function sprintf;
use function str_contains;
use function str_replace;
+use function trim;
class CliContext implements Context
{
@@ -476,9 +478,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 +553,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 +563,96 @@ 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 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
+ {
+ $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 +663,59 @@ 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();
+ }
+
+ #[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();
}
}
diff --git a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php
index b6836c99..862045d7 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)
@@ -180,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,
@@ -212,7 +253,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,
+ ),
+ ),
+ );
+ }
}