summaryrefslogtreecommitdiffstatshomepage
path: root/composer/Plugin
diff options
context:
space:
mode:
Diffstat (limited to 'composer/Plugin')
-rw-r--r--composer/Plugin/RecipeUnpack/CommandProvider.php21
-rw-r--r--composer/Plugin/RecipeUnpack/Plugin.php170
-rw-r--r--composer/Plugin/RecipeUnpack/README.md256
-rw-r--r--composer/Plugin/RecipeUnpack/RootComposer.php161
-rw-r--r--composer/Plugin/RecipeUnpack/SemVer.php64
-rw-r--r--composer/Plugin/RecipeUnpack/UnpackCollection.php104
-rw-r--r--composer/Plugin/RecipeUnpack/UnpackCommand.php105
-rw-r--r--composer/Plugin/RecipeUnpack/UnpackManager.php99
-rw-r--r--composer/Plugin/RecipeUnpack/UnpackOptions.php79
-rw-r--r--composer/Plugin/RecipeUnpack/Unpacker.php217
-rw-r--r--composer/Plugin/RecipeUnpack/composer.json26
11 files changed, 1302 insertions, 0 deletions
diff --git a/composer/Plugin/RecipeUnpack/CommandProvider.php b/composer/Plugin/RecipeUnpack/CommandProvider.php
new file mode 100644
index 000000000000..ae7632024baa
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/CommandProvider.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
+
+/**
+ * List of all commands provided by this package.
+ *
+ * @internal
+ */
+final class CommandProvider implements CommandProviderCapability {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCommands(): array {
+ return [new UnpackCommand()];
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/Plugin.php b/composer/Plugin/RecipeUnpack/Plugin.php
new file mode 100644
index 000000000000..fe294ecb8b78
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/Plugin.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Command\RequireCommand;
+use Composer\Composer;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\Installer;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+use Composer\Plugin\Capability\CommandProvider;
+use Composer\Plugin\Capable;
+use Composer\Plugin\PluginInterface;
+use Composer\Script\Event;
+use Composer\Script\ScriptEvents;
+use Drupal\Composer\Plugin\RecipeUnpack\CommandProvider as UnpackCommandProvider;
+
+/**
+ * Composer plugin for handling dependency unpacking.
+ *
+ * @internal
+ */
+final class Plugin implements PluginInterface, EventSubscriberInterface, Capable {
+
+ /**
+ * The composer package type of Drupal recipes.
+ */
+ public const string RECIPE_PACKAGE_TYPE = 'drupal-recipe';
+
+ /**
+ * The handler for dependency unpacking.
+ */
+ private UnpackManager $manager;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCapabilities(): array {
+ return [CommandProvider::class => UnpackCommandProvider::class];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function activate(Composer $composer, IOInterface $io): void {
+ $this->manager = new UnpackManager($composer, $io);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deactivate(Composer $composer, IOInterface $io): void {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function uninstall(Composer $composer, IOInterface $io): void {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents(): array {
+ return [
+ ScriptEvents::POST_UPDATE_CMD => 'unpackOnRequire',
+ ScriptEvents::POST_CREATE_PROJECT_CMD => 'unpackOnCreateProject',
+ ];
+ }
+
+ /**
+ * Post update command event callback.
+ */
+ public function unpackOnRequire(Event $event): void {
+ if (!$this->manager->unpackOptions->options['on-require']) {
+ return;
+ }
+
+ // @todo https://www.drupal.org/project/drupal/issues/3523269 Use Composer
+ // API once it exists.
+ $backtrace = debug_backtrace();
+ $composer = $event->getComposer();
+ foreach ($backtrace as $trace) {
+ if (isset($trace['object']) && $trace['object'] instanceof Installer) {
+ $installer = $trace['object'];
+
+ // Get the list of packages being required. This code is largely copied
+ // from https://github.com/symfony/flex/blob/2.x/src/Flex.php#L218.
+ $updateAllowList = \Closure::bind(function () {
+ return $this->updateAllowList ?? [];
+ }, $installer, $installer)();
+
+ // Determine if the --no-install flag has been passed to require.
+ $isInstalling = \Closure::bind(function () {
+ return $this->install;
+ }, $installer, $installer)();
+ }
+
+ // If the command is a require command, populate the list of recipes to
+ // unpack.
+ if (isset($trace['object']) && $trace['object'] instanceof RequireCommand && isset($installer, $updateAllowList, $isInstalling)) {
+ // Determines if a message has been sent about require-dev and recipes.
+ $devRecipeWarningEmitted = FALSE;
+ $unpackCollection = new UnpackCollection();
+
+ foreach ($updateAllowList as $package_name) {
+ $packages = $composer->getRepositoryManager()->getLocalRepository()->findPackages($package_name);
+ $package = reset($packages);
+
+ if (!$package instanceof PackageInterface) {
+ if (!$isInstalling) {
+ $event->getIO()->write('Recipes are not unpacked when the --no-install option is used.', verbosity: IOInterface::VERBOSE);
+ return;
+ }
+ $event->getIO()->error(sprintf('%s does not resolve to a package.', $package_name));
+ return;
+ }
+
+ // Only recipes are supported.
+ if ($package->getType() === self::RECIPE_PACKAGE_TYPE) {
+ if ($this->manager->unpackOptions->isIgnored($package)) {
+ $event->getIO()->write(sprintf('<info>%s</info> not unpacked because it is ignored.', $package_name), verbosity: IOInterface::VERBOSE);
+ }
+ elseif (UnpackManager::isDevRequirement($package)) {
+ if (!$devRecipeWarningEmitted) {
+ $event->getIO()->write('<info>Recipes required as a development dependency are not automatically unpacked.</info>');
+ $devRecipeWarningEmitted = TRUE;
+ }
+ }
+ else {
+ $unpackCollection->add($package);
+ }
+ }
+ }
+
+ // Unpack any recipes that have been added to the collection.
+ $this->manager->unpack($unpackCollection);
+ // The trace has been processed far enough and the $updateAllowList has
+ // been used.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Post create-project command event callback.
+ */
+ public function unpackOnCreateProject(Event $event): void {
+ $composer = $event->getComposer();
+ $unpackCollection = new UnpackCollection();
+ foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) {
+ // Only recipes are supported.
+ if ($package->getType() === self::RECIPE_PACKAGE_TYPE) {
+ if ($this->manager->unpackOptions->isIgnored($package)) {
+ $event->getIO()->write(sprintf('<info>%s</info> not unpacked because it is ignored.', $package->getName()), verbosity: IOInterface::VERBOSE);
+ }
+ elseif (UnpackManager::isDevRequirement($package)) {
+ continue;
+ }
+ else {
+ $unpackCollection->add($package);
+ }
+ }
+ }
+
+ // Unpack any recipes that have been registered.
+ $this->manager->unpack($unpackCollection);
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/README.md b/composer/Plugin/RecipeUnpack/README.md
new file mode 100644
index 000000000000..fcb4e42527ec
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/README.md
@@ -0,0 +1,256 @@
+# Drupal Recipe Unpack Plugin
+
+Thanks for using this Drupal component.
+
+You can participate in its development on Drupal.org, through our issue system:
+https://www.drupal.org/project/issues/drupal
+
+You can get the full Drupal repo here:
+https://www.drupal.org/project/drupal/git-instructions
+
+You can browse the full Drupal repo here:
+https://git.drupalcode.org/project/drupal
+
+## Overview
+
+The Recipe Unpacking system is a Composer plugin that manages "drupal-recipe"
+packages. Recipes are special Composer packages designed to bootstrap Drupal
+projects with necessary dependencies. When a recipe is installed, this plugin
+"unpacks" it by moving the recipe's dependencies directly into your project's
+root `composer.json`, and removes the recipe as a project dependency.
+
+## Key Concepts
+
+### What is a Recipe?
+
+A recipe is a Composer package with type `drupal-recipe` that contains a curated
+set of dependencies, configuration and content but no code of its own. Recipes
+are meant to be "unpacked" and "applied" rather than remain as runtime
+dependencies.
+
+### What is Unpacking?
+
+Unpacking is the process where:
+
+1. A recipe's dependencies are added to your project's root `composer.json`
+2. The recipe itself is removed from your dependencies
+3. The `composer.lock` and vendor installation files are updated accordingly
+4. The recipe will remain in the project's recipes folder so it can be applied
+
+## Commands
+
+### `drupal:recipe-unpack`
+
+Unpack a recipe package that's already required in your project.
+
+```bash
+composer drupal:recipe-unpack drupal/example_recipe
+```
+
+Unpack all recipes that are required in your project.
+
+```bash
+composer drupal:recipe-unpack
+```
+
+#### Options
+
+This command doesn't take additional options.
+
+## Automatic Unpacking
+
+### After `composer require`
+
+By default, recipes are automatically unpacked after running `composer require`
+for a recipe package:
+
+```bash
+composer require drupal/example_recipe
+```
+
+This will:
+1. Download the recipe and its dependencies
+2. Add the recipe's dependencies to your project's root `composer.json`
+3. Remove the recipe itself from your dependencies
+4. Update your `composer.lock` file
+
+### After `composer create-project`
+
+Recipes are always automatically unpacked when creating a new project from a
+template that requires this plugin:
+
+```bash
+composer create-project drupal/recommended-project my-project
+```
+
+Any recipes included in the project template will be unpacked during
+installation, as long as the plugin is enabled.
+
+## Configuration
+
+Configuration options are set in the `extra` section of your `composer.json`
+file:
+
+```json
+{
+ "extra": {
+ "drupal-recipe-unpack": {
+ "ignore": ["drupal/recipe_to_ignore"],
+ "on-require": true
+ }
+ }
+}
+```
+
+### Available Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `ignore` | array | `[]` | List of recipe packages to exclude from unpacking |
+| `on-require` | boolean | `true` | Automatically unpack recipes when required by `composer require` |
+
+## How Recipe Unpacking Works
+
+1. The system identifies packages of type `drupal-recipe` during installation
+2. For each recipe not in the ignore list, it:
+ - Extracts its dependencies
+ - Adds them to the root `composer.json`
+ - Recursively processes any dependencies that are also recipes
+ - Removes the recipe and any dependencies that are also recipes from the root
+ `composer.json`
+3. Updates all necessary Composer files:
+ - `composer.json`
+ - `composer.lock`
+ - `vendor/composer/installed.json`
+ - `vendor/composer/installed.php`
+
+## Cases Where Recipes Will Not Be Unpacked
+
+Recipes will **not** be unpacked in the following scenarios:
+
+1. **Explicit Ignore List**: If the recipe is listed in the `ignore` array in
+ your `extra.drupal-recipe-unpack` configuration
+ ```json
+ {
+ "extra": {
+ "drupal-recipe-unpack": {
+ "ignore": ["drupal/recipe_name"]
+ }
+ }
+ }
+ ```
+
+2. **Disabled Automatic Unpacking**: If `on-require` is set to `false` in your
+ `extra.drupal-recipe-unpack` configuration
+ ```json
+ {
+ "extra": {
+ "drupal-recipe-unpack": {
+ "on-require": false
+ }
+ }
+ }
+ ```
+
+3. **Development Dependencies**: Recipes in the `require-dev` section are not
+ automatically unpacked
+ ```json
+ {
+ "require-dev": {
+ "drupal/dev_recipe": "^1.0"
+ }
+ }
+ ```
+ You will need to manually unpack these using the `drupal:recipe-unpack`
+ command if desired.
+
+4. **With `--no-install` Option**: When using `composer require` with the
+ `--no-install` flag
+ ```bash
+ composer require drupal/example_recipe --no-install
+ ```
+ In this case, you'll need to run `composer install` afterward and then
+ manually unpack using the `drupal:recipe-unpack` command.
+
+## Example Usage Scenarios
+
+### Basic Recipe Installation
+
+```bash
+# This will automatically install and unpack the recipe
+composer require drupal/example_recipe
+```
+
+The result:
+- Dependencies from `drupal/example_recipe` are added to your root
+ `composer.json`
+- `drupal/example_recipe` itself is removed from your dependencies
+- You'll see a message: "drupal/example_recipe unpacked successfully."
+- The recipe files will be present in the drupal-recipe installer path
+
+### Manual Recipe Unpacking
+
+```bash
+# First require the recipe without unpacking
+composer require drupal/example_recipe --no-install
+composer install
+
+# Then manually unpack it
+composer drupal:recipe-unpack drupal/example_recipe
+```
+
+### Working with Dev Recipes
+
+```bash
+# This won't automatically unpack (dev dependencies aren't auto-unpacked)
+composer require --dev drupal/dev_recipe
+
+# You'll need to manually unpack if desired (with confirmation prompt)
+composer drupal:recipe-unpack drupal/dev_recipe
+```
+
+### Creating a New Project with Recipes
+
+```bash
+composer create-project drupal/recipe-based-project my-project
+```
+
+Any recipes included in the project template will be automatically unpacked
+during installation.
+
+## Best Practices
+
+1. **Review Recipe Contents**: Before requiring a recipe, review its
+ dependencies to understand what will be added to your project.
+
+2. **Consider Versioning**: When a recipe is unpacked, its version constraints
+ for dependencies are merged with your existing constraints, which may result
+ in complex version requirements.
+
+3. **Dev Dependencies**: Be cautious when unpacking development recipes, as
+ their dependencies will be moved to the main `require` section, not
+ `require-dev`.
+
+4. **Custom Recipes**: When creating custom recipes, ensure they have the
+ correct package type `drupal-recipe` and include appropriate dependencies.
+
+## Troubleshooting
+
+### Recipe Not Unpacking
+
+- Check if the package type is `drupal-recipe`
+- Verify it's not in your ignore list
+- Confirm it's not in `require-dev` (which requires manual unpacking)
+- Ensure you haven't used the `--no-install` flag without following up with
+ installation and manual unpacking
+
+### Unpacking Errors
+
+If you encounter issues during unpacking:
+
+1. Check Composer's error output for specific issues and run commands with the
+ `--verbose` flag
+2. Verify that version constraints between your existing dependencies and the
+ recipe's dependencies are compatible
+3. For manual troubleshooting, consider temporarily setting `on-require` to
+ `false` and unpacking recipes one by one
diff --git a/composer/Plugin/RecipeUnpack/RootComposer.php b/composer/Plugin/RecipeUnpack/RootComposer.php
new file mode 100644
index 000000000000..da07224a275c
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/RootComposer.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Composer;
+use Composer\Factory;
+use Composer\IO\IOInterface;
+use Composer\Json\JsonFile;
+use Composer\Json\JsonManipulator;
+use Composer\Package\Locker;
+use Composer\Package\RootPackageInterface;
+
+/**
+ * Provides access to and manipulation of the root composer files.
+ *
+ * @internal
+ */
+final class RootComposer {
+
+ /**
+ * The JSON manipulator for the contents of the root composer.json.
+ */
+ private JsonManipulator $composerManipulator;
+
+ /**
+ * The locked root composer.json content.
+ *
+ * @var array<string, mixed>|null
+ */
+ private ?array $composerLockedContent = NULL;
+
+ public function __construct(
+ private readonly Composer $composer,
+ private readonly IOInterface $io,
+ ) {}
+
+ /**
+ * Retrieves the JSON manipulator for the contents of the root composer.json.
+ *
+ * @return \Composer\Json\JsonManipulator
+ * The JSON manipulator.
+ */
+ public function getComposerManipulator(): JsonManipulator {
+ $this->composerManipulator ??= new JsonManipulator(file_get_contents(Factory::getComposerFile()));
+ return $this->composerManipulator;
+ }
+
+ /**
+ * Gets the locked root composer.json content.
+ *
+ * @return array<string, mixed>
+ * The locked root composer.json content.
+ */
+ public function getComposerLockedContent(): array {
+ $this->composerLockedContent ??= $this->composer->getLocker()->getLockData();
+ return $this->composerLockedContent;
+ }
+
+ /**
+ * Removes an element from the composer lock.
+ *
+ * @param string $key
+ * The key of the element to remove.
+ * @param string $index
+ * The index of the element to remove.
+ */
+ public function removeFromComposerLock(string $key, string $index): void {
+ unset($this->composerLockedContent[$key][$index]);
+ }
+
+ /**
+ * Adds an element to the composer lock.
+ *
+ * @param string $key
+ * The key of the element to add.
+ * @param array $data
+ * The data to add.
+ */
+ public function addToComposerLock(string $key, array $data): void {
+ $this->composerLockedContent[$key][] = $data;
+ }
+
+ /**
+ * Writes the root composer files.
+ *
+ * The files written are:
+ * - composer.json
+ * - composer.lock
+ * - vendor/composer/installed.json
+ * - vendor/composer/installed.php
+ *
+ * @throws \RuntimeException
+ * If the root composer could not be updated.
+ */
+ public function writeFiles(): void {
+ // Write composer.json.
+ $composer_json = Factory::getComposerFile();
+ $composer_content = $this->getComposerManipulator()->getContents();
+ if (!file_put_contents($composer_json, $composer_content)) {
+ throw new \RuntimeException(sprintf('Could not update %s', $composer_json));
+ }
+
+ // Create package lists for lock file update.
+ $local_repo = $this->composer->getRepositoryManager()->getLocalRepository();
+ $packages = $dev_packages = [];
+ $dev_package_names = $local_repo->getDevPackageNames();
+ foreach ($local_repo->getPackages() as $package) {
+ if (in_array($package->getName(), $dev_package_names, TRUE)) {
+ $dev_packages[] = $package;
+ }
+ else {
+ $packages[] = $package;
+ }
+ }
+
+ $lock_file_path = Factory::getLockFile(Factory::getComposerFile());
+ $lock_file = new JsonFile($lock_file_path, io: $this->io);
+ $old_locker = $this->composer->getLocker();
+ $locker = new Locker($this->io, $lock_file, $this->composer->getInstallationManager(), $composer_content);
+ $composer_locker_content = $this->getComposerLockedContent();
+
+ // Write the lock file.
+ $locker->setLockData(
+ $packages,
+ $dev_packages,
+ $composer_locker_content['platform'],
+ $composer_locker_content['platform-dev'],
+ $composer_locker_content['aliases'],
+ $old_locker->getMinimumStability(),
+ $old_locker->getStabilityFlags(),
+ $old_locker->getPreferStable(),
+ $old_locker->getPreferLowest(),
+ $old_locker->getPlatformOverrides(),
+ );
+ $this->composer->setLocker($locker);
+
+ // Update installed.json and installed.php.
+ $local_repo->write($local_repo->getDevMode() ?? TRUE, $this->composer->getInstallationManager());
+
+ $this->io->write("Unpacking has updated the root composer files.", verbosity: IOInterface::VERBOSE);
+
+ assert(self::checkRootPackage($composer_content, $this->composer->getPackage()), 'Composer root package and composer.json match');
+ }
+
+ /**
+ * Checks that the composer content and root package match.
+ *
+ * @param string $composer_content
+ * The root composer content.
+ * @param \Composer\Package\RootPackageInterface $root_package
+ * The root package.
+ *
+ * @return bool
+ * TRUE if the composer content and root package match, FALSE if not.
+ */
+ private static function checkRootPackage(string $composer_content, RootPackageInterface $root_package): bool {
+ $composer = JsonFile::parseJson($composer_content);
+ return empty(array_diff_key($root_package->getRequires(), $composer['require'] ?? [])) && empty(array_diff_key($root_package->getDevRequires(), $composer['require-dev'] ?? []));
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/SemVer.php b/composer/Plugin/RecipeUnpack/SemVer.php
new file mode 100644
index 000000000000..95a7862d8adb
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/SemVer.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Semver\Constraint\MatchNoneConstraint;
+use Composer\Semver\Constraint\MultiConstraint;
+use Composer\Semver\Intervals;
+use Composer\Semver\VersionParser;
+
+/**
+ * Helper class to manipulate semantic versioning constraints.
+ *
+ * @internal
+ */
+final class SemVer {
+
+ private function __construct() {}
+
+ /**
+ * Minimizes two constraints.
+ *
+ * Compares two constraints and determines if one is a subset of the other. If
+ * this is the case, the constraint that is a subset is returned. For example,
+ * if called with '^6.2' and '^6.3' the function will return '^6.3'. If
+ * neither constraint is a subset then the constraints are compacted and the
+ * intersection is returned. For example, if called with ">=10.3" and
+ * "^10.4 || ^11" the function will return ">=10.4.0.0-dev, <12.0.0.0-dev".
+ *
+ * @param \Composer\Semver\VersionParser $version_parser
+ * A version parser.
+ * @param string $constraint_a
+ * A constraint to compact.
+ * @param string $constraint_b
+ * A constraint to compact.
+ *
+ * @return string
+ * The compacted constraint.
+ *
+ * @throws \LogicException
+ * Thrown when the provided constraints have no intersection.
+ */
+ public static function minimizeConstraints(VersionParser $version_parser, string $constraint_a, string $constraint_b): string {
+ $constraint_object_a = $version_parser->parseConstraints($constraint_a);
+ $constraint_object_b = $version_parser->parseConstraints($constraint_b);
+ if (Intervals::isSubsetOf($constraint_object_a, $constraint_object_b)) {
+ return $constraint_a;
+ }
+ if (Intervals::isSubsetOf($constraint_object_b, $constraint_object_a)) {
+ return $constraint_b;
+ }
+ $constraint = Intervals::compactConstraint(new MultiConstraint([$constraint_object_a, $constraint_object_b]));
+ if ($constraint instanceof MatchNoneConstraint) {
+ throw new \LogicException(sprintf('The constraints "%s" and "%s" do not intersect and cannot be minimized.', $constraint_a, $constraint_b));
+ }
+ return sprintf(
+ '%s%s, %s%s',
+ $constraint->getLowerBound()->isInclusive() ? '>=' : '>',
+ $constraint->getLowerBound()->getVersion(),
+ $constraint->getUpperBound()->isInclusive() ? '<=' : '<',
+ $constraint->getUpperBound()->getVersion()
+ );
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/UnpackCollection.php b/composer/Plugin/RecipeUnpack/UnpackCollection.php
new file mode 100644
index 000000000000..359ed84f007e
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/UnpackCollection.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Package\PackageInterface;
+
+/**
+ * A collection with packages to unpack.
+ *
+ * @internal
+ */
+final class UnpackCollection implements \Iterator, \Countable {
+
+ /**
+ * The queue of packages to unpack.
+ *
+ * @var \Composer\Package\PackageInterface[]
+ */
+ private array $packagesToUnpack = [];
+
+ /**
+ * The list of packages that have been unpacked.
+ *
+ * @var array<string, \Composer\Package\PackageInterface>
+ */
+ private array $unpackedPackages = [];
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rewind(): void {
+ reset($this->packagesToUnpack);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function current(): PackageInterface|false {
+ return current($this->packagesToUnpack);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function key(): ?string {
+ return key($this->packagesToUnpack);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function next(): void {
+ next($this->packagesToUnpack);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function valid(): bool {
+ return current($this->packagesToUnpack) !== FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function count(): int {
+ return count($this->packagesToUnpack);
+ }
+
+ /**
+ * Adds a package to the queue of packages to unpack.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package to add to the queue.
+ */
+ public function add(PackageInterface $package): self {
+ $this->packagesToUnpack[$package->getUniqueName()] = $package;
+ return $this;
+ }
+
+ /**
+ * Marks a package as unpacked.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package that has been unpacked.
+ */
+ public function markPackageUnpacked(PackageInterface $package): void {
+ $this->unpackedPackages[$package->getUniqueName()] = $package;
+ }
+
+ /**
+ * Checks if a package has been unpacked, or it's queued for unpacking.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package to check.
+ *
+ * @return bool
+ * TRUE if the package has been unpacked.
+ */
+ public function isUnpacked(PackageInterface $package): bool {
+ return isset($this->unpackedPackages[$package->getUniqueName()]);
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/UnpackCommand.php b/composer/Plugin/RecipeUnpack/UnpackCommand.php
new file mode 100644
index 000000000000..60bd86f08c17
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/UnpackCommand.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Command\BaseCommand;
+use Composer\Package\PackageInterface;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * The "drupal:recipe-unpack" command class.
+ *
+ * Manually run the unpack operation that normally happens after
+ * 'composer require'.
+ *
+ * @internal
+ */
+final class UnpackCommand extends BaseCommand {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure(): void {
+ $name = 'drupal:recipe-unpack';
+ $this
+ ->setName($name)
+ ->setDescription('Unpack Drupal recipes.')
+ ->addArgument('recipes', InputArgument::IS_ARRAY, "A list of recipe package names separated by a space, e.g. drupal/recipe_one drupal/recipe_two. If not provided, all recipes listed in the require section of the root composer are unpacked.")
+ ->setHelp(
+ <<<EOT
+The <info>$name</info> command unpacks dependencies from the specified recipe
+packages into the composer.json file.
+
+<info>php composer.phar $name drupal/my-recipe [...]</info>
+
+It is usually not necessary to call <info>$name</info> manually,
+because by default it is called automatically as needed, after a
+<info>require</info> command.
+EOT
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $composer = $this->requireComposer();
+ $io = $this->getIO();
+ $local_repo = $composer->getRepositoryManager()->getLocalRepository();
+ $package_names = $input->getArgument('recipes') ?? [];
+
+ // If no recipes are provided unpack all recipes that are required by the
+ // root package.
+ if (empty($package_names)) {
+ foreach ($composer->getPackage()->getRequires() as $link) {
+ $package = $local_repo->findPackage($link->getTarget(), $link->getConstraint());
+ if ($package->getType() === Plugin::RECIPE_PACKAGE_TYPE) {
+ $package_names[] = $package->getName();
+ }
+ }
+ if (empty($package_names)) {
+ $io->write('<info>No recipes to unpack.</info>');
+ return 0;
+ }
+ }
+
+ $manager = new UnpackManager($composer, $io);
+ $unpack_collection = new UnpackCollection();
+ foreach ($package_names as $package_name) {
+ if (!$manager->isRootDependency($package_name)) {
+ $io->error(sprintf('<info>%s</info> not found in the root composer.json.', $package_name));
+ return 1;
+ }
+ $packages = $local_repo->findPackages($package_name);
+ $package = reset($packages);
+
+ if (!$package instanceof PackageInterface) {
+ $io->error(sprintf('<info>%s</info> does not resolve to a package.', $package_name));
+ return 1;
+ }
+
+ if ($package->getType() !== Plugin::RECIPE_PACKAGE_TYPE) {
+ $io->error(sprintf('<info>%s</info> is not a recipe.', $package->getPrettyName()));
+ return 1;
+ }
+
+ if ($manager->unpackOptions->isIgnored($package)) {
+ $io->error(sprintf('<info>%s</info> is in the extra.drupal-recipe-unpack.ignore list.', $package->getName()));
+ return 1;
+ }
+
+ if (UnpackManager::isDevRequirement($package)) {
+ $io->warning(sprintf('<info>%s</info> is present in the require-dev key. Unpacking will move the recipe\'s dependencies to the require key.', $package->getName()));
+ if ($io->isInteractive() && !$io->askConfirmation('<info>Do you want to continue</info> [<comment>yes</comment>]?')) {
+ return 0;
+ }
+ }
+ $unpack_collection->add($package);
+ }
+ $manager->unpack($unpack_collection);
+ return 0;
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/UnpackManager.php b/composer/Plugin/RecipeUnpack/UnpackManager.php
new file mode 100644
index 000000000000..cf61b75e1313
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/UnpackManager.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Composer;
+use Composer\InstalledVersions;
+use Composer\IO\IOInterface;
+use Composer\Package\PackageInterface;
+use Composer\Semver\Intervals;
+
+/**
+ * Manages the recipe unpacking process.
+ *
+ * @internal
+ */
+final readonly class UnpackManager {
+
+ /**
+ * The root composer with the root dependencies to be manipulated.
+ */
+ private RootComposer $rootComposer;
+
+ /**
+ * The unpack options.
+ */
+ public UnpackOptions $unpackOptions;
+
+ public function __construct(
+ private Composer $composer,
+ private IOInterface $io,
+ ) {
+ $this->rootComposer = new RootComposer($composer, $io);
+ $this->unpackOptions = UnpackOptions::create($composer->getPackage()->getExtra());
+ }
+
+ /**
+ * Unpacks the packages in the provided collection.
+ *
+ * @param \Drupal\Composer\Plugin\RecipeUnpack\UnpackCollection $unpackCollection
+ * The collection of recipe packages to unpack.
+ */
+ public function unpack(UnpackCollection $unpackCollection): void {
+ if (count($unpackCollection) === 0) {
+ // Early return to avoid unnecessary work.
+ return;
+ }
+
+ foreach ($unpackCollection as $package) {
+ $unpacker = new Unpacker(
+ $package,
+ $this->composer,
+ $this->rootComposer,
+ $unpackCollection,
+ $this->unpackOptions,
+ $this->io,
+ );
+ $unpacker->unpackDependencies();
+ $this->io->write("<info>{$package->getName()}</info> unpacked.");
+ }
+
+ // Unpacking uses \Composer\Semver\Intervals::isSubsetOf() to choose between
+ // constraints.
+ Intervals::clear();
+
+ $this->rootComposer->writeFiles();
+ }
+
+ /**
+ * Determines if the provided package is present in the root composer.json.
+ *
+ * @param string $package_name
+ * The package name to check.
+ *
+ * @return bool
+ * TRUE if the package is present in the root composer.json, FALSE if not.
+ */
+ public function isRootDependency(string $package_name): bool {
+ $root_package = $this->composer->getPackage();
+ return isset($root_package->getRequires()[$package_name]) || isset($root_package->getDevRequires()[$package_name]);
+ }
+
+ /**
+ * Checks if a package is a dev requirement.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package to check.
+ *
+ * @return bool
+ * TRUE if the package is present in require-dev or due to a package in
+ * require-dev, FALSE if not.
+ */
+ public static function isDevRequirement(PackageInterface $package): bool {
+ // Check if package is either a regular or dev requirement.
+ return InstalledVersions::isInstalled($package->getName()) &&
+ // Check if package is a regular requirement.
+ !InstalledVersions::isInstalled($package->getName(), FALSE);
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/UnpackOptions.php b/composer/Plugin/RecipeUnpack/UnpackOptions.php
new file mode 100644
index 000000000000..ea479ee587e1
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/UnpackOptions.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Package\PackageInterface;
+
+/**
+ * Per-project options from the 'extras' section of the composer.json file.
+ *
+ * Projects that implement dependency unpacking plugin can further configure it.
+ * This data is pulled from the 'drupal-recipe-unpack' portion of the extras
+ * section.
+ *
+ * @code
+ * "extras": {
+ * "drupal-recipe-unpack": {
+ * "ignore": ["drupal/recipe_name"],
+ * "on-require": true
+ * }
+ * }
+ * @endcode
+ *
+ * Supported options:
+ * - `ignore` (array):
+ * Specifies packages to exclude from unpacking into the root composer.json.
+ * - `on-require` (boolean):
+ * Whether to unpack recipes automatically on require.
+ *
+ * @internal
+ */
+final readonly class UnpackOptions {
+
+ /**
+ * The ID of the extra section in the top-level composer.json file.
+ */
+ const string ID = 'drupal-recipe-unpack';
+
+ /**
+ * The raw data from the 'extras' section of the top-level composer.json file.
+ *
+ * @var array{ignore: string[], on-require: boolean}
+ */
+ public array $options;
+
+ private function __construct(array $options) {
+ $this->options = $options + [
+ 'ignore' => [],
+ 'on-require' => TRUE,
+ ];
+ }
+
+ /**
+ * Checks if a package should be ignored.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package.
+ *
+ * @return bool
+ * TRUE if the package should be ignored, FALSE if not.
+ */
+ public function isIgnored(PackageInterface $package): bool {
+ return in_array($package->getName(), $this->options['ignore'], TRUE);
+ }
+
+ /**
+ * Creates an unpack options object.
+ *
+ * @param array $extras
+ * The contents of the 'extras' section.
+ *
+ * @return self
+ * The unpack options object representing the provided unpack options
+ */
+ public static function create(array $extras): self {
+ $options = $extras[self::ID] ?? [];
+ return new self($options);
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/Unpacker.php b/composer/Plugin/RecipeUnpack/Unpacker.php
new file mode 100644
index 000000000000..5b1613ee777c
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/Unpacker.php
@@ -0,0 +1,217 @@
+<?php
+
+namespace Drupal\Composer\Plugin\RecipeUnpack;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Package\Link;
+use Composer\Package\PackageInterface;
+use Composer\Semver\VersionParser;
+
+/**
+ * Handles the details of unpacking a specific recipe.
+ *
+ * @internal
+ */
+final readonly class Unpacker {
+
+ /**
+ * The version parser.
+ */
+ private VersionParser $versionParser;
+
+ public function __construct(
+ private PackageInterface $package,
+ private Composer $composer,
+ private RootComposer $rootComposer,
+ private UnpackCollection $unpackCollection,
+ private UnpackOptions $unpackOptions,
+ private IOInterface $io,
+ ) {
+ $this->versionParser = new VersionParser();
+ }
+
+ /**
+ * Unpacks the package's dependencies to the root composer.json and lock file.
+ */
+ public function unpackDependencies(): void {
+ $this->updateComposerJsonPackages();
+ $this->updateComposerLockContent();
+ $this->unpackCollection->markPackageUnpacked($this->package);
+ }
+
+ /**
+ * Processes dependencies of the package that is being unpacked.
+ *
+ * If the dependency is a recipe and should be unpacked, we add it into the
+ * package queue so that it will be unpacked as well. If the dependency is not
+ * a recipe, or an ignored recipe, the package link will be yielded.
+ *
+ * @param array<string, \Composer\Package\Link> $package_dependency_links
+ * The package dependencies to process.
+ *
+ * @return iterable<\Composer\Package\Link>
+ * The package dependencies to add to composer.json.
+ */
+ private function processPackageDependencies(array $package_dependency_links): iterable {
+ foreach ($package_dependency_links as $link) {
+ if ($link->getTarget() === $this->package->getName()) {
+ // This dependency is the same as the current package, so let's skip it.
+ continue;
+ }
+
+ $package = $this->getPackageFromLinkTarget($link);
+
+ // If we can't find the package in the local repository that's because it
+ // has already been removed therefore skip it.
+ if ($package === NULL) {
+ continue;
+ }
+
+ if ($package->getType() === Plugin::RECIPE_PACKAGE_TYPE) {
+ if ($this->unpackCollection->isUnpacked($package)) {
+ // This dependency is already unpacked.
+ continue;
+ }
+
+ if (!$this->unpackOptions->isIgnored($package)) {
+ // This recipe should be unpacked as well.
+ $this->unpackCollection->add($package);
+ continue;
+ }
+ else {
+ // This recipe should not be unpacked. But it might need to be added
+ // to the root composer.json
+ $this->io->write(sprintf('<info>%s</info> not unpacked because it is ignored.', $package->getName()), verbosity: IOInterface::VERBOSE);
+ }
+ }
+
+ yield $link;
+ }
+ }
+
+ /**
+ * Updates the composer.json content with the package being unpacked.
+ *
+ * This method will add all the package dependencies to the root composer.json
+ * content and also remove the package itself from the root composer.json.
+ *
+ * @throws \RuntimeException
+ * If the composer.json could not be updated.
+ */
+ private function updateComposerJsonPackages(): void {
+ $composer_manipulator = $this->rootComposer->getComposerManipulator();
+ $composer_config = $this->composer->getConfig();
+ $sort_packages = $composer_config->get('sort-packages');
+ $root_package = $this->composer->getPackage();
+ $root_requires = $root_package->getRequires();
+ $root_dev_requires = $root_package->getDevRequires();
+
+ foreach ($this->processPackageDependencies($this->package->getRequires()) as $package_dependency) {
+ $dependency_name = $package_dependency->getTarget();
+ $recipe_constraint_string = $package_dependency->getPrettyConstraint();
+ if (isset($root_requires[$dependency_name])) {
+ $recipe_constraint_string = SemVer::minimizeConstraints($this->versionParser, $recipe_constraint_string, $root_requires[$dependency_name]->getPrettyConstraint());
+ if ($recipe_constraint_string === $root_requires[$dependency_name]) {
+ // This dependency is already in the required section with the
+ // correct constraint.
+ continue;
+ }
+ }
+ elseif (isset($root_dev_requires[$dependency_name])) {
+ $recipe_constraint_string = SemVer::minimizeConstraints($this->versionParser, $recipe_constraint_string, $root_dev_requires[$dependency_name]->getPrettyConstraint());
+ // This dependency is already in the require-dev section. We will
+ // move it to the require section.
+ $composer_manipulator->removeSubNode('require-dev', $dependency_name);
+ }
+
+ // Add the dependency to the required section. If it cannot be added, then
+ // throw an exception.
+ if (!$composer_manipulator->addLink(
+ 'require',
+ $dependency_name,
+ $recipe_constraint_string,
+ $sort_packages,
+ )) {
+ throw new \RuntimeException(sprintf('Unable to manipulate composer.json during the unpack of %s',
+ $dependency_name,
+ ));
+ }
+ $link = new Link($root_package->getName(), $dependency_name, $this->versionParser->parseConstraints($recipe_constraint_string), Link::TYPE_REQUIRE, $recipe_constraint_string);
+ $root_requires[$dependency_name] = $link;
+ unset($root_dev_requires[$dependency_name]);
+ $this->io->write(sprintf('Adding <info>%s</info> (<comment>%s</comment>) to composer.json during the unpack of <info>%s</info>', $dependency_name, $recipe_constraint_string, $this->package->getName()), verbosity: IOInterface::VERBOSE);
+ }
+
+ // Ensure the written packages are no longer in the dev package names.
+ $local_repo = $this->composer->getRepositoryManager()->getLocalRepository();
+ $local_repo->setDevPackageNames(array_diff($local_repo->getDevPackageNames(), array_keys($root_requires)));
+
+ // Update the root package to reflect the changes.
+ $root_package->setDevRequires($root_dev_requires);
+ $root_package->setRequires($root_requires);
+
+ $composer_manipulator->removeSubNode(UnpackManager::isDevRequirement($this->package) ? 'require-dev' : 'require', $this->package->getName());
+ $this->io->write(sprintf('Removing <info>%s</info> from composer.json', $this->package->getName()), verbosity: IOInterface::VERBOSE);
+
+ $composer_manipulator->removeMainKeyIfEmpty('require-dev');
+ }
+
+ /**
+ * Updates the composer.lock content and keeps the local repo in sync.
+ *
+ * This method will remove the package itself from the composer.lock content
+ * in the root composer.
+ */
+ private function updateComposerLockContent(): void {
+ $composer_locker_content = $this->rootComposer->getComposerLockedContent();
+ $root_package = $this->composer->getPackage();
+ $root_requires = $root_package->getRequires();
+ $root_dev_requires = $root_package->getDevRequires();
+ $local_repo = $this->composer->getRepositoryManager()->getLocalRepository();
+
+ if (isset($root_requires[$this->package->getName()])) {
+ unset($root_requires[$this->package->getName()]);
+ $root_package->setRequires($root_requires);
+ }
+
+ foreach ($composer_locker_content['packages'] as $key => $lock_data) {
+ // Find the package being unpacked in the composer.lock content and
+ // remove it.
+ if ($lock_data['name'] === $this->package->getName()) {
+ $this->rootComposer->removeFromComposerLock('packages', $key);
+ // If the package is in require-dev we need to move the lock data.
+ if (isset($root_dev_requires[$lock_data['name']])) {
+ $this->rootComposer->addToComposerLock('packages-dev', $lock_data);
+ $dev_package_names = $local_repo->getDevPackageNames();
+ $dev_package_names[] = $lock_data['name'];
+ $local_repo->setDevPackageNames($dev_package_names);
+ return;
+ }
+ break;
+ }
+ }
+ $local_repo->setDevPackageNames(array_diff($local_repo->getDevPackageNames(), [$this->package->getName()]));
+ $local_repo->removePackage($this->package);
+ if (isset($root_dev_requires[$this->package->getName()])) {
+ unset($root_dev_requires[$this->package->getName()]);
+ $root_package->setDevRequires($root_dev_requires);
+ }
+ }
+
+ /**
+ * Gets the package object from a link's target.
+ *
+ * @param \Composer\Package\Link $dependency
+ * The link dependency.
+ *
+ * @return \Composer\Package\PackageInterface|null
+ * The package object.
+ */
+ private function getPackageFromLinkTarget(Link $dependency): ?PackageInterface {
+ return $this->composer->getRepositoryManager()
+ ->getLocalRepository()
+ ->findPackage($dependency->getTarget(), $dependency->getConstraint());
+ }
+
+}
diff --git a/composer/Plugin/RecipeUnpack/composer.json b/composer/Plugin/RecipeUnpack/composer.json
new file mode 100644
index 000000000000..61fb20f75bdc
--- /dev/null
+++ b/composer/Plugin/RecipeUnpack/composer.json
@@ -0,0 +1,26 @@
+{
+ "name": "drupal/core-recipe-unpack",
+ "description": "A Composer project unpacker for Drupal recipes.",
+ "type": "composer-plugin",
+ "keywords": ["drupal"],
+ "homepage": "https://www.drupal.org/project/drupal",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "composer-plugin-api": "^2",
+ "php": ">=8.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "Drupal\\Composer\\Plugin\\RecipeUnpack\\": ""
+ }
+ },
+ "extra": {
+ "class": "Drupal\\Composer\\Plugin\\RecipeUnpack\\Plugin"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "require-dev": {
+ "composer/composer": "^2.7"
+ }
+}