From 322a59ec798a3d662a216e276d45afafc4669161 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 19:55:08 +0100 Subject: [PATCH 01/14] 793: Bump composer deps --- composer.json | 6 ++-- composer.lock | 82 +++++++++++++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/composer.json b/composer.json index d3544557..d14aa03d 100644 --- a/composer.json +++ b/composer.json @@ -34,9 +34,9 @@ "fidry/cpu-core-counter": "^1.4.1", "illuminate/container": "^10.49.0", "psr/container": "^2.0.2", - "symfony/console": "^6.4.47", + "symfony/console": "^6.4.48", "symfony/event-dispatcher": "^6.4.44", - "symfony/process": "^6.4.46", + "symfony/process": "^6.4.48", "thecodingmachine/safe": "^3.4", "thephpf/attestation": "^0.0.8", "webmozart/assert": "^1.12.1" @@ -47,7 +47,7 @@ "bnf/phpstan-psr-container": "^1.1", "cyclonedx/cyclonedx-php-composer": "^6.2.0", "doctrine/coding-standard": "^14.0.0", - "phpstan/phpstan": "^2.3.0", + "phpstan/phpstan": "^2.3.1", "phpstan/phpstan-phpunit": "^2.1.1", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^10.5.66", diff --git a/composer.lock b/composer.lock index 4426b6b9..48dcf221 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": "567d1d311c4ca03c00d92fe1f8de1e27", + "content-hash": "c9f18b0427104f9cff23ca3926bbadfc", "packages": [ { "name": "composer/ca-bundle", @@ -1373,16 +1373,16 @@ }, { "name": "symfony/console", - "version": "v6.4.47", + "version": "v6.4.48", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca" + "reference": "c9c4cf6745e8b25db27466f59cf126ac8d3db3a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", - "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", + "url": "https://api.github.com/repos/symfony/console/zipball/c9c4cf6745e8b25db27466f59cf126ac8d3db3a4", + "reference": "c9c4cf6745e8b25db27466f59cf126ac8d3db3a4", "shasum": "" }, "require": { @@ -1447,7 +1447,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.47" + "source": "https://github.com/symfony/console/tree/v6.4.48" }, "funding": [ { @@ -1467,7 +1467,7 @@ "type": "tidelift" } ], - "time": "2026-09-18T10:23:48+00:00" + "time": "2026-10-07T08:10:34+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1706,16 +1706,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.45", + "version": "v6.4.48", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd" + "reference": "e3ff48b19ac71ee79f89021122211710b892ae0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/6dabf41c21957a2060f101bd7bf7e322989866bd", - "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e3ff48b19ac71ee79f89021122211710b892ae0b", + "reference": "e3ff48b19ac71ee79f89021122211710b892ae0b", "shasum": "" }, "require": { @@ -1752,7 +1752,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.45" + "source": "https://github.com/symfony/filesystem/tree/v6.4.48" }, "funding": [ { @@ -1772,7 +1772,7 @@ "type": "tidelift" } ], - "time": "2026-08-23T07:59:31+00:00" + "time": "2026-10-06T17:30:58+00:00" }, { "name": "symfony/finder", @@ -2259,16 +2259,16 @@ }, { "name": "symfony/process", - "version": "v6.4.46", + "version": "v6.4.48", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781" + "reference": "2a0cb83994a4b8a5f92fc91b8b4527e6e227c6f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b9f3d9ddf67543b92dbfa6d210c407d7a7982781", - "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781", + "url": "https://api.github.com/repos/symfony/process/zipball/2a0cb83994a4b8a5f92fc91b8b4527e6e227c6f5", + "reference": "2a0cb83994a4b8a5f92fc91b8b4527e6e227c6f5", "shasum": "" }, "require": { @@ -2300,7 +2300,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.46" + "source": "https://github.com/symfony/process/tree/v6.4.48" }, "funding": [ { @@ -2320,7 +2320,7 @@ "type": "tidelift" } ], - "time": "2026-09-02T12:35:55+00:00" + "time": "2026-10-08T05:06:07+00:00" }, { "name": "symfony/service-contracts", @@ -2411,16 +2411,16 @@ }, { "name": "symfony/string", - "version": "v6.4.46", + "version": "v6.4.48", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" + "reference": "9459a27a00b1f0fec1954e9eb362d00aedcfe048" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", - "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "url": "https://api.github.com/repos/symfony/string/zipball/9459a27a00b1f0fec1954e9eb362d00aedcfe048", + "reference": "9459a27a00b1f0fec1954e9eb362d00aedcfe048", "shasum": "" }, "require": { @@ -2476,7 +2476,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.46" + "source": "https://github.com/symfony/string/tree/v6.4.48" }, "funding": [ { @@ -2496,7 +2496,7 @@ "type": "tidelift" } ], - "time": "2026-09-11T09:03:57+00:00" + "time": "2026-10-03T14:51:46+00:00" }, { "name": "thecodingmachine/safe", @@ -3884,11 +3884,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.3.0", + "version": "2.3.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b1f9890d9af53a25c58129e78f0aadd31f90d7f4", - "reference": "b1f9890d9af53a25c58129e78f0aadd31f90d7f4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", "shasum": "" }, "require": { @@ -3944,7 +3944,7 @@ "type": "github" } ], - "time": "2026-10-06T06:15:36+00:00" + "time": "2026-10-08T21:09:11+00:00" }, { "name": "phpstan/phpstan-phpunit", @@ -5652,16 +5652,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.4.47", + "version": "v6.4.48", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "c7ffb6da26739b461ae18dca3438bc68c35cd2a0" + "reference": "3d340875612e33d1969437eb0e88a499426b2343" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/c7ffb6da26739b461ae18dca3438bc68c35cd2a0", - "reference": "c7ffb6da26739b461ae18dca3438bc68c35cd2a0", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3d340875612e33d1969437eb0e88a499426b2343", + "reference": "3d340875612e33d1969437eb0e88a499426b2343", "shasum": "" }, "require": { @@ -5713,7 +5713,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.47" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.48" }, "funding": [ { @@ -5733,7 +5733,7 @@ "type": "tidelift" } ], - "time": "2026-09-29T10:29:26+00:00" + "time": "2026-10-13T12:43:54+00:00" }, { "name": "symfony/translation", @@ -5999,16 +5999,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.47", + "version": "v6.4.48", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "928e3ef5d46756da90cc2f54c3bd4a21bc18ceff" + "reference": "91288fddb497a598b564343ea663ea272370a7dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/928e3ef5d46756da90cc2f54c3bd4a21bc18ceff", - "reference": "928e3ef5d46756da90cc2f54c3bd4a21bc18ceff", + "url": "https://api.github.com/repos/symfony/yaml/zipball/91288fddb497a598b564343ea663ea272370a7dc", + "reference": "91288fddb497a598b564343ea663ea272370a7dc", "shasum": "" }, "require": { @@ -6051,7 +6051,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.47" + "source": "https://github.com/symfony/yaml/tree/v6.4.48" }, "funding": [ { @@ -6071,7 +6071,7 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:15:25+00:00" + "time": "2026-10-12T09:18:28+00:00" }, { "name": "thecodingmachine/phpstan-safe-rule", From 0e373584b90897b96313d85690692382b174e063 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 21:02:56 +0100 Subject: [PATCH 02/14] 793: Add ExtensionToPackageSelections value object --- src/Command/CommandHelper.php | 6 +-- .../InstallExtensionsForProjectCommand.php | 6 +-- .../ExtensionToPackageSelections.php | 44 +++++++++++++++++++ .../SelectPackageForExtension.php | 8 ++-- .../ExtensionToPackageSelectionsTest.php | 26 +++++++++++ .../SelectPackageForExtensionTest.php | 15 ++++--- 6 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php create mode 100644 test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index ed3348ba..b97da8a5 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -26,6 +26,7 @@ use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Downloading\DownloadUrlMethod; use Php\Pie\ExtensionName; +use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Platform as PiePlatform; use Php\Pie\Platform\OperatingSystem; @@ -796,8 +797,7 @@ public static function handleWorkingDirectory(InputInterface $input, IOInterface return $restoreWorkingDir; } - /** @return array */ - public static function determineExtensionToPackageSelections(InputInterface $input): array + public static function determineExtensionToPackageSelections(InputInterface $input): ExtensionToPackageSelections { $extensionToPackageSelections = []; $selectionOptions = $input->getOption(self::OPTION_PACKAGE_SELECTION); @@ -810,6 +810,6 @@ public static function determineExtensionToPackageSelections(InputInterface $inp $extensionToPackageSelections[ExtensionName::normaliseFromString($extNameString)->name()] = (new RequestedPackageAndVersion($packageSelectionString, null))->package; } - return $extensionToPackageSelections; + return ExtensionToPackageSelections::fromArrayMap($extensionToPackageSelections); } } diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 7b2eecbc..1f155ffa 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -19,6 +19,7 @@ use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject; use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired; use Php\Pie\Installing\InstallForPhpProject\ExtensionInstalledStatus; +use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Php\Pie\Installing\InstallForPhpProject\NoMatchingPackagesFound; @@ -206,8 +207,7 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac * Returns false if any error happened whilst trying to install the extension; returns true if was already * installed, or it was successfully installed if needed. * - * @param array $phpEnabledExtensions - * @param array $extensionToPackageSelections + * @param array $phpEnabledExtensions */ private function handleSingleExtensionRequiredByPhpProject( Composer $pieComposer, @@ -215,7 +215,7 @@ private function handleSingleExtensionRequiredByPhpProject( PiePackageList $installedPiePackages, TargetPlatform $targetPlatform, array $phpEnabledExtensions, - array $extensionToPackageSelections, + ExtensionToPackageSelections $extensionToPackageSelections, ): RequestedPackageAndVersion|bool { $extension = ExtensionName::normaliseFromString($link->getTarget()); $piePackagesForExtension = $installedPiePackages diff --git a/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php b/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php new file mode 100644 index 00000000..73fe5e38 --- /dev/null +++ b/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php @@ -0,0 +1,44 @@ + $map */ + private function __construct(private readonly array $map) + { + } + + /** @param array $map */ + public static function fromArrayMap(array $map): self + { + Assert::allStringNotEmpty(array_keys($map)); + Assert::allStringNotEmpty(array_values($map)); + + // @phpstan-ignore argument.type + return new self($map); + } + + public function has(ExtensionName $extension): bool + { + return array_key_exists($extension->name(), $this->map); + } + + /** @return non-empty-string */ + public function packageFor(ExtensionName $extension): string + { + Assert::keyExists($this->map, $extension->name()); + + return $this->map[$extension->name()]; + } +} diff --git a/src/Installing/InstallForPhpProject/SelectPackageForExtension.php b/src/Installing/InstallForPhpProject/SelectPackageForExtension.php index 944e7747..20f252f2 100644 --- a/src/Installing/InstallForPhpProject/SelectPackageForExtension.php +++ b/src/Installing/InstallForPhpProject/SelectPackageForExtension.php @@ -36,21 +36,19 @@ public function __construct( * in non-interactive mode (e.g. CI/Docker builds etc), we do NOT install anything, we just tell the user to * provide the appropriate `--select` flags * - * @param array $extensionToPackageSelections - * * @throws NoMatchingPackagesFound * @throws PackageSelectionRequired */ public function __invoke( ExtensionName $extension, string $linkRequiresConstraint, - array $extensionToPackageSelections, + ExtensionToPackageSelections $extensionToPackageSelections, Composer $pieComposer, bool $isInteractive, ): RequestedPackageAndVersion|null { - if (array_key_exists($extension->name(), $extensionToPackageSelections)) { + if ($extensionToPackageSelections->has($extension)) { return new RequestedPackageAndVersion( - $extensionToPackageSelections[$extension->name()], + $extensionToPackageSelections->packageFor($extension), $this->normaliseConstraint($linkRequiresConstraint), ); } diff --git a/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php b/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php new file mode 100644 index 00000000..8a31d556 --- /dev/null +++ b/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php @@ -0,0 +1,26 @@ + 'foo/foo', + 'bar' => 'bat/baz', + ]); + + self::assertTrue($selections->has(ExtensionName::normaliseFromString('foo'))); + self::assertFalse($selections->has(ExtensionName::normaliseFromString('baz'))); + self::assertSame('bat/baz', $selections->packageFor(ExtensionName::normaliseFromString('bar'))); + } +} diff --git a/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php b/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php index 766b300d..325df6de 100644 --- a/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php +++ b/test/unit/Installing/InstallForPhpProject/SelectPackageForExtensionTest.php @@ -9,6 +9,7 @@ use Composer\IO\IOInterface; use OutOfRangeException; use Php\Pie\ExtensionName; +use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\NoMatchingPackagesFound; use Php\Pie\Installing\InstallForPhpProject\PackageSelectionRequired; @@ -50,7 +51,7 @@ public function testExplicitSelectOptionIsUsedWithoutLookup(): void $result = ($this->selectPackageForExtension)( $this->extension, '^1.0', - ['foobar' => 'vendor/foobar'], + ExtensionToPackageSelections::fromArrayMap(['foobar' => 'vendor/foobar']), $this->pieComposer, false, ); @@ -67,7 +68,7 @@ public function testExplicitSelectWithWildcardConstraintNormalisesToNull(): void $result = ($this->selectPackageForExtension)( $this->extension, '*', - ['foobar' => 'vendor/foobar'], + ExtensionToPackageSelections::fromArrayMap(['foobar' => 'vendor/foobar']), $this->pieComposer, false, ); @@ -86,7 +87,7 @@ public function testNonInteractiveWithNoMatchesThrowsNoMatchingPackagesFound(): ($this->selectPackageForExtension)( $this->extension, '^1.0', - [], + ExtensionToPackageSelections::fromArrayMap([]), $this->pieComposer, false, ); @@ -101,7 +102,7 @@ public function testNonInteractiveWithMatchesThrowsPackageSelectionRequired(): v ($this->selectPackageForExtension)( $this->extension, '^1.0', - [], + ExtensionToPackageSelections::fromArrayMap([]), $this->pieComposer, false, ); @@ -123,7 +124,7 @@ public function testInteractiveWithNoMatchesThrowsNoMatchingPackagesFound(): voi ($this->selectPackageForExtension)( $this->extension, '^1.0', - [], + ExtensionToPackageSelections::fromArrayMap([]), $this->pieComposer, true, ); @@ -141,7 +142,7 @@ public function testInteractiveUserSelectsPackageWillReturnSelectedPackage(): vo $result = $service( $this->extension, '^1.0', - [], + ExtensionToPackageSelections::fromArrayMap([]), $this->pieComposer, true, ); @@ -164,7 +165,7 @@ public function testInteractiveUserSelectsNothingReturnsNull(): void $result = $service( $this->extension, '^1.0', - [], + ExtensionToPackageSelections::fromArrayMap([]), $this->pieComposer, true, ); From 00459408fc35488c1ba1a4a0dc3d355fec141be8 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 21:19:49 +0100 Subject: [PATCH 03/14] 793: Allow overriding package selections with ->overrideWith(..) --- .../ExtensionToPackageSelections.php | 6 ++++++ .../ExtensionToPackageSelectionsTest.php | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php b/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php index 73fe5e38..0da28126 100644 --- a/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php +++ b/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php @@ -9,6 +9,7 @@ use function array_key_exists; use function array_keys; +use function array_merge; use function array_values; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ @@ -29,6 +30,11 @@ public static function fromArrayMap(array $map): self return new self($map); } + public function overrideWith(ExtensionToPackageSelections $overrides): self + { + return new self(array_merge($this->map, $overrides->map)); + } + public function has(ExtensionName $extension): bool { return array_key_exists($extension->name(), $this->map); diff --git a/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php b/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php index 8a31d556..da9470a2 100644 --- a/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php +++ b/test/unit/Installing/InstallForPhpProject/ExtensionToPackageSelectionsTest.php @@ -23,4 +23,19 @@ public function testFromArrayMap(): void self::assertFalse($selections->has(ExtensionName::normaliseFromString('baz'))); self::assertSame('bat/baz', $selections->packageFor(ExtensionName::normaliseFromString('bar'))); } + + public function testOverrideWith(): void + { + $selections = ExtensionToPackageSelections::fromArrayMap([ + 'aa' => 'a/a', + 'bb' => 'b/b', + ])->overrideWith(ExtensionToPackageSelections::fromArrayMap([ + 'bb' => 'fork/bfork', + 'cc' => 'c/c', + ])); + + self::assertSame('a/a', $selections->packageFor(ExtensionName::normaliseFromString('aa'))); + self::assertSame('fork/bfork', $selections->packageFor(ExtensionName::normaliseFromString('bb'))); + self::assertSame('c/c', $selections->packageFor(ExtensionName::normaliseFromString('cc'))); + } } From 48a7271d38f2ad7aff6139b5785dae281672c732 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 21:58:48 +0100 Subject: [PATCH 04/14] 793: Update feature test to only verify example_pie_extension --- test/behaviour/CliContext.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index e54f3136..0121bac0 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -584,8 +584,6 @@ public function iRunACommandToInstallTheExtensions(): void 'install', '--select', 'example_pie_extension=asgrim/example-pie-extension', - '--select', - 'redis=phpredis/phpredis', ]); } @@ -646,9 +644,8 @@ public function iShouldSeeInformationOnHowToSelectPackagesForInstall(): void Assert::same($this->exitCode, 1); Assert::notNull($this->errorOutput); - Assert::contains($this->errorOutput, 'No package selections were made for ext-redis; you MUST specify a package selection in non-interactive mode'); + Assert::contains($this->errorOutput, 'No package selections were made for ext-example_pie_extension; you MUST specify a package selection in non-interactive mode'); Assert::contains($this->errorOutput, '--select=example_pie_extension=asgrim/example-pie-extension'); - Assert::contains($this->errorOutput, '--select=redis=phpredis/phpredis'); } #[Given('I am in a PIE project')] From 54ca11a40654c2f0efd66152c1c86e7450541362 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 19 Oct 2026 21:14:46 +0100 Subject: [PATCH 05/14] 793: Initial structure for default package selections --- resources/package-map.json | 7 +++++++ src/Command/CommandHelper.php | 5 ++++- .../ExtensionToPackageSelections.php | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 resources/package-map.json diff --git a/resources/package-map.json b/resources/package-map.json new file mode 100644 index 00000000..d8a29192 --- /dev/null +++ b/resources/package-map.json @@ -0,0 +1,7 @@ +{ + "apcu": "apcu/apcu", + "mcrypt": "pecl/mcrypt", + "mongodb": "mongodb/mongodb-extension", + "redis": "phpredis/phpredis", + "xdebug": "xdebug/xdebug" +} diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index b97da8a5..c13d545d 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -810,6 +810,9 @@ public static function determineExtensionToPackageSelections(InputInterface $inp $extensionToPackageSelections[ExtensionName::normaliseFromString($extNameString)->name()] = (new RequestedPackageAndVersion($packageSelectionString, null))->package; } - return ExtensionToPackageSelections::fromArrayMap($extensionToPackageSelections); + return ExtensionToPackageSelections::fromReferenceList() + // @todo overrideWith a local map, if provided (how to provide?) + // options: config global pie.json, CLI param? + ->overrideWith(ExtensionToPackageSelections::fromArrayMap($extensionToPackageSelections)); } } diff --git a/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php b/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php index 0da28126..2dfc88e5 100644 --- a/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php +++ b/src/Installing/InstallForPhpProject/ExtensionToPackageSelections.php @@ -15,11 +15,25 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class ExtensionToPackageSelections { + // @todo real URL + private const REFERENCE_LIST_URL = __DIR__ . '/../../../resources/package-map.json'; + /** @param array $map */ private function __construct(private readonly array $map) { } + public static function fromReferenceList(): self + { + // @todo timeout should be VERY quick; we don't want connectivity holdups + // @todo maybe an ENV or CLI param to skip fetching the reference list? + // @todo verify attestation/signature of reference list + // @todo cache the list locally + $x = file_get_contents(self::REFERENCE_LIST_URL); + $xx = json_decode($x, true, flags: JSON_THROW_ON_ERROR); + return new self($xx); + } + /** @param array $map */ public static function fromArrayMap(array $map): self { From f6969f394bda066a5609e99a72994a604e647e5b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 20:02:32 +0000 Subject: [PATCH 06/14] 793: Storing package map in PIE settings json --- composer.json | 1 + composer.lock | 208 ++++++++++++- .../InstallForPhpProject/CachedPackageMap.php | 22 ++ .../ExtensionToPackageSelections.php | 26 +- .../FetchReferencePackageMap.php | 26 ++ src/Settings.php | 68 +++++ .../FetchReferencePackageMapTest.php | 280 ++++++++++++++++++ test/unit/SettingsTest.php | 93 ++++++ 8 files changed, 708 insertions(+), 16 deletions(-) create mode 100644 src/Installing/InstallForPhpProject/CachedPackageMap.php create mode 100644 src/Installing/InstallForPhpProject/FetchReferencePackageMap.php create mode 100644 test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php diff --git a/composer.json b/composer.json index d14aa03d..0c33b52f 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "fidry/cpu-core-counter": "^1.4.1", "illuminate/container": "^10.49.0", "psr/container": "^2.0.2", + "symfony/clock": "^6.4", "symfony/console": "^6.4.48", "symfony/event-dispatcher": "^6.4.44", "symfony/process": "^6.4.48", diff --git a/composer.lock b/composer.lock index 48dcf221..56033b58 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": "c9f18b0427104f9cff23ca3926bbadfc", + "content-hash": "4ce068c7c31f7cb94229d3e75c28673b", "packages": [ { "name": "composer/ca-bundle", @@ -921,6 +921,54 @@ }, "time": "2025-09-14T11:18:39+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -1371,6 +1419,84 @@ }, "time": "2023-09-03T09:24:00+00:00" }, + { + "name": "symfony/clock", + "version": "v6.4.48", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "907397721c27e69c93220bab59ee3261f6714391" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/907397721c27e69c93220bab59ee3261f6714391", + "reference": "907397721c27e69c93220bab59ee3261f6714391", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v6.4.48" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T15:42:14+00:00" + }, { "name": "symfony/console", "version": "v6.4.48", @@ -2177,6 +2303,86 @@ ], "time": "2026-09-24T15:19:04+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.43.0", diff --git a/src/Installing/InstallForPhpProject/CachedPackageMap.php b/src/Installing/InstallForPhpProject/CachedPackageMap.php new file mode 100644 index 00000000..0b0b82e1 --- /dev/null +++ b/src/Installing/InstallForPhpProject/CachedPackageMap.php @@ -0,0 +1,22 @@ + $map */ private function __construct(private readonly array $map) { } - public static function fromReferenceList(): self - { - // @todo timeout should be VERY quick; we don't want connectivity holdups - // @todo maybe an ENV or CLI param to skip fetching the reference list? - // @todo verify attestation/signature of reference list - // @todo cache the list locally - $x = file_get_contents(self::REFERENCE_LIST_URL); - $xx = json_decode($x, true, flags: JSON_THROW_ON_ERROR); - return new self($xx); - } - /** @param array $map */ public static function fromArrayMap(array $map): self { @@ -61,4 +51,10 @@ public function packageFor(ExtensionName $extension): string return $this->map[$extension->name()]; } + + /** @return array */ + public function toArray(): array + { + return $this->map; + } } diff --git a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php new file mode 100644 index 00000000..bfa9bf9c --- /dev/null +++ b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php @@ -0,0 +1,26 @@ +, + * etag: non-empty-string, + * expires: non-empty-string, * } */ class Settings @@ -103,4 +115,60 @@ public function changeUpdateChannel(Channel $channel): void $this->write($config); } + + public function updatePackageMap(CachedPackageMap $map): void + { + $config = $this->read(); + + $config['package_map'] = [ + 'map' => $map->map->toArray(), + 'expires' => $map->expiry->format(DateTimeImmutable::ATOM), + 'etag' => $map->etag, + ]; + $this->write($config); + } + + public function packageMap(): CachedPackageMap|null + { + $config = $this->read(); + + if (! array_key_exists('package_map', $config) || ! self::isValidPackageMap($config['package_map'])) { + return null; + } + + try { + $expires = \Safe\DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $config['package_map']['expires']); + } catch (DatetimeException) { + return null; + } + + return new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap($config['package_map']['map']), + $config['package_map']['etag'], + $expires, + ); + } + + /** @phpstan-assert-if-true PackageMapSettings $packageMap */ + private static function isValidPackageMap(mixed $packageMap): bool + { + if ( + ! is_array($packageMap) + || ! is_array($packageMap['map'] ?? null) + || ! is_string($packageMap['etag'] ?? null) + || $packageMap['etag'] === '' + || ! is_string($packageMap['expires'] ?? null) + || $packageMap['expires'] === '' + ) { + return false; + } + + foreach ($packageMap['map'] as $extension => $package) { + if (! is_string($extension) || $extension === '' || ! is_string($package) || $package === '') { + return false; + } + } + + return true; + } } diff --git a/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php b/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php new file mode 100644 index 00000000..e4934b80 --- /dev/null +++ b/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php @@ -0,0 +1,280 @@ +httpDownloader = $this->createMock(HttpDownloader::class); + $this->pieSettings = $this->createMock(Settings::class); + $this->clock = new MockClock('2026-10-20 15:00:00', new DateTimeZone('UTC')); + + $this->fetcher = new FetchReferencePackageMap( + $this->httpDownloader, + $this->pieSettings, + $this->clock, + ); + } + + public function testNewDownloadUpdatesCache(): void + { + $expiry = new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')); + + $this->pieSettings + ->method('packageMap') + ->willReturn(null); + $this->pieSettings->expects(self::once()) + ->method('updatePackageMap') + ->with( + new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + $expiry, + ), + ); + + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with( + self::REFERENCE_LIST_URL, + [ + 'http' => [ + 'method' => 'GET', + 'header' => [], + 'timeout' => 1, + ], + ], + ) + ->willReturn(new Response( + ['url' => self::REFERENCE_LIST_URL], + 200, + headers: [ + 'ETag: "etag123"', + 'Cache-Control: max-age=300', + ], + body: json_encode(['foo' => 'foo/foo'], JSON_THROW_ON_ERROR), + )); + + self::assertSame( + ['foo' => 'foo/foo'], + ($this->fetcher)()->toArray(), + ); + } + + public function testCacheWithinMaxAgeDoesNotRequestNewMap(): void + { + $expiry = new DateTimeImmutable('2026-10-20 15:03:00', new DateTimeZone('UTC')); + + $this->pieSettings + ->method('packageMap') + ->willReturn(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + $expiry, + )); + $this->pieSettings->expects(self::never()) + ->method('updatePackageMap'); + + $this->httpDownloader->expects(self::never()) + ->method('get'); + + self::assertSame( + ['foo' => 'foo/foo'], + ($this->fetcher)()->toArray(), + ); + } + + public function testGetWith304UsesExistingMapAndExpiryUpdated(): void + { + $oldExpiry = new DateTimeImmutable('2026-10-20 14:55:00', new DateTimeZone('UTC')); + $newExpiry = new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')); + + $this->pieSettings + ->method('packageMap') + ->willReturn(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + $oldExpiry, + )); + $this->pieSettings->expects(self::once()) + ->method('updatePackageMap') + ->with( + new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + $newExpiry, + ), + ); + + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with( + self::REFERENCE_LIST_URL, + [ + 'http' => [ + 'method' => 'GET', + 'header' => ['If-None-Match: "etag123"'], + 'timeout' => 1, + ], + ], + ) + ->willReturn(new Response( + ['url' => self::REFERENCE_LIST_URL], + 304, + headers: [ + 'ETag: "etag123"', + 'Cache-Control: max-age=300', + ], + body: null, + )); + + self::assertSame( + ['foo' => 'foo/foo'], + ($this->fetcher)()->toArray(), + ); + } + + public function testGetWith200RefreshesMap(): void + { + $oldExpiry = new DateTimeImmutable('2026-10-20 14:55:00', new DateTimeZone('UTC')); + $newExpiry = new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')); + + $this->pieSettings + ->method('packageMap') + ->willReturn(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo', 'bar' => 'bar/bar']), + '"etag123"', + $oldExpiry, + )); + $this->pieSettings->expects(self::once()) + ->method('updatePackageMap') + ->with( + new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo2/foo2']), + '"etag456"', + $newExpiry, + ), + ); + + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with( + self::REFERENCE_LIST_URL, + [ + 'http' => [ + 'method' => 'GET', + 'header' => ['If-None-Match: "etag123"'], + 'timeout' => 1, + ], + ], + ) + ->willReturn(new Response( + ['url' => self::REFERENCE_LIST_URL], + 200, + headers: [ + 'ETag: "etag456"', + 'Cache-Control: max-age=300', + ], + body: json_encode(['foo' => 'foo2/foo2'], JSON_THROW_ON_ERROR), + )); + + self::assertSame( + ['foo' => 'foo2/foo2'], + ($this->fetcher)()->toArray(), + ); + } + + public function testGetWithErrorAndNoCacheReturnsEmptyMap(): void + { + $this->pieSettings + ->method('packageMap') + ->willReturn(null); + $this->pieSettings->expects(self::never()) + ->method('updatePackageMap'); + + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with( + self::REFERENCE_LIST_URL, + [ + 'http' => [ + 'method' => 'GET', + 'header' => [], + 'timeout' => 1, + ], + ], + ) + ->willThrowException(new TransportException('The "' . self::REFERENCE_LIST_URL . '" file could not be downloaded (HTTP/2 404)', 404)); + + self::assertSame( + [], + ($this->fetcher)()->toArray(), + ); + } + + public function testGetWithErrorWithCachedMapUsesExistingMap(): void + { + // even if expired, best guess + $expiry = new DateTimeImmutable('2026-10-20 14:55:00', new DateTimeZone('UTC')); + + $this->pieSettings + ->method('packageMap') + ->willReturn(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + $expiry, + )); + $this->pieSettings->expects(self::never()) + ->method('updatePackageMap'); + + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with( + self::REFERENCE_LIST_URL, + [ + 'http' => [ + 'method' => 'GET', + 'header' => ['If-None-Match: "etag123"'], + 'timeout' => 1, + ], + ], + ) + ->willThrowException(new TransportException('The "' . self::REFERENCE_LIST_URL . '" file could not be downloaded (HTTP/2 404)', 404)); + + self::assertSame( + ['foo' => 'foo/foo'], + ($this->fetcher)()->toArray(), + ); + } +} diff --git a/test/unit/SettingsTest.php b/test/unit/SettingsTest.php index 2727f4c2..84ac27f3 100644 --- a/test/unit/SettingsTest.php +++ b/test/unit/SettingsTest.php @@ -6,10 +6,16 @@ use Composer\Json\JsonValidationException; use Composer\Util\Filesystem; +use DateTimeZone; +use JsonException; +use Php\Pie\Installing\InstallForPhpProject\CachedPackageMap; +use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; use Php\Pie\SelfManage\Update\Channel; use Php\Pie\Settings; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Safe\DateTimeImmutable; use function Safe\file_get_contents; use function Safe\file_put_contents; @@ -71,4 +77,91 @@ public function testExistingSettingsCanBeUpdated(): void (new Filesystem())->remove($workingDir); } + + public function testPackageMapIsNullWhenNotCached(): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + + self::assertNull((new Settings($workingDir))->packageMap()); + + (new Filesystem())->remove($workingDir); + } + + public function testPackageMapCanBeWrittenAndReadBack(): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + $cached = new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')), + ); + + (new Settings($workingDir))->updatePackageMap($cached); + + self::assertJsonStringEqualsJsonString( + '{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}', + (string) file_get_contents($workingDir . 'pie-settings.json'), + ); + self::assertEquals($cached, (new Settings($workingDir))->packageMap()); + + (new Filesystem())->remove($workingDir); + } + + public function testUpdatingPackageMapKeepsExistingChannel(): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + mkdir($workingDir, recursive: true); + file_put_contents($workingDir . 'pie-settings.json', '{"channel": "nightly"}'); + + $settings = new Settings($workingDir); + $settings->updatePackageMap(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')), + )); + + self::assertSame(Channel::Nightly, $settings->updateChannel()); + + (new Filesystem())->remove($workingDir); + } + + /** @return array */ + public static function corruptPackageMapProvider(): array + { + return [ + 'missing map' => ['{"package_map": {"etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], + 'missing etag' => ['{"package_map": {"map": {"foo": "foo/foo"}, "expires": "2026-10-20T15:05:00+00:00"}}'], + 'missing expires' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\""}}'], + 'expires is not a date' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "not a date"}}'], + 'expires is not ATOM format' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "2026-10-20 15:05:00"}}'], + 'empty package name' => ['{"package_map": {"map": {"foo": ""}, "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], + 'empty extension name' => ['{"package_map": {"map": {"": "foo/foo"}, "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], + 'map is a string' => ['{"package_map": {"map": "foo/foo", "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], + 'etag is a number' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": 123, "expires": "2026-10-20T15:05:00+00:00"}}'], + ]; + } + + #[DataProvider('corruptPackageMapProvider')] + public function testCorruptPackageMapIsTreatedAsNotCached(string $settingsJson): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + mkdir($workingDir, recursive: true); + file_put_contents($workingDir . 'pie-settings.json', $settingsJson); + + self::assertNull((new Settings($workingDir))->packageMap()); + + (new Filesystem())->remove($workingDir); + } + + public function testInvalidJsonWhenReadingPackageMapThrowsException(): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + mkdir($workingDir, recursive: true); + file_put_contents($workingDir . 'pie-settings.json', '{"package_map":'); + + $settings = new Settings($workingDir); + + $this->expectException(JsonException::class); + $settings->packageMap(); + } } From b3c5d86c94b8b37951d1826953c15140b434adea Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 20:11:51 +0000 Subject: [PATCH 07/14] 793: Validate package map --- resources/pie-settings-schema.json | 15 +++++++++++ src/Settings.php | 38 +++++----------------------- test/unit/SettingsTest.php | 40 ++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/resources/pie-settings-schema.json b/resources/pie-settings-schema.json index 87ed6542..e478aedd 100644 --- a/resources/pie-settings-schema.json +++ b/resources/pie-settings-schema.json @@ -13,6 +13,21 @@ "preview", "stable" ] + }, + "package_map": { + "type": "object", + "description": "Cached copy of the reference extension-to-package map", + "required": ["map", "etag", "expires"], + "properties": { + "map": { + "type": ["object", "array"], + "maxItems": 0, + "patternProperties": {"^.+$": {"type": "string", "minLength": 1}}, + "additionalProperties": false + }, + "etag": {"type": "string", "minLength": 1}, + "expires": {"type": "string", "minLength": 1} + } } } } diff --git a/src/Settings.php b/src/Settings.php index a040b33a..6b92926e 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -13,8 +13,6 @@ use function array_key_exists; use function file_exists; -use function is_array; -use function is_string; use function json_decode; use function json_encode; use function rtrim; @@ -31,12 +29,11 @@ * * @phpstan-type PieSettings = array{ * channel?: non-empty-string, - * package_map?: mixed, - * } - * @phpstan-type PackageMapSettings = array{ - * map: array, - * etag: non-empty-string, - * expires: non-empty-string, + * package_map?: array{ + * map: array, + * etag: non-empty-string, + * expires: non-empty-string, + * }, * } */ class Settings @@ -132,7 +129,7 @@ public function packageMap(): CachedPackageMap|null { $config = $this->read(); - if (! array_key_exists('package_map', $config) || ! self::isValidPackageMap($config['package_map'])) { + if (! array_key_exists('package_map', $config)) { return null; } @@ -148,27 +145,4 @@ public function packageMap(): CachedPackageMap|null $expires, ); } - - /** @phpstan-assert-if-true PackageMapSettings $packageMap */ - private static function isValidPackageMap(mixed $packageMap): bool - { - if ( - ! is_array($packageMap) - || ! is_array($packageMap['map'] ?? null) - || ! is_string($packageMap['etag'] ?? null) - || $packageMap['etag'] === '' - || ! is_string($packageMap['expires'] ?? null) - || $packageMap['expires'] === '' - ) { - return false; - } - - foreach ($packageMap['map'] as $extension => $package) { - if (! is_string($extension) || $extension === '' || ! is_string($package) || $package === '') { - return false; - } - } - - return true; - } } diff --git a/test/unit/SettingsTest.php b/test/unit/SettingsTest.php index 84ac27f3..8f8fe03a 100644 --- a/test/unit/SettingsTest.php +++ b/test/unit/SettingsTest.php @@ -127,13 +127,20 @@ public function testUpdatingPackageMapKeepsExistingChannel(): void /** @return array */ public static function corruptPackageMapProvider(): array + { + return [ + 'expires is not a date' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "not a date"}}'], + 'expires is not ATOM format' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "2026-10-20 15:05:00"}}'], + ]; + } + + /** @return array */ + public static function invalidPackageMapProvider(): array { return [ 'missing map' => ['{"package_map": {"etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], 'missing etag' => ['{"package_map": {"map": {"foo": "foo/foo"}, "expires": "2026-10-20T15:05:00+00:00"}}'], 'missing expires' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\""}}'], - 'expires is not a date' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "not a date"}}'], - 'expires is not ATOM format' => ['{"package_map": {"map": {"foo": "foo/foo"}, "etag": "\"etag123\"", "expires": "2026-10-20 15:05:00"}}'], 'empty package name' => ['{"package_map": {"map": {"foo": ""}, "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], 'empty extension name' => ['{"package_map": {"map": {"": "foo/foo"}, "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], 'map is a string' => ['{"package_map": {"map": "foo/foo", "etag": "\"etag123\"", "expires": "2026-10-20T15:05:00+00:00"}}'], @@ -141,6 +148,35 @@ public static function corruptPackageMapProvider(): array ]; } + #[DataProvider('invalidPackageMapProvider')] + public function testInvalidPackageMapThrowsException(string $settingsJson): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + mkdir($workingDir, recursive: true); + file_put_contents($workingDir . 'pie-settings.json', $settingsJson); + + $settings = new Settings($workingDir); + + $this->expectException(JsonValidationException::class); + $settings->packageMap(); + } + + public function testEmptyPackageMapCanBeWrittenAndReadBack(): void + { + $workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_settings_test', true) . DIRECTORY_SEPARATOR; + $cached = new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap([]), + '"etag123"', + new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')), + ); + + (new Settings($workingDir))->updatePackageMap($cached); + + self::assertEquals($cached, (new Settings($workingDir))->packageMap()); + + (new Filesystem())->remove($workingDir); + } + #[DataProvider('corruptPackageMapProvider')] public function testCorruptPackageMapIsTreatedAsNotCached(string $settingsJson): void { From bc834f518183341d58fc29cd009d5645e4b7693b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 20:17:57 +0000 Subject: [PATCH 08/14] 793: Implement FetchReferencePackageMap --- .../FetchReferencePackageMap.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php index bfa9bf9c..f2b985a5 100644 --- a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php +++ b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php @@ -4,14 +4,20 @@ namespace Php\Pie\Installing\InstallForPhpProject; +use Composer\Downloader\TransportException; +use Composer\Pcre\Preg; +use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; +use DateInterval; use Php\Pie\Settings; use Psr\Clock\ClockInterface; +use Webmozart\Assert\Assert; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class FetchReferencePackageMap { private const REFERENCE_LIST_URL = 'https://raw.githubusercontent.com/php/pie/HEAD/resources/package-map.json'; + private const TIMEOUT_SECONDS = 1; public function __construct( private readonly HttpDownloader $httpDownloader, @@ -22,5 +28,55 @@ public function __construct( public function __invoke(): ExtensionToPackageSelections { + $cached = $this->pieSettings->packageMap(); + $now = $this->clock->now(); + + if ($cached !== null && $now < $cached->expiry) { + return $cached->map; + } + + try { + $response = $this->httpDownloader->get( + self::REFERENCE_LIST_URL, + [ + 'http' => [ + 'method' => 'GET', + 'header' => $cached !== null ? ['If-None-Match: ' . $cached->etag] : [], + 'timeout' => self::TIMEOUT_SECONDS, + ], + ], + ); + } catch (TransportException) { + return $cached->map ?? ExtensionToPackageSelections::fromArrayMap([]); + } + + $expiry = $now->add(new DateInterval('PT' . self::maxAgeSeconds($response) . 'S')); + + if ($response->getStatusCode() === 304 && $cached !== null) { + $this->pieSettings->updatePackageMap(new CachedPackageMap($cached->map, $cached->etag, $expiry)); + + return $cached->map; + } + + $decoded = $response->decodeJson(); + Assert::isMap($decoded); + Assert::allString($decoded); + $map = ExtensionToPackageSelections::fromArrayMap($decoded); + + $etag = $response->getHeader('etag'); + if ($etag !== null && $etag !== '') { + $this->pieSettings->updatePackageMap(new CachedPackageMap($map, $etag, $expiry)); + } + + return $map; + } + + private static function maxAgeSeconds(Response $response): int + { + if (Preg::isMatch('{max-age=(\d+)}', (string) $response->getHeader('cache-control'), $matches)) { + return (int) $matches[1]; + } + + return 0; } } From c304b89491bd427c494e948ecbb1c89ba1e85111 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 20:25:36 +0000 Subject: [PATCH 09/14] 793: Wire up caching fetch of package map --- src/Command/CommandHelper.php | 9 ++++++--- .../InstallExtensionsForProjectCommand.php | 4 +++- src/Container.php | 18 ++++++++++++++++++ .../InstallExtensionsForProjectCommandTest.php | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index c13d545d..0e55b59e 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -27,6 +27,7 @@ use Php\Pie\Downloading\DownloadUrlMethod; use Php\Pie\ExtensionName; use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; +use Php\Pie\Installing\InstallForPhpProject\FetchReferencePackageMap; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Platform as PiePlatform; use Php\Pie\Platform\OperatingSystem; @@ -797,8 +798,10 @@ public static function handleWorkingDirectory(InputInterface $input, IOInterface return $restoreWorkingDir; } - public static function determineExtensionToPackageSelections(InputInterface $input): ExtensionToPackageSelections - { + public static function determineExtensionToPackageSelections( + InputInterface $input, + FetchReferencePackageMap $fetchReferencePackageMap, + ): ExtensionToPackageSelections { $extensionToPackageSelections = []; $selectionOptions = $input->getOption(self::OPTION_PACKAGE_SELECTION); assert(is_array($selectionOptions)); @@ -810,7 +813,7 @@ public static function determineExtensionToPackageSelections(InputInterface $inp $extensionToPackageSelections[ExtensionName::normaliseFromString($extNameString)->name()] = (new RequestedPackageAndVersion($packageSelectionString, null))->package; } - return ExtensionToPackageSelections::fromReferenceList() + return $fetchReferencePackageMap() // @todo overrideWith a local map, if provided (how to provide?) // options: config global pie.json, CLI param? ->overrideWith(ExtensionToPackageSelections::fromArrayMap($extensionToPackageSelections)); diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 1f155ffa..199a9af6 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -20,6 +20,7 @@ use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired; use Php\Pie\Installing\InstallForPhpProject\ExtensionInstalledStatus; use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; +use Php\Pie\Installing\InstallForPhpProject\FetchReferencePackageMap; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Php\Pie\Installing\InstallForPhpProject\NoMatchingPackagesFound; @@ -65,6 +66,7 @@ public function __construct( private readonly SelectPackageForExtension $selectPackageForExtension, private readonly InstallSelectedPackage $installSelectedPackage, private readonly InstallPiePackageFromPath $installPiePackageFromPath, + private readonly FetchReferencePackageMap $fetchReferencePackageMap, private readonly ContainerInterface $container, private readonly IOInterface $io, ) { @@ -109,7 +111,7 @@ private function handlePieProject(InputInterface $input, RootPackageInterface $r private function handlePhpProject(InputInterface $input, RootPackageInterface $rootPackage, callable $restoreWorkingDir): int { - $extensionToPackageSelections = CommandHelper::determineExtensionToPackageSelections($input); + $extensionToPackageSelections = CommandHelper::determineExtensionToPackageSelections($input, $this->fetchReferencePackageMap); $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); CommandHelper::assertExtensionPathIsConsistent($targetPlatform, $input, $this->io); diff --git a/src/Container.php b/src/Container.php index 2c65a4f2..d04e0a34 100644 --- a/src/Container.php +++ b/src/Container.php @@ -8,6 +8,7 @@ use Composer\IO\BufferIO; use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; +use Composer\Util\HttpDownloader; use Composer\Util\Platform as ComposerPlatform; use Illuminate\Container\Container as IlluminateContainer; use Php\Pie\Building\Build; @@ -39,6 +40,7 @@ use Php\Pie\File\FullPathToSelf; use Php\Pie\Installing\Ini; use Php\Pie\Installing\Install; +use Php\Pie\Installing\InstallForPhpProject\FetchReferencePackageMap; use Php\Pie\Installing\Uninstall; use Php\Pie\Installing\UninstallUsingUnlink; use Php\Pie\Installing\UnixInstall; @@ -46,6 +48,7 @@ use Php\Pie\Platform\PackageManager; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; use Psr\Container\ContainerInterface; +use Symfony\Component\Clock\NativeClock; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -214,6 +217,21 @@ static function (): CheckAllBuildTools { }, ); + $container->singleton( + FetchReferencePackageMap::class, + static function (ContainerInterface $container): FetchReferencePackageMap { + $io = $container->get(QuieterConsoleIO::class); + $config = Factory::createConfig($io); + $io->loadConfiguration($config); + + return new FetchReferencePackageMap( + new HttpDownloader($io, $config), + new Settings(Platform::getPieBaseWorkingDirectory()), + new NativeClock(), + ); + }, + ); + $container->alias(UninstallUsingUnlink::class, Uninstall::class); $container->alias(Ini\RemoveIniEntryWithFileGetContents::class, Ini\RemoveIniEntry::class); diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index b814dff6..bbefbf38 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -13,6 +13,8 @@ use Composer\Semver\Constraint\Constraint; use Composer\Semver\Constraint\MatchAllConstraint; use Composer\Semver\Constraint\MultiConstraint; +use Composer\Util\HttpDownloader; +use DateTimeZone; use Php\Pie\Command\InstallExtensionsForProjectCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; use Php\Pie\ComposerIntegration\PieJsonEditor; @@ -20,19 +22,24 @@ use Php\Pie\Container; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\ExtensionType; +use Php\Pie\Installing\InstallForPhpProject\CachedPackageMap; use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject; use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired; +use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; +use Php\Pie\Installing\InstallForPhpProject\FetchReferencePackageMap; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Php\Pie\Installing\InstallForPhpProject\SelectPackageForExtension; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\PiePackageList; +use Php\Pie\Settings; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Symfony\Component\Clock\MockClock; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; @@ -80,6 +87,14 @@ function (string $service): mixed { $this->installPiePackage = $this->createMock(InstallPiePackageFromPath::class); $this->questionHelper = $this->createMock(QuestionHelper::class); + $clock = new MockClock('2026-10-20 15:00:00', new DateTimeZone('UTC')); + $settings = $this->createMock(Settings::class); + $settings->method('packageMap')->willReturn(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap([]), + '"etag123"', + $clock->now()->modify('+1 hour'), + )); + Container::testFactory(); $cmd = new InstallExtensionsForProjectCommand( $this->composerFactoryForProject, @@ -89,6 +104,7 @@ function (string $service): mixed { new SelectPackageForExtension($this->findMatchingPackages, Container::testBuffer()), $this->installSelectedPackage, $this->installPiePackage, + new FetchReferencePackageMap($this->createMock(HttpDownloader::class), $settings, $clock), $container, Container::testBuffer(), ); From 383e2b3a1af5c92d35c9b57b07eadda6727b4d48 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 20:32:40 +0000 Subject: [PATCH 10/14] 793: Add PIE_DISABLE_PACKAGE_MAP_FETCH=1 env var to opt out of package map fetch --- .../FetchReferencePackageMap.php | 5 ++ .../FetchReferencePackageMapTest.php | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php index f2b985a5..f3e003be 100644 --- a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php +++ b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php @@ -8,6 +8,7 @@ use Composer\Pcre\Preg; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; +use Composer\Util\Platform; use DateInterval; use Php\Pie\Settings; use Psr\Clock\ClockInterface; @@ -35,6 +36,10 @@ public function __invoke(): ExtensionToPackageSelections return $cached->map; } + if ((bool) Platform::getEnv('PIE_DISABLE_PACKAGE_MAP_FETCH')) { + return $cached->map ?? ExtensionToPackageSelections::fromArrayMap([]); + } + try { $response = $this->httpDownloader->get( self::REFERENCE_LIST_URL, diff --git a/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php b/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php index e4934b80..2d326b2e 100644 --- a/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php +++ b/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php @@ -7,6 +7,7 @@ use Composer\Downloader\TransportException; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; +use Composer\Util\Platform; use DateTimeZone; use Php\Pie\Installing\InstallForPhpProject\CachedPackageMap; use Php\Pie\Installing\InstallForPhpProject\ExtensionToPackageSelections; @@ -47,6 +48,13 @@ public function setUp(): void ); } + public function tearDown(): void + { + Platform::clearEnv('PIE_DISABLE_PACKAGE_MAP_FETCH'); + + parent::tearDown(); + } + public function testNewDownloadUpdatesCache(): void { $expiry = new DateTimeImmutable('2026-10-20 15:05:00', new DateTimeZone('UTC')); @@ -277,4 +285,46 @@ public function testGetWithErrorWithCachedMapUsesExistingMap(): void ($this->fetcher)()->toArray(), ); } + + public function testFetchDisabledWithCachedMapUsesExistingMap(): void + { + Platform::putEnv('PIE_DISABLE_PACKAGE_MAP_FETCH', '1'); + + $this->pieSettings + ->method('packageMap') + ->willReturn(new CachedPackageMap( + ExtensionToPackageSelections::fromArrayMap(['foo' => 'foo/foo']), + '"etag123"', + new DateTimeImmutable('2026-10-20 14:55:00', new DateTimeZone('UTC')), + )); + $this->pieSettings->expects(self::never()) + ->method('updatePackageMap'); + + $this->httpDownloader->expects(self::never()) + ->method('get'); + + self::assertSame( + ['foo' => 'foo/foo'], + ($this->fetcher)()->toArray(), + ); + } + + public function testFetchDisabledAndNoCacheReturnsEmptyMap(): void + { + Platform::putEnv('PIE_DISABLE_PACKAGE_MAP_FETCH', '1'); + + $this->pieSettings + ->method('packageMap') + ->willReturn(null); + $this->pieSettings->expects(self::never()) + ->method('updatePackageMap'); + + $this->httpDownloader->expects(self::never()) + ->method('get'); + + self::assertSame( + [], + ($this->fetcher)()->toArray(), + ); + } } From 3a595603523b4a7e96ebe476a28de957154d8274 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 20:34:11 +0000 Subject: [PATCH 11/14] 793: Remove local map; this can be provided by modifying package-map for now --- src/Command/CommandHelper.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 0e55b59e..cf612251 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -814,8 +814,6 @@ public static function determineExtensionToPackageSelections( } return $fetchReferencePackageMap() - // @todo overrideWith a local map, if provided (how to provide?) - // options: config global pie.json, CLI param? ->overrideWith(ExtensionToPackageSelections::fromArrayMap($extensionToPackageSelections)); } } From 3a7860196efc4652bc1a154607b54ae3033e1ab2 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 21:00:02 +0000 Subject: [PATCH 12/14] 793: Add some more unambiguous packages to the builtin package map --- resources/package-map.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/resources/package-map.json b/resources/package-map.json index d8a29192..e34160c7 100644 --- a/resources/package-map.json +++ b/resources/package-map.json @@ -1,7 +1,16 @@ { "apcu": "apcu/apcu", + "imagick": "imagick/imagick", + "lzf": "pecl/lzf", + "mailparse": "pecl/mailparse", "mcrypt": "pecl/mcrypt", + "memcached": "php-memcached/php-memcached", "mongodb": "mongodb/mongodb-extension", + "parallel": "pecl/parallel", + "pcov": "pecl/pcov", "redis": "phpredis/phpredis", - "xdebug": "xdebug/xdebug" + "timezonedb": "pecl/timezonedb", + "uuid": "pecl/uuid", + "xdebug": "xdebug/xdebug", + "yaml": "pecl/yaml" } From 2448f9fcd303ca29f096573eb41dbb1433f58778 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 21:17:49 +0000 Subject: [PATCH 13/14] 793: Add docs note about new release map fetch --- docs/usage.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 0af7c039..e1769ea4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -497,14 +497,18 @@ You can specify the missing extensions with: ```bash pie install \ - --select example_pie_extension=asgrim/example-pie-extension \ - --select redis=phpredis/phpredis + --select example_pie_extension=asgrim/example-pie-extension ``` > [!IMPORTANT] > The `--allow-non-interactive-project-install` will no longer work. You must > provide package selections from PIE 1.5 onwards. +Since PIE 1.6.0+, a map of extensions to package names is automatically updated +in PIE that will resolve certain well-known and well-used extensions. The map +is cached in the PIE settings, but this can be opted out by providing the +`PIE_DISABLE_PACKAGE_MAP_FETCH=1` environment variable. + ### Excluding require-dev extensions By default, PIE checks extensions declared in both `require` and From a2df98bdf2fd9cd9457bf515d9ae375083f64dd0 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 28 Oct 2026 21:37:39 +0000 Subject: [PATCH 14/14] 793: Add backup bundled list --- .../FetchReferencePackageMap.php | 19 ++++++++++++++++-- .../FetchReferencePackageMapTest.php | 20 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php index f3e003be..69c2f411 100644 --- a/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php +++ b/src/Installing/InstallForPhpProject/FetchReferencePackageMap.php @@ -14,11 +14,17 @@ use Psr\Clock\ClockInterface; use Webmozart\Assert\Assert; +use function json_decode; +use function Safe\file_get_contents; + +use const JSON_THROW_ON_ERROR; + /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class FetchReferencePackageMap { private const REFERENCE_LIST_URL = 'https://raw.githubusercontent.com/php/pie/HEAD/resources/package-map.json'; private const TIMEOUT_SECONDS = 1; + private const BUNDLED_MAP_PATH = __DIR__ . '/../../../resources/package-map.json'; public function __construct( private readonly HttpDownloader $httpDownloader, @@ -37,7 +43,7 @@ public function __invoke(): ExtensionToPackageSelections } if ((bool) Platform::getEnv('PIE_DISABLE_PACKAGE_MAP_FETCH')) { - return $cached->map ?? ExtensionToPackageSelections::fromArrayMap([]); + return $cached->map ?? self::bundledMap(); } try { @@ -52,7 +58,7 @@ public function __invoke(): ExtensionToPackageSelections ], ); } catch (TransportException) { - return $cached->map ?? ExtensionToPackageSelections::fromArrayMap([]); + return $cached->map ?? self::bundledMap(); } $expiry = $now->add(new DateInterval('PT' . self::maxAgeSeconds($response) . 'S')); @@ -76,6 +82,15 @@ public function __invoke(): ExtensionToPackageSelections return $map; } + private static function bundledMap(): ExtensionToPackageSelections + { + $decoded = json_decode(file_get_contents(self::BUNDLED_MAP_PATH), true, flags: JSON_THROW_ON_ERROR); + Assert::isMap($decoded); + Assert::allString($decoded); + + return ExtensionToPackageSelections::fromArrayMap($decoded); + } + private static function maxAgeSeconds(Response $response): int { if (Preg::isMatch('{max-age=(\d+)}', (string) $response->getHeader('cache-control'), $matches)) { diff --git a/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php b/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php index 2d326b2e..5f8d3857 100644 --- a/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php +++ b/test/unit/Installing/InstallForPhpProject/FetchReferencePackageMapTest.php @@ -19,7 +19,9 @@ use Safe\DateTimeImmutable; use Symfony\Component\Clock\MockClock; +use function json_decode; use function json_encode; +use function Safe\file_get_contents; use const JSON_THROW_ON_ERROR; @@ -223,7 +225,7 @@ public function testGetWith200RefreshesMap(): void ); } - public function testGetWithErrorAndNoCacheReturnsEmptyMap(): void + public function testGetWithErrorAndNoCacheReturnsBundledMap(): void { $this->pieSettings ->method('packageMap') @@ -246,7 +248,7 @@ public function testGetWithErrorAndNoCacheReturnsEmptyMap(): void ->willThrowException(new TransportException('The "' . self::REFERENCE_LIST_URL . '" file could not be downloaded (HTTP/2 404)', 404)); self::assertSame( - [], + self::bundledMap(), ($this->fetcher)()->toArray(), ); } @@ -309,7 +311,7 @@ public function testFetchDisabledWithCachedMapUsesExistingMap(): void ); } - public function testFetchDisabledAndNoCacheReturnsEmptyMap(): void + public function testFetchDisabledAndNoCacheReturnsBundledMap(): void { Platform::putEnv('PIE_DISABLE_PACKAGE_MAP_FETCH', '1'); @@ -323,8 +325,18 @@ public function testFetchDisabledAndNoCacheReturnsEmptyMap(): void ->method('get'); self::assertSame( - [], + self::bundledMap(), ($this->fetcher)()->toArray(), ); } + + /** @return array */ + private static function bundledMap(): array + { + $map = json_decode(file_get_contents(__DIR__ . '/../../../../resources/package-map.json'), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($map); + self::assertNotEmpty($map); + + return $map; + } }