From 1d3b0c1fb40ac65c86846ad1fb083521a57f3af1 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Wed, 22 Aug 2018 17:41:48 -0500 Subject: [PATCH 01/15] MAGETWO-94153: Add functionality to composer update to lookahead for root composer.json changes when updating a magento/product* package --- LICENSE.txt | 48 + LICENSE_AFL.txt | 48 + composer.json | 30 + .../RootUpdate/PluginCommandProvider.php | 25 + .../Plugin/RootUpdate/RootUpdateCommand.php | 1072 +++++++++++++++++ .../Plugin/RootUpdate/RootUpdatePlugin.php | 37 + .../RootUpdate/RootUpdateCommandTest.php | 905 ++++++++++++++ .../Magento/TestHelper/TestApplication.php | 80 ++ 8 files changed, 2245 insertions(+) create mode 100644 LICENSE.txt create mode 100644 LICENSE_AFL.txt create mode 100644 composer.json create mode 100644 src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php create mode 100644 src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php create mode 100644 src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php create mode 100644 tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php create mode 100644 tests/Unit/Magento/TestHelper/TestApplication.php diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..49525fd --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/LICENSE_AFL.txt b/LICENSE_AFL.txt new file mode 100644 index 0000000..f39d641 --- /dev/null +++ b/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a8d8339 --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "magento/composer-root-update-plugin", + "description": "Plugin to look ahead for Magento project root changes when running composer update for new Magento versions", + "version": "1.0.0-beta1", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "type": "composer-plugin", + "require": { + "composer/composer": "*", + "composer-plugin-api": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "~6.5.0" + }, + "autoload": { + "psr-4": { + "Magento\\": "src/Magento/" + } + }, + "autoload-dev": { + "psr-4": { + "Magento\\TestHelper\\": "tests/Unit/Magento/TestHelper/" + } + }, + "extra": { + "class": "Magento\\Composer\\Plugin\\RootUpdate\\RootUpdatePlugin" + } +} \ No newline at end of file diff --git a/src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php b/src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php new file mode 100644 index 0000000..3c16b13 --- /dev/null +++ b/src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php @@ -0,0 +1,25 @@ +filePath = null; + $this->interactiveInput = false; + $this->override = false; + $this->interactive = false; + $this->baseLabel = null; + $this->targetLabel = null; + $this->jsonChanges = []; + $this->fuzzyConstraint = false; + $this->targetProduct = null; + $this->targetConstraint = null; + } + + /** + * Call the parent setApplication method but also change the command's name to update + * + * @param Application|null $application + * @return void + */ + public function setApplication(Application $application = null) + { + // In order to trick Composer into overriding its native UpdateCommand with this object, the name needs to be + // different before Application->add() is called to pass the verification check but changed to update before + // being added to the command registry + $this->setName('update'); + parent::setApplication($application); + } + + /** + * Use the native UpdateCommand config with options/doc additions for the Magento root composer.json update + * + * @return void + */ + public function configure() + { + parent::configure(); + $this->setName('update-magento-root'); + $this->addOption( + static::SKIP_OPT, + null, + null, + 'Skip the Magento root composer.json update.' + ); + $this->addOption( + static::OVERRIDE_OPT, + null, + null, + 'Override conflicting root composer.json customizations with expected Magento project values.' + ); + $this->addOption( + static::INTERACTIVE_OPT, + null, + null, + 'Interactive interface to resolve conflicts during the Magento root composer.json update.' + ); + $this->addOption( + static::ROOT_ONLY_OPT, + null, + null, + 'Update the root composer.json file with Magento changes without running the rest of the update process.' + ); + + $mageHelp = ' +Magento Root Updates: + With magento/composer-root-update-plugin installed, update will also check for and + execute any changes to the root composer.json file that exist between the Magento + project package corresponding to the currently-installed version and the project + for the target Magento product version if the package requirement has changed. + + By default, any changes that would affect values that have been customized in the + existing installation will not be applied. Using --' . static::OVERRIDE_OPT . ' will instead + apply all deltas found between the expected base project and the new version, + overriding any custom values. Use --' . static::INTERACTIVE_OPT . ' to interactively + resolve deltas that conflict with the existing installation. + + To skip the Magento root composer.json update, use --' . static::SKIP_OPT . '. +'; + $this->setHelp($this->getHelp() . $mageHelp); + + $mageDesc = ' If a Magento metapackage change is found, also make any associated composer.json changes.'; + $this->setDescription($this->getDescription() . $mageDesc); + } + + /** + * Look ahead at the target Magento version for root composer.json changes before running composer's native update + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null null or 0 if everything went fine, or an error code + * @throws FilesystemException if the write operation failed when ROOT_ONLY_OPT is passed + */ + public function execute(InputInterface $input, OutputInterface $output) + { + $this->interactiveInput = $input->isInteractive(); + if ($input->getOption('dry-run')) { + $output->setVerbosity(max(OutputInterface::VERBOSITY_VERBOSE, $output->getVerbosity())); + $input->setOption('verbose', true); + } + + $composer = $this->getComposer(); + + $this->filePath = $composer->getConfig()->getConfigSource()->getName(); + + $updatePrepared = false; + try { + $updatePrepared = $this->magentoUpdate($input, $composer); + } catch (\Exception $e) { + $this->getIO()->writeError('Magento root update operation failed', true, IOInterface::QUIET); + $this->getIO()->writeError($e->getMessage()); + } + + $errorCode = null; + if (!$input->getOption(static::ROOT_ONLY_OPT)) { + $errorCode = parent::execute($input, $output); + + if ($errorCode && $this->fuzzyConstraint) { + $this->getIO()->writeError( + 'Recommended: Use a specific Magento version constraint instead of "' . + $this->targetProduct . ': ' . $this->targetConstraint . '"', + true, + IOInterface::QUIET + ); + } + } elseif (!$input->getOption('dry-run') && $updatePrepared) { + // If running a full update, writeUpdatedRoot() is called as a post-update-cmd event + $this->writeUpdatedRoot(); + } + + return $errorCode; + } + + /** + * Look ahead to the target Magento version and execute any changes to the root composer.json file in-memory + * + * @param InputInterface $input + * @param Composer $composer + * @return boolean Returns true if updates were successfully prepared, false if no updates were necessary + */ + public function magentoUpdate($input, $composer) + { + if ($input->getOption('no-custom-installers')) { + // --no-custom-installers has been replaced with --no-plugins, which would have skipped this functionality + return false; + } + + $io = $this->getIO(); + // Move the native UpdateCommand's deprecation message before the added Magento functionality + if ($input->getOption('dev')) { + $io->writeError('' . + 'You are using the deprecated option "dev". Dev packages are installed by default now.' . + ''); + $input->setOption('dev', false); + }; + + $locker = $composer->getLocker(); + $skipped = $input->getOption(static::SKIP_OPT); + $this->override = $input->getOption(static::OVERRIDE_OPT); + $this->interactive = $input->getOption(static::INTERACTIVE_OPT); + + if ($locker->isLocked() && !$skipped) { + $installRoot = $composer->getPackage(); + $targetRoot = null; + $targetConstraint = null; + foreach ($installRoot->getRequires() as $link) { + $packageInfo = static::getMagentoPackageInfo($link->getTarget()); + if ($packageInfo !== null) { + $targetConstraint = $link->getPrettyConstraint() ?? + $link->getConstraint()->getPrettyString() ?? + $link->getConstraint()->__toString(); + $edition = $packageInfo['edition']; + $this->targetProduct = "magento/product-$edition-edition"; + $this->targetConstraint = $targetConstraint; + $targetRoot = $this->fetchRoot( + $edition, + $targetConstraint, + $composer, + $input, + true + ); + break; + } + } + if ($targetRoot == null || $targetRoot == false) { + throw new \RuntimeException('Magento root updates cannot run without a valid target package'); + } + + $targetVersion = $targetRoot->getVersion(); + $prettyTargetVersion = $targetRoot->getPrettyVersion() ?? $targetVersion; + $targetEd = static::getMagentoPackageInfo($targetRoot->getName())['edition']; + + $baseEd = null; + $baseVersion = null; + $prettyBaseVersion = null; + $lockPackages = $locker->getLockedRepository()->getPackages(); + foreach ($lockPackages as $lockedPackage) { + $packageInfo = static::getMagentoPackageInfo($lockedPackage->getName()); + if ($packageInfo !== null && $packageInfo['type'] == 'product') { + $baseEd = $packageInfo['edition']; + $baseVersion = $lockedPackage->getVersion(); + $prettyBaseVersion = $lockedPackage->getPrettyVersion() ?? $baseVersion; + + // Both editions exist for enterprise, so stop at enterprise to not overwrite with community + if ($baseEd == 'enterprise') { + break; + } + } + } + $baseRoot = null; + if ($baseEd != null && $baseVersion != null) { + $baseRoot = $this->fetchRoot( + $baseEd, + $prettyBaseVersion, + $composer, + $input + ); + } + + if ($baseRoot == null || $baseRoot == false) { + if ($baseEd == null || $baseVersion == null) { + $io->writeError( + 'No Magento product package was found in the current installation.' + ); + } else { + $io->writeError( + 'The Magento project package corresponding to the currently installed ' . + "\"magento/product-$baseEd-edition: $prettyBaseVersion\" package is unavailable." + ); + } + + $overrideRoot = $this->override; + if (!$overrideRoot) { + $question = 'Would you like to update the root composer.json file anyway? This will ' . + 'override changes you may have made to the default installation if the same values ' . + "are different in magento/project-$targetEd-edition $prettyTargetVersion"; + $overrideRoot = $this->getConfirmation($question); + } + if ($overrideRoot) { + $baseRoot = $installRoot; + } else { + $io->writeError('Skipping Magento composer.json update.'); + return false; + } + } elseif ($baseEd === $targetEd && $baseVersion === $targetVersion) { + $this->getIO()->writeError( + 'The Magento product requirement matched the current installation; no root updates are required', + true, + IOInterface::VERBOSE + ); + return false; + } + + $baseEd = static::getMagentoPackageInfo($baseRoot->getName())['edition']; + $this->targetLabel = 'Magento ' . ucfirst($targetEd) . " Edition $prettyTargetVersion"; + $this->baseLabel = 'Magento ' . ucfirst($baseEd) . " Edition $prettyBaseVersion"; + + $io->writeError( + "Base Magento project package version: magento/project-$baseEd-edition $prettyBaseVersion", + true, + IOInterface::DEBUG + ); + + $changedRoot = $composer->getPackage(); + $this->resolveLinkSection( + 'require', + $baseRoot->getRequires(), + $targetRoot->getRequires(), + $installRoot->getRequires(), + [$changedRoot, 'setRequires'] + ); + + if (!$input->getOption('no-dev')) { + $this->resolveLinkSection( + 'require-dev', + $baseRoot->getDevRequires(), + $targetRoot->getDevRequires(), + $installRoot->getDevRequires(), + [$changedRoot, 'setDevRequires'] + ); + } + + if (!$input->getOption('no-autoloader')) { + $this->resolveArraySection( + 'autoload', + $baseRoot->getAutoload(), + $targetRoot->getAutoload(), + $installRoot->getAutoload(), + [$changedRoot, 'setAutoload'] + ); + + if (!$input->getOption('no-dev')) { + $this->resolveArraySection( + 'autoload-dev', + $baseRoot->getDevAutoload(), + $targetRoot->getDevAutoload(), + $installRoot->getDevAutoload(), + [$changedRoot, 'setDevAutoload'] + ); + } + } + + $this->resolveLinkSection( + 'conflict', + $baseRoot->getConflicts(), + $targetRoot->getConflicts(), + $installRoot->getConflicts(), + [$changedRoot, 'setConflicts'] + ); + + $this->resolveArraySection( + 'extra', + $baseRoot->getExtra(), + $targetRoot->getExtra(), + $installRoot->getExtra(), + [$changedRoot, 'setExtra'] + ); + + $this->resolveLinkSection( + 'provides', + $baseRoot->getProvides(), + $targetRoot->getProvides(), + $installRoot->getProvides(), + [$changedRoot, 'setProvides'] + ); + + $this->resolveLinkSection( + 'replaces', + $baseRoot->getReplaces(), + $targetRoot->getReplaces(), + $installRoot->getReplaces(), + [$changedRoot, 'setReplaces'] + ); + + $this->resolveArraySection( + 'suggests', + $baseRoot->getSuggests(), + $targetRoot->getSuggests(), + $installRoot->getSuggests(), + [$changedRoot, 'setSuggests'] + ); + + $composer->setPackage($changedRoot); + $this->setComposer($composer); + + if (!$input->getOption('dry-run')) { + // Add composer.json write code at the head of the list of post command script hooks so + // the file is accurate for any other hooks that may exist in the installation that use it + $eventDispatcher = $composer->getEventDispatcher(); + $eventDispatcher->addListener( + ScriptEvents::POST_UPDATE_CMD, + [$this, 'writeUpdatedRoot'], + PHP_INT_MAX + ); + } + + if ($this->jsonChanges !== []) { + return true; + } + } + + return false; + } + + /** + * Find value deltas from original->target version and resolve any conflicts with overlapping user changes + * + * @param string $field + * @param array|mixed|null $baseVal + * @param array|mixed|null $targetVal + * @param array|mixed|null $installVal + * @param string|null $prettyBase + * @param string|null $prettyTarget + * @param string|null $prettyInstall + * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the existing composer.json file, null for no change + */ + public function findResolution( + $field, + $baseVal, + $targetVal, + $installVal, + $prettyBase = null, + $prettyTarget = null, + $prettyInstall = null + ) { + $io = $this->getIO(); + if ($prettyBase === null) { + $prettyBase = json_encode($baseVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyBase = trim($prettyBase, "'\""); + } + if ($prettyTarget === null) { + $prettyTarget = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyTarget = trim($prettyTarget, "'\""); + } + if ($prettyInstall === null) { + $prettyInstall = json_encode($installVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyInstall = trim($prettyInstall, "'\""); + } + + $targetLabel = $this->targetLabel; + $baseLabel = $this->baseLabel; + + $action = null; + $conflictDesc = null; + + if ($baseVal == $targetVal || $installVal == $targetVal) { + $action = null; + } elseif ($baseVal === null) { + if ($installVal === null) { + $action = static::ADD_VAL; + } else { + $action = 'change'; + $conflictDesc = "add $field=$prettyTarget but it is instead $prettyInstall"; + } + } elseif ($targetVal === null) { + $action = static::REMOVE_VAL; + if ($installVal !== $baseVal) { + $conflictDesc = "remove the $field=$prettyBase entry in $baseLabel but it is instead $prettyInstall"; + } + } else { + $action = static::CHANGE_VAL; + if ($installVal !== $baseVal) { + $conflictDesc = "update $field to $prettyTarget from $prettyBase in $baseLabel"; + if ($installVal === null) { + $action = static::ADD_VAL; + $conflictDesc = "$conflictDesc but the field has been removed"; + } else { + $conflictDesc = "$conflictDesc but it is instead $prettyInstall"; + } + } + } + + if ($conflictDesc !== null) { + $conflictDesc = "$targetLabel is trying to $conflictDesc in this installation"; + + $shouldOverride = $this->override; + if ($this->override) { + $overrideMessage = "$conflictDesc.\n Overriding local changes due to --" . static::OVERRIDE_OPT . '.'; + $io->writeError($overrideMessage); + } else { + $shouldOverride = $this->getConfirmation( + "$conflictDesc.\nWould you like to override the local changes?" + ); + } + + if (!$shouldOverride) { + $io->writeError("$conflictDesc and will not be changed. Re-run using " . + '--' . static::OVERRIDE_OPT . ' or --' . static::INTERACTIVE_OPT . ' to override with Magento ' . + 'values.'); + $action = null; + } + } + + return $action; + } + + /** + * Process changes to corresponding sets of package version links + * + * @param string $section + * @param Link[] $baseLinks + * @param Link[] $targetLinks + * @param Link[] $installLinks + * @param callable $setterCallback + * @return void + */ + public function resolveLinkSection($section, $baseLinks, $targetLinks, $installLinks, $setterCallback) + { + /** @var Link[] $baseMap */ + $baseMap = static::linksToMap($baseLinks); + + /** @var Link[] $targetMap */ + $targetMap = static::linksToMap($targetLinks); + + /** @var Link[] $installMap */ + $installMap = static::linksToMap($installLinks); + + $adds = []; + $removes = []; + $changes = []; + $magePackages = array_unique(array_merge(array_keys($baseMap), array_keys($targetMap))); + foreach ($magePackages as $package) { + if ($section === 'require' && static::getMagentoPackageInfo($package)) { + continue; + } + $field = "$section:$package"; + $baseConstraint = key_exists($package, $baseMap) ? $baseMap[$package]->getConstraint() : null; + $baseVal = ($baseConstraint === null) ? null : $baseConstraint->__toString(); + $prettyBaseVal = ($baseConstraint === null) ? null : $baseConstraint->getPrettyString(); + $targetConstraint = key_exists($package, $targetMap) ? $targetMap[$package]->getConstraint() : null; + $targetVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); + $prettyTargetVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); + $installConstraint = key_exists($package, $installMap) ? $installMap[$package]->getConstraint() : null; + $installVal = ($installConstraint === null) ? null : $installConstraint->__toString(); + $prettyInstallVal = ($installConstraint === null) ? null : $installConstraint->getPrettyString(); + + $action = $this->findResolution( + $field, + $baseVal, + $targetVal, + $installVal, + $prettyBaseVal, + $prettyTargetVal, + $prettyInstallVal + ); + if ($action == static::ADD_VAL) { + $adds[$package] = $targetMap[$package]; + } elseif ($action == static::REMOVE_VAL) { + $removes[] = $package; + } elseif ($action == static::CHANGE_VAL) { + $changes[$package] = $targetMap[$package]; + } + } + + $changed = false; + if ($adds !== []) { + $changed = true; + $prettyAdds = array_map(function ($package) use ($adds) { + $newVal = $adds[$package]->getConstraint()->getPrettyString(); + return "$package=$newVal"; + }, array_keys($adds)); + $this->verboseLog("Adding $section constraints: " . implode(', ', $prettyAdds)); + } + if ($removes !== []) { + $changed = true; + $this->verboseLog("Removing $section entries: " . implode(', ', $removes)); + } + if ($changes !== []) { + $changed = true; + $prettyChanges = array_map(function ($package) use ($changes) { + $newVal = $changes[$package]->getConstraint()->getPrettyString(); + return "$package=$newVal"; + }, array_keys($changes)); + $this->verboseLog("Updating $section constraints: " . implode(', ', $prettyChanges)); + } + + if ($changed) { + $replacements = array_values($adds); + + /** @var Link $installLink */ + foreach ($installMap as $package => $installLink) { + if (in_array($package, $removes)) { + continue; + } elseif (key_exists($package, $changes)) { + $replacements[] = $changes[$package]; + } else { + $replacements[] = $installLink; + } + } + + $newJson = []; + /** @var Link $link */ + foreach ($replacements as $link) { + $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); + } + + call_user_func($setterCallback, $replacements); + $this->jsonChanges[$section] = $newJson; + } + } + + /** + * Process changes to an array (non-package link) section + * + * @param string $section + * @param array|mixed|null $baseVal + * @param array|mixed|null $targetVal + * @param array|mixed|null $installVal + * @param callable $setterCallback + * @return void + */ + public function resolveArraySection($section, $baseVal, $targetVal, $installVal, $setterCallback) + { + $resolution = $this->resolveNestedArray($section, $baseVal, $targetVal, $installVal); + if ($resolution['changed']) { + call_user_func($setterCallback, $resolution['value']); + $this->jsonChanges[$section] = $resolution['value']; + } + } + + /** + * Process changes to arrays that could be nested + * + * Associative arrays are resolved recursively and non-associative arrays are treated as unordered sets + * + * @param string $field + * @param array|mixed|null $baseVal + * @param array|mixed|null $targetVal + * @param array|mixed|null $installVal + * @return array Two-element array: ['changed' => boolean, 'value' => updated value], null and empty array values + * indicate the entry should be removed from the parent + */ + public function resolveNestedArray($field, $baseVal, $targetVal, $installVal) + { + $valChanged = false; + $result = $installVal ?? []; + + if (is_array($baseVal) && is_array($targetVal) && is_array($installVal)) { + $baseAssociative = []; + $baseFlat = []; + foreach ($baseVal as $key => $value) { + if (is_string($key)) { + $baseAssociative[$key] = $value; + } else { + $baseFlat[] = $value; + } + } + + $targetAssociative = []; + $targetFlat = []; + foreach ($targetVal as $key => $value) { + if (is_string($key)) { + $targetAssociative[$key] = $value; + } else { + $targetFlat[] = $value; + } + } + + $installAssociative = []; + $installFlat = []; + foreach ($installVal as $key => $value) { + if (is_string($key)) { + $installAssociative[$key] = $value; + } else { + $installFlat[] = $value; + } + } + + $associativeResult = array_filter($result, 'is_string', ARRAY_FILTER_USE_KEY); + $mageKeys = array_unique(array_merge(array_keys($baseAssociative), array_keys($targetAssociative))); + foreach ($mageKeys as $key) { + $baseNestedVal = $baseAssociative[$key] ?? []; + $targetNestedVal = $targetAssociative[$key] ?? []; + $installNestedVal = $installAssociative[$key] ?? []; + + $resolution = $this->resolveNestedArray( + "$field.$key", + $baseNestedVal, + $targetNestedVal, + $installNestedVal + ); + if ($resolution['value'] === null || $resolution['value'] === []) { + if (key_exists($key, $associativeResult)) { + $valChanged = true; + unset($associativeResult[$key]); + } + } else { + $valChanged = $valChanged || $resolution['changed']; + $associativeResult[$key] = $resolution['value']; + } + } + + $flatResult = array_filter($result, 'is_int', ARRAY_FILTER_USE_KEY); + $flatAdds = array_diff(array_diff($targetFlat, $baseFlat), $flatResult); + if ($flatAdds !== []) { + $valChanged = true; + $this->verboseLog("Adding $field entries: " . implode(', ', $flatAdds)); + $flatResult = array_unique(array_merge($flatResult, $flatAdds)); + } + + $flatRemoves = array_intersect(array_diff($baseFlat, $targetFlat), $flatResult); + if ($flatRemoves !== []) { + $valChanged = true; + $this->verboseLog("Removing $field entries: " . implode(', ', $flatRemoves)); + $flatResult = array_diff($flatResult, $flatRemoves); + } + + $result = array_merge($flatResult, $associativeResult); + } else { + // Some or all of the values aren't arrays so they should all be compared as non-array values + $action = $this->findResolution($field, $baseVal, $targetVal, $installVal); + $prettyTargetVal = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($action == static::ADD_VAL) { + $valChanged = true; + $this->verboseLog("Adding $field entry: $prettyTargetVal"); + $result = $targetVal; + } elseif ($action == static::CHANGE_VAL) { + $valChanged = true; + $this->verboseLog("Updating $field entry: $prettyTargetVal"); + $result = $targetVal; + } elseif ($action == static::REMOVE_VAL) { + $valChanged = true; + $this->verboseLog("Removing $field entry"); + $result = null; + } + } + + return ['changed' => $valChanged, 'value' => $result]; + } + + /** + * Write the changed composer.json file + * + * Called as a script event on non-dry runs after a successful update before other post-update-cmd scripts + * + * @return void + * @throws FilesystemException if the composer.json read or write failed + */ + public function writeUpdatedRoot() + { + if ($this->jsonChanges === []) { + return; + } + + $io = $this->getIO(); + $json = json_decode(file_get_contents($this->filePath), true); + if ($json === null) { + throw new FilesystemException('Failed to read ' . $this->filePath); + } + + foreach ($this->jsonChanges as $section => $newContents) { + if ($newContents === null || $newContents === []) { + if (key_exists($section, $json)) { + unset($json[$section]); + } + } else { + $json[$section] = $newContents; + } + } + + $this->verboseLog('Writing changes to the root composer.json...'); + + $retVal = file_put_contents( + $this->filePath, + json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) + ); + + if ($retVal === false) { + throw new FilesystemException('Failed to write updated Magento root values to ' . $this->filePath); + } + $io->writeError('' . $this->filePath . ' has been updated'); + } + + /** + * Label and log the given message if output is set to verbose + * + * @param string $message + * @return void + */ + private function verboseLog($message) + { + $this->getIO()->writeError($this->targetLabel . ": $message", true, IOInterface::VERBOSE); + } + + /** + * Helper function to convert a set of links to an associative array with target package names as keys + * + * @param Link[] $links + * @return array + */ + private function linksToMap($links) + { + $targets = array_map(function ($link) { + /** @var Link $link */ + return $link->getTarget(); + }, $links); + return array_combine($targets, $links); + } + + /** + * Helper function to extract the edition and package type if it is a Magento package name + * + * @param string $packageName + * @return array|null + */ + private static function getMagentoPackageInfo($packageName) + { + $regex = '/^magento\/(?product|project)-(?community|enterprise)-edition$/'; + if (preg_match($regex, $packageName, $matches)) { + return $matches; + } else { + return null; + } + } + + /** + * Retrieve the Magento root package for an edition and version constraint from the composer file's repositories + * + * @param string $edition + * @param string $constraint + * @param Composer $composer + * @param InputInterface $input + * @param boolean $isTarget + * @return \Composer\Package\PackageInterface|bool Best root package candidate or false if no valid packages found + */ + private function fetchRoot($edition, $constraint, $composer, $input, $isTarget = false) + { + $rootName = strtolower("magento/project-$edition-edition"); + $phpVersion = null; + $prettyPhpVersion = null; + $versionParser = new VersionParser(); + $parsedConstraint = $versionParser->parseConstraints($constraint); + + $minimumStability = $composer->getPackage()->getMinimumStability() ?? 'stable'; + $stabilityFlags = $this->extractStabilityFlags($rootName, $constraint, $minimumStability); + $stability = key_exists($rootName, $stabilityFlags) + ? array_search($stabilityFlags[$rootName], BasePackage::$stabilities) + : $minimumStability; + $this->getIO()->writeError( + "Minimum stability for \"$rootName: $constraint\": $stability", + true, + IOInterface::DEBUG + ); + $pool = new Pool( + $stability, + $stabilityFlags, + [$rootName => $parsedConstraint] + ); + $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); + $pool->addRepository($repos); + + if ($isTarget) { + if (strpbrk($parsedConstraint->__toString(), '[]|<>!') !== false) { + $this->fuzzyConstraint = true; + $this->getIO()->writeError( + "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . + 'the Magento root updater might not accurately determine the version to use according to other ' . + 'requirements in this installation. It is recommended to use an exact version number.' + ); + } + if (!$input->getOption('ignore-platform-reqs')) { + $platformOverrides = $composer->getConfig()->get('platform') ?: []; + $platform = new PlatformRepository([], $platformOverrides); + $phpPackage = $platform->findPackage('php', '*'); + if ($phpPackage != null) { + $phpVersion = $phpPackage->getVersion(); + $prettyPhpVersion = $phpPackage->getPrettyVersion(); + } + } + } + + $versionSelector = new VersionSelector($pool); + $result = $versionSelector->findBestCandidate($rootName, $constraint, $phpVersion); + + if ($result == false) { + $err = "Could not find a Magento project package matching \"magento/product-$edition-edition $constraint\""; + if ($phpVersion) { + $err = "$err for PHP version $prettyPhpVersion"; + } + $this->getIO()->writeError("$err", true, IOInterface::QUIET); + } + + return $result; + } + + /** + * Helper method to construct stability flags needed to fetch new root packages + * + * @see RootPackageLoader::extractStabilityFlags() + * + * @param string $reqName + * @param string $reqVersion + * @param string $minimumStability + * @return array + */ + private function extractStabilityFlags($reqName, $reqVersion, $minimumStability) + { + $stabilityFlags = []; + $stabilityMap = BasePackage::$stabilities; + $minimumStability = $stabilityMap[$minimumStability]; + $constraints = []; + + // extract all sub-constraints in case it is an OR/AND multi-constraint + $orSplit = preg_split('{\s*\|\|?\s*}', trim($reqVersion)); + foreach ($orSplit as $orConstraint) { + $andSplit = preg_split('{(?< ,]) *(? $stability) { + continue; + } + $stabilityFlags[$reqName] = $stability; + $match = true; + } + } + + if (!$match) { + foreach ($constraints as $constraint) { + // infer flags for requirements that have an explicit -dev or -beta version specified but only + // for those that are more unstable than the minimumStability or existing flags + $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $constraint); + if (preg_match('{^[^,\s@]+$}', $reqVersion) + && 'stable' !== ($stabilityName = VersionParser::parseStability($reqVersion))) { + $stability = $stabilityMap[$stabilityName]; + if ((isset($stabilityFlags[$reqName]) && $stabilityFlags[$reqName] > $stability) + || ($minimumStability > $stability)) { + continue; + } + $stabilityFlags[$reqName] = $stability; + } + } + } + + return $stabilityFlags; + } + + /** + * If interactive, ask the given question and return the result, otherwise return the default + * + * @param string $question + * @param bool $default + * @return bool + */ + private function getConfirmation($question, $default = false) + { + $result = $default; + if ($this->interactive) { + if (!$this->interactiveInput) { + throw new \InvalidArgumentException( + '--' . static::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' + ); + } + $opts = $default ? 'Y,n' : 'y,N'; + $result = $this->getIO()->askConfirmation("$question [$opts]? ", $default); + } + return $result; + } + + /** + * Set the flag for the interactivity of the current environment (used for testing) + * + * @param bool $interactiveInput + * @return void + */ + public function setInteractiveInput($interactiveInput) + { + $this->interactiveInput = $interactiveInput; + } + + /** + * Set the flag for whether or not the plugin should override user changes with Magento values + * + * @param bool $override + * @return void + */ + public function setOverride($override) + { + $this->override = $override; + } + + /** + * Set the flag to interactively prompt for conflict resolution between Magento deltas and installed values + * + * @param bool $interactive + * @return void + */ + public function setInteractive($interactive) + { + $this->interactive = $interactive; + } + + /** + * Get the map of section name -> new contents to use to update the composer.json file after running the update + * + * @return array + */ + public function getJsonChanges() + { + return $this->jsonChanges; + } +} diff --git a/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php b/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php new file mode 100644 index 0000000..112f677 --- /dev/null +++ b/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php @@ -0,0 +1,37 @@ + PluginCommandProvider::class]; + } +} diff --git a/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php b/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php new file mode 100644 index 0000000..81f36a4 --- /dev/null +++ b/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php @@ -0,0 +1,905 @@ +getMockForAbstractClass(OutputInterface::class); + + $this->application->doRun($this->input, $output); + + $this->assertEquals($this->rootUpdateCommand, $this->application->getCalledCommand()); + } + + public function testUpdateCommandNoPlugins() + { + /** @var MockObject|OutputInterface $output */ + $output = $this->getMockForAbstractClass(OutputInterface::class); + $this->input->method('hasParameterOption')->willReturnMap([['--no-plugins', false, true]]); + + $this->application->doRun($this->input, $output); + + $this->assertNotEquals($this->rootUpdateCommand, $this->application->getCalledCommand()); + } + + public function testFindResolutionAddElement() + { + $resolution = $this->rootUpdateCommand->findResolution('field', null, 'newVal', null); + + $this->assertEquals(RootUpdateCommand::ADD_VAL, $resolution); + } + + public function testFindResolutionRemoveElement() + { + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', null, 'oldVal'); + + $this->assertEquals(RootUpdateCommand::REMOVE_VAL, $resolution); + } + + public function testFindResolutionChangeElement() + { + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'oldVal'); + + $this->assertEquals(RootUpdateCommand::CHANGE_VAL, $resolution); + } + + public function testFindResolutionNoUpdate() + { + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'newVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionConflictNoOverride() + { + $this->io->expects($this->at(0))->method('writeError') + ->with($this->stringContains('will not be changed')); + + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionConflictOverride() + { + $this->rootUpdateCommand->setOverride(true); + + $this->io->expects($this->once())->method('writeError') + ->with($this->stringContains('overriding local changes')); + + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertEquals(RootUpdateCommand::CHANGE_VAL, $resolution); + } + + public function testFindResolutionConflictOverrideRestoreRemoved() + { + $this->rootUpdateCommand->setOverride(true); + $this->io->expects($this->once())->method('writeError') + ->with($this->stringContains('overriding local changes')); + + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', null); + + $this->assertEquals(RootUpdateCommand::ADD_VAL, $resolution); + } + + public function testFindResolutionInteractiveConfirm() + { + $this->rootUpdateCommand->setInteractiveInput(true); + $this->rootUpdateCommand->setInteractive(true); + + $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); + + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertEquals(RootUpdateCommand::CHANGE_VAL, $resolution); + } + + public function testFindResolutionInteractiveNoConfirm() + { + $this->rootUpdateCommand->setInteractiveInput(true); + $this->rootUpdateCommand->setInteractive(true); + $this->rootUpdateCommand->setOverride(false); + + $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); + + $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionNonInteractiveEnvironmentError() + { + $this->rootUpdateCommand->setInteractiveInput(false); + $this->rootUpdateCommand->setInteractive(true); + $this->rootUpdateCommand->setOverride(false); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' + ); + $this->io->expects($this->never())->method('askConfirmation'); + + $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + } + + public function testResolveNestedArrayNonArrayAdd() + { + $result = $this->rootUpdateCommand->resolveNestedArray('field', null, 'newVal', null); + + $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); + } + + public function testResolveNestedArrayNonArrayRemove() + { + $result = $this->rootUpdateCommand->resolveNestedArray('field', 'oldVal', null, 'oldVal'); + + $this->assertEquals(['changed' => true, 'value' => null], $result); + } + + public function testResolveNestedArrayNonArrayChange() + { + $result = $this->rootUpdateCommand->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); + + $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); + } + + public function testResolveArrayMismatchedArray() + { + $this->rootUpdateCommand->resolveArraySection( + 'extra', + 'oldVal', + ['newVal'], + 'oldVal', + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['newVal'], $this->installRoot->getExtra()); + } + + public function testResolveArrayMismatchedMap() + { + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['oldVal'], + ['key' => 'newVal'], + ['oldVal'], + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['key' => 'newVal'], $this->installRoot->getExtra()); + } + + public function testResolveArrayFlatArrayAddElement() + { + $expected = ['val1', 'val2', 'val3']; + + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['val1'], + ['val1', 'val3'], + ['val2', 'val1'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff($expected, $result), array_diff($result, $expected))); + } + + public function testResolveArrayFlatArrayRemoveElement() + { + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['val1', 'val2', 'val3'], + ['val2'], + ['val1', 'val2', 'val3', 'val4'], + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['val2', 'val4'], array_values($this->installRoot->getExtra())); + } + + public function testResolveArrayFlatArrayAddAndRemoveElement() + { + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['val1', 'val2', 'val3'], + ['val2', 'val5'], + ['val1', 'val2', 'val3', 'val4'], + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['val2', 'val4', 'val5'], array_values($this->installRoot->getExtra())); + } + + public function testResolveArrayAssociativeAddElement() + { + $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; + + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['key1' => 'val1'], + ['key1' => 'val1', 'key3' => 'val3'], + ['key2' => 'val2', 'key1' => 'val1'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayAssociativeRemoveElement() + { + $expected = ['key2' => 'val2', 'key3' => 'val3']; + + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['key1' => 'val1', 'key2' => 'val2'], + ['key2' => 'val2'], + ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayAssociativeAddAndRemoveElement() + { + $expected = ['key3' => 'val3', 'key4' => 'val4']; + + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['key1' => 'val1', 'key2' => 'val2'], + ['key4' => 'val4'], + ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayNestedAdd() + { + $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; + + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], + [$this->installRoot, 'setExtra'] + ); + + $expectedKeys = array_keys($expected); + $actualKeys = array_keys($this->installRoot->getExtra()); + $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); + foreach ($expected as $key => $expectedVal) { + $actualVal = $this->installRoot->getExtra()[$key]; + $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); + } + } + + public function testResolveArrayNestedRemove() + { + $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; + + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1'], 'key2' => ['k2v2']], + ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], + [$this->installRoot, 'setExtra'] + ); + + $expectedKeys = array_keys($expected); + $actualKeys = array_keys($this->installRoot->getExtra()); + $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); + foreach ($expected as $key => $expectedVal) { + $actualVal = $this->installRoot->getExtra()[$key]; + $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); + } + } + + public function testResolveArrayTracksChanges() + { + $expected = ['val1', 'val2', 'val3']; + + $this->assertEmpty($this->rootUpdateCommand->getJsonChanges()); + $this->rootUpdateCommand->resolveArraySection( + 'extra', + ['val1'], + ['val1', 'val3'], + ['val2', 'val1'], + [$this->installRoot, 'setExtra'] + ); + $actual = $this->rootUpdateCommand->getJsonChanges(); + + $this->assertEquals(['extra'], array_keys($actual)); + $actual = $actual['extra']; + $this->assertEmpty(array_merge(array_diff($expected, $actual), array_diff($actual, $expected))); + } + + public function testResolveLinksAddLink() + { + $installLink = $this->createLinks(1, 'install/link'); + $baseLinks = $this->createLinks(2); + $installLinks = array_merge($baseLinks, $installLink); + $targetLinks = array_merge($baseLinks, $this->createLinks(1, 'target/link')); + $expected = array_merge($targetLinks, $installLink); + + $this->rootUpdateCommand->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $this->assertLinksEqual($expected, $this->installRoot->getRequires()); + } + + public function testResolveLinksRemoveLink() + { + $installLink = $this->createLinks(1, 'install/link'); + $baseLinks = $this->createLinks(2); + $installLinks = array_merge($baseLinks, $installLink); + $targetLinks = array_slice($baseLinks, 1); + $expected = array_merge($targetLinks, $installLink); + + $this->rootUpdateCommand->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $this->assertLinksEqual($expected, $this->installRoot->getRequires()); + } + + public function testResolveLinksChangeLink() + { + $installLink = $this->createLinks(1, 'install/link'); + $baseLinks = $this->createLinks(2); + $installLinks = array_merge($baseLinks, $installLink); + $targetLinks = $this->changeLink($baseLinks, 1); + $expected = array_merge($targetLinks, $installLink); + + $this->rootUpdateCommand->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $this->assertLinksEqual($expected, $this->installRoot->getRequires()); + } + + public function testResolveLinksTracksChanges() + { + $installLink = $this->createLinks(1, 'install/link')[0]; + $baseLinks = $this->createLinks(1); + /** @var Link[] $installLinks */ + $installLinks = array_merge($baseLinks, [$installLink]); + $targetLinks = $this->changeLink($baseLinks, 0); + + $this->assertEmpty($this->rootUpdateCommand->getJsonChanges()); + $this->rootUpdateCommand->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $changed = $this->rootUpdateCommand->getJsonChanges(); + $this->assertEquals(['require'], array_keys($changed)); + $actual = $changed['require']; + $this->assertEquals(2, count($actual)); + $this->assertTrue(key_exists($targetLinks[0]->getTarget(), $actual)); + $this->assertEquals($targetLinks[0]->getConstraint()->getPrettyString(), $actual[$targetLinks[0]->getTarget()]); + $this->assertTrue(key_exists($installLink->getTarget(), $actual)); + $this->assertEquals($installLinks[1]->getConstraint()->getPrettyString(), $actual[$installLink->getTarget()]); + } + + public function testMagentoUpdateSkipOption() + { + $this->input->method('getOption')->willReturnMap([[RootUpdateCommand::SKIP_OPT, true]]); + + $this->composer->expects($this->never())->method('setPackage'); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + } + + public function testMagentoUpdateNotMagentoRoot() + { + $this->installRoot->setRequires($this->createLinks(2, 'vndr/package')); + + $this->composer->expects($this->never())->method('setPackage'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Magento root updates cannot run without a valid target package'); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + } + + public function testMagentoUpdateRegistersPostUpdateWrites() + { + $this->rootUpdateCommand->setApplication($this->application); + + $this->eventDispatcher->expects($this->once())->method('addListener')->with( + ScriptEvents::POST_UPDATE_CMD, + [$this->rootUpdateCommand, 'writeUpdatedRoot'], + PHP_INT_MAX + ); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + } + + public function testMagentoUpdateDryRun() + { + $this->rootUpdateCommand->setApplication($this->application); + $this->input->method('getOption')->willReturnMap([['dry-run', true]]); + + $this->eventDispatcher->expects($this->never())->method('addListener'); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + + $this->assertNotEmpty($this->rootUpdateCommand->getJsonChanges()); + } + + public function testMagentoUpdateSetsFieldsNoOverride() + { + $this->rootUpdateCommand->setApplication($this->application); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); + $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + } + + public function testMagentoUpdateSetsFieldsWithOverride() + { + $this->rootUpdateCommand->setApplication($this->application); + $this->input->method('getOption')->willReturnMap([[RootUpdateCommand::OVERRIDE_OPT, true]]); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + + $this->assertLinksEqual($this->expectedWithOverride->getRequires(), $newRoot->getRequires()); + $this->assertLinksEqual($this->expectedWithOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertEquals($this->expectedWithOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertEquals($this->expectedWithOverride->getDevAutoload(), $newRoot->getDevAutoload()); + $this->assertLinksEqual($this->expectedWithOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedWithOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedWithOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedWithOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedWithOverride->getSuggests(), $newRoot->getSuggests()); + } + + public function testMagentoUpdateNoDev() + { + $this->rootUpdateCommand->setApplication($this->application); + $this->input->method('getOption')->willReturnMap([['no-dev', true]]); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); + $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + + $this->assertLinksNotEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + } + + public function testMagentoUpdateNoAutoloader() + { + $this->rootUpdateCommand->setApplication($this->application); + $this->input->method('getOption')->willReturnMap([['no-autoloader', true]]); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); + $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + + $this->assertNotEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + } + + /** + * Setup test data, expected results, and necessary mocked objects + */ + public function setUp() + { + /** + * Create instance of RootUpdateCommand for testing + */ + $this->rootUpdateCommand = new RootUpdateCommand(); + + /** + * Set up input RootPackage objects for magentoUpdate() + */ + $baseRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $baseRoot->setRequires([ + new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '1.0.0'), null, '1.0.0'), + new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') + ]); + $baseRoot->setDevRequires($this->createLinks(2, 'vendor/dev-package')); + $baseRoot->setAutoload(['psr-4' => ['Magento\\' => 'src/Magento/']]); + $baseRoot->setDevAutoload(['psr-4' => ['Magento\\Tools\\' => 'dev/tools/Magento/Tools/']]); + $baseRoot->setConflicts($this->createLinks(2, 'vendor/conflicting')); + $baseRoot->setExtra(['extra-key1' => 'base1', 'extra-key2' => 'base2']); + $baseRoot->setProvides($this->createLinks(3, 'magento/sub-package')); + $baseRoot->setReplaces([]); + $baseRoot->setSuggests(['magento/sample-data' => 'Suggested Sample Data 1.0.0']); + $this->baseRoot = $baseRoot; + + $targetRoot = new RootPackage('magento/project-community-edition', '2.0.0.0', '2.0.0'); + $targetRoot->setRequires([ + new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), + new Link('root/pkg', 'vendor/package1', new Constraint('==', '2.0.0'), null, '2.0.0') + ]); + $targetRoot->setDevRequires($this->createLinks(1, 'vendor/dev-package')); + $targetRoot->setAutoload(['psr-4' => [ + 'Magento\\' => 'src/Magento/', + 'Zend\\Mvc\\Controller\\'=> 'setup/src/Zend/Mvc/Controller/' + ]]); + $targetRoot->setDevAutoload(['psr-4' => ['Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/']]); + $targetRoot->setConflicts($this->changeLink($this->createLinks(3, 'vendor/conflicting'), 1)); + $targetRoot->setExtra(['extra-key1' => 'target1', 'extra-key2' => 'target2', 'extra-key3' => ['a' => 'b']]); + $targetRoot->setProvides($this->changeLink($this->createLinks(3, 'magento/sub-package'), 1)); + $targetRoot->setReplaces($this->createLinks(3, 'replaced/package')); + $targetRoot->setSuggests([]); + $this->targetRoot = $targetRoot; + + $installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $installRoot->setRequires([ + new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), + new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') + ]); + $installRoot->setDevRequires($baseRoot->getDevRequires()); + $installRoot->setAutoload(array_merge($baseRoot->getAutoload(), ['files' => 'app/etc/Register.php'])); + $installRoot->setDevAutoload(['psr-4' => ['Magento\\Tools\\' => 'dev/tools/Magento/Tools2/']]); + $installRoot->setConflicts(array_merge( + array_slice($this->changeLink($baseRoot->getConflicts(), 0), 0, 1), + $this->createLinks(3, 'vendor/different-conflicting') + )); + $installRoot->setExtra(['extra-key1' => 'install1', 'extra-key2' => 'base2']); + $installRoot->setProvides($baseRoot->getProvides()); + $installRoot->setReplaces($baseRoot->getReplaces()); + $installRoot->setSuggests([ + 'magento/sample-data' => 'Suggested Sample Data 1.0.0', + 'vendor/suggested' => 'Another Suggested Package' + ]); + $this->installRoot = $installRoot; + + /** + * Set up expected results from magentoUpdate() with and without overriding conflicting install values + */ + $expectedNoOverride = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $expectedNoOverride->setRequires($targetRoot->getRequires()); + $expectedNoOverride->setDevRequires($targetRoot->getDevRequires()); + $expectedNoOverride->setAutoload( + array_merge($targetRoot->getAutoload(), ['files' => 'app/etc/Register.php']) + ); + $expectedNoOverride->setDevAutoload(['psr-4' => [ + 'Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/', + 'Magento\\Tools\\' => 'dev/tools/Magento/Tools2/' + ]]); + $expectedNoOverride->setConflicts( + array_merge($this->installRoot->getConflicts(), [$targetRoot->getConflicts()[2]]) + ); + $noOverrideExtra = $targetRoot->getExtra(); + $noOverrideExtra['extra-key1'] = $this->installRoot->getExtra()['extra-key1']; + $expectedNoOverride->setExtra($noOverrideExtra); + $expectedNoOverride->setProvides($targetRoot->getProvides()); + $expectedNoOverride->setReplaces($targetRoot->getReplaces()); + $expectedNoOverride->setSuggests(['vendor/suggested' => 'Another Suggested Package']); + $this->expectedNoOverride = $expectedNoOverride; + + $expectedWithOverride = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $expectedWithOverride->setRequires($expectedNoOverride->getRequires()); + $expectedWithOverride->setDevRequires($expectedNoOverride->getDevRequires()); + $expectedWithOverride->setAutoload($expectedNoOverride->getAutoload()); + $expectedWithOverride->setDevAutoload([ + 'psr-4' => ['Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/'] + ]); + $expectedWithOverride->setConflicts(array_merge( + $this->installRoot->getConflicts(), + array_slice($targetRoot->getConflicts(), 1) + )); + $expectedWithOverride->setExtra($targetRoot->getExtra()); + $expectedWithOverride->setProvides($expectedNoOverride->getProvides()); + $expectedWithOverride->setReplaces($expectedNoOverride->getReplaces()); + $expectedWithOverride->setSuggests($expectedNoOverride->getSuggests()); + $this->expectedWithOverride = $expectedWithOverride; + + /** + * Mock plugin boilerplate + */ + $capability = $this->createPartialMock(Capability::class, ['getCommands']); + $capability->method('getCommands')->willReturn([$this->rootUpdateCommand]); + $pluginManager = $this->createPartialMock(PluginManager::class, ['getPluginCapabilities']); + $pluginManager->method('getPluginCapabilities')->willReturn([$capability]); + $this->eventDispatcher = $this->createPartialMock(EventDispatcher::class, ['addListener']); + + /** + * Mock InputInterface for CLI options and IOInterface for interaction + */ + $input = $this->getMockForAbstractClass(InputInterface::class); + $input->method('getFirstArgument')->willReturn('update'); +// $input->method('hasParameterOption')->willReturnMap([ +// ['--no-plugins', false], +// ['--profile', false] +// ]); + $input->method('isInteractive')->willReturn(false); + $input->method('getParameterOption')->with(['--working-dir', '-d'])->willReturn(false); + $this->input = $input; + $this->io = $this->getMockForAbstractClass(IOInterface::class); + $this->rootUpdateCommand->setIO($this->io); + + /** + * Mock package repositories + */ + $repo = $this->createPartialMock(ComposerRepository::class, ['hasProviders', 'whatProvides']); + $repo->method('hasProviders')->willReturn(true); + $repo->method('whatProvides')->willReturn([$targetRoot, $baseRoot]); + $repoManager = $this->createPartialMock(RepositoryManager::class, ['getRepositories']); + $repoManager->method('getRepositories')->willReturn([$repo]); + $lockedRepo = $this->getMockForAbstractClass(RepositoryInterface::class); + $lockedRepo->method('getPackages')->willReturn([ + new Package('magento/product-community-edition', '1.0.0.0', '1.0.0') + ]); + $locker = $this->createPartialMock(Locker::class, ['isLocked', 'getLockedRepository']); + $locker->method('isLocked')->willReturn(true); + $locker->method('getLockedRepository')->willReturn($lockedRepo); + + /** + * Mock local Composer object + */ + $config = $this->createPartialMock(Config::class, ['get']); + $config->method('get')->with('platform')->willReturn([]); + $composer = $this->createPartialMock(Composer::class, [ + 'getPluginManager', + 'getLocker', + 'getPackage', + 'getRepositoryManager', + 'getEventDispatcher', + 'getConfig', + 'setPackage' + ]); + $composer->method('getPluginManager')->willReturn($pluginManager); + $composer->method('getLocker')->willReturn($locker); + $composer->method('getEventDispatcher')->willReturn($this->eventDispatcher); + $composer->method('getRepositoryManager')->willReturn($repoManager); + $composer->method('getPackage')->willReturn($installRoot); + $composer->method('getConfig')->willReturn($config); + $this->composer = $composer; + $this->application = new TestApplication(); + $this->application->setComposer($composer); + } + + /** + * Data setup helper function to create a number of Link objects + * + * @param int $count + * @param string $target + * + * @return Link[] + */ + public function createLinks($count, $target = 'package/name') + { + $links = []; + for ($i = 1; $i <= $count; $i++) { + $links[] = new Link('root/pkg', "$target$i", new Constraint('==', "$i.0.0"), null, "$i.0.0"); + } + return $links; + } + + /** + * Data setup helper function to change the version constraint on one of the links in a list + * + * @param Link[] $links + * @param int $index + * + * @return Link[] + */ + public function changeLink($links, $index) + { + $result = $links; + $changeLink = $links[$index]; + $version = explode(' ', $changeLink->getConstraint()->getPrettyString())[1]; + $versionParts = array_map('intval', explode('.', $version)); + $versionParts[1] = $versionParts[1] + 1; + $version = implode('.', $versionParts); + $result[$index] = new Link( + $changeLink->getSource(), + $changeLink->getTarget(), + new Constraint('==', $version), + null, + $version + ); + return $result; + } + + /** + * Callback to capture an argument passed to a mock function in the given variable + * + * @param &$arg + * + * @return \PHPUnit\Framework\Constraint\Callback + */ + public function captureArg(&$arg) + { + return $this->callback(function ($argToMock) use (&$arg) { + $arg = $argToMock; + return true; + }); + } + + /** + * Assert that two arrays of links are equal without checking order + * + * @param Link[] $expected + * @param Link[] $actual + * + * @return void + */ + public function assertLinksEqual($expected, $actual) + { + $this->assertEquals(count($expected), count($actual)); + while (count($expected) > 0) { + $expectedLink = array_shift($expected); + $expectedSource = $expectedLink->getSource(); + $expectedTarget = $expectedLink->getTarget(); + $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); + $found = -1; + foreach ($actual as $key => $actualLink) { + if ($actualLink->getSource() === $expectedSource && + $actualLink->getTarget() === $expectedTarget && + $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { + $found = $key; + break; + } + } + $this->assertGreaterThan(-1, $found, "Could not find a link matching $expectedLink"); + unset($actual[$found]); + } + } + + /** + * Assert that two arrays of links are not equal without checking order + * + * @param Link[] $expected + * @param Link[] $actual + * + * @return void + */ + public function assertLinksNotEqual($expected, $actual) + { + if (count($expected) !== count($actual)) { + $this->assertNotEquals(count($expected), count($actual)); + return; + } + + while (count($expected) > 0) { + $expectedLink = array_shift($expected); + $expectedSource = $expectedLink->getSource(); + $expectedTarget = $expectedLink->getTarget(); + $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); + $found = -1; + foreach ($actual as $key => $actualLink) { + if ($actualLink->getSource() === $expectedSource && + $actualLink->getTarget() === $expectedTarget && + $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { + $found = $key; + break; + } + } + if ($found === -1) { + $this->assertEquals(-1, $found); + return; + } + unset($actual[$found]); + } + $this->fail('Expected Link sets to not be equal'); + } +} diff --git a/tests/Unit/Magento/TestHelper/TestApplication.php b/tests/Unit/Magento/TestHelper/TestApplication.php new file mode 100644 index 0000000..083a1fd --- /dev/null +++ b/tests/Unit/Magento/TestHelper/TestApplication.php @@ -0,0 +1,80 @@ +composer = $composer; + } + + /** + * Set whether or not doRunCommand should actually be run or not + * + * @param bool $shouldRun + * + * @return void + */ + public function setShouldRun($shouldRun) + { + $this->shouldRun = $shouldRun; + } + + /** + * Captures the called command for testing and executes the command if $shouldRun is true + * + * @param Command $command + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + * + * @throws \Throwable + */ + protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output) + { + $this->command = $command; + if ($this->shouldRun) { + return parent::doRunCommand($command, $input, $output); + } else { + return 0; + } + } + + /** + * Get the Command that was passed to doRunCommand + * + * @return Command + */ + public function getCalledCommand() + { + return $this->command; + } +} From 13baa9730c3643b0004cb44555d10d4c3238be01 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Mon, 22 Oct 2018 10:03:09 -0500 Subject: [PATCH 02/15] MAGETWO-94153: Fixing a missed findResolution() static::CHANGE_VAL return value --- src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php b/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php index da853b3..c7054ad 100644 --- a/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php +++ b/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php @@ -511,7 +511,7 @@ public function findResolution( if ($installVal === null) { $action = static::ADD_VAL; } else { - $action = 'change'; + $action = static::CHANGE_VAL; $conflictDesc = "add $field=$prettyTarget but it is instead $prettyInstall"; } } elseif ($targetVal === null) { From 2e50d40a2566bc2fc99ddff2b2245070448b0911 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Wed, 7 Nov 2018 02:35:20 -0600 Subject: [PATCH 03/15] MAGETWO-94153: Enabling the plugin for Web Setup Wizard upgrades --- composer.json | 10 +- .../Plugin/RootUpdate/ConflictResolver.php | 460 +++++++++ .../Plugin/RootUpdate/MagentoRootUpdater.php | 700 +++++++++++++ .../Plugin/RootUpdate/RootUpdateCommand.php | 968 +----------------- .../Plugin/RootUpdate/RootUpdatePlugin.php | 35 +- .../Setup/RecurringData.php | 112 ++ .../WebSetupWizardPluginInstaller.php | 211 ++++ .../RootUpdatePluginInstaller/etc/module.xml | 11 + .../registration.php | 13 + .../RootUpdate/ConflictResolverTest.php | 437 ++++++++ .../RootUpdate/MagentoRootUpdaterTest.php | 333 ++++++ .../RootUpdate/RootUpdateCommandTest.php | 840 +-------------- .../TestHelper/UpdatePluginTestCase.php | 144 +++ 13 files changed, 2498 insertions(+), 1776 deletions(-) create mode 100644 src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php create mode 100644 src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php create mode 100644 src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php create mode 100644 src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php create mode 100644 src/Magento/RootUpdatePluginInstaller/etc/module.xml create mode 100644 src/Magento/RootUpdatePluginInstaller/registration.php create mode 100644 tests/Unit/Magento/Composer/Plugin/RootUpdate/ConflictResolverTest.php create mode 100644 tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php create mode 100644 tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php diff --git a/composer.json b/composer.json index a8d8339..b9e0a78 100644 --- a/composer.json +++ b/composer.json @@ -1,20 +1,23 @@ { "name": "magento/composer-root-update-plugin", "description": "Plugin to look ahead for Magento project root changes when running composer update for new Magento versions", - "version": "1.0.0-beta1", + "version": "1.0.0-beta2", "license": [ "OSL-3.0", "AFL-3.0" ], "type": "composer-plugin", "require": { - "composer/composer": "*", + "composer/composer": "<=1.7.2", "composer-plugin-api": "^1.1" }, "require-dev": { "phpunit/phpunit": "~6.5.0" }, "autoload": { + "files": [ + "src/Magento/RootUpdatePluginInstaller/registration.php" + ], "psr-4": { "Magento\\": "src/Magento/" } @@ -26,5 +29,8 @@ }, "extra": { "class": "Magento\\Composer\\Plugin\\RootUpdate\\RootUpdatePlugin" + }, + "suggests": { + "magento/framework": "Enables the Magento Composer Root Update Plugin's functionality for the Web Setup Wizard" } } \ No newline at end of file diff --git a/src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php b/src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php new file mode 100644 index 0000000..3a8da4f --- /dev/null +++ b/src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php @@ -0,0 +1,460 @@ +io = $io; + $this->interactive = $interactive; + $this->override = $override; + $this->targetLabel = $targetLabel; + $this->baseLabel = $baseLabel; + $this->jsonChanges = []; + } + + /** + * Find value deltas from original->target version and resolve any conflicts with overlapping user changes + * + * @param string $field + * @param array|mixed|null $baseVal + * @param array|mixed|null $targetVal + * @param array|mixed|null $installVal + * @param string|null $prettyBase + * @param string|null $prettyTarget + * @param string|null $prettyInstall + * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the existing composer.json file, null for no change + */ + public function findResolution( + $field, + $baseVal, + $targetVal, + $installVal, + $prettyBase = null, + $prettyTarget = null, + $prettyInstall = null + ) { + $io = $this->io; + if ($prettyBase === null) { + $prettyBase = json_encode($baseVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyBase = trim($prettyBase, "'\""); + } + if ($prettyTarget === null) { + $prettyTarget = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyTarget = trim($prettyTarget, "'\""); + } + if ($prettyInstall === null) { + $prettyInstall = json_encode($installVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyInstall = trim($prettyInstall, "'\""); + } + + $targetLabel = $this->targetLabel; + $baseLabel = $this->baseLabel; + + $action = null; + $conflictDesc = null; + + if ($baseVal == $targetVal || $installVal == $targetVal) { + $action = null; + } elseif ($baseVal === null) { + if ($installVal === null) { + $action = static::ADD_VAL; + } else { + $action = static::CHANGE_VAL; + $conflictDesc = "add $field=$prettyTarget but it is instead $prettyInstall"; + } + } elseif ($targetVal === null) { + $action = static::REMOVE_VAL; + if ($installVal !== $baseVal) { + $conflictDesc = "remove the $field=$prettyBase entry in $baseLabel but it is instead $prettyInstall"; + } + } else { + $action = static::CHANGE_VAL; + if ($installVal !== $baseVal) { + $conflictDesc = "update $field to $prettyTarget from $prettyBase in $baseLabel"; + if ($installVal === null) { + $action = static::ADD_VAL; + $conflictDesc = "$conflictDesc but the field has been removed"; + } else { + $conflictDesc = "$conflictDesc but it is instead $prettyInstall"; + } + } + } + + if ($conflictDesc !== null) { + $conflictDesc = "$targetLabel is trying to $conflictDesc in this installation"; + + $shouldOverride = $this->override; + if ($this->override) { + $overrideMessage = "$conflictDesc.\n Overriding local changes due to --" . + RootUpdateCommand::OVERRIDE_OPT . '.'; + $io->writeError($overrideMessage); + } else { + $shouldOverride = $this->getConfirmation( + "$conflictDesc.\nWould you like to override the local changes?" + ); + } + + if (!$shouldOverride) { + $io->writeError("$conflictDesc and will not be changed. Re-run using " . + '--' . RootUpdateCommand::OVERRIDE_OPT . ' or --' . RootUpdateCommand::INTERACTIVE_OPT . + ' to override with Magento values.'); + $action = null; + } + } + + return $action; + } + + /** + * Process changes to corresponding sets of package version links + * + * @param string $section + * @param Link[] $baseLinks + * @param Link[] $targetLinks + * @param Link[] $installLinks + * @param callable $setterCallback + * @return void + */ + public function resolveLinkSection($section, $baseLinks, $targetLinks, $installLinks, $setterCallback) + { + /** @var Link[] $baseMap */ + $baseMap = static::linksToMap($baseLinks); + + /** @var Link[] $targetMap */ + $targetMap = static::linksToMap($targetLinks); + + /** @var Link[] $installMap */ + $installMap = static::linksToMap($installLinks); + + $adds = []; + $removes = []; + $changes = []; + $magePackages = array_unique(array_merge(array_keys($baseMap), array_keys($targetMap))); + foreach ($magePackages as $package) { + if ($section === 'require' && MagentoRootUpdater::getMagentoPackageInfo($package)) { + continue; + } + $field = "$section:$package"; + $baseConstraint = key_exists($package, $baseMap) ? $baseMap[$package]->getConstraint() : null; + $baseVal = ($baseConstraint === null) ? null : $baseConstraint->__toString(); + $prettyBaseVal = ($baseConstraint === null) ? null : $baseConstraint->getPrettyString(); + $targetConstraint = key_exists($package, $targetMap) ? $targetMap[$package]->getConstraint() : null; + $targetVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); + $prettyTargetVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); + $installConstraint = key_exists($package, $installMap) ? $installMap[$package]->getConstraint() : null; + $installVal = ($installConstraint === null) ? null : $installConstraint->__toString(); + $prettyInstallVal = ($installConstraint === null) ? null : $installConstraint->getPrettyString(); + + $action = $this->findResolution( + $field, + $baseVal, + $targetVal, + $installVal, + $prettyBaseVal, + $prettyTargetVal, + $prettyInstallVal + ); + if ($action == static::ADD_VAL) { + $adds[$package] = $targetMap[$package]; + } elseif ($action == static::REMOVE_VAL) { + $removes[] = $package; + } elseif ($action == static::CHANGE_VAL) { + $changes[$package] = $targetMap[$package]; + } + } + + $changed = false; + if ($adds !== []) { + $changed = true; + $prettyAdds = array_map(function ($package) use ($adds) { + $newVal = $adds[$package]->getConstraint()->getPrettyString(); + return "$package=$newVal"; + }, array_keys($adds)); + $this->verboseLog("Adding $section constraints: " . implode(', ', $prettyAdds)); + } + if ($removes !== []) { + $changed = true; + $this->verboseLog("Removing $section entries: " . implode(', ', $removes)); + } + if ($changes !== []) { + $changed = true; + $prettyChanges = array_map(function ($package) use ($changes) { + $newVal = $changes[$package]->getConstraint()->getPrettyString(); + return "$package=$newVal"; + }, array_keys($changes)); + $this->verboseLog("Updating $section constraints: " . implode(', ', $prettyChanges)); + } + + if ($changed) { + $replacements = array_values($adds); + + /** @var Link $installLink */ + foreach ($installMap as $package => $installLink) { + if (in_array($package, $removes)) { + continue; + } elseif (key_exists($package, $changes)) { + $replacements[] = $changes[$package]; + } else { + $replacements[] = $installLink; + } + } + + $newJson = []; + /** @var Link $link */ + foreach ($replacements as $link) { + $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); + } + + call_user_func($setterCallback, $replacements); + $this->jsonChanges[$section] = $newJson; + } + } + + /** + * Process changes to an array (non-package link) section + * + * @param string $section + * @param array|mixed|null $baseVal + * @param array|mixed|null $targetVal + * @param array|mixed|null $installVal + * @param callable $setterCallback + * @return void + */ + public function resolveArraySection($section, $baseVal, $targetVal, $installVal, $setterCallback) + { + $resolution = $this->resolveNestedArray($section, $baseVal, $targetVal, $installVal); + if ($resolution['changed']) { + call_user_func($setterCallback, $resolution['value']); + $this->jsonChanges[$section] = $resolution['value']; + } + } + + /** + * Process changes to arrays that could be nested + * + * Associative arrays are resolved recursively and non-associative arrays are treated as unordered sets + * + * @param string $field + * @param array|mixed|null $baseVal + * @param array|mixed|null $targetVal + * @param array|mixed|null $installVal + * @return array Two-element array: ['changed' => boolean, 'value' => updated value], null and empty array values + * indicate the entry should be removed from the parent + */ + public function resolveNestedArray($field, $baseVal, $targetVal, $installVal) + { + $valChanged = false; + $result = $installVal ?? []; + + if (is_array($baseVal) && is_array($targetVal) && is_array($installVal)) { + $baseAssociative = []; + $baseFlat = []; + foreach ($baseVal as $key => $value) { + if (is_string($key)) { + $baseAssociative[$key] = $value; + } else { + $baseFlat[] = $value; + } + } + + $targetAssociative = []; + $targetFlat = []; + foreach ($targetVal as $key => $value) { + if (is_string($key)) { + $targetAssociative[$key] = $value; + } else { + $targetFlat[] = $value; + } + } + + $installAssociative = []; + $installFlat = []; + foreach ($installVal as $key => $value) { + if (is_string($key)) { + $installAssociative[$key] = $value; + } else { + $installFlat[] = $value; + } + } + + $associativeResult = array_filter($result, 'is_string', ARRAY_FILTER_USE_KEY); + $mageKeys = array_unique(array_merge(array_keys($baseAssociative), array_keys($targetAssociative))); + foreach ($mageKeys as $key) { + $baseNestedVal = $baseAssociative[$key] ?? []; + $targetNestedVal = $targetAssociative[$key] ?? []; + $installNestedVal = $installAssociative[$key] ?? []; + + $resolution = $this->resolveNestedArray( + "$field.$key", + $baseNestedVal, + $targetNestedVal, + $installNestedVal + ); + if ($resolution['value'] === null || $resolution['value'] === []) { + if (key_exists($key, $associativeResult)) { + $valChanged = true; + unset($associativeResult[$key]); + } + } else { + $valChanged = $valChanged || $resolution['changed']; + $associativeResult[$key] = $resolution['value']; + } + } + + $flatResult = array_filter($result, 'is_int', ARRAY_FILTER_USE_KEY); + $flatAdds = array_diff(array_diff($targetFlat, $baseFlat), $flatResult); + if ($flatAdds !== []) { + $valChanged = true; + $this->verboseLog("Adding $field entries: " . implode(', ', $flatAdds)); + $flatResult = array_unique(array_merge($flatResult, $flatAdds)); + } + + $flatRemoves = array_intersect(array_diff($baseFlat, $targetFlat), $flatResult); + if ($flatRemoves !== []) { + $valChanged = true; + $this->verboseLog("Removing $field entries: " . implode(', ', $flatRemoves)); + $flatResult = array_diff($flatResult, $flatRemoves); + } + + $result = array_merge($flatResult, $associativeResult); + } else { + // Some or all of the values aren't arrays so they should all be compared as non-array values + $action = $this->findResolution($field, $baseVal, $targetVal, $installVal); + $prettyTargetVal = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($action == static::ADD_VAL) { + $valChanged = true; + $this->verboseLog("Adding $field entry: $prettyTargetVal"); + $result = $targetVal; + } elseif ($action == static::CHANGE_VAL) { + $valChanged = true; + $this->verboseLog("Updating $field entry: $prettyTargetVal"); + $result = $targetVal; + } elseif ($action == static::REMOVE_VAL) { + $valChanged = true; + $this->verboseLog("Removing $field entry"); + $result = null; + } + } + + return ['changed' => $valChanged, 'value' => $result]; + } + + /** + * Get the json array representation of the changed fields + * + * @return array + */ + public function getJsonChanges() + { + return $this->jsonChanges; + } + + /** + * If interactive, ask the given question and return the result, otherwise return the default + * + * @param string $question + * @param bool $default + * @return bool + */ + private function getConfirmation($question, $default = false) + { + $result = $default; + if ($this->interactive) { + if (!$this->io->isInteractive()) { + throw new \InvalidArgumentException( + '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' + ); + } + $opts = $default ? 'Y,n' : 'y,N'; + $result = $this->io->askConfirmation("$question [$opts]? ", $default); + } + return $result; + } + + /** + * Label and log the given message if output is set to verbose + * + * @param string $message + * @return void + */ + private function verboseLog($message) + { + $label = $this->targetLabel; + $this->io->writeError(" [$label] $message", true, IOInterface::VERBOSE); + } + + /** + * Helper function to convert a set of links to an associative array with target package names as keys + * + * @param Link[] $links + * @return array + */ + private function linksToMap($links) + { + $targets = array_map(function ($link) { + /** @var Link $link */ + return $link->getTarget(); + }, $links); + return array_combine($targets, $links); + } +} diff --git a/src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php b/src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php new file mode 100644 index 0000000..195ebec --- /dev/null +++ b/src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php @@ -0,0 +1,700 @@ +io = $io; + $this->composer = $composer; + $this->override = $input->getOption(RootUpdateCommand::OVERRIDE_OPT); + $this->interactive = $input->getOption(RootUpdateCommand::INTERACTIVE_OPT); + $this->fromRoot = static::formatRequirements($input->getOption(RootUpdateCommand::FROM_PRODUCT_OPT)); + $this->noDev = $input->getOption('no-dev'); + $this->noAutoloader = $input->getOption('no-autoloader'); + $this->dryRun = $input->getOption('dry-run'); + $this->ignorePlatformReqs = $input->getOption('ignore-platform-reqs'); + $this->jsonChanges = []; + $this->targetLabel = null; + $this->targetProduct = null; + $this->targetConstraint = null; + $this->strictConstraint = true; + } + + /** + * Look ahead to the target Magento version and execute any changes to the root composer.json file in-memory + * + * @return boolean Returns true if updates were necessary and prepared successfully + */ + public function runUpdate() + { + $composer = $this->composer; + $io = $this->io; + + $composerPath = $composer->getConfig()->getConfigSource()->getName(); + $locker = null; + $fromRoot = $this->fromRoot; + if (empty($fromRoot)) { + if (preg_match('/\/var\/composer\.json$/', $composerPath)) { + $parentDir = preg_replace('/\/var\/composer\.json$/', '', $composerPath); + if (file_exists("$parentDir/composer.json") && file_exists("$parentDir/composer.lock")) { + $locker = new Locker( + $io, + new JsonFile("$parentDir/composer.lock"), + $composer->getRepositoryManager(), + $composer->getInstallationManager(), + file_get_contents("$parentDir/composer.json") + ); + } + } + $locker = $locker ?? $composer->getLocker(); + } + + if (!empty($fromRoot) || ($locker !== null && $locker->isLocked())) { + $installRoot = $composer->getPackage(); + $targetRoot = null; + $targetConstraint = null; + $requiresPlugin = false; + foreach ($installRoot->getRequires() as $link) { + $packageInfo = static::getMagentoPackageInfo($link->getTarget()); + if ($packageInfo !== null) { + $targetConstraint = $link->getPrettyConstraint() ?? + $link->getConstraint()->getPrettyString() ?? + $link->getConstraint()->__toString(); + $edition = $packageInfo['edition']; + $this->targetProduct = "magento/product-$edition-edition"; + $this->targetConstraint = $targetConstraint; + $targetRoot = $this->fetchRoot( + $edition, + $targetConstraint, + $composer, + true + ); + } + $requiresPlugin = $requiresPlugin || ($link->getTarget() == RootUpdatePlugin::PACKAGE_NAME); + } + if (!$requiresPlugin) { + // If the plugin requirement has been removed but we're still trying to run (code still existing in the + // vendor directory), return without executing. + return false; + } + + if ($targetRoot == null || $targetRoot == false) { + throw new \RuntimeException('Magento root updates cannot run without a valid target package'); + } + + $targetVersion = $targetRoot->getVersion(); + $prettyTargetVersion = $targetRoot->getPrettyVersion() ?? $targetVersion; + $targetEd = static::getMagentoPackageInfo($targetRoot->getName())['edition']; + + $baseEd = null; + $baseVersion = null; + $prettyBaseVersion = null; + if (empty($fromRoot)) { + $lockPackages = $locker->getLockedRepository()->getPackages(); + foreach ($lockPackages as $lockedPackage) { + $packageInfo = static::getMagentoPackageInfo($lockedPackage->getName()); + if ($packageInfo !== null && $packageInfo['type'] == 'product') { + $baseEd = $packageInfo['edition']; + $baseVersion = $lockedPackage->getVersion(); + $prettyBaseVersion = $lockedPackage->getPrettyVersion() ?? $baseVersion; + + // Both editions exist for enterprise, so stop at enterprise to not overwrite with community + if ($baseEd == 'enterprise') { + break; + } + } + } + } else { + $baseEd = $fromRoot['edition']; + $baseVersion = $fromRoot['version']; + $prettyBaseVersion = $fromRoot['version']; + } + + $baseRoot = null; + if ($baseEd != null && $baseVersion != null) { + $baseRoot = $this->fetchRoot( + $baseEd, + $prettyBaseVersion, + $composer + ); + } + + if ($baseRoot == null || $baseRoot == false) { + if ($baseEd == null || $baseVersion == null) { + $io->writeError( + 'No Magento product package was found in the current installation.' + ); + } else { + $io->writeError( + 'The Magento project package corresponding to the currently installed ' . + "\"magento/product-$baseEd-edition: $prettyBaseVersion\" package is unavailable." + ); + } + + $overrideRoot = $this->override; + if (!$overrideRoot) { + $question = 'Would you like to update the root composer.json file anyway? This will ' . + 'override changes you may have made to the default installation if the same values ' . + "are different in magento/project-$targetEd-edition $prettyTargetVersion"; + $overrideRoot = $this->getConfirmation($question); + } + if ($overrideRoot) { + $baseRoot = $installRoot; + } else { + $io->writeError('Skipping Magento composer.json update.'); + return false; + } + } elseif ($baseEd === $targetEd && $baseVersion === $targetVersion) { + $io->writeError( + 'The Magento product requirement matched the current installation; no root updates are required', + true, + IOInterface::VERBOSE + ); + return false; + } + + $baseEd = static::getMagentoPackageInfo($baseRoot->getName())['edition']; + $this->targetLabel = 'Magento ' . ucfirst($targetEd) . " Edition $prettyTargetVersion"; + $baseLabel = 'Magento ' . ucfirst($baseEd) . " Edition $prettyBaseVersion"; + + $io->writeError( + "Base Magento project package version: magento/project-$baseEd-edition $prettyBaseVersion", + true, + IOInterface::DEBUG + ); + + $this->updateComposer($baseLabel, $baseRoot, $targetRoot, $installRoot); + + if (!$this->dryRun) { + // Add composer.json write code at the head of the list of post command script hooks so + // the file is accurate for any other hooks that may exist in the installation that use it + $eventDispatcher = $composer->getEventDispatcher(); + $eventDispatcher->addListener( + ScriptEvents::POST_UPDATE_CMD, + [$this, 'writeUpdatedRoot'], + PHP_INT_MAX + ); + } + + if ($this->jsonChanges !== []) { + return true; + } + } + + return false; + } + + /** + * Update the composer object for each relevant section and track json changes + * + * @param string $baseLabel + * @param PackageInterface $baseRoot + * @param PackageInterface $targetRoot + * @param PackageInterface $installRoot + * @return void + */ + protected function updateComposer($baseLabel, $baseRoot, $targetRoot, $installRoot) + { + $composer = $this->composer; + + $resolver = new ConflictResolver( + $this->io, + $this->interactive, + $this->override, + $this->targetLabel, + $baseLabel + ); + + $changedRoot = $composer->getPackage(); + $resolver->resolveLinkSection( + 'require', + $baseRoot->getRequires(), + $targetRoot->getRequires(), + $installRoot->getRequires(), + [$changedRoot, 'setRequires'] + ); + + if (!$this->noDev) { + $resolver->resolveLinkSection( + 'require-dev', + $baseRoot->getDevRequires(), + $targetRoot->getDevRequires(), + $installRoot->getDevRequires(), + [$changedRoot, 'setDevRequires'] + ); + } + + if (!$this->noAutoloader) { + $resolver->resolveArraySection( + 'autoload', + $baseRoot->getAutoload(), + $targetRoot->getAutoload(), + $installRoot->getAutoload(), + [$changedRoot, 'setAutoload'] + ); + + if (!$this->noDev) { + $resolver->resolveArraySection( + 'autoload-dev', + $baseRoot->getDevAutoload(), + $targetRoot->getDevAutoload(), + $installRoot->getDevAutoload(), + [$changedRoot, 'setDevAutoload'] + ); + } + } + + $resolver->resolveLinkSection( + 'conflict', + $baseRoot->getConflicts(), + $targetRoot->getConflicts(), + $installRoot->getConflicts(), + [$changedRoot, 'setConflicts'] + ); + + $resolver->resolveArraySection( + 'extra', + $baseRoot->getExtra(), + $targetRoot->getExtra(), + $installRoot->getExtra(), + [$changedRoot, 'setExtra'] + ); + + $resolver->resolveLinkSection( + 'provides', + $baseRoot->getProvides(), + $targetRoot->getProvides(), + $installRoot->getProvides(), + [$changedRoot, 'setProvides'] + ); + + $resolver->resolveLinkSection( + 'replaces', + $baseRoot->getReplaces(), + $targetRoot->getReplaces(), + $installRoot->getReplaces(), + [$changedRoot, 'setReplaces'] + ); + + $resolver->resolveArraySection( + 'suggests', + $baseRoot->getSuggests(), + $targetRoot->getSuggests(), + $installRoot->getSuggests(), + [$changedRoot, 'setSuggests'] + ); + + $composer->setPackage($changedRoot); + $this->composer = $composer; + + if ($resolver->getJsonChanges() !== []) { + $this->jsonChanges = $resolver->getJsonChanges(); + } + } + + /** + * If interactive, ask the given question and return the result, otherwise return the default + * + * @param string $question + * @param bool $default + * @return bool + */ + protected function getConfirmation($question, $default = false) + { + $result = $default; + if ($this->interactive) { + if (!$this->io->isInteractive()) { + throw new \InvalidArgumentException( + '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' + ); + } + $opts = $default ? 'Y,n' : 'y,N'; + $result = $this->io->askConfirmation("$question [$opts]? ", $default); + } + return $result; + } + + /** + * Write the changed composer.json file + * + * Called as a script event on non-dry runs after a successful update before other post-update-cmd scripts + * + * @return void + * @throws FilesystemException if the composer.json read or write failed + */ + public function writeUpdatedRoot() + { + if ($this->jsonChanges === []) { + return; + } + $filePath = $this->composer->getConfig()->getConfigSource()->getName(); + $io = $this->io; + $json = json_decode(file_get_contents($filePath), true); + if ($json === null) { + throw new FilesystemException('Failed to read ' . $filePath); + } + + foreach ($this->jsonChanges as $section => $newContents) { + if ($newContents === null || $newContents === []) { + if (key_exists($section, $json)) { + unset($json[$section]); + } + } else { + $json[$section] = $newContents; + } + } + + $this->verboseLog('Writing changes to the root composer.json...'); + + $retVal = file_put_contents( + $filePath, + json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) + ); + + if ($retVal === false) { + throw new FilesystemException('Failed to write updated Magento root values to ' . $filePath); + } + $io->writeError('' . $filePath . ' has been updated'); + } + + /** + * Label and log the given message if output is set to verbose + * + * @param string $message + * @return void + */ + protected function verboseLog($message) + { + $label = $this->targetLabel; + $this->io->writeError(" [$label] $message", true, IOInterface::VERBOSE); + } + + /** + * Helper function to extract the edition and package type if it is a Magento package name + * + * @param string $packageName + * @return array|null + */ + public static function getMagentoPackageInfo($packageName) + { + $regex = '/^magento\/(?product|project)-(?community|enterprise)-edition$/'; + if (preg_match($regex, $packageName, $matches)) { + return $matches; + } else { + return null; + } + } + + /** + * Retrieve the Magento root package for an edition and version constraint from the composer file's repositories + * + * @param string $edition + * @param string $constraint + * @param Composer $composer + * @param boolean $isTarget + * @return \Composer\Package\PackageInterface|bool Best root package candidate or false if no valid packages found + */ + protected function fetchRoot($edition, $constraint, $composer, $isTarget = false) + { + $rootName = strtolower("magento/project-$edition-edition"); + $phpVersion = null; + $prettyPhpVersion = null; + $versionParser = new VersionParser(); + $parsedConstraint = $versionParser->parseConstraints($constraint); + + $minimumStability = $composer->getPackage()->getMinimumStability() ?? 'stable'; + $stabilityFlags = $this->extractStabilityFlags($rootName, $constraint, $minimumStability); + $stability = key_exists($rootName, $stabilityFlags) + ? array_search($stabilityFlags[$rootName], BasePackage::$stabilities) + : $minimumStability; + $this->io->writeError( + "Minimum stability for \"$rootName: $constraint\": $stability", + true, + IOInterface::DEBUG + ); + $pool = new Pool( + $stability, + $stabilityFlags, + [$rootName => $parsedConstraint] + ); + $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); + $pool->addRepository($repos); + + if ($isTarget) { + if (strpbrk($parsedConstraint->__toString(), '[]|<>!') !== false) { + $this->strictConstraint = false; + $this->io->writeError( + "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . + 'the Magento root updater might not accurately determine the version to use according to other ' . + 'requirements in this installation. It is recommended to use an exact version number.' + ); + } + if (!$this->ignorePlatformReqs) { + $platformOverrides = $composer->getConfig()->get('platform') ?: []; + $platform = new PlatformRepository([], $platformOverrides); + $phpPackage = $platform->findPackage('php', '*'); + if ($phpPackage != null) { + $phpVersion = $phpPackage->getVersion(); + $prettyPhpVersion = $phpPackage->getPrettyVersion(); + } + } + } + + $versionSelector = new VersionSelector($pool); + $result = $versionSelector->findBestCandidate($rootName, $constraint, $phpVersion); + + if ($result == false) { + $err = "Could not find a Magento project package matching \"magento/product-$edition-edition $constraint\""; + if ($phpVersion) { + $err = "$err for PHP version $prettyPhpVersion"; + } + $this->io->writeError("$err", true, IOInterface::QUIET); + } + + return $result; + } + + /** + * Helper method to construct stability flags needed to fetch new root packages + * + * @see RootPackageLoader::extractStabilityFlags() + * + * @param string $reqName + * @param string $reqVersion + * @param string $minimumStability + * @return array + */ + protected function extractStabilityFlags($reqName, $reqVersion, $minimumStability) + { + $stabilityFlags = []; + $stabilityMap = BasePackage::$stabilities; + $minimumStability = $stabilityMap[$minimumStability]; + $constraints = []; + + // extract all sub-constraints in case it is an OR/AND multi-constraint + $orSplit = preg_split('{\s*\|\|?\s*}', trim($reqVersion)); + foreach ($orSplit as $orConstraint) { + $andSplit = preg_split('{(?< ,]) *(? $stability) { + continue; + } + $stabilityFlags[$reqName] = $stability; + $match = true; + } + } + + if (!$match) { + foreach ($constraints as $constraint) { + // infer flags for requirements that have an explicit -dev or -beta version specified but only + // for those that are more unstable than the minimumStability or existing flags + $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $constraint); + if (preg_match('{^[^,\s@]+$}', $reqVersion) + && 'stable' !== ($stabilityName = VersionParser::parseStability($reqVersion))) { + $stability = $stabilityMap[$stabilityName]; + if ((isset($stabilityFlags[$reqName]) && $stabilityFlags[$reqName] > $stability) + || ($minimumStability > $stability)) { + continue; + } + $stabilityFlags[$reqName] = $stability; + } + } + } + + return $stabilityFlags; + } + + /** + * Parse inputs to the FROM_PRODUCT_OPT option + * + * @param string[] $requirements + * @return array[] + */ + protected static function formatRequirements($requirements) + { + if (empty($requirements)) { + return null; + } + $parser = new VersionParser(); + $requirements = $parser->parseNameVersionPairs($requirements); + $opt = '--' . RootUpdateCommand::FROM_PRODUCT_OPT; + if (count($requirements) !== 1) { + throw new InvalidOptionException("'$opt' accepts only one package requirement"); + } elseif (count($requirements[0]) !== 2) { + throw new InvalidOptionException("'$opt' requires both a package and version"); + } + $req = $requirements[0]; + $name = $req['name']; + $packageInfo = static::getMagentoPackageInfo($name); + if ($packageInfo == null || $packageInfo['type'] !== 'product') { + throw new InvalidOptionException("'$opt' accepts only Magento product packages; \"$name\" given"); + } + + return ['edition' => $packageInfo['edition'], 'version' => $req['version']]; + } + + /** + * Get the Composer object + * + * @return Composer + */ + public function getComposer() + { + return $this->composer; + } + + /** + * Was a strict constraint used for the target product requirement + * + * @return bool + */ + public function isStrictConstraint() + { + return $this->strictConstraint; + } + + /** + * Get the constraint used for the target product requirement + * + * @return string + */ + public function getTargetConstraint() + { + return $this->targetConstraint; + } + + /** + * Get the package name for the target product requirement + * + * @return string + */ + public function getTargetProduct() + { + return $this->targetProduct; + } + + /** + * Get the json array representation of the root composer updates + * + * @return array + */ + public function getJsonChanges() + { + return $this->jsonChanges; + } +} diff --git a/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php b/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php index c7054ad..42ba72d 100644 --- a/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php +++ b/src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php @@ -6,21 +6,12 @@ namespace Magento\Composer\Plugin\RootUpdate; -use Composer\Composer; use Composer\Command\UpdateCommand; -use Composer\DependencyResolver\Pool; use Composer\Downloader\FilesystemException; use Composer\IO\IOInterface; -use Composer\Package\BasePackage; -use Composer\Package\Link; -use Composer\Package\Loader\RootPackageLoader; -use Composer\Package\Version\VersionParser; -use Composer\Package\Version\VersionSelector; -use Composer\Repository\CompositeRepository; -use Composer\Repository\PlatformRepository; -use Composer\Script\ScriptEvents; use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -37,83 +28,7 @@ class RootUpdateCommand extends UpdateCommand const OVERRIDE_OPT = 'use-magento-values'; const INTERACTIVE_OPT = 'interactive-magento-conflicts'; const ROOT_ONLY_OPT = 'magento-root-only'; - - /** - * Types of action to take on individual values when a delta is found; returned by findResolution() - */ - const ADD_VAL = 'add_value'; - const REMOVE_VAL = 'remove_value'; - const CHANGE_VAL = 'change_value'; - - /** - * @var string $filePath Path to the relevant composer.json file - */ - private $filePath; - - /** - * @var bool $interactiveInput Is the current terminal interactive - */ - private $interactiveInput; - - /** - * @var bool $override Has OVERRIDE_OPT been passed to the command - */ - private $override; - - /** - * @var bool $interactive Has INTERACTIVE_OPT been passed to the command - */ - private $interactive; - - /** - * @var string $targetLabel Pretty label for the target Magento edition version - */ - private $targetLabel; - - /** - * @var string $baseLabel Pretty label for the current installation's Magento edition version - */ - private $baseLabel; - - /** - * @var array $jsonChanges Json-writable sections of composer.json that have been updated - */ - private $jsonChanges; - - /** - * @var boolean $fuzzyConstraint - */ - private $fuzzyConstraint; - - /** - * @var string $targetProduct - */ - private $targetProduct; - - /** - * @var string $targetConstraint - */ - private $targetConstraint; - - /** - * RootUpdateCommand constructor - * @return void - */ - public function __construct() - { - parent::__construct(); - - $this->filePath = null; - $this->interactiveInput = false; - $this->override = false; - $this->interactive = false; - $this->baseLabel = null; - $this->targetLabel = null; - $this->jsonChanges = []; - $this->fuzzyConstraint = false; - $this->targetProduct = null; - $this->targetConstraint = null; - } + const FROM_PRODUCT_OPT = 'original-magento-product'; /** * Call the parent setApplication method but also change the command's name to update @@ -163,10 +78,17 @@ public function configure() null, 'Update the root composer.json file with Magento changes without running the rest of the update process.' ); + $this->addOption( + static::FROM_PRODUCT_OPT, + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Update the current root composer.json file with changes needed from a previously-installed product ' . + 'version, e.g. magento/product-community-edition=2.2.0' + ); $mageHelp = ' Magento Root Updates: - With magento/composer-root-update-plugin installed, update will also check for and + With ' . RootUpdatePlugin::PACKAGE_NAME . ' installed, update will also check for and execute any changes to the root composer.json file that exist between the Magento project package corresponding to the currently-installed version and the project for the target Magento product version if the package requirement has changed. @@ -195,878 +117,54 @@ public function configure() */ public function execute(InputInterface $input, OutputInterface $output) { - $this->interactiveInput = $input->isInteractive(); if ($input->getOption('dry-run')) { $output->setVerbosity(max(OutputInterface::VERBOSITY_VERBOSE, $output->getVerbosity())); $input->setOption('verbose', true); } $composer = $this->getComposer(); - - $this->filePath = $composer->getConfig()->getConfigSource()->getName(); + $io = $this->getIO(); $updatePrepared = false; + $updater = new MagentoRootUpdater($io, $composer, $input); try { - $updatePrepared = $this->magentoUpdate($input, $composer); + // Move the native UpdateCommand's deprecation message before the added Magento functionality + if ($input->getOption('dev')) { + $io->writeError('' . + 'You are using the deprecated option "dev". Dev packages are installed by default now.' . + ''); + $input->setOption('dev', false); + }; + + if (!$input->getOption('no-custom-installers') && !$input->getOption(static::SKIP_OPT)) { + // --no-custom-installers has been replaced with --no-plugins and should skip this functionality + $updatePrepared = $updater->runUpdate(); + if ($updatePrepared) { + $this->setComposer($updater->getComposer()); + } + } } catch (\Exception $e) { - $this->getIO()->writeError('Magento root update operation failed', true, IOInterface::QUIET); - $this->getIO()->writeError($e->getMessage()); + $io->writeError('Magento root update operation failed', true, IOInterface::QUIET); + $io->writeError($e->getMessage()); } $errorCode = null; if (!$input->getOption(static::ROOT_ONLY_OPT)) { $errorCode = parent::execute($input, $output); - if ($errorCode && $this->fuzzyConstraint) { - $this->getIO()->writeError( + if ($errorCode && !$updater->isStrictConstraint()) { + $io->writeError( 'Recommended: Use a specific Magento version constraint instead of "' . - $this->targetProduct . ': ' . $this->targetConstraint . '"', + $updater->getTargetProduct() . ': ' . $updater->getTargetConstraint() . '"', true, IOInterface::QUIET ); } } elseif (!$input->getOption('dry-run') && $updatePrepared) { // If running a full update, writeUpdatedRoot() is called as a post-update-cmd event - $this->writeUpdatedRoot(); + $updater->writeUpdatedRoot(); } return $errorCode; } - - /** - * Look ahead to the target Magento version and execute any changes to the root composer.json file in-memory - * - * @param InputInterface $input - * @param Composer $composer - * @return boolean Returns true if updates were successfully prepared, false if no updates were necessary - */ - public function magentoUpdate($input, $composer) - { - if ($input->getOption('no-custom-installers')) { - // --no-custom-installers has been replaced with --no-plugins, which would have skipped this functionality - return false; - } - - $io = $this->getIO(); - // Move the native UpdateCommand's deprecation message before the added Magento functionality - if ($input->getOption('dev')) { - $io->writeError('' . - 'You are using the deprecated option "dev". Dev packages are installed by default now.' . - ''); - $input->setOption('dev', false); - }; - - $locker = $composer->getLocker(); - $skipped = $input->getOption(static::SKIP_OPT); - $this->override = $input->getOption(static::OVERRIDE_OPT); - $this->interactive = $input->getOption(static::INTERACTIVE_OPT); - - if ($locker->isLocked() && !$skipped) { - $installRoot = $composer->getPackage(); - $targetRoot = null; - $targetConstraint = null; - foreach ($installRoot->getRequires() as $link) { - $packageInfo = static::getMagentoPackageInfo($link->getTarget()); - if ($packageInfo !== null) { - $targetConstraint = $link->getPrettyConstraint() ?? - $link->getConstraint()->getPrettyString() ?? - $link->getConstraint()->__toString(); - $edition = $packageInfo['edition']; - $this->targetProduct = "magento/product-$edition-edition"; - $this->targetConstraint = $targetConstraint; - $targetRoot = $this->fetchRoot( - $edition, - $targetConstraint, - $composer, - $input, - true - ); - break; - } - } - if ($targetRoot == null || $targetRoot == false) { - throw new \RuntimeException('Magento root updates cannot run without a valid target package'); - } - - $targetVersion = $targetRoot->getVersion(); - $prettyTargetVersion = $targetRoot->getPrettyVersion() ?? $targetVersion; - $targetEd = static::getMagentoPackageInfo($targetRoot->getName())['edition']; - - $baseEd = null; - $baseVersion = null; - $prettyBaseVersion = null; - $lockPackages = $locker->getLockedRepository()->getPackages(); - foreach ($lockPackages as $lockedPackage) { - $packageInfo = static::getMagentoPackageInfo($lockedPackage->getName()); - if ($packageInfo !== null && $packageInfo['type'] == 'product') { - $baseEd = $packageInfo['edition']; - $baseVersion = $lockedPackage->getVersion(); - $prettyBaseVersion = $lockedPackage->getPrettyVersion() ?? $baseVersion; - - // Both editions exist for enterprise, so stop at enterprise to not overwrite with community - if ($baseEd == 'enterprise') { - break; - } - } - } - $baseRoot = null; - if ($baseEd != null && $baseVersion != null) { - $baseRoot = $this->fetchRoot( - $baseEd, - $prettyBaseVersion, - $composer, - $input - ); - } - - if ($baseRoot == null || $baseRoot == false) { - if ($baseEd == null || $baseVersion == null) { - $io->writeError( - 'No Magento product package was found in the current installation.' - ); - } else { - $io->writeError( - 'The Magento project package corresponding to the currently installed ' . - "\"magento/product-$baseEd-edition: $prettyBaseVersion\" package is unavailable." - ); - } - - $overrideRoot = $this->override; - if (!$overrideRoot) { - $question = 'Would you like to update the root composer.json file anyway? This will ' . - 'override changes you may have made to the default installation if the same values ' . - "are different in magento/project-$targetEd-edition $prettyTargetVersion"; - $overrideRoot = $this->getConfirmation($question); - } - if ($overrideRoot) { - $baseRoot = $installRoot; - } else { - $io->writeError('Skipping Magento composer.json update.'); - return false; - } - } elseif ($baseEd === $targetEd && $baseVersion === $targetVersion) { - $this->getIO()->writeError( - 'The Magento product requirement matched the current installation; no root updates are required', - true, - IOInterface::VERBOSE - ); - return false; - } - - $baseEd = static::getMagentoPackageInfo($baseRoot->getName())['edition']; - $this->targetLabel = 'Magento ' . ucfirst($targetEd) . " Edition $prettyTargetVersion"; - $this->baseLabel = 'Magento ' . ucfirst($baseEd) . " Edition $prettyBaseVersion"; - - $io->writeError( - "Base Magento project package version: magento/project-$baseEd-edition $prettyBaseVersion", - true, - IOInterface::DEBUG - ); - - $changedRoot = $composer->getPackage(); - $this->resolveLinkSection( - 'require', - $baseRoot->getRequires(), - $targetRoot->getRequires(), - $installRoot->getRequires(), - [$changedRoot, 'setRequires'] - ); - - if (!$input->getOption('no-dev')) { - $this->resolveLinkSection( - 'require-dev', - $baseRoot->getDevRequires(), - $targetRoot->getDevRequires(), - $installRoot->getDevRequires(), - [$changedRoot, 'setDevRequires'] - ); - } - - if (!$input->getOption('no-autoloader')) { - $this->resolveArraySection( - 'autoload', - $baseRoot->getAutoload(), - $targetRoot->getAutoload(), - $installRoot->getAutoload(), - [$changedRoot, 'setAutoload'] - ); - - if (!$input->getOption('no-dev')) { - $this->resolveArraySection( - 'autoload-dev', - $baseRoot->getDevAutoload(), - $targetRoot->getDevAutoload(), - $installRoot->getDevAutoload(), - [$changedRoot, 'setDevAutoload'] - ); - } - } - - $this->resolveLinkSection( - 'conflict', - $baseRoot->getConflicts(), - $targetRoot->getConflicts(), - $installRoot->getConflicts(), - [$changedRoot, 'setConflicts'] - ); - - $this->resolveArraySection( - 'extra', - $baseRoot->getExtra(), - $targetRoot->getExtra(), - $installRoot->getExtra(), - [$changedRoot, 'setExtra'] - ); - - $this->resolveLinkSection( - 'provides', - $baseRoot->getProvides(), - $targetRoot->getProvides(), - $installRoot->getProvides(), - [$changedRoot, 'setProvides'] - ); - - $this->resolveLinkSection( - 'replaces', - $baseRoot->getReplaces(), - $targetRoot->getReplaces(), - $installRoot->getReplaces(), - [$changedRoot, 'setReplaces'] - ); - - $this->resolveArraySection( - 'suggests', - $baseRoot->getSuggests(), - $targetRoot->getSuggests(), - $installRoot->getSuggests(), - [$changedRoot, 'setSuggests'] - ); - - $composer->setPackage($changedRoot); - $this->setComposer($composer); - - if (!$input->getOption('dry-run')) { - // Add composer.json write code at the head of the list of post command script hooks so - // the file is accurate for any other hooks that may exist in the installation that use it - $eventDispatcher = $composer->getEventDispatcher(); - $eventDispatcher->addListener( - ScriptEvents::POST_UPDATE_CMD, - [$this, 'writeUpdatedRoot'], - PHP_INT_MAX - ); - } - - if ($this->jsonChanges !== []) { - return true; - } - } - - return false; - } - - /** - * Find value deltas from original->target version and resolve any conflicts with overlapping user changes - * - * @param string $field - * @param array|mixed|null $baseVal - * @param array|mixed|null $targetVal - * @param array|mixed|null $installVal - * @param string|null $prettyBase - * @param string|null $prettyTarget - * @param string|null $prettyInstall - * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the existing composer.json file, null for no change - */ - public function findResolution( - $field, - $baseVal, - $targetVal, - $installVal, - $prettyBase = null, - $prettyTarget = null, - $prettyInstall = null - ) { - $io = $this->getIO(); - if ($prettyBase === null) { - $prettyBase = json_encode($baseVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyBase = trim($prettyBase, "'\""); - } - if ($prettyTarget === null) { - $prettyTarget = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyTarget = trim($prettyTarget, "'\""); - } - if ($prettyInstall === null) { - $prettyInstall = json_encode($installVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyInstall = trim($prettyInstall, "'\""); - } - - $targetLabel = $this->targetLabel; - $baseLabel = $this->baseLabel; - - $action = null; - $conflictDesc = null; - - if ($baseVal == $targetVal || $installVal == $targetVal) { - $action = null; - } elseif ($baseVal === null) { - if ($installVal === null) { - $action = static::ADD_VAL; - } else { - $action = static::CHANGE_VAL; - $conflictDesc = "add $field=$prettyTarget but it is instead $prettyInstall"; - } - } elseif ($targetVal === null) { - $action = static::REMOVE_VAL; - if ($installVal !== $baseVal) { - $conflictDesc = "remove the $field=$prettyBase entry in $baseLabel but it is instead $prettyInstall"; - } - } else { - $action = static::CHANGE_VAL; - if ($installVal !== $baseVal) { - $conflictDesc = "update $field to $prettyTarget from $prettyBase in $baseLabel"; - if ($installVal === null) { - $action = static::ADD_VAL; - $conflictDesc = "$conflictDesc but the field has been removed"; - } else { - $conflictDesc = "$conflictDesc but it is instead $prettyInstall"; - } - } - } - - if ($conflictDesc !== null) { - $conflictDesc = "$targetLabel is trying to $conflictDesc in this installation"; - - $shouldOverride = $this->override; - if ($this->override) { - $overrideMessage = "$conflictDesc.\n Overriding local changes due to --" . static::OVERRIDE_OPT . '.'; - $io->writeError($overrideMessage); - } else { - $shouldOverride = $this->getConfirmation( - "$conflictDesc.\nWould you like to override the local changes?" - ); - } - - if (!$shouldOverride) { - $io->writeError("$conflictDesc and will not be changed. Re-run using " . - '--' . static::OVERRIDE_OPT . ' or --' . static::INTERACTIVE_OPT . ' to override with Magento ' . - 'values.'); - $action = null; - } - } - - return $action; - } - - /** - * Process changes to corresponding sets of package version links - * - * @param string $section - * @param Link[] $baseLinks - * @param Link[] $targetLinks - * @param Link[] $installLinks - * @param callable $setterCallback - * @return void - */ - public function resolveLinkSection($section, $baseLinks, $targetLinks, $installLinks, $setterCallback) - { - /** @var Link[] $baseMap */ - $baseMap = static::linksToMap($baseLinks); - - /** @var Link[] $targetMap */ - $targetMap = static::linksToMap($targetLinks); - - /** @var Link[] $installMap */ - $installMap = static::linksToMap($installLinks); - - $adds = []; - $removes = []; - $changes = []; - $magePackages = array_unique(array_merge(array_keys($baseMap), array_keys($targetMap))); - foreach ($magePackages as $package) { - if ($section === 'require' && static::getMagentoPackageInfo($package)) { - continue; - } - $field = "$section:$package"; - $baseConstraint = key_exists($package, $baseMap) ? $baseMap[$package]->getConstraint() : null; - $baseVal = ($baseConstraint === null) ? null : $baseConstraint->__toString(); - $prettyBaseVal = ($baseConstraint === null) ? null : $baseConstraint->getPrettyString(); - $targetConstraint = key_exists($package, $targetMap) ? $targetMap[$package]->getConstraint() : null; - $targetVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); - $prettyTargetVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); - $installConstraint = key_exists($package, $installMap) ? $installMap[$package]->getConstraint() : null; - $installVal = ($installConstraint === null) ? null : $installConstraint->__toString(); - $prettyInstallVal = ($installConstraint === null) ? null : $installConstraint->getPrettyString(); - - $action = $this->findResolution( - $field, - $baseVal, - $targetVal, - $installVal, - $prettyBaseVal, - $prettyTargetVal, - $prettyInstallVal - ); - if ($action == static::ADD_VAL) { - $adds[$package] = $targetMap[$package]; - } elseif ($action == static::REMOVE_VAL) { - $removes[] = $package; - } elseif ($action == static::CHANGE_VAL) { - $changes[$package] = $targetMap[$package]; - } - } - - $changed = false; - if ($adds !== []) { - $changed = true; - $prettyAdds = array_map(function ($package) use ($adds) { - $newVal = $adds[$package]->getConstraint()->getPrettyString(); - return "$package=$newVal"; - }, array_keys($adds)); - $this->verboseLog("Adding $section constraints: " . implode(', ', $prettyAdds)); - } - if ($removes !== []) { - $changed = true; - $this->verboseLog("Removing $section entries: " . implode(', ', $removes)); - } - if ($changes !== []) { - $changed = true; - $prettyChanges = array_map(function ($package) use ($changes) { - $newVal = $changes[$package]->getConstraint()->getPrettyString(); - return "$package=$newVal"; - }, array_keys($changes)); - $this->verboseLog("Updating $section constraints: " . implode(', ', $prettyChanges)); - } - - if ($changed) { - $replacements = array_values($adds); - - /** @var Link $installLink */ - foreach ($installMap as $package => $installLink) { - if (in_array($package, $removes)) { - continue; - } elseif (key_exists($package, $changes)) { - $replacements[] = $changes[$package]; - } else { - $replacements[] = $installLink; - } - } - - $newJson = []; - /** @var Link $link */ - foreach ($replacements as $link) { - $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); - } - - call_user_func($setterCallback, $replacements); - $this->jsonChanges[$section] = $newJson; - } - } - - /** - * Process changes to an array (non-package link) section - * - * @param string $section - * @param array|mixed|null $baseVal - * @param array|mixed|null $targetVal - * @param array|mixed|null $installVal - * @param callable $setterCallback - * @return void - */ - public function resolveArraySection($section, $baseVal, $targetVal, $installVal, $setterCallback) - { - $resolution = $this->resolveNestedArray($section, $baseVal, $targetVal, $installVal); - if ($resolution['changed']) { - call_user_func($setterCallback, $resolution['value']); - $this->jsonChanges[$section] = $resolution['value']; - } - } - - /** - * Process changes to arrays that could be nested - * - * Associative arrays are resolved recursively and non-associative arrays are treated as unordered sets - * - * @param string $field - * @param array|mixed|null $baseVal - * @param array|mixed|null $targetVal - * @param array|mixed|null $installVal - * @return array Two-element array: ['changed' => boolean, 'value' => updated value], null and empty array values - * indicate the entry should be removed from the parent - */ - public function resolveNestedArray($field, $baseVal, $targetVal, $installVal) - { - $valChanged = false; - $result = $installVal ?? []; - - if (is_array($baseVal) && is_array($targetVal) && is_array($installVal)) { - $baseAssociative = []; - $baseFlat = []; - foreach ($baseVal as $key => $value) { - if (is_string($key)) { - $baseAssociative[$key] = $value; - } else { - $baseFlat[] = $value; - } - } - - $targetAssociative = []; - $targetFlat = []; - foreach ($targetVal as $key => $value) { - if (is_string($key)) { - $targetAssociative[$key] = $value; - } else { - $targetFlat[] = $value; - } - } - - $installAssociative = []; - $installFlat = []; - foreach ($installVal as $key => $value) { - if (is_string($key)) { - $installAssociative[$key] = $value; - } else { - $installFlat[] = $value; - } - } - - $associativeResult = array_filter($result, 'is_string', ARRAY_FILTER_USE_KEY); - $mageKeys = array_unique(array_merge(array_keys($baseAssociative), array_keys($targetAssociative))); - foreach ($mageKeys as $key) { - $baseNestedVal = $baseAssociative[$key] ?? []; - $targetNestedVal = $targetAssociative[$key] ?? []; - $installNestedVal = $installAssociative[$key] ?? []; - - $resolution = $this->resolveNestedArray( - "$field.$key", - $baseNestedVal, - $targetNestedVal, - $installNestedVal - ); - if ($resolution['value'] === null || $resolution['value'] === []) { - if (key_exists($key, $associativeResult)) { - $valChanged = true; - unset($associativeResult[$key]); - } - } else { - $valChanged = $valChanged || $resolution['changed']; - $associativeResult[$key] = $resolution['value']; - } - } - - $flatResult = array_filter($result, 'is_int', ARRAY_FILTER_USE_KEY); - $flatAdds = array_diff(array_diff($targetFlat, $baseFlat), $flatResult); - if ($flatAdds !== []) { - $valChanged = true; - $this->verboseLog("Adding $field entries: " . implode(', ', $flatAdds)); - $flatResult = array_unique(array_merge($flatResult, $flatAdds)); - } - - $flatRemoves = array_intersect(array_diff($baseFlat, $targetFlat), $flatResult); - if ($flatRemoves !== []) { - $valChanged = true; - $this->verboseLog("Removing $field entries: " . implode(', ', $flatRemoves)); - $flatResult = array_diff($flatResult, $flatRemoves); - } - - $result = array_merge($flatResult, $associativeResult); - } else { - // Some or all of the values aren't arrays so they should all be compared as non-array values - $action = $this->findResolution($field, $baseVal, $targetVal, $installVal); - $prettyTargetVal = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if ($action == static::ADD_VAL) { - $valChanged = true; - $this->verboseLog("Adding $field entry: $prettyTargetVal"); - $result = $targetVal; - } elseif ($action == static::CHANGE_VAL) { - $valChanged = true; - $this->verboseLog("Updating $field entry: $prettyTargetVal"); - $result = $targetVal; - } elseif ($action == static::REMOVE_VAL) { - $valChanged = true; - $this->verboseLog("Removing $field entry"); - $result = null; - } - } - - return ['changed' => $valChanged, 'value' => $result]; - } - - /** - * Write the changed composer.json file - * - * Called as a script event on non-dry runs after a successful update before other post-update-cmd scripts - * - * @return void - * @throws FilesystemException if the composer.json read or write failed - */ - public function writeUpdatedRoot() - { - if ($this->jsonChanges === []) { - return; - } - - $io = $this->getIO(); - $json = json_decode(file_get_contents($this->filePath), true); - if ($json === null) { - throw new FilesystemException('Failed to read ' . $this->filePath); - } - - foreach ($this->jsonChanges as $section => $newContents) { - if ($newContents === null || $newContents === []) { - if (key_exists($section, $json)) { - unset($json[$section]); - } - } else { - $json[$section] = $newContents; - } - } - - $this->verboseLog('Writing changes to the root composer.json...'); - - $retVal = file_put_contents( - $this->filePath, - json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) - ); - - if ($retVal === false) { - throw new FilesystemException('Failed to write updated Magento root values to ' . $this->filePath); - } - $io->writeError('' . $this->filePath . ' has been updated'); - } - - /** - * Label and log the given message if output is set to verbose - * - * @param string $message - * @return void - */ - private function verboseLog($message) - { - $this->getIO()->writeError($this->targetLabel . ": $message", true, IOInterface::VERBOSE); - } - - /** - * Helper function to convert a set of links to an associative array with target package names as keys - * - * @param Link[] $links - * @return array - */ - private function linksToMap($links) - { - $targets = array_map(function ($link) { - /** @var Link $link */ - return $link->getTarget(); - }, $links); - return array_combine($targets, $links); - } - - /** - * Helper function to extract the edition and package type if it is a Magento package name - * - * @param string $packageName - * @return array|null - */ - private static function getMagentoPackageInfo($packageName) - { - $regex = '/^magento\/(?product|project)-(?community|enterprise)-edition$/'; - if (preg_match($regex, $packageName, $matches)) { - return $matches; - } else { - return null; - } - } - - /** - * Retrieve the Magento root package for an edition and version constraint from the composer file's repositories - * - * @param string $edition - * @param string $constraint - * @param Composer $composer - * @param InputInterface $input - * @param boolean $isTarget - * @return \Composer\Package\PackageInterface|bool Best root package candidate or false if no valid packages found - */ - private function fetchRoot($edition, $constraint, $composer, $input, $isTarget = false) - { - $rootName = strtolower("magento/project-$edition-edition"); - $phpVersion = null; - $prettyPhpVersion = null; - $versionParser = new VersionParser(); - $parsedConstraint = $versionParser->parseConstraints($constraint); - - $minimumStability = $composer->getPackage()->getMinimumStability() ?? 'stable'; - $stabilityFlags = $this->extractStabilityFlags($rootName, $constraint, $minimumStability); - $stability = key_exists($rootName, $stabilityFlags) - ? array_search($stabilityFlags[$rootName], BasePackage::$stabilities) - : $minimumStability; - $this->getIO()->writeError( - "Minimum stability for \"$rootName: $constraint\": $stability", - true, - IOInterface::DEBUG - ); - $pool = new Pool( - $stability, - $stabilityFlags, - [$rootName => $parsedConstraint] - ); - $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); - $pool->addRepository($repos); - - if ($isTarget) { - if (strpbrk($parsedConstraint->__toString(), '[]|<>!') !== false) { - $this->fuzzyConstraint = true; - $this->getIO()->writeError( - "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . - 'the Magento root updater might not accurately determine the version to use according to other ' . - 'requirements in this installation. It is recommended to use an exact version number.' - ); - } - if (!$input->getOption('ignore-platform-reqs')) { - $platformOverrides = $composer->getConfig()->get('platform') ?: []; - $platform = new PlatformRepository([], $platformOverrides); - $phpPackage = $platform->findPackage('php', '*'); - if ($phpPackage != null) { - $phpVersion = $phpPackage->getVersion(); - $prettyPhpVersion = $phpPackage->getPrettyVersion(); - } - } - } - - $versionSelector = new VersionSelector($pool); - $result = $versionSelector->findBestCandidate($rootName, $constraint, $phpVersion); - - if ($result == false) { - $err = "Could not find a Magento project package matching \"magento/product-$edition-edition $constraint\""; - if ($phpVersion) { - $err = "$err for PHP version $prettyPhpVersion"; - } - $this->getIO()->writeError("$err", true, IOInterface::QUIET); - } - - return $result; - } - - /** - * Helper method to construct stability flags needed to fetch new root packages - * - * @see RootPackageLoader::extractStabilityFlags() - * - * @param string $reqName - * @param string $reqVersion - * @param string $minimumStability - * @return array - */ - private function extractStabilityFlags($reqName, $reqVersion, $minimumStability) - { - $stabilityFlags = []; - $stabilityMap = BasePackage::$stabilities; - $minimumStability = $stabilityMap[$minimumStability]; - $constraints = []; - - // extract all sub-constraints in case it is an OR/AND multi-constraint - $orSplit = preg_split('{\s*\|\|?\s*}', trim($reqVersion)); - foreach ($orSplit as $orConstraint) { - $andSplit = preg_split('{(?< ,]) *(? $stability) { - continue; - } - $stabilityFlags[$reqName] = $stability; - $match = true; - } - } - - if (!$match) { - foreach ($constraints as $constraint) { - // infer flags for requirements that have an explicit -dev or -beta version specified but only - // for those that are more unstable than the minimumStability or existing flags - $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $constraint); - if (preg_match('{^[^,\s@]+$}', $reqVersion) - && 'stable' !== ($stabilityName = VersionParser::parseStability($reqVersion))) { - $stability = $stabilityMap[$stabilityName]; - if ((isset($stabilityFlags[$reqName]) && $stabilityFlags[$reqName] > $stability) - || ($minimumStability > $stability)) { - continue; - } - $stabilityFlags[$reqName] = $stability; - } - } - } - - return $stabilityFlags; - } - - /** - * If interactive, ask the given question and return the result, otherwise return the default - * - * @param string $question - * @param bool $default - * @return bool - */ - private function getConfirmation($question, $default = false) - { - $result = $default; - if ($this->interactive) { - if (!$this->interactiveInput) { - throw new \InvalidArgumentException( - '--' . static::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' - ); - } - $opts = $default ? 'Y,n' : 'y,N'; - $result = $this->getIO()->askConfirmation("$question [$opts]? ", $default); - } - return $result; - } - - /** - * Set the flag for the interactivity of the current environment (used for testing) - * - * @param bool $interactiveInput - * @return void - */ - public function setInteractiveInput($interactiveInput) - { - $this->interactiveInput = $interactiveInput; - } - - /** - * Set the flag for whether or not the plugin should override user changes with Magento values - * - * @param bool $override - * @return void - */ - public function setOverride($override) - { - $this->override = $override; - } - - /** - * Set the flag to interactively prompt for conflict resolution between Magento deltas and installed values - * - * @param bool $interactive - * @return void - */ - public function setInteractive($interactive) - { - $this->interactive = $interactive; - } - - /** - * Get the map of section name -> new contents to use to update the composer.json file after running the update - * - * @return array - */ - public function getJsonChanges() - { - return $this->jsonChanges; - } } diff --git a/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php b/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php index 112f677..e0ea466 100644 --- a/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php +++ b/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php @@ -7,24 +7,30 @@ namespace Magento\Composer\Plugin\RootUpdate; use Composer\Composer; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Installer; +use Composer\Installer\PackageEvent; use Composer\IO\IOInterface; use Composer\Plugin\Capability\CommandProvider; use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface; +use Magento\RootUpdatePluginInstaller\WebSetupWizardPluginInstaller; /** * Class RootUpdatePlugin * * @package Magento\Composer\Plugin\RootUpdate */ -class RootUpdatePlugin implements PluginInterface, Capable +class RootUpdatePlugin implements PluginInterface, Capable, EventSubscriberInterface { + const PACKAGE_NAME = 'magento/composer-root-update-plugin'; + /** * @inheritdoc */ public function activate(Composer $composer, IOInterface $io) { - // Nothing needs to be done when this is installed, it operates at runtime + // Method must exist } /** @@ -34,4 +40,29 @@ public function getCapabilities() { return [CommandProvider::class => PluginCommandProvider::class]; } + + /** + * When a package is installed or updated, check if the WebSetupWizard installation needs to be updated + * + * @return array + */ + public static function getSubscribedEvents() + { + return [Installer\PackageEvents::POST_PACKAGE_INSTALL => 'packageUpdate', + Installer\PackageEvents::POST_PACKAGE_UPDATE => 'packageUpdate']; + } + + /** + * Forward package update events to WebSetupWizardPluginInstaller to update the plugin on install or version change + * + * @param PackageEvent $event + * @return void + */ + public function packageUpdate(PackageEvent $event) + { + // Safeguard against the source file being removed before the event is triggered + if (class_exists('\Magento\RootUpdatePluginInstaller\WebSetupWizardPluginInstaller')) { + WebSetupWizardPluginInstaller::packageEvent($event); + } + } } diff --git a/src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php b/src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php new file mode 100644 index 0000000..7d224bf --- /dev/null +++ b/src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php @@ -0,0 +1,112 @@ +doVarInstall(); + } + + /** + * Passthrough Magento upgrade command to check the plugin installation in the var directory + * + * @param ModuleDataSetupInterface $setup + * @param ModuleContextInterface $context + */ + public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $context) + { + $this->doVarInstall(); + } + + /** + * Install magento/composer-root-update-plugin in var/vendor when 'bin/magento setup' commands are called + * + * The plugin is needed there for the Web Setup Wizard's dependencies check and var/* gets cleared out + * when 'bin/magento setup:uninstall' is called, so it needs to be reinstalled + */ + public function doVarInstall() + { + $packageName = RootUpdatePlugin::PACKAGE_NAME; + + $io = new ConsoleIO(new ArrayInput([]), new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), new HelperSet([ + new FormatterHelper(), + new DebugFormatterHelper(), + new ProcessHelper(), + new QuestionHelper(), + ])); + $factory = new Factory(); + $rootDir = preg_split('/vendor/', __DIR__)[0]; + $path = "${rootDir}composer.json"; + $composer = $factory->createComposer($io, $path, true, null, true); + $locker = $composer->getLocker(); + if ($locker->isLocked()) { + $pkg = $locker->getLockedRepository()->findPackage(RootUpdatePlugin::PACKAGE_NAME, '*'); + if ($pkg !== null) { + $version = $pkg->getPrettyVersion(); + try { + $io->writeError( + "Checking for \"$packageName: $version\" for the Web Setup Wizard...", + true, + IOInterface::QUIET + ); + WebSetupWizardPluginInstaller::updateSetupWizardPlugin($io, $composer, $path, $version); + } catch (Exception $e) { + $io->writeError( + "Web Setup Wizard installation of \"$packageName: $version\" failed.", + true, + IOInterface::QUIET + ); + $io->writeError($e->getMessage()); + } + } else { + $io->writeError( + "Web Setup Wizard installation of \"$packageName\" failed; " . + "package not found in ${rootDir}composer.lock.", + true, + IOInterface::QUIET + ); + } + } else { + $io->writeError( + "Web Setup Wizard installation of \"$packageName\" failed; " . + "unable to load ${rootDir}composer.lock.", + true, + IOInterface::QUIET + ); + } + } +} diff --git a/src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php b/src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php new file mode 100644 index 0000000..861c52c --- /dev/null +++ b/src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php @@ -0,0 +1,211 @@ +getIO(); + $jobs = $event->getRequest()->getJobs(); + $packageName = RootUpdatePlugin::PACKAGE_NAME; + foreach ($jobs as $job) { + if (key_exists('packageName', $job) && $job['packageName'] === $packageName) { + $pkg = $event->getInstalledRepo()->findPackage($packageName, '*'); + if ($pkg !== null) { + $version = $pkg->getPrettyVersion(); + try { + $composer = $event->getComposer(); + static::updateSetupWizardPlugin( + $io, + $composer, + $composer->getConfig()->getConfigSource()->getName(), + $version + ); + } catch (Exception $e) { + $io->writeError( + "Web Setup Wizard installation of \"$packageName: $version\" failed.", + true, + IOInterface::QUIET + ); + $io->writeError($e->getMessage()); + } + break; + } + } + } + } + + /** + * Update the plugin installation inside the ./var directory used by the Web Setup Wizard + * + * @param IOInterface $io + * @param Composer $composer + * @param string $path + * @param string $version + * @return void + * @throws Exception + */ + public static function updateSetupWizardPlugin($io, $composer, $path, $version) + { + $productRegex = '/magento\/product-(community|enterprise)-edition/'; + $packageName = RootUpdatePlugin::PACKAGE_NAME; + $productLinks = array_filter( + array_values($composer->getPackage()->getRequires()), + function ($link) use ($productRegex) { + /** @var Link $link */ + return preg_match($productRegex, $link->getTarget()); + } + ); + $pluginLinks = array_filter( + array_values($composer->getPackage()->getRequires()), + function ($link) { + /** @var Link $link */ + return $link->getTarget() == RootUpdatePlugin::PACKAGE_NAME; + } + ); + if ($productLinks == [] || $pluginLinks == []) { + return; + } + + if (!preg_match('/\/var\/composer\.json$/', $path)) { + $rootDir = preg_replace('/\/composer\.json$/', '', $path); + $varDir = "$rootDir/var"; + $factory = new Factory(); + if (file_exists("$varDir/vendor/$packageName/composer.json")) { + $varPluginComposer = $factory->createComposer( + $io, + "$varDir/vendor/$packageName/composer.json", + true, + "$varDir/vendor/$packageName", + false + ); + // If the current version of the plugin is already the version in this update, noop + if ($varPluginComposer->getPackage()->getPrettyVersion() == $version) { + $io->writeError( + " No Web Setup Wizard update needed for $packageName; version $version is already in $varDir.", + true, + IOInterface::VERBOSE + ); + return; + } + } + + $io->writeError("Installing \"$packageName: $version\" for the Web Setup Wizard"); + if (!file_exists($varDir)) { + mkdir($varDir); + } + $tmpDir = tempnam($varDir, "composer-plugin_tmp."); + $exception = null; + try { + unlink($tmpDir); + mkdir($tmpDir); + if (file_exists("$rootDir/auth.json")) { + static::copyAndReplace("$rootDir/auth.json", "$tmpDir/auth.json"); + } + $tmpConfig = []; + $tmpConfig['repositories'] = $composer->getPackage()->getRepositories(); + $tmpConfig['require'] = [$packageName => $version]; + if ($composer->getPackage()->getMinimumStability()) { + $tmpConfig['minimum-stability'] = $composer->getPackage()->getMinimumStability(); + } + $tmpJson = new JsonFile("$tmpDir/composer.json"); + $tmpJson->write($tmpConfig); + $tmpComposer = $factory->createComposer($io, "$tmpDir/composer.json", true, $tmpDir); + $install = Installer::create($io, $tmpComposer); + $install + ->setDumpAutoloader(true) + ->setRunScripts(false) + ->setDryRun(false) + ->disablePlugins(); + $install->run(); + + if (!file_exists($varDir)) { + mkdir($varDir); + } + static::copyAndReplace("$tmpDir/vendor", "$varDir/vendor"); + } catch (Exception $e) { + $exception = $e; + } finally { + static::deleteFile($tmpDir); + } + if ($exception !== null) { + throw $exception; + } + } + } + + /** + * Deletes a file or a directory and all its contents + * + * @param string $path + * @return void + * @throws FilesystemException + */ + private static function deleteFile($path) + { + if (!file_exists($path)) { + return; + } + if (!is_link($path) && is_dir($path)) { + $files = array_diff(scandir($path), ['..', '.']); + foreach ($files as $file) { + static::deleteFile("$path/$file"); + } + rmdir($path); + } else { + unlink($path); + } + if (file_exists($path)) { + throw new FilesystemException("Failed to delete $path"); + } + } + + /** + * Copies a file or directory and all its contents, replacing anything that exists there beforehand + * + * @param string $source + * @param string $target + * @return void + * @throws FilesystemException + */ + private static function copyAndReplace($source, $target) + { + static::deleteFile($target); + if (is_dir($source)) { + mkdir($target); + $files = array_diff(scandir($source), ['..', '.']); + foreach ($files as $file) { + static::copyAndReplace("$source/$file", "$target/$file"); + } + } else { + copy($source, $target); + } + } +} diff --git a/src/Magento/RootUpdatePluginInstaller/etc/module.xml b/src/Magento/RootUpdatePluginInstaller/etc/module.xml new file mode 100644 index 0000000..2f9af5a --- /dev/null +++ b/src/Magento/RootUpdatePluginInstaller/etc/module.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/Magento/RootUpdatePluginInstaller/registration.php b/src/Magento/RootUpdatePluginInstaller/registration.php new file mode 100644 index 0000000..feaded9 --- /dev/null +++ b/src/Magento/RootUpdatePluginInstaller/registration.php @@ -0,0 +1,13 @@ +io, false, false, '', ''); + $resolution = $resolver->findResolution('field', null, 'newVal', null); + + $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); + } + + public function testFindResolutionRemoveElement() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolution = $resolver->findResolution('field', 'oldVal', null, 'oldVal'); + + $this->assertEquals(ConflictResolver::REMOVE_VAL, $resolution); + } + + public function testFindResolutionChangeElement() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'oldVal'); + + $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + } + + public function testFindResolutionNoUpdate() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'newVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionConflictNoOverride() + { + $this->io->expects($this->at(0))->method('writeError') + ->with($this->stringContains('will not be changed')); + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionConflictOverride() + { + $resolver = new ConflictResolver($this->io, false, true, '', ''); + + $this->io->expects($this->once())->method('writeError') + ->with($this->stringContains('overriding local changes')); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + } + + public function testFindResolutionConflictOverrideRestoreRemoved() + { + $resolver = new ConflictResolver($this->io, false, true, '', ''); + + $this->io->expects($this->once())->method('writeError') + ->with($this->stringContains('overriding local changes')); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', null); + + $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); + } + + public function testFindResolutionInteractiveConfirm() + { + $this->io->method('isInteractive')->willReturn(true); + $resolver = new ConflictResolver($this->io, true, false, '', ''); + $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + } + + public function testFindResolutionInteractiveNoConfirm() + { + $resolver = new ConflictResolver($this->io, true, false, '', ''); + $this->io->method('isInteractive')->willReturn(true); + $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionNonInteractiveEnvironmentError() + { + $resolver = new ConflictResolver($this->io, true, false, '', ''); + $this->io->method('isInteractive')->willReturn(false); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' + ); + $this->io->expects($this->never())->method('askConfirmation'); + + $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + } + + public function testResolveNestedArrayNonArrayAdd() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $result = $resolver->resolveNestedArray('field', null, 'newVal', null); + + $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); + } + + public function testResolveNestedArrayNonArrayRemove() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal'); + + $this->assertEquals(['changed' => true, 'value' => null], $result); + } + + public function testResolveNestedArrayNonArrayChange() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); + + $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); + } + + public function testResolveArrayMismatchedArray() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + 'oldVal', + ['newVal'], + 'oldVal', + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['newVal'], $this->installRoot->getExtra()); + } + + public function testResolveArrayMismatchedMap() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['oldVal'], + ['key' => 'newVal'], + ['oldVal'], + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['key' => 'newVal'], $this->installRoot->getExtra()); + } + + public function testResolveArrayFlatArrayAddElement() + { + $expected = ['val1', 'val2', 'val3']; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['val1'], + ['val1', 'val3'], + ['val2', 'val1'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff($expected, $result), array_diff($result, $expected))); + } + + public function testResolveArrayFlatArrayRemoveElement() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['val1', 'val2', 'val3'], + ['val2'], + ['val1', 'val2', 'val3', 'val4'], + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['val2', 'val4'], array_values($this->installRoot->getExtra())); + } + + public function testResolveArrayFlatArrayAddAndRemoveElement() + { + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['val1', 'val2', 'val3'], + ['val2', 'val5'], + ['val1', 'val2', 'val3', 'val4'], + [$this->installRoot, 'setExtra'] + ); + + $this->assertEquals(['val2', 'val4', 'val5'], array_values($this->installRoot->getExtra())); + } + + public function testResolveArrayAssociativeAddElement() + { + $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['key1' => 'val1'], + ['key1' => 'val1', 'key3' => 'val3'], + ['key2' => 'val2', 'key1' => 'val1'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayAssociativeRemoveElement() + { + $expected = ['key2' => 'val2', 'key3' => 'val3']; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['key1' => 'val1', 'key2' => 'val2'], + ['key2' => 'val2'], + ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayAssociativeAddAndRemoveElement() + { + $expected = ['key3' => 'val3', 'key4' => 'val4']; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['key1' => 'val1', 'key2' => 'val2'], + ['key4' => 'val4'], + ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], + [$this->installRoot, 'setExtra'] + ); + + $result = $this->installRoot->getExtra(); + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayNestedAdd() + { + $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], + [$this->installRoot, 'setExtra'] + ); + + $expectedKeys = array_keys($expected); + $actualKeys = array_keys($this->installRoot->getExtra()); + $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); + foreach ($expected as $key => $expectedVal) { + $actualVal = $this->installRoot->getExtra()[$key]; + $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); + } + } + + public function testResolveArrayNestedRemove() + { + $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveArraySection( + 'extra', + ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1'], 'key2' => ['k2v2']], + ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], + [$this->installRoot, 'setExtra'] + ); + + $expectedKeys = array_keys($expected); + $actualKeys = array_keys($this->installRoot->getExtra()); + $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); + foreach ($expected as $key => $expectedVal) { + $actualVal = $this->installRoot->getExtra()[$key]; + $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); + } + } + + public function testResolveArrayTracksChanges() + { + $expected = ['val1', 'val2', 'val3']; + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $this->assertEmpty($resolver->getJsonChanges()); + $resolver->resolveArraySection( + 'extra', + ['val1'], + ['val1', 'val3'], + ['val2', 'val1'], + [$this->installRoot, 'setExtra'] + ); + $actual = $resolver->getJsonChanges(); + + $this->assertEquals(['extra'], array_keys($actual)); + $actual = $actual['extra']; + $this->assertEmpty(array_merge(array_diff($expected, $actual), array_diff($actual, $expected))); + } + + public function testResolveLinksAddLink() + { + $installLink = $this->createLinks(1, 'install/link'); + $baseLinks = $this->createLinks(2); + $installLinks = array_merge($baseLinks, $installLink); + $targetLinks = array_merge($baseLinks, $this->createLinks(1, 'target/link')); + $expected = array_merge($targetLinks, $installLink); + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $this->assertLinksEqual($expected, $this->installRoot->getRequires()); + } + + public function testResolveLinksRemoveLink() + { + $installLink = $this->createLinks(1, 'install/link'); + $baseLinks = $this->createLinks(2); + $installLinks = array_merge($baseLinks, $installLink); + $targetLinks = array_slice($baseLinks, 1); + $expected = array_merge($targetLinks, $installLink); + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $this->assertLinksEqual($expected, $this->installRoot->getRequires()); + } + + public function testResolveLinksChangeLink() + { + $installLink = $this->createLinks(1, 'install/link'); + $baseLinks = $this->createLinks(2); + $installLinks = array_merge($baseLinks, $installLink); + $targetLinks = $this->changeLink($baseLinks, 1); + $expected = array_merge($targetLinks, $installLink); + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $resolver->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $this->assertLinksEqual($expected, $this->installRoot->getRequires()); + } + + public function testResolveLinksTracksChanges() + { + $installLink = $this->createLinks(1, 'install/link')[0]; + $baseLinks = $this->createLinks(1); + /** @var Link[] $installLinks */ + $installLinks = array_merge($baseLinks, [$installLink]); + $targetLinks = $this->changeLink($baseLinks, 0); + + $resolver = new ConflictResolver($this->io, false, false, '', ''); + $this->assertEmpty($resolver->getJsonChanges()); + $resolver->resolveLinkSection( + 'require', + $baseLinks, + $targetLinks, + $installLinks, + [$this->installRoot, 'setRequires'] + ); + + $changed = $resolver->getJsonChanges(); + $this->assertEquals(['require'], array_keys($changed)); + $actual = $changed['require']; + $this->assertEquals(2, count($actual)); + $this->assertTrue(key_exists($targetLinks[0]->getTarget(), $actual)); + $this->assertEquals($targetLinks[0]->getConstraint()->getPrettyString(), $actual[$targetLinks[0]->getTarget()]); + $this->assertTrue(key_exists($installLink->getTarget(), $actual)); + $this->assertEquals($installLinks[1]->getConstraint()->getPrettyString(), $actual[$installLink->getTarget()]); + } + + public function setUp() + { + $this->io = $this->getMockForAbstractClass(IOInterface::class); + $this->installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + } +} diff --git a/tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php b/tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php new file mode 100644 index 0000000..788fab5 --- /dev/null +++ b/tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php @@ -0,0 +1,333 @@ +installRoot->setRequires($links); + + $this->composer->expects($this->never())->method('setPackage'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Magento root updates cannot run without a valid target package'); + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + + $updater->runUpdate(); + } + + public function testMagentoUpdateRegistersPostUpdateWrites() + { + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + + $this->eventDispatcher->expects($this->once())->method('addListener')->with( + ScriptEvents::POST_UPDATE_CMD, + [$updater, 'writeUpdatedRoot'], + PHP_INT_MAX + ); + + $updater->runUpdate(); + } + + public function testMagentoUpdateDryRun() + { + $this->input->method('getOption')->willReturnMap([['dry-run', true]]); + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + + $this->eventDispatcher->expects($this->never())->method('addListener'); + + $updater->runUpdate(); + + $this->assertNotEmpty($updater->getJsonChanges()); + } + + public function testMagentoUpdateSetsFieldsNoOverride() + { + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + $updater->runUpdate(); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); + $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + } + + public function testMagentoUpdateSetsFieldsWithOverride() + { + $this->input->method('getOption')->willReturnMap([[RootUpdateCommand::OVERRIDE_OPT, true]]); + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $updater->runUpdate(); + + $this->assertLinksEqual($this->expectedWithOverride->getRequires(), $newRoot->getRequires()); + $this->assertLinksEqual($this->expectedWithOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertEquals($this->expectedWithOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertEquals($this->expectedWithOverride->getDevAutoload(), $newRoot->getDevAutoload()); + $this->assertLinksEqual($this->expectedWithOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedWithOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedWithOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedWithOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedWithOverride->getSuggests(), $newRoot->getSuggests()); + } + + public function testMagentoUpdateNoDev() + { + $this->input->method('getOption')->willReturnMap([['no-dev', true]]); + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $updater->runUpdate(); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); + $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + + $this->assertLinksNotEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + } + + public function testMagentoUpdateNoAutoloader() + { + $this->input->method('getOption')->willReturnMap([['no-autoloader', true]]); + $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); + + /** @var RootPackage $newRoot */ + $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); + + $updater->runUpdate(); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); + $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); + $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + + $this->assertNotEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); + $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + } + + public function setUp() + { + /** + * Set up input RootPackage objects for runUpdate() + */ + $baseRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $baseRoot->setRequires([ + new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '1.0.0'), null, '1.0.0'), + new Link('root/pkg', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), + new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') + ]); + $baseRoot->setDevRequires($this->createLinks(2, 'vendor/dev-package')); + $baseRoot->setAutoload(['psr-4' => ['Magento\\' => 'src/Magento/']]); + $baseRoot->setDevAutoload(['psr-4' => ['Magento\\Tools\\' => 'dev/tools/Magento/Tools/']]); + $baseRoot->setConflicts($this->createLinks(2, 'vendor/conflicting')); + $baseRoot->setExtra(['extra-key1' => 'base1', 'extra-key2' => 'base2']); + $baseRoot->setProvides($this->createLinks(3, 'magento/sub-package')); + $baseRoot->setReplaces([]); + $baseRoot->setSuggests(['magento/sample-data' => 'Suggested Sample Data 1.0.0']); + + $targetRoot = new RootPackage('magento/project-community-edition', '2.0.0.0', '2.0.0'); + $targetRoot->setRequires([ + new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), + new Link('root/pkg', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), + new Link('root/pkg', 'vendor/package1', new Constraint('==', '2.0.0'), null, '2.0.0') + ]); + $targetRoot->setDevRequires($this->createLinks(1, 'vendor/dev-package')); + $targetRoot->setAutoload(['psr-4' => [ + 'Magento\\' => 'src/Magento/', + 'Zend\\Mvc\\Controller\\'=> 'setup/src/Zend/Mvc/Controller/' + ]]); + $targetRoot->setDevAutoload(['psr-4' => ['Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/']]); + $targetRoot->setConflicts($this->changeLink($this->createLinks(3, 'vendor/conflicting'), 1)); + $targetRoot->setExtra(['extra-key1' => 'target1', 'extra-key2' => 'target2', 'extra-key3' => ['a' => 'b']]); + $targetRoot->setProvides($this->changeLink($this->createLinks(3, 'magento/sub-package'), 1)); + $targetRoot->setReplaces($this->createLinks(3, 'replaced/package')); + $targetRoot->setSuggests([]); + + $installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $installRoot->setRequires([ + new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), + new Link('root/pkg', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), + new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') + ]); + $installRoot->setDevRequires($baseRoot->getDevRequires()); + $installRoot->setAutoload(array_merge($baseRoot->getAutoload(), ['files' => 'app/etc/Register.php'])); + $installRoot->setDevAutoload(['psr-4' => ['Magento\\Tools\\' => 'dev/tools/Magento/Tools2/']]); + $installRoot->setConflicts(array_merge( + array_slice($this->changeLink($baseRoot->getConflicts(), 0), 0, 1), + $this->createLinks(3, 'vendor/different-conflicting') + )); + $installRoot->setExtra(['extra-key1' => 'install1', 'extra-key2' => 'base2']); + $installRoot->setProvides($baseRoot->getProvides()); + $installRoot->setReplaces($baseRoot->getReplaces()); + $installRoot->setSuggests([ + 'magento/sample-data' => 'Suggested Sample Data 1.0.0', + 'vendor/suggested' => 'Another Suggested Package' + ]); + $this->installRoot = $installRoot; + + /** + * Set up expected results from runUpdate() with and without overriding conflicting install values + */ + $expectedNoOverride = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $expectedNoOverride->setRequires($targetRoot->getRequires()); + $expectedNoOverride->setDevRequires($targetRoot->getDevRequires()); + $expectedNoOverride->setAutoload( + array_merge($targetRoot->getAutoload(), ['files' => 'app/etc/Register.php']) + ); + $expectedNoOverride->setDevAutoload(['psr-4' => [ + 'Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/', + 'Magento\\Tools\\' => 'dev/tools/Magento/Tools2/' + ]]); + $expectedNoOverride->setConflicts( + array_merge($this->installRoot->getConflicts(), [$targetRoot->getConflicts()[2]]) + ); + $noOverrideExtra = $targetRoot->getExtra(); + $noOverrideExtra['extra-key1'] = $this->installRoot->getExtra()['extra-key1']; + $expectedNoOverride->setExtra($noOverrideExtra); + $expectedNoOverride->setProvides($targetRoot->getProvides()); + $expectedNoOverride->setReplaces($targetRoot->getReplaces()); + $expectedNoOverride->setSuggests(['vendor/suggested' => 'Another Suggested Package']); + $this->expectedNoOverride = $expectedNoOverride; + + $expectedWithOverride = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); + $expectedWithOverride->setRequires($expectedNoOverride->getRequires()); + $expectedWithOverride->setDevRequires($expectedNoOverride->getDevRequires()); + $expectedWithOverride->setAutoload($expectedNoOverride->getAutoload()); + $expectedWithOverride->setDevAutoload([ + 'psr-4' => ['Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/'] + ]); + $expectedWithOverride->setConflicts(array_merge( + $this->installRoot->getConflicts(), + array_slice($targetRoot->getConflicts(), 1) + )); + $expectedWithOverride->setExtra($targetRoot->getExtra()); + $expectedWithOverride->setProvides($expectedNoOverride->getProvides()); + $expectedWithOverride->setReplaces($expectedNoOverride->getReplaces()); + $expectedWithOverride->setSuggests($expectedNoOverride->getSuggests()); + $this->expectedWithOverride = $expectedWithOverride; + + /** + * Mock plugin boilerplate + */ + $this->eventDispatcher = $this->createPartialMock(EventDispatcher::class, ['addListener']); + + /** + * Mock InputInterface for CLI options and IOInterface for interaction + */ + /** @var InputInterface|MockObject $input */ + $input = $this->getMockForAbstractClass(InputInterface::class); + $input->method('isInteractive')->willReturn(false); + $this->input = $input; + $this->io = $this->getMockForAbstractClass(IOInterface::class); + + /** + * Mock package repositories + */ + $repo = $this->createPartialMock(ComposerRepository::class, ['hasProviders', 'whatProvides']); + $repo->method('hasProviders')->willReturn(true); + $repo->method('whatProvides')->willReturn([$targetRoot, $baseRoot]); + $repoManager = $this->createPartialMock(RepositoryManager::class, ['getRepositories']); + $repoManager->method('getRepositories')->willReturn([$repo]); + $lockedRepo = $this->getMockForAbstractClass(RepositoryInterface::class); + $lockedRepo->method('getPackages')->willReturn([ + new Package('magento/product-community-edition', '1.0.0.0', '1.0.0') + ]); + $locker = $this->createPartialMock(Locker::class, ['isLocked', 'getLockedRepository']); + $locker->method('isLocked')->willReturn(true); + $locker->method('getLockedRepository')->willReturn($lockedRepo); + + /** + * Mock local Composer object + */ + $configSource = $this->getMockForAbstractClass(Config\ConfigSourceInterface::class); + $config = $this->createPartialMock(Config::class, ['get', 'getConfigSource']); + $config->method('get')->with('platform')->willReturn([]); + $config->method('getConfigSource')->willReturn($configSource); + /** @var Composer|MockObject $composer */ + $composer = $this->createPartialMock(Composer::class, [ + 'getLocker', + 'getPackage', + 'getRepositoryManager', + 'getEventDispatcher', + 'getConfig', + 'setPackage' + ]); + $composer->method('getLocker')->willReturn($locker); + $composer->method('getEventDispatcher')->willReturn($this->eventDispatcher); + $composer->method('getRepositoryManager')->willReturn($repoManager); + $composer->method('getPackage')->willReturn($installRoot); + $composer->method('getConfig')->willReturn($config); + $this->composer = $composer; + } +} diff --git a/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php b/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php index 81f36a4..a1a8d2f 100644 --- a/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php +++ b/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php @@ -7,22 +7,10 @@ namespace Magento\Composer\Plugin\RootUpdate; use Composer\Composer; -use Composer\Config; -use Composer\EventDispatcher\EventDispatcher; -use Composer\IO\BaseIO; -use Composer\IO\IOInterface; -use Composer\Package\Link; -use Composer\Package\Locker; -use Composer\Package\Package; -use Composer\Package\RootPackage; use Composer\Plugin\Capability\Capability; use Composer\Plugin\PluginManager; -use Composer\Repository\ComposerRepository; -use Composer\Repository\RepositoryInterface; -use Composer\Repository\RepositoryManager; -use Composer\Script\ScriptEvents; -use Composer\Semver\Constraint\Constraint; use Magento\TestHelper\TestApplication; +use Magento\TestHelper\UpdatePluginTestCase; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -32,7 +20,7 @@ * * @package Magento\Composer\Plugin\RootUpdate */ -class RootUpdateCommandTest extends \PHPUnit\Framework\TestCase +class RootUpdateCommandTest extends UpdatePluginTestCase { /** @var TestApplication */ public $application; @@ -40,36 +28,9 @@ class RootUpdateCommandTest extends \PHPUnit\Framework\TestCase /** @var RootUpdateCommand */ public $rootUpdateCommand; - /** @var MockObject|Composer */ - public $composer; - - /** @var RootPackage */ - public $baseRoot; - - /** @var RootPackage */ - public $targetRoot; - - /** @var RootPackage */ - public $installRoot; - - /** @var RootPackage */ - public $expectedNoOverride; - - /** @var RootPackage */ - public $expectedWithOverride; - - /** @var MockObject|EventDispatcher */ - public $eventDispatcher; - /** @var MockObject|InputInterface */ public $input; - /** @var MockObject|OutputInterface */ - public $output; - - /** @var MockObject|BaseIO */ - public $io; - public function testOverwriteUpdateCommand() { /** @var MockObject|OutputInterface $output */ @@ -91,815 +52,20 @@ public function testUpdateCommandNoPlugins() $this->assertNotEquals($this->rootUpdateCommand, $this->application->getCalledCommand()); } - public function testFindResolutionAddElement() - { - $resolution = $this->rootUpdateCommand->findResolution('field', null, 'newVal', null); - - $this->assertEquals(RootUpdateCommand::ADD_VAL, $resolution); - } - - public function testFindResolutionRemoveElement() - { - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', null, 'oldVal'); - - $this->assertEquals(RootUpdateCommand::REMOVE_VAL, $resolution); - } - - public function testFindResolutionChangeElement() - { - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'oldVal'); - - $this->assertEquals(RootUpdateCommand::CHANGE_VAL, $resolution); - } - - public function testFindResolutionNoUpdate() - { - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'newVal'); - - $this->assertNull($resolution); - } - - public function testFindResolutionConflictNoOverride() - { - $this->io->expects($this->at(0))->method('writeError') - ->with($this->stringContains('will not be changed')); - - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertNull($resolution); - } - - public function testFindResolutionConflictOverride() - { - $this->rootUpdateCommand->setOverride(true); - - $this->io->expects($this->once())->method('writeError') - ->with($this->stringContains('overriding local changes')); - - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertEquals(RootUpdateCommand::CHANGE_VAL, $resolution); - } - - public function testFindResolutionConflictOverrideRestoreRemoved() - { - $this->rootUpdateCommand->setOverride(true); - $this->io->expects($this->once())->method('writeError') - ->with($this->stringContains('overriding local changes')); - - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', null); - - $this->assertEquals(RootUpdateCommand::ADD_VAL, $resolution); - } - - public function testFindResolutionInteractiveConfirm() - { - $this->rootUpdateCommand->setInteractiveInput(true); - $this->rootUpdateCommand->setInteractive(true); - - $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); - - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertEquals(RootUpdateCommand::CHANGE_VAL, $resolution); - } - - public function testFindResolutionInteractiveNoConfirm() - { - $this->rootUpdateCommand->setInteractiveInput(true); - $this->rootUpdateCommand->setInteractive(true); - $this->rootUpdateCommand->setOverride(false); - - $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); - - $resolution = $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertNull($resolution); - } - - public function testFindResolutionNonInteractiveEnvironmentError() - { - $this->rootUpdateCommand->setInteractiveInput(false); - $this->rootUpdateCommand->setInteractive(true); - $this->rootUpdateCommand->setOverride(false); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' - ); - $this->io->expects($this->never())->method('askConfirmation'); - - $this->rootUpdateCommand->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - } - - public function testResolveNestedArrayNonArrayAdd() - { - $result = $this->rootUpdateCommand->resolveNestedArray('field', null, 'newVal', null); - - $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); - } - - public function testResolveNestedArrayNonArrayRemove() - { - $result = $this->rootUpdateCommand->resolveNestedArray('field', 'oldVal', null, 'oldVal'); - - $this->assertEquals(['changed' => true, 'value' => null], $result); - } - - public function testResolveNestedArrayNonArrayChange() - { - $result = $this->rootUpdateCommand->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); - - $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); - } - - public function testResolveArrayMismatchedArray() - { - $this->rootUpdateCommand->resolveArraySection( - 'extra', - 'oldVal', - ['newVal'], - 'oldVal', - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['newVal'], $this->installRoot->getExtra()); - } - - public function testResolveArrayMismatchedMap() - { - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['oldVal'], - ['key' => 'newVal'], - ['oldVal'], - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['key' => 'newVal'], $this->installRoot->getExtra()); - } - - public function testResolveArrayFlatArrayAddElement() - { - $expected = ['val1', 'val2', 'val3']; - - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['val1'], - ['val1', 'val3'], - ['val2', 'val1'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff($expected, $result), array_diff($result, $expected))); - } - - public function testResolveArrayFlatArrayRemoveElement() - { - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['val1', 'val2', 'val3'], - ['val2'], - ['val1', 'val2', 'val3', 'val4'], - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['val2', 'val4'], array_values($this->installRoot->getExtra())); - } - - public function testResolveArrayFlatArrayAddAndRemoveElement() - { - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['val1', 'val2', 'val3'], - ['val2', 'val5'], - ['val1', 'val2', 'val3', 'val4'], - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['val2', 'val4', 'val5'], array_values($this->installRoot->getExtra())); - } - - public function testResolveArrayAssociativeAddElement() - { - $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; - - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['key1' => 'val1'], - ['key1' => 'val1', 'key3' => 'val3'], - ['key2' => 'val2', 'key1' => 'val1'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); - } - - public function testResolveArrayAssociativeRemoveElement() - { - $expected = ['key2' => 'val2', 'key3' => 'val3']; - - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['key1' => 'val1', 'key2' => 'val2'], - ['key2' => 'val2'], - ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); - } - - public function testResolveArrayAssociativeAddAndRemoveElement() - { - $expected = ['key3' => 'val3', 'key4' => 'val4']; - - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['key1' => 'val1', 'key2' => 'val2'], - ['key4' => 'val4'], - ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); - } - - public function testResolveArrayNestedAdd() - { - $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; - - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], - ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], - ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], - [$this->installRoot, 'setExtra'] - ); - - $expectedKeys = array_keys($expected); - $actualKeys = array_keys($this->installRoot->getExtra()); - $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); - foreach ($expected as $key => $expectedVal) { - $actualVal = $this->installRoot->getExtra()[$key]; - $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); - } - } - - public function testResolveArrayNestedRemove() - { - $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; - - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], - ['key1' => ['k1v1'], 'key2' => ['k2v2']], - ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], - [$this->installRoot, 'setExtra'] - ); - - $expectedKeys = array_keys($expected); - $actualKeys = array_keys($this->installRoot->getExtra()); - $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); - foreach ($expected as $key => $expectedVal) { - $actualVal = $this->installRoot->getExtra()[$key]; - $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); - } - } - - public function testResolveArrayTracksChanges() - { - $expected = ['val1', 'val2', 'val3']; - - $this->assertEmpty($this->rootUpdateCommand->getJsonChanges()); - $this->rootUpdateCommand->resolveArraySection( - 'extra', - ['val1'], - ['val1', 'val3'], - ['val2', 'val1'], - [$this->installRoot, 'setExtra'] - ); - $actual = $this->rootUpdateCommand->getJsonChanges(); - - $this->assertEquals(['extra'], array_keys($actual)); - $actual = $actual['extra']; - $this->assertEmpty(array_merge(array_diff($expected, $actual), array_diff($actual, $expected))); - } - - public function testResolveLinksAddLink() - { - $installLink = $this->createLinks(1, 'install/link'); - $baseLinks = $this->createLinks(2); - $installLinks = array_merge($baseLinks, $installLink); - $targetLinks = array_merge($baseLinks, $this->createLinks(1, 'target/link')); - $expected = array_merge($targetLinks, $installLink); - - $this->rootUpdateCommand->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $this->assertLinksEqual($expected, $this->installRoot->getRequires()); - } - - public function testResolveLinksRemoveLink() - { - $installLink = $this->createLinks(1, 'install/link'); - $baseLinks = $this->createLinks(2); - $installLinks = array_merge($baseLinks, $installLink); - $targetLinks = array_slice($baseLinks, 1); - $expected = array_merge($targetLinks, $installLink); - - $this->rootUpdateCommand->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $this->assertLinksEqual($expected, $this->installRoot->getRequires()); - } - - public function testResolveLinksChangeLink() - { - $installLink = $this->createLinks(1, 'install/link'); - $baseLinks = $this->createLinks(2); - $installLinks = array_merge($baseLinks, $installLink); - $targetLinks = $this->changeLink($baseLinks, 1); - $expected = array_merge($targetLinks, $installLink); - - $this->rootUpdateCommand->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $this->assertLinksEqual($expected, $this->installRoot->getRequires()); - } - - public function testResolveLinksTracksChanges() - { - $installLink = $this->createLinks(1, 'install/link')[0]; - $baseLinks = $this->createLinks(1); - /** @var Link[] $installLinks */ - $installLinks = array_merge($baseLinks, [$installLink]); - $targetLinks = $this->changeLink($baseLinks, 0); - - $this->assertEmpty($this->rootUpdateCommand->getJsonChanges()); - $this->rootUpdateCommand->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $changed = $this->rootUpdateCommand->getJsonChanges(); - $this->assertEquals(['require'], array_keys($changed)); - $actual = $changed['require']; - $this->assertEquals(2, count($actual)); - $this->assertTrue(key_exists($targetLinks[0]->getTarget(), $actual)); - $this->assertEquals($targetLinks[0]->getConstraint()->getPrettyString(), $actual[$targetLinks[0]->getTarget()]); - $this->assertTrue(key_exists($installLink->getTarget(), $actual)); - $this->assertEquals($installLinks[1]->getConstraint()->getPrettyString(), $actual[$installLink->getTarget()]); - } - - public function testMagentoUpdateSkipOption() - { - $this->input->method('getOption')->willReturnMap([[RootUpdateCommand::SKIP_OPT, true]]); - - $this->composer->expects($this->never())->method('setPackage'); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - } - - public function testMagentoUpdateNotMagentoRoot() - { - $this->installRoot->setRequires($this->createLinks(2, 'vndr/package')); - - $this->composer->expects($this->never())->method('setPackage'); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Magento root updates cannot run without a valid target package'); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - } - - public function testMagentoUpdateRegistersPostUpdateWrites() - { - $this->rootUpdateCommand->setApplication($this->application); - - $this->eventDispatcher->expects($this->once())->method('addListener')->with( - ScriptEvents::POST_UPDATE_CMD, - [$this->rootUpdateCommand, 'writeUpdatedRoot'], - PHP_INT_MAX - ); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - } - - public function testMagentoUpdateDryRun() - { - $this->rootUpdateCommand->setApplication($this->application); - $this->input->method('getOption')->willReturnMap([['dry-run', true]]); - - $this->eventDispatcher->expects($this->never())->method('addListener'); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - - $this->assertNotEmpty($this->rootUpdateCommand->getJsonChanges()); - } - - public function testMagentoUpdateSetsFieldsNoOverride() - { - $this->rootUpdateCommand->setApplication($this->application); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - - $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); - $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); - $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); - } - - public function testMagentoUpdateSetsFieldsWithOverride() - { - $this->rootUpdateCommand->setApplication($this->application); - $this->input->method('getOption')->willReturnMap([[RootUpdateCommand::OVERRIDE_OPT, true]]); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - - $this->assertLinksEqual($this->expectedWithOverride->getRequires(), $newRoot->getRequires()); - $this->assertLinksEqual($this->expectedWithOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertEquals($this->expectedWithOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertEquals($this->expectedWithOverride->getDevAutoload(), $newRoot->getDevAutoload()); - $this->assertLinksEqual($this->expectedWithOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedWithOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedWithOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedWithOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedWithOverride->getSuggests(), $newRoot->getSuggests()); - } - - public function testMagentoUpdateNoDev() - { - $this->rootUpdateCommand->setApplication($this->application); - $this->input->method('getOption')->willReturnMap([['no-dev', true]]); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - - $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); - $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); - - $this->assertLinksNotEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); - } - - public function testMagentoUpdateNoAutoloader() - { - $this->rootUpdateCommand->setApplication($this->application); - $this->input->method('getOption')->willReturnMap([['no-autoloader', true]]); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $this->rootUpdateCommand->magentoUpdate($this->input, $this->composer); - - $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); - $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); - - $this->assertNotEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); - } - - /** - * Setup test data, expected results, and necessary mocked objects - */ public function setUp() { - /** - * Create instance of RootUpdateCommand for testing - */ $this->rootUpdateCommand = new RootUpdateCommand(); - - /** - * Set up input RootPackage objects for magentoUpdate() - */ - $baseRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); - $baseRoot->setRequires([ - new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '1.0.0'), null, '1.0.0'), - new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') - ]); - $baseRoot->setDevRequires($this->createLinks(2, 'vendor/dev-package')); - $baseRoot->setAutoload(['psr-4' => ['Magento\\' => 'src/Magento/']]); - $baseRoot->setDevAutoload(['psr-4' => ['Magento\\Tools\\' => 'dev/tools/Magento/Tools/']]); - $baseRoot->setConflicts($this->createLinks(2, 'vendor/conflicting')); - $baseRoot->setExtra(['extra-key1' => 'base1', 'extra-key2' => 'base2']); - $baseRoot->setProvides($this->createLinks(3, 'magento/sub-package')); - $baseRoot->setReplaces([]); - $baseRoot->setSuggests(['magento/sample-data' => 'Suggested Sample Data 1.0.0']); - $this->baseRoot = $baseRoot; - - $targetRoot = new RootPackage('magento/project-community-edition', '2.0.0.0', '2.0.0'); - $targetRoot->setRequires([ - new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), - new Link('root/pkg', 'vendor/package1', new Constraint('==', '2.0.0'), null, '2.0.0') - ]); - $targetRoot->setDevRequires($this->createLinks(1, 'vendor/dev-package')); - $targetRoot->setAutoload(['psr-4' => [ - 'Magento\\' => 'src/Magento/', - 'Zend\\Mvc\\Controller\\'=> 'setup/src/Zend/Mvc/Controller/' - ]]); - $targetRoot->setDevAutoload(['psr-4' => ['Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/']]); - $targetRoot->setConflicts($this->changeLink($this->createLinks(3, 'vendor/conflicting'), 1)); - $targetRoot->setExtra(['extra-key1' => 'target1', 'extra-key2' => 'target2', 'extra-key3' => ['a' => 'b']]); - $targetRoot->setProvides($this->changeLink($this->createLinks(3, 'magento/sub-package'), 1)); - $targetRoot->setReplaces($this->createLinks(3, 'replaced/package')); - $targetRoot->setSuggests([]); - $this->targetRoot = $targetRoot; - - $installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); - $installRoot->setRequires([ - new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), - new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') - ]); - $installRoot->setDevRequires($baseRoot->getDevRequires()); - $installRoot->setAutoload(array_merge($baseRoot->getAutoload(), ['files' => 'app/etc/Register.php'])); - $installRoot->setDevAutoload(['psr-4' => ['Magento\\Tools\\' => 'dev/tools/Magento/Tools2/']]); - $installRoot->setConflicts(array_merge( - array_slice($this->changeLink($baseRoot->getConflicts(), 0), 0, 1), - $this->createLinks(3, 'vendor/different-conflicting') - )); - $installRoot->setExtra(['extra-key1' => 'install1', 'extra-key2' => 'base2']); - $installRoot->setProvides($baseRoot->getProvides()); - $installRoot->setReplaces($baseRoot->getReplaces()); - $installRoot->setSuggests([ - 'magento/sample-data' => 'Suggested Sample Data 1.0.0', - 'vendor/suggested' => 'Another Suggested Package' - ]); - $this->installRoot = $installRoot; - - /** - * Set up expected results from magentoUpdate() with and without overriding conflicting install values - */ - $expectedNoOverride = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); - $expectedNoOverride->setRequires($targetRoot->getRequires()); - $expectedNoOverride->setDevRequires($targetRoot->getDevRequires()); - $expectedNoOverride->setAutoload( - array_merge($targetRoot->getAutoload(), ['files' => 'app/etc/Register.php']) - ); - $expectedNoOverride->setDevAutoload(['psr-4' => [ - 'Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/', - 'Magento\\Tools\\' => 'dev/tools/Magento/Tools2/' - ]]); - $expectedNoOverride->setConflicts( - array_merge($this->installRoot->getConflicts(), [$targetRoot->getConflicts()[2]]) - ); - $noOverrideExtra = $targetRoot->getExtra(); - $noOverrideExtra['extra-key1'] = $this->installRoot->getExtra()['extra-key1']; - $expectedNoOverride->setExtra($noOverrideExtra); - $expectedNoOverride->setProvides($targetRoot->getProvides()); - $expectedNoOverride->setReplaces($targetRoot->getReplaces()); - $expectedNoOverride->setSuggests(['vendor/suggested' => 'Another Suggested Package']); - $this->expectedNoOverride = $expectedNoOverride; - - $expectedWithOverride = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); - $expectedWithOverride->setRequires($expectedNoOverride->getRequires()); - $expectedWithOverride->setDevRequires($expectedNoOverride->getDevRequires()); - $expectedWithOverride->setAutoload($expectedNoOverride->getAutoload()); - $expectedWithOverride->setDevAutoload([ - 'psr-4' => ['Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/'] - ]); - $expectedWithOverride->setConflicts(array_merge( - $this->installRoot->getConflicts(), - array_slice($targetRoot->getConflicts(), 1) - )); - $expectedWithOverride->setExtra($targetRoot->getExtra()); - $expectedWithOverride->setProvides($expectedNoOverride->getProvides()); - $expectedWithOverride->setReplaces($expectedNoOverride->getReplaces()); - $expectedWithOverride->setSuggests($expectedNoOverride->getSuggests()); - $this->expectedWithOverride = $expectedWithOverride; - - /** - * Mock plugin boilerplate - */ $capability = $this->createPartialMock(Capability::class, ['getCommands']); $capability->method('getCommands')->willReturn([$this->rootUpdateCommand]); $pluginManager = $this->createPartialMock(PluginManager::class, ['getPluginCapabilities']); $pluginManager->method('getPluginCapabilities')->willReturn([$capability]); - $this->eventDispatcher = $this->createPartialMock(EventDispatcher::class, ['addListener']); - - /** - * Mock InputInterface for CLI options and IOInterface for interaction - */ $input = $this->getMockForAbstractClass(InputInterface::class); $input->method('getFirstArgument')->willReturn('update'); -// $input->method('hasParameterOption')->willReturnMap([ -// ['--no-plugins', false], -// ['--profile', false] -// ]); - $input->method('isInteractive')->willReturn(false); $input->method('getParameterOption')->with(['--working-dir', '-d'])->willReturn(false); $this->input = $input; - $this->io = $this->getMockForAbstractClass(IOInterface::class); - $this->rootUpdateCommand->setIO($this->io); - - /** - * Mock package repositories - */ - $repo = $this->createPartialMock(ComposerRepository::class, ['hasProviders', 'whatProvides']); - $repo->method('hasProviders')->willReturn(true); - $repo->method('whatProvides')->willReturn([$targetRoot, $baseRoot]); - $repoManager = $this->createPartialMock(RepositoryManager::class, ['getRepositories']); - $repoManager->method('getRepositories')->willReturn([$repo]); - $lockedRepo = $this->getMockForAbstractClass(RepositoryInterface::class); - $lockedRepo->method('getPackages')->willReturn([ - new Package('magento/product-community-edition', '1.0.0.0', '1.0.0') - ]); - $locker = $this->createPartialMock(Locker::class, ['isLocked', 'getLockedRepository']); - $locker->method('isLocked')->willReturn(true); - $locker->method('getLockedRepository')->willReturn($lockedRepo); - - /** - * Mock local Composer object - */ - $config = $this->createPartialMock(Config::class, ['get']); - $config->method('get')->with('platform')->willReturn([]); - $composer = $this->createPartialMock(Composer::class, [ - 'getPluginManager', - 'getLocker', - 'getPackage', - 'getRepositoryManager', - 'getEventDispatcher', - 'getConfig', - 'setPackage' - ]); + $composer = $this->createPartialMock(Composer::class, ['getPluginManager']); $composer->method('getPluginManager')->willReturn($pluginManager); - $composer->method('getLocker')->willReturn($locker); - $composer->method('getEventDispatcher')->willReturn($this->eventDispatcher); - $composer->method('getRepositoryManager')->willReturn($repoManager); - $composer->method('getPackage')->willReturn($installRoot); - $composer->method('getConfig')->willReturn($config); - $this->composer = $composer; $this->application = new TestApplication(); $this->application->setComposer($composer); } - - /** - * Data setup helper function to create a number of Link objects - * - * @param int $count - * @param string $target - * - * @return Link[] - */ - public function createLinks($count, $target = 'package/name') - { - $links = []; - for ($i = 1; $i <= $count; $i++) { - $links[] = new Link('root/pkg', "$target$i", new Constraint('==', "$i.0.0"), null, "$i.0.0"); - } - return $links; - } - - /** - * Data setup helper function to change the version constraint on one of the links in a list - * - * @param Link[] $links - * @param int $index - * - * @return Link[] - */ - public function changeLink($links, $index) - { - $result = $links; - $changeLink = $links[$index]; - $version = explode(' ', $changeLink->getConstraint()->getPrettyString())[1]; - $versionParts = array_map('intval', explode('.', $version)); - $versionParts[1] = $versionParts[1] + 1; - $version = implode('.', $versionParts); - $result[$index] = new Link( - $changeLink->getSource(), - $changeLink->getTarget(), - new Constraint('==', $version), - null, - $version - ); - return $result; - } - - /** - * Callback to capture an argument passed to a mock function in the given variable - * - * @param &$arg - * - * @return \PHPUnit\Framework\Constraint\Callback - */ - public function captureArg(&$arg) - { - return $this->callback(function ($argToMock) use (&$arg) { - $arg = $argToMock; - return true; - }); - } - - /** - * Assert that two arrays of links are equal without checking order - * - * @param Link[] $expected - * @param Link[] $actual - * - * @return void - */ - public function assertLinksEqual($expected, $actual) - { - $this->assertEquals(count($expected), count($actual)); - while (count($expected) > 0) { - $expectedLink = array_shift($expected); - $expectedSource = $expectedLink->getSource(); - $expectedTarget = $expectedLink->getTarget(); - $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); - $found = -1; - foreach ($actual as $key => $actualLink) { - if ($actualLink->getSource() === $expectedSource && - $actualLink->getTarget() === $expectedTarget && - $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { - $found = $key; - break; - } - } - $this->assertGreaterThan(-1, $found, "Could not find a link matching $expectedLink"); - unset($actual[$found]); - } - } - - /** - * Assert that two arrays of links are not equal without checking order - * - * @param Link[] $expected - * @param Link[] $actual - * - * @return void - */ - public function assertLinksNotEqual($expected, $actual) - { - if (count($expected) !== count($actual)) { - $this->assertNotEquals(count($expected), count($actual)); - return; - } - - while (count($expected) > 0) { - $expectedLink = array_shift($expected); - $expectedSource = $expectedLink->getSource(); - $expectedTarget = $expectedLink->getTarget(); - $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); - $found = -1; - foreach ($actual as $key => $actualLink) { - if ($actualLink->getSource() === $expectedSource && - $actualLink->getTarget() === $expectedTarget && - $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { - $found = $key; - break; - } - } - if ($found === -1) { - $this->assertEquals(-1, $found); - return; - } - unset($actual[$found]); - } - $this->fail('Expected Link sets to not be equal'); - } } diff --git a/tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php b/tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php new file mode 100644 index 0000000..2969c51 --- /dev/null +++ b/tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php @@ -0,0 +1,144 @@ +getConstraint()->getPrettyString())[1]; + $versionParts = array_map('intval', explode('.', $version)); + $versionParts[1] = $versionParts[1] + 1; + $version = implode('.', $versionParts); + $result[$index] = new Link( + $changeLink->getSource(), + $changeLink->getTarget(), + new Constraint('==', $version), + null, + $version + ); + return $result; + } + + /** + * Callback to capture an argument passed to a mock function in the given variable + * + * @param &$arg + * + * @return \PHPUnit\Framework\Constraint\Callback + */ + public static function captureArg(&$arg) + { + return static::callback(function ($argToMock) use (&$arg) { + $arg = $argToMock; + return true; + }); + } + + /** + * Assert that two arrays of links are equal without checking order + * + * @param Link[] $expected + * @param Link[] $actual + * + * @return void + */ + public static function assertLinksEqual($expected, $actual) + { + static::assertEquals(count($expected), count($actual)); + while (count($expected) > 0) { + $expectedLink = array_shift($expected); + $expectedSource = $expectedLink->getSource(); + $expectedTarget = $expectedLink->getTarget(); + $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); + $found = -1; + foreach ($actual as $key => $actualLink) { + if ($actualLink->getSource() === $expectedSource && + $actualLink->getTarget() === $expectedTarget && + $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { + $found = $key; + break; + } + } + static::assertGreaterThan(-1, $found, "Could not find a link matching $expectedLink"); + unset($actual[$found]); + } + } + + /** + * Assert that two arrays of links are not equal without checking order + * + * @param Link[] $expected + * @param Link[] $actual + * + * @return void + */ + public static function assertLinksNotEqual($expected, $actual) + { + if (count($expected) !== count($actual)) { + static::assertNotEquals(count($expected), count($actual)); + return; + } + + while (count($expected) > 0) { + $expectedLink = array_shift($expected); + $expectedSource = $expectedLink->getSource(); + $expectedTarget = $expectedLink->getTarget(); + $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); + $found = -1; + foreach ($actual as $key => $actualLink) { + if ($actualLink->getSource() === $expectedSource && + $actualLink->getTarget() === $expectedTarget && + $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { + $found = $key; + break; + } + } + if ($found === -1) { + static::assertEquals(-1, $found); + return; + } + unset($actual[$found]); + } + static::fail('Expected Link sets to not be equal'); + } +} From 5625792c64a507a7713f4f0f268c5dde6accafe2 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Thu, 15 Nov 2018 16:33:42 -0600 Subject: [PATCH 04/15] MC-5465: Refactoring to use the require command and add WebSetupWizard install command and README.md text --- .gitignore | 1 + README.md | 53 +- composer.json | 25 +- .../Plugin/RootUpdate/ConflictResolver.php | 460 ------------ .../Plugin/RootUpdate/MagentoRootUpdater.php | 700 ------------------ .../RootUpdate/PluginCommandProvider.php | 25 - .../Plugin/RootUpdate/RootUpdateCommand.php | 170 ----- .../AccessibleRootPackageLoader.php | 80 ++ .../ExtendableRequireCommand.php | 277 +++++++ .../ComposerRootUpdatePlugin/LICENSE.txt | 48 ++ .../ComposerRootUpdatePlugin/LICENSE_AFL.txt | 48 ++ .../Plugin/CommandProvider.php | 25 + .../Commands/MageRootRequireCommand.php | 271 +++++++ .../UpdatePluginNamespaceCommands.php | 95 +++ .../Plugin/PluginDefinition.php} | 18 +- .../ComposerRootUpdatePlugin/README.md | 52 ++ .../Setup/InstallData.php | 29 + .../Setup/RecurringData.php | 29 + .../Setup/UpgradeData.php | 29 + .../Setup/WebSetupWizardPluginInstaller.php | 261 +++++++ .../Updater/ConflictResolver.php | 450 +++++++++++ .../Updater/MagentoRootUpdater.php | 155 ++++ .../Updater/RootPackageRetriever.php | 407 ++++++++++ .../Utils/Console.php | 234 ++++++ .../Utils/PackageUtils.php | 90 +++ .../ComposerRootUpdatePlugin/composer.json | 28 + .../etc/module.xml | 2 +- .../registration.php | 2 +- .../Setup/RecurringData.php | 112 --- .../WebSetupWizardPluginInstaller.php | 211 ------ .../ComposerRootUpdatePluginTest.php | 215 ++++++ .../_files/expected_no_override.composer.json | 49 ++ .../_files/expected_override.composer.json | 52 ++ .../_files/test.composer.json | 41 + .../original_mage_root/composer.json | 41 + .../original_product/composer.json | 5 + .../target_mage_root/composer.json | 47 ++ .../target_product/composer.json | 5 + .../vendor_devpackage1/composer.json | 5 + .../vendor_devpackage1_1/composer.json | 5 + .../vendor_devpackage2/composer.json | 5 + .../vendor_package1_1/composer.json | 5 + .../vendor_package1_2/composer.json | 5 + .../RootUpdate/ConflictResolverTest.php | 437 ----------- .../Commands/MageRootRequireCommandTest.php} | 29 +- .../TestHelpers}/TestApplication.php | 8 +- .../UpdatePluginTestCase.php | 63 +- .../Updater/ConflictResolverTest.php | 378 ++++++++++ .../Updater}/MagentoRootUpdaterTest.php | 186 ++--- .../Updater/RootPackageRetrieverTest.php | 220 ++++++ 50 files changed, 3859 insertions(+), 2329 deletions(-) delete mode 100644 src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php delete mode 100644 src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php delete mode 100644 src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php delete mode 100644 src/Magento/Composer/Plugin/RootUpdate/RootUpdateCommand.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/LICENSE.txt create mode 100644 src/Magento/ComposerRootUpdatePlugin/LICENSE_AFL.txt create mode 100644 src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php rename src/Magento/{Composer/Plugin/RootUpdate/RootUpdatePlugin.php => ComposerRootUpdatePlugin/Plugin/PluginDefinition.php} (69%) create mode 100644 src/Magento/ComposerRootUpdatePlugin/README.md create mode 100644 src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Utils/Console.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php create mode 100644 src/Magento/ComposerRootUpdatePlugin/composer.json rename src/Magento/{RootUpdatePluginInstaller => ComposerRootUpdatePlugin}/etc/module.xml (78%) rename src/Magento/{RootUpdatePluginInstaller => ComposerRootUpdatePlugin}/registration.php (88%) delete mode 100644 src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php delete mode 100644 src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_no_override.composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_override.composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test.composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_mage_root/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_product/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_mage_root/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_product/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1_1/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage2/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_1/composer.json create mode 100644 tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_2/composer.json delete mode 100644 tests/Unit/Magento/Composer/Plugin/RootUpdate/ConflictResolverTest.php rename tests/Unit/Magento/{Composer/Plugin/RootUpdate/RootUpdateCommandTest.php => ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php} (68%) rename tests/Unit/Magento/{TestHelper => ComposerRootUpdatePlugin/TestHelpers}/TestApplication.php (95%) rename tests/Unit/Magento/{TestHelper => ComposerRootUpdatePlugin}/UpdatePluginTestCase.php (73%) create mode 100644 tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php rename tests/Unit/Magento/{Composer/Plugin/RootUpdate => ComposerRootUpdatePlugin/Updater}/MagentoRootUpdaterTest.php (63%) create mode 100644 tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php diff --git a/.gitignore b/.gitignore index 5049f84..33d515d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor +/tests/Integration/Magento/ComposerRootUpdatePlugin/tmp /.idea diff --git a/README.md b/README.md index 0a95e04..bd0aec3 100644 --- a/README.md +++ b/README.md @@ -1 +1,52 @@ -# composer-root-update-plugin \ No newline at end of file +# Overview +## Purpose of plugin + +The **magento/composer-root-update-plugin** Composer plugin resolves changes that need to be made to the root project `composer.json` file before updating to a new Magento product requirement. + +This is accomplished by comparing the root `composer.json` file for the Magento project corresponding to the Magento version and edition in the current installation with the Magento project `composer.json` file for the target Magento product package when the `composer require` command runs and applying any deltas found between the two files if they do not conflict with the existing `composer.json` file in the Magento root directory. + +# Getting Started +## System requirements +The **magento/composer-root-update-plugin** package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. + +## Installation +To install the plugin, run `composer require magento/composer-root-update-plugin ~1.0` in the Magento root directory. + +# Usage +The plugin adds functionality to the `composer require` command when a new Magento product package is required, and in most cases will not need additional options or commands run to function. + +If the `composer require` command for the target Magento package fails, one of the following may be necessary. + +## Installations that started with another Magento product +If the local Magento installation has previously been updated from a previous Magento product version or edition, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. + +In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. + + composer require --previous-magento-package = + +## Conflicting custom values +If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. + +To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. + +To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-magento-values` option. + +## Bypassing the plugin +To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root` option. + +## Refreshing the plugin for the Web Setup Wizard +If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. + +To reinstall the plugin in `var`, run the following command in the Magento root directory. + + composer magento-update-plugin install + +# License + +Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license. + +[Open Software License (OSL 3.0)](https://opensource.org/licenses/osl-3.0.php). +Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. + +Subject to Licensee's payment of fees and compliance with the terms and conditions of the MEE License, the MEE License supersedes the OSL 3.0 license for each source file. +Please see LICENSE_EE.txt for the full text of the MEE License or visit https://magento.com/legal/terms/enterprise. diff --git a/composer.json b/composer.json index b9e0a78..ff77d54 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,25 @@ { - "name": "magento/composer-root-update-plugin", - "description": "Plugin to look ahead for Magento project root changes when running composer update for new Magento versions", - "version": "1.0.0-beta2", + "description": "Git source for the Magento root update lookahead composer plugin", "license": [ "OSL-3.0", "AFL-3.0" ], - "type": "composer-plugin", + "type": "project", "require": { - "composer/composer": "<=1.7.2", - "composer-plugin-api": "^1.1" + "composer/composer": "<=1.8.0", + "composer-plugin-api": "^1.0" }, "require-dev": { "phpunit/phpunit": "~6.5.0" }, "autoload": { - "files": [ - "src/Magento/RootUpdatePluginInstaller/registration.php" - ], "psr-4": { - "Magento\\": "src/Magento/" + "Magento\\ComposerRootUpdatePlugin\\": "src/Magento/ComposerRootUpdatePlugin/" } }, "autoload-dev": { "psr-4": { - "Magento\\TestHelper\\": "tests/Unit/Magento/TestHelper/" + "Magento\\ComposerRootUpdatePlugin\\": "tests/Unit/Magento/ComposerRootUpdatePlugin/" } - }, - "extra": { - "class": "Magento\\Composer\\Plugin\\RootUpdate\\RootUpdatePlugin" - }, - "suggests": { - "magento/framework": "Enables the Magento Composer Root Update Plugin's functionality for the Web Setup Wizard" } -} \ No newline at end of file +} diff --git a/src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php b/src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php deleted file mode 100644 index 3a8da4f..0000000 --- a/src/Magento/Composer/Plugin/RootUpdate/ConflictResolver.php +++ /dev/null @@ -1,460 +0,0 @@ -io = $io; - $this->interactive = $interactive; - $this->override = $override; - $this->targetLabel = $targetLabel; - $this->baseLabel = $baseLabel; - $this->jsonChanges = []; - } - - /** - * Find value deltas from original->target version and resolve any conflicts with overlapping user changes - * - * @param string $field - * @param array|mixed|null $baseVal - * @param array|mixed|null $targetVal - * @param array|mixed|null $installVal - * @param string|null $prettyBase - * @param string|null $prettyTarget - * @param string|null $prettyInstall - * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the existing composer.json file, null for no change - */ - public function findResolution( - $field, - $baseVal, - $targetVal, - $installVal, - $prettyBase = null, - $prettyTarget = null, - $prettyInstall = null - ) { - $io = $this->io; - if ($prettyBase === null) { - $prettyBase = json_encode($baseVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyBase = trim($prettyBase, "'\""); - } - if ($prettyTarget === null) { - $prettyTarget = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyTarget = trim($prettyTarget, "'\""); - } - if ($prettyInstall === null) { - $prettyInstall = json_encode($installVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyInstall = trim($prettyInstall, "'\""); - } - - $targetLabel = $this->targetLabel; - $baseLabel = $this->baseLabel; - - $action = null; - $conflictDesc = null; - - if ($baseVal == $targetVal || $installVal == $targetVal) { - $action = null; - } elseif ($baseVal === null) { - if ($installVal === null) { - $action = static::ADD_VAL; - } else { - $action = static::CHANGE_VAL; - $conflictDesc = "add $field=$prettyTarget but it is instead $prettyInstall"; - } - } elseif ($targetVal === null) { - $action = static::REMOVE_VAL; - if ($installVal !== $baseVal) { - $conflictDesc = "remove the $field=$prettyBase entry in $baseLabel but it is instead $prettyInstall"; - } - } else { - $action = static::CHANGE_VAL; - if ($installVal !== $baseVal) { - $conflictDesc = "update $field to $prettyTarget from $prettyBase in $baseLabel"; - if ($installVal === null) { - $action = static::ADD_VAL; - $conflictDesc = "$conflictDesc but the field has been removed"; - } else { - $conflictDesc = "$conflictDesc but it is instead $prettyInstall"; - } - } - } - - if ($conflictDesc !== null) { - $conflictDesc = "$targetLabel is trying to $conflictDesc in this installation"; - - $shouldOverride = $this->override; - if ($this->override) { - $overrideMessage = "$conflictDesc.\n Overriding local changes due to --" . - RootUpdateCommand::OVERRIDE_OPT . '.'; - $io->writeError($overrideMessage); - } else { - $shouldOverride = $this->getConfirmation( - "$conflictDesc.\nWould you like to override the local changes?" - ); - } - - if (!$shouldOverride) { - $io->writeError("$conflictDesc and will not be changed. Re-run using " . - '--' . RootUpdateCommand::OVERRIDE_OPT . ' or --' . RootUpdateCommand::INTERACTIVE_OPT . - ' to override with Magento values.'); - $action = null; - } - } - - return $action; - } - - /** - * Process changes to corresponding sets of package version links - * - * @param string $section - * @param Link[] $baseLinks - * @param Link[] $targetLinks - * @param Link[] $installLinks - * @param callable $setterCallback - * @return void - */ - public function resolveLinkSection($section, $baseLinks, $targetLinks, $installLinks, $setterCallback) - { - /** @var Link[] $baseMap */ - $baseMap = static::linksToMap($baseLinks); - - /** @var Link[] $targetMap */ - $targetMap = static::linksToMap($targetLinks); - - /** @var Link[] $installMap */ - $installMap = static::linksToMap($installLinks); - - $adds = []; - $removes = []; - $changes = []; - $magePackages = array_unique(array_merge(array_keys($baseMap), array_keys($targetMap))); - foreach ($magePackages as $package) { - if ($section === 'require' && MagentoRootUpdater::getMagentoPackageInfo($package)) { - continue; - } - $field = "$section:$package"; - $baseConstraint = key_exists($package, $baseMap) ? $baseMap[$package]->getConstraint() : null; - $baseVal = ($baseConstraint === null) ? null : $baseConstraint->__toString(); - $prettyBaseVal = ($baseConstraint === null) ? null : $baseConstraint->getPrettyString(); - $targetConstraint = key_exists($package, $targetMap) ? $targetMap[$package]->getConstraint() : null; - $targetVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); - $prettyTargetVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); - $installConstraint = key_exists($package, $installMap) ? $installMap[$package]->getConstraint() : null; - $installVal = ($installConstraint === null) ? null : $installConstraint->__toString(); - $prettyInstallVal = ($installConstraint === null) ? null : $installConstraint->getPrettyString(); - - $action = $this->findResolution( - $field, - $baseVal, - $targetVal, - $installVal, - $prettyBaseVal, - $prettyTargetVal, - $prettyInstallVal - ); - if ($action == static::ADD_VAL) { - $adds[$package] = $targetMap[$package]; - } elseif ($action == static::REMOVE_VAL) { - $removes[] = $package; - } elseif ($action == static::CHANGE_VAL) { - $changes[$package] = $targetMap[$package]; - } - } - - $changed = false; - if ($adds !== []) { - $changed = true; - $prettyAdds = array_map(function ($package) use ($adds) { - $newVal = $adds[$package]->getConstraint()->getPrettyString(); - return "$package=$newVal"; - }, array_keys($adds)); - $this->verboseLog("Adding $section constraints: " . implode(', ', $prettyAdds)); - } - if ($removes !== []) { - $changed = true; - $this->verboseLog("Removing $section entries: " . implode(', ', $removes)); - } - if ($changes !== []) { - $changed = true; - $prettyChanges = array_map(function ($package) use ($changes) { - $newVal = $changes[$package]->getConstraint()->getPrettyString(); - return "$package=$newVal"; - }, array_keys($changes)); - $this->verboseLog("Updating $section constraints: " . implode(', ', $prettyChanges)); - } - - if ($changed) { - $replacements = array_values($adds); - - /** @var Link $installLink */ - foreach ($installMap as $package => $installLink) { - if (in_array($package, $removes)) { - continue; - } elseif (key_exists($package, $changes)) { - $replacements[] = $changes[$package]; - } else { - $replacements[] = $installLink; - } - } - - $newJson = []; - /** @var Link $link */ - foreach ($replacements as $link) { - $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); - } - - call_user_func($setterCallback, $replacements); - $this->jsonChanges[$section] = $newJson; - } - } - - /** - * Process changes to an array (non-package link) section - * - * @param string $section - * @param array|mixed|null $baseVal - * @param array|mixed|null $targetVal - * @param array|mixed|null $installVal - * @param callable $setterCallback - * @return void - */ - public function resolveArraySection($section, $baseVal, $targetVal, $installVal, $setterCallback) - { - $resolution = $this->resolveNestedArray($section, $baseVal, $targetVal, $installVal); - if ($resolution['changed']) { - call_user_func($setterCallback, $resolution['value']); - $this->jsonChanges[$section] = $resolution['value']; - } - } - - /** - * Process changes to arrays that could be nested - * - * Associative arrays are resolved recursively and non-associative arrays are treated as unordered sets - * - * @param string $field - * @param array|mixed|null $baseVal - * @param array|mixed|null $targetVal - * @param array|mixed|null $installVal - * @return array Two-element array: ['changed' => boolean, 'value' => updated value], null and empty array values - * indicate the entry should be removed from the parent - */ - public function resolveNestedArray($field, $baseVal, $targetVal, $installVal) - { - $valChanged = false; - $result = $installVal ?? []; - - if (is_array($baseVal) && is_array($targetVal) && is_array($installVal)) { - $baseAssociative = []; - $baseFlat = []; - foreach ($baseVal as $key => $value) { - if (is_string($key)) { - $baseAssociative[$key] = $value; - } else { - $baseFlat[] = $value; - } - } - - $targetAssociative = []; - $targetFlat = []; - foreach ($targetVal as $key => $value) { - if (is_string($key)) { - $targetAssociative[$key] = $value; - } else { - $targetFlat[] = $value; - } - } - - $installAssociative = []; - $installFlat = []; - foreach ($installVal as $key => $value) { - if (is_string($key)) { - $installAssociative[$key] = $value; - } else { - $installFlat[] = $value; - } - } - - $associativeResult = array_filter($result, 'is_string', ARRAY_FILTER_USE_KEY); - $mageKeys = array_unique(array_merge(array_keys($baseAssociative), array_keys($targetAssociative))); - foreach ($mageKeys as $key) { - $baseNestedVal = $baseAssociative[$key] ?? []; - $targetNestedVal = $targetAssociative[$key] ?? []; - $installNestedVal = $installAssociative[$key] ?? []; - - $resolution = $this->resolveNestedArray( - "$field.$key", - $baseNestedVal, - $targetNestedVal, - $installNestedVal - ); - if ($resolution['value'] === null || $resolution['value'] === []) { - if (key_exists($key, $associativeResult)) { - $valChanged = true; - unset($associativeResult[$key]); - } - } else { - $valChanged = $valChanged || $resolution['changed']; - $associativeResult[$key] = $resolution['value']; - } - } - - $flatResult = array_filter($result, 'is_int', ARRAY_FILTER_USE_KEY); - $flatAdds = array_diff(array_diff($targetFlat, $baseFlat), $flatResult); - if ($flatAdds !== []) { - $valChanged = true; - $this->verboseLog("Adding $field entries: " . implode(', ', $flatAdds)); - $flatResult = array_unique(array_merge($flatResult, $flatAdds)); - } - - $flatRemoves = array_intersect(array_diff($baseFlat, $targetFlat), $flatResult); - if ($flatRemoves !== []) { - $valChanged = true; - $this->verboseLog("Removing $field entries: " . implode(', ', $flatRemoves)); - $flatResult = array_diff($flatResult, $flatRemoves); - } - - $result = array_merge($flatResult, $associativeResult); - } else { - // Some or all of the values aren't arrays so they should all be compared as non-array values - $action = $this->findResolution($field, $baseVal, $targetVal, $installVal); - $prettyTargetVal = json_encode($targetVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if ($action == static::ADD_VAL) { - $valChanged = true; - $this->verboseLog("Adding $field entry: $prettyTargetVal"); - $result = $targetVal; - } elseif ($action == static::CHANGE_VAL) { - $valChanged = true; - $this->verboseLog("Updating $field entry: $prettyTargetVal"); - $result = $targetVal; - } elseif ($action == static::REMOVE_VAL) { - $valChanged = true; - $this->verboseLog("Removing $field entry"); - $result = null; - } - } - - return ['changed' => $valChanged, 'value' => $result]; - } - - /** - * Get the json array representation of the changed fields - * - * @return array - */ - public function getJsonChanges() - { - return $this->jsonChanges; - } - - /** - * If interactive, ask the given question and return the result, otherwise return the default - * - * @param string $question - * @param bool $default - * @return bool - */ - private function getConfirmation($question, $default = false) - { - $result = $default; - if ($this->interactive) { - if (!$this->io->isInteractive()) { - throw new \InvalidArgumentException( - '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' - ); - } - $opts = $default ? 'Y,n' : 'y,N'; - $result = $this->io->askConfirmation("$question [$opts]? ", $default); - } - return $result; - } - - /** - * Label and log the given message if output is set to verbose - * - * @param string $message - * @return void - */ - private function verboseLog($message) - { - $label = $this->targetLabel; - $this->io->writeError(" [$label] $message", true, IOInterface::VERBOSE); - } - - /** - * Helper function to convert a set of links to an associative array with target package names as keys - * - * @param Link[] $links - * @return array - */ - private function linksToMap($links) - { - $targets = array_map(function ($link) { - /** @var Link $link */ - return $link->getTarget(); - }, $links); - return array_combine($targets, $links); - } -} diff --git a/src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php b/src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php deleted file mode 100644 index 195ebec..0000000 --- a/src/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdater.php +++ /dev/null @@ -1,700 +0,0 @@ -io = $io; - $this->composer = $composer; - $this->override = $input->getOption(RootUpdateCommand::OVERRIDE_OPT); - $this->interactive = $input->getOption(RootUpdateCommand::INTERACTIVE_OPT); - $this->fromRoot = static::formatRequirements($input->getOption(RootUpdateCommand::FROM_PRODUCT_OPT)); - $this->noDev = $input->getOption('no-dev'); - $this->noAutoloader = $input->getOption('no-autoloader'); - $this->dryRun = $input->getOption('dry-run'); - $this->ignorePlatformReqs = $input->getOption('ignore-platform-reqs'); - $this->jsonChanges = []; - $this->targetLabel = null; - $this->targetProduct = null; - $this->targetConstraint = null; - $this->strictConstraint = true; - } - - /** - * Look ahead to the target Magento version and execute any changes to the root composer.json file in-memory - * - * @return boolean Returns true if updates were necessary and prepared successfully - */ - public function runUpdate() - { - $composer = $this->composer; - $io = $this->io; - - $composerPath = $composer->getConfig()->getConfigSource()->getName(); - $locker = null; - $fromRoot = $this->fromRoot; - if (empty($fromRoot)) { - if (preg_match('/\/var\/composer\.json$/', $composerPath)) { - $parentDir = preg_replace('/\/var\/composer\.json$/', '', $composerPath); - if (file_exists("$parentDir/composer.json") && file_exists("$parentDir/composer.lock")) { - $locker = new Locker( - $io, - new JsonFile("$parentDir/composer.lock"), - $composer->getRepositoryManager(), - $composer->getInstallationManager(), - file_get_contents("$parentDir/composer.json") - ); - } - } - $locker = $locker ?? $composer->getLocker(); - } - - if (!empty($fromRoot) || ($locker !== null && $locker->isLocked())) { - $installRoot = $composer->getPackage(); - $targetRoot = null; - $targetConstraint = null; - $requiresPlugin = false; - foreach ($installRoot->getRequires() as $link) { - $packageInfo = static::getMagentoPackageInfo($link->getTarget()); - if ($packageInfo !== null) { - $targetConstraint = $link->getPrettyConstraint() ?? - $link->getConstraint()->getPrettyString() ?? - $link->getConstraint()->__toString(); - $edition = $packageInfo['edition']; - $this->targetProduct = "magento/product-$edition-edition"; - $this->targetConstraint = $targetConstraint; - $targetRoot = $this->fetchRoot( - $edition, - $targetConstraint, - $composer, - true - ); - } - $requiresPlugin = $requiresPlugin || ($link->getTarget() == RootUpdatePlugin::PACKAGE_NAME); - } - if (!$requiresPlugin) { - // If the plugin requirement has been removed but we're still trying to run (code still existing in the - // vendor directory), return without executing. - return false; - } - - if ($targetRoot == null || $targetRoot == false) { - throw new \RuntimeException('Magento root updates cannot run without a valid target package'); - } - - $targetVersion = $targetRoot->getVersion(); - $prettyTargetVersion = $targetRoot->getPrettyVersion() ?? $targetVersion; - $targetEd = static::getMagentoPackageInfo($targetRoot->getName())['edition']; - - $baseEd = null; - $baseVersion = null; - $prettyBaseVersion = null; - if (empty($fromRoot)) { - $lockPackages = $locker->getLockedRepository()->getPackages(); - foreach ($lockPackages as $lockedPackage) { - $packageInfo = static::getMagentoPackageInfo($lockedPackage->getName()); - if ($packageInfo !== null && $packageInfo['type'] == 'product') { - $baseEd = $packageInfo['edition']; - $baseVersion = $lockedPackage->getVersion(); - $prettyBaseVersion = $lockedPackage->getPrettyVersion() ?? $baseVersion; - - // Both editions exist for enterprise, so stop at enterprise to not overwrite with community - if ($baseEd == 'enterprise') { - break; - } - } - } - } else { - $baseEd = $fromRoot['edition']; - $baseVersion = $fromRoot['version']; - $prettyBaseVersion = $fromRoot['version']; - } - - $baseRoot = null; - if ($baseEd != null && $baseVersion != null) { - $baseRoot = $this->fetchRoot( - $baseEd, - $prettyBaseVersion, - $composer - ); - } - - if ($baseRoot == null || $baseRoot == false) { - if ($baseEd == null || $baseVersion == null) { - $io->writeError( - 'No Magento product package was found in the current installation.' - ); - } else { - $io->writeError( - 'The Magento project package corresponding to the currently installed ' . - "\"magento/product-$baseEd-edition: $prettyBaseVersion\" package is unavailable." - ); - } - - $overrideRoot = $this->override; - if (!$overrideRoot) { - $question = 'Would you like to update the root composer.json file anyway? This will ' . - 'override changes you may have made to the default installation if the same values ' . - "are different in magento/project-$targetEd-edition $prettyTargetVersion"; - $overrideRoot = $this->getConfirmation($question); - } - if ($overrideRoot) { - $baseRoot = $installRoot; - } else { - $io->writeError('Skipping Magento composer.json update.'); - return false; - } - } elseif ($baseEd === $targetEd && $baseVersion === $targetVersion) { - $io->writeError( - 'The Magento product requirement matched the current installation; no root updates are required', - true, - IOInterface::VERBOSE - ); - return false; - } - - $baseEd = static::getMagentoPackageInfo($baseRoot->getName())['edition']; - $this->targetLabel = 'Magento ' . ucfirst($targetEd) . " Edition $prettyTargetVersion"; - $baseLabel = 'Magento ' . ucfirst($baseEd) . " Edition $prettyBaseVersion"; - - $io->writeError( - "Base Magento project package version: magento/project-$baseEd-edition $prettyBaseVersion", - true, - IOInterface::DEBUG - ); - - $this->updateComposer($baseLabel, $baseRoot, $targetRoot, $installRoot); - - if (!$this->dryRun) { - // Add composer.json write code at the head of the list of post command script hooks so - // the file is accurate for any other hooks that may exist in the installation that use it - $eventDispatcher = $composer->getEventDispatcher(); - $eventDispatcher->addListener( - ScriptEvents::POST_UPDATE_CMD, - [$this, 'writeUpdatedRoot'], - PHP_INT_MAX - ); - } - - if ($this->jsonChanges !== []) { - return true; - } - } - - return false; - } - - /** - * Update the composer object for each relevant section and track json changes - * - * @param string $baseLabel - * @param PackageInterface $baseRoot - * @param PackageInterface $targetRoot - * @param PackageInterface $installRoot - * @return void - */ - protected function updateComposer($baseLabel, $baseRoot, $targetRoot, $installRoot) - { - $composer = $this->composer; - - $resolver = new ConflictResolver( - $this->io, - $this->interactive, - $this->override, - $this->targetLabel, - $baseLabel - ); - - $changedRoot = $composer->getPackage(); - $resolver->resolveLinkSection( - 'require', - $baseRoot->getRequires(), - $targetRoot->getRequires(), - $installRoot->getRequires(), - [$changedRoot, 'setRequires'] - ); - - if (!$this->noDev) { - $resolver->resolveLinkSection( - 'require-dev', - $baseRoot->getDevRequires(), - $targetRoot->getDevRequires(), - $installRoot->getDevRequires(), - [$changedRoot, 'setDevRequires'] - ); - } - - if (!$this->noAutoloader) { - $resolver->resolveArraySection( - 'autoload', - $baseRoot->getAutoload(), - $targetRoot->getAutoload(), - $installRoot->getAutoload(), - [$changedRoot, 'setAutoload'] - ); - - if (!$this->noDev) { - $resolver->resolveArraySection( - 'autoload-dev', - $baseRoot->getDevAutoload(), - $targetRoot->getDevAutoload(), - $installRoot->getDevAutoload(), - [$changedRoot, 'setDevAutoload'] - ); - } - } - - $resolver->resolveLinkSection( - 'conflict', - $baseRoot->getConflicts(), - $targetRoot->getConflicts(), - $installRoot->getConflicts(), - [$changedRoot, 'setConflicts'] - ); - - $resolver->resolveArraySection( - 'extra', - $baseRoot->getExtra(), - $targetRoot->getExtra(), - $installRoot->getExtra(), - [$changedRoot, 'setExtra'] - ); - - $resolver->resolveLinkSection( - 'provides', - $baseRoot->getProvides(), - $targetRoot->getProvides(), - $installRoot->getProvides(), - [$changedRoot, 'setProvides'] - ); - - $resolver->resolveLinkSection( - 'replaces', - $baseRoot->getReplaces(), - $targetRoot->getReplaces(), - $installRoot->getReplaces(), - [$changedRoot, 'setReplaces'] - ); - - $resolver->resolveArraySection( - 'suggests', - $baseRoot->getSuggests(), - $targetRoot->getSuggests(), - $installRoot->getSuggests(), - [$changedRoot, 'setSuggests'] - ); - - $composer->setPackage($changedRoot); - $this->composer = $composer; - - if ($resolver->getJsonChanges() !== []) { - $this->jsonChanges = $resolver->getJsonChanges(); - } - } - - /** - * If interactive, ask the given question and return the result, otherwise return the default - * - * @param string $question - * @param bool $default - * @return bool - */ - protected function getConfirmation($question, $default = false) - { - $result = $default; - if ($this->interactive) { - if (!$this->io->isInteractive()) { - throw new \InvalidArgumentException( - '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' - ); - } - $opts = $default ? 'Y,n' : 'y,N'; - $result = $this->io->askConfirmation("$question [$opts]? ", $default); - } - return $result; - } - - /** - * Write the changed composer.json file - * - * Called as a script event on non-dry runs after a successful update before other post-update-cmd scripts - * - * @return void - * @throws FilesystemException if the composer.json read or write failed - */ - public function writeUpdatedRoot() - { - if ($this->jsonChanges === []) { - return; - } - $filePath = $this->composer->getConfig()->getConfigSource()->getName(); - $io = $this->io; - $json = json_decode(file_get_contents($filePath), true); - if ($json === null) { - throw new FilesystemException('Failed to read ' . $filePath); - } - - foreach ($this->jsonChanges as $section => $newContents) { - if ($newContents === null || $newContents === []) { - if (key_exists($section, $json)) { - unset($json[$section]); - } - } else { - $json[$section] = $newContents; - } - } - - $this->verboseLog('Writing changes to the root composer.json...'); - - $retVal = file_put_contents( - $filePath, - json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) - ); - - if ($retVal === false) { - throw new FilesystemException('Failed to write updated Magento root values to ' . $filePath); - } - $io->writeError('' . $filePath . ' has been updated'); - } - - /** - * Label and log the given message if output is set to verbose - * - * @param string $message - * @return void - */ - protected function verboseLog($message) - { - $label = $this->targetLabel; - $this->io->writeError(" [$label] $message", true, IOInterface::VERBOSE); - } - - /** - * Helper function to extract the edition and package type if it is a Magento package name - * - * @param string $packageName - * @return array|null - */ - public static function getMagentoPackageInfo($packageName) - { - $regex = '/^magento\/(?product|project)-(?community|enterprise)-edition$/'; - if (preg_match($regex, $packageName, $matches)) { - return $matches; - } else { - return null; - } - } - - /** - * Retrieve the Magento root package for an edition and version constraint from the composer file's repositories - * - * @param string $edition - * @param string $constraint - * @param Composer $composer - * @param boolean $isTarget - * @return \Composer\Package\PackageInterface|bool Best root package candidate or false if no valid packages found - */ - protected function fetchRoot($edition, $constraint, $composer, $isTarget = false) - { - $rootName = strtolower("magento/project-$edition-edition"); - $phpVersion = null; - $prettyPhpVersion = null; - $versionParser = new VersionParser(); - $parsedConstraint = $versionParser->parseConstraints($constraint); - - $minimumStability = $composer->getPackage()->getMinimumStability() ?? 'stable'; - $stabilityFlags = $this->extractStabilityFlags($rootName, $constraint, $minimumStability); - $stability = key_exists($rootName, $stabilityFlags) - ? array_search($stabilityFlags[$rootName], BasePackage::$stabilities) - : $minimumStability; - $this->io->writeError( - "Minimum stability for \"$rootName: $constraint\": $stability", - true, - IOInterface::DEBUG - ); - $pool = new Pool( - $stability, - $stabilityFlags, - [$rootName => $parsedConstraint] - ); - $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); - $pool->addRepository($repos); - - if ($isTarget) { - if (strpbrk($parsedConstraint->__toString(), '[]|<>!') !== false) { - $this->strictConstraint = false; - $this->io->writeError( - "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . - 'the Magento root updater might not accurately determine the version to use according to other ' . - 'requirements in this installation. It is recommended to use an exact version number.' - ); - } - if (!$this->ignorePlatformReqs) { - $platformOverrides = $composer->getConfig()->get('platform') ?: []; - $platform = new PlatformRepository([], $platformOverrides); - $phpPackage = $platform->findPackage('php', '*'); - if ($phpPackage != null) { - $phpVersion = $phpPackage->getVersion(); - $prettyPhpVersion = $phpPackage->getPrettyVersion(); - } - } - } - - $versionSelector = new VersionSelector($pool); - $result = $versionSelector->findBestCandidate($rootName, $constraint, $phpVersion); - - if ($result == false) { - $err = "Could not find a Magento project package matching \"magento/product-$edition-edition $constraint\""; - if ($phpVersion) { - $err = "$err for PHP version $prettyPhpVersion"; - } - $this->io->writeError("$err", true, IOInterface::QUIET); - } - - return $result; - } - - /** - * Helper method to construct stability flags needed to fetch new root packages - * - * @see RootPackageLoader::extractStabilityFlags() - * - * @param string $reqName - * @param string $reqVersion - * @param string $minimumStability - * @return array - */ - protected function extractStabilityFlags($reqName, $reqVersion, $minimumStability) - { - $stabilityFlags = []; - $stabilityMap = BasePackage::$stabilities; - $minimumStability = $stabilityMap[$minimumStability]; - $constraints = []; - - // extract all sub-constraints in case it is an OR/AND multi-constraint - $orSplit = preg_split('{\s*\|\|?\s*}', trim($reqVersion)); - foreach ($orSplit as $orConstraint) { - $andSplit = preg_split('{(?< ,]) *(? $stability) { - continue; - } - $stabilityFlags[$reqName] = $stability; - $match = true; - } - } - - if (!$match) { - foreach ($constraints as $constraint) { - // infer flags for requirements that have an explicit -dev or -beta version specified but only - // for those that are more unstable than the minimumStability or existing flags - $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $constraint); - if (preg_match('{^[^,\s@]+$}', $reqVersion) - && 'stable' !== ($stabilityName = VersionParser::parseStability($reqVersion))) { - $stability = $stabilityMap[$stabilityName]; - if ((isset($stabilityFlags[$reqName]) && $stabilityFlags[$reqName] > $stability) - || ($minimumStability > $stability)) { - continue; - } - $stabilityFlags[$reqName] = $stability; - } - } - } - - return $stabilityFlags; - } - - /** - * Parse inputs to the FROM_PRODUCT_OPT option - * - * @param string[] $requirements - * @return array[] - */ - protected static function formatRequirements($requirements) - { - if (empty($requirements)) { - return null; - } - $parser = new VersionParser(); - $requirements = $parser->parseNameVersionPairs($requirements); - $opt = '--' . RootUpdateCommand::FROM_PRODUCT_OPT; - if (count($requirements) !== 1) { - throw new InvalidOptionException("'$opt' accepts only one package requirement"); - } elseif (count($requirements[0]) !== 2) { - throw new InvalidOptionException("'$opt' requires both a package and version"); - } - $req = $requirements[0]; - $name = $req['name']; - $packageInfo = static::getMagentoPackageInfo($name); - if ($packageInfo == null || $packageInfo['type'] !== 'product') { - throw new InvalidOptionException("'$opt' accepts only Magento product packages; \"$name\" given"); - } - - return ['edition' => $packageInfo['edition'], 'version' => $req['version']]; - } - - /** - * Get the Composer object - * - * @return Composer - */ - public function getComposer() - { - return $this->composer; - } - - /** - * Was a strict constraint used for the target product requirement - * - * @return bool - */ - public function isStrictConstraint() - { - return $this->strictConstraint; - } - - /** - * Get the constraint used for the target product requirement - * - * @return string - */ - public function getTargetConstraint() - { - return $this->targetConstraint; - } - - /** - * Get the package name for the target product requirement - * - * @return string - */ - public function getTargetProduct() - { - return $this->targetProduct; - } - - /** - * Get the json array representation of the root composer updates - * - * @return array - */ - public function getJsonChanges() - { - return $this->jsonChanges; - } -} diff --git a/src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php b/src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php deleted file mode 100644 index 3c16b13..0000000 --- a/src/Magento/Composer/Plugin/RootUpdate/PluginCommandProvider.php +++ /dev/null @@ -1,25 +0,0 @@ -add() is called to pass the verification check but changed to update before - // being added to the command registry - $this->setName('update'); - parent::setApplication($application); - } - - /** - * Use the native UpdateCommand config with options/doc additions for the Magento root composer.json update - * - * @return void - */ - public function configure() - { - parent::configure(); - $this->setName('update-magento-root'); - $this->addOption( - static::SKIP_OPT, - null, - null, - 'Skip the Magento root composer.json update.' - ); - $this->addOption( - static::OVERRIDE_OPT, - null, - null, - 'Override conflicting root composer.json customizations with expected Magento project values.' - ); - $this->addOption( - static::INTERACTIVE_OPT, - null, - null, - 'Interactive interface to resolve conflicts during the Magento root composer.json update.' - ); - $this->addOption( - static::ROOT_ONLY_OPT, - null, - null, - 'Update the root composer.json file with Magento changes without running the rest of the update process.' - ); - $this->addOption( - static::FROM_PRODUCT_OPT, - null, - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'Update the current root composer.json file with changes needed from a previously-installed product ' . - 'version, e.g. magento/product-community-edition=2.2.0' - ); - - $mageHelp = ' -Magento Root Updates: - With ' . RootUpdatePlugin::PACKAGE_NAME . ' installed, update will also check for and - execute any changes to the root composer.json file that exist between the Magento - project package corresponding to the currently-installed version and the project - for the target Magento product version if the package requirement has changed. - - By default, any changes that would affect values that have been customized in the - existing installation will not be applied. Using --' . static::OVERRIDE_OPT . ' will instead - apply all deltas found between the expected base project and the new version, - overriding any custom values. Use --' . static::INTERACTIVE_OPT . ' to interactively - resolve deltas that conflict with the existing installation. - - To skip the Magento root composer.json update, use --' . static::SKIP_OPT . '. -'; - $this->setHelp($this->getHelp() . $mageHelp); - - $mageDesc = ' If a Magento metapackage change is found, also make any associated composer.json changes.'; - $this->setDescription($this->getDescription() . $mageDesc); - } - - /** - * Look ahead at the target Magento version for root composer.json changes before running composer's native update - * - * @param InputInterface $input - * @param OutputInterface $output - * @return int|null null or 0 if everything went fine, or an error code - * @throws FilesystemException if the write operation failed when ROOT_ONLY_OPT is passed - */ - public function execute(InputInterface $input, OutputInterface $output) - { - if ($input->getOption('dry-run')) { - $output->setVerbosity(max(OutputInterface::VERBOSITY_VERBOSE, $output->getVerbosity())); - $input->setOption('verbose', true); - } - - $composer = $this->getComposer(); - $io = $this->getIO(); - - $updatePrepared = false; - $updater = new MagentoRootUpdater($io, $composer, $input); - try { - // Move the native UpdateCommand's deprecation message before the added Magento functionality - if ($input->getOption('dev')) { - $io->writeError('' . - 'You are using the deprecated option "dev". Dev packages are installed by default now.' . - ''); - $input->setOption('dev', false); - }; - - if (!$input->getOption('no-custom-installers') && !$input->getOption(static::SKIP_OPT)) { - // --no-custom-installers has been replaced with --no-plugins and should skip this functionality - $updatePrepared = $updater->runUpdate(); - if ($updatePrepared) { - $this->setComposer($updater->getComposer()); - } - } - } catch (\Exception $e) { - $io->writeError('Magento root update operation failed', true, IOInterface::QUIET); - $io->writeError($e->getMessage()); - } - - $errorCode = null; - if (!$input->getOption(static::ROOT_ONLY_OPT)) { - $errorCode = parent::execute($input, $output); - - if ($errorCode && !$updater->isStrictConstraint()) { - $io->writeError( - 'Recommended: Use a specific Magento version constraint instead of "' . - $updater->getTargetProduct() . ': ' . $updater->getTargetConstraint() . '"', - true, - IOInterface::QUIET - ); - } - } elseif (!$input->getOption('dry-run') && $updatePrepared) { - // If running a full update, writeUpdatedRoot() is called as a post-update-cmd event - $updater->writeUpdatedRoot(); - } - - return $errorCode; - } -} diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php new file mode 100644 index 0000000..5ba44d8 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php @@ -0,0 +1,80 @@ +< ,]) *(? $stability) { + continue; + } + $stabilityFlags[$reqName] = $stability; + $match = true; + } + } + + if (!$match) { + foreach ($constraints as $constraint) { + // infer flags for requirements that have an explicit -dev or -beta version specified but only + // for those that are more unstable than the minimumStability or existing flags + $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $constraint); + if (preg_match('{^[^,\s@]+$}', $reqVersion) + && 'stable' !== ($stabilityName = VersionParser::parseStability($reqVersion))) { + $stability = $stabilityMap[$stabilityName]; + if ((isset($stabilityFlags[$reqName]) && $stabilityFlags[$reqName] > $stability) + || ($minimumStability > $stability)) { + continue; + } + $stabilityFlags[$reqName] = $stability; + } + } + } + + return $stabilityFlags; + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php new file mode 100644 index 0000000..e51b40b --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php @@ -0,0 +1,277 @@ +fileName = null; + $this->jsonFile = null; + $this->mageNewlyCreated = null; + $this->mageComposerBackup = null; + $this->preferredStability = null; + $this->phpVersion = null; + } + + /** + * Validate composer.json file permissions and extract necessary info before new requirements are determined + * + * Copied first half of RequireCommand::execute(), which should run before the plugin's update operation + * + * @see RequireCommand::execute() + * + * @param InputInterface $input + * @return int|array + */ + protected function parseComposerJsonFile($input) + { + $file = Factory::getComposerFile(); + $io = $this->getIO(); + + $newlyCreated = !file_exists($file); + if ($newlyCreated && !file_put_contents($file, "{\n}\n")) { + $io->writeError(''.$file.' could not be created.'); + + return 1; + } + if (!is_readable($file)) { + $io->writeError(''.$file.' is not readable.'); + + return 1; + } + if (!is_writable($file)) { + $io->writeError(''.$file.' is not writable.'); + + return 1; + } + + if (filesize($file) === 0) { + file_put_contents($file, "{\n}\n"); + } + + $json = new JsonFile($file); + $composerBackup = file_get_contents($json->getPath()); + + $composer = $this->getComposer(true, $input->getOption('no-plugins')); + $repos = $composer->getRepositoryManager()->getRepositories(); + + $platformOverrides = $composer->getConfig()->get('platform') ?: []; + // initialize $this->repos as it is used by the parent InitCommand + $this->repos = new CompositeRepository(array_merge( + [new PlatformRepository([], $platformOverrides)], + $repos + )); + + if ($composer->getPackage()->getPreferStable()) { + $preferredStability = 'stable'; + } else { + $preferredStability = $composer->getPackage()->getMinimumStability(); + } + + $phpVersion = $this->repos->findPackage('php', '*')->getPrettyVersion(); + + $this->fileName = $file; + $this->jsonFile = $json; + $this->mageNewlyCreated = $newlyCreated; + $this->mageComposerBackup = $composerBackup; + $this->preferredStability = $preferredStability; + $this->phpVersion = $phpVersion; + return 0; + } + + /** + * Interactively ask for the requirement arguments + * + * Copied second half of InitCommand::determineRequirements() without calling findBestVersionAndNameForPackage(), + * which would try to use existing requirements before the plugin can update new Magento values + * + * @see InitCommand::determineRequirements() + * + * @return array + * @throws \Exception + */ + protected function getRequirementsInteractive() + { + $versionParser = new VersionParser(); + $io = $this->getIO(); + $requires = []; + while (null !== $package = $io->ask('Search for a package: ')) { + $matches = $this->findPackages($package); + + if (count($matches)) { + $exactMatch = null; + $choices = []; + foreach ($matches as $position => $foundPackage) { + $abandoned = ''; + if (isset($foundPackage['abandoned'])) { + if (is_string($foundPackage['abandoned'])) { + $replacement = sprintf('Use %s instead', $foundPackage['abandoned']); + } else { + $replacement = 'No replacement was suggested'; + } + $abandoned = sprintf('Abandoned. %s.', $replacement); + } + + $choices[] = sprintf(' %5s %s %s', "[$position]", $foundPackage['name'], $abandoned); + if ($foundPackage['name'] === $package) { + $exactMatch = true; + break; + } + } + + // no match, prompt which to pick + if (!$exactMatch) { + $io->writeError([ + '', + sprintf('Found %s packages matching %s', count($matches), $package), + '', + ]); + + $io->writeError($choices); + $io->writeError(''); + + $validator = function ($selection) use ($matches, $versionParser) { + if ('' === $selection) { + return false; + } + + if (is_numeric($selection) && isset($matches[(int) $selection])) { + $package = $matches[(int) $selection]; + + return $package['name']; + } + + if (preg_match('{^\s*(?P[\S/]+)(?:\s+(?P\S+))?\s*$}', $selection, $pkgMatches)) { + if (isset($pkgMatches['version'])) { + // parsing `acme/example ~2.3` + + // validate version constraint + $versionParser->parseConstraints($pkgMatches['version']); + + return $pkgMatches['name'].' '.$pkgMatches['version']; + } + + // parsing `acme/example` + return $pkgMatches['name']; + } + + throw new \Exception('Not a valid selection'); + }; + + $package = $io->askAndValidate( + 'Enter package # to add, or the complete package name if it is not listed: ', + $validator, + 3, + false + ); + } + + // no constraint yet, determine the best version automatically + if (false !== $package && false === strpos($package, ' ')) { + $validator = function ($input) { + $input = trim($input); + + return $input ?: false; + }; + + $constraint = $io->askAndValidate( + 'Enter the version constraint to require (or leave blank to use the latest version): ', + $validator, + 3, + false + ); + + if ($constraint !== false) { + $package .= ' '.$constraint; + } + } + + if (false !== $package) { + $requires[] = $package; + } + } + } + + return $requires; + } + + /** + * Reset the composer.json file after an operation failure + * + * Copied from RequireCommand::revertComposerFile() in Composer 1.8.0, it needs to be separate to use the plugin's + * file backup rather than the one that RequireCommand natively picks up, which will contain the plugin's changes + * + * @param string $message + * @return void + */ + protected function revertMageComposerFile($message) + { + $file = $this->fileName; + $io = $this->getIO(); + if ($this->mageNewlyCreated) { + if (file_exists($this->jsonFile->getPath())) { + $io->writeError("\n$message, deleting $file."); + unlink($this->jsonFile->getPath()); + } + } else { + $io->writeError("\n$message, " . + "reverting $file to its original content from before the Magento root update."); + file_put_contents($this->jsonFile->getPath(), $this->mageComposerBackup); + } + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/LICENSE.txt b/src/Magento/ComposerRootUpdatePlugin/LICENSE.txt new file mode 100644 index 0000000..49525fd --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/src/Magento/ComposerRootUpdatePlugin/LICENSE_AFL.txt b/src/Magento/ComposerRootUpdatePlugin/LICENSE_AFL.txt new file mode 100644 index 0000000..f39d641 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php new file mode 100644 index 0000000..20b68b0 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php @@ -0,0 +1,25 @@ +-edition='; + + /** + * @var string $commandName + */ + private $commandName; + + /** + * @var RootPackageRetriever $retriever + */ + private $retriever; + + /** + * Call the parent setApplication method but also change the command's name to update + * + * @param Application|null $application + * @return void + */ + public function setApplication(Application $application = null) + { + // In order to trick Composer into overriding its native RequireCommand with this object, the name needs to be + // different before Application->add() is called to pass the verification check but changed back before being + // added to the command registry + $this->setName($this->commandName); + parent::setApplication($application); + Console::setIO($this->getIO()); + } + + /** + * Use the native UpdateCommand config with options/doc additions for the Magento root composer.json update + * + * @return void + */ + public function configure() + { + parent::configure(); + + $origName = $this->getName(); + $this->commandName = $origName; + $this->retriever = null; + $this->setName('require-magento-root') + ->addOption( + static::SKIP_OPT, + null, + null, + 'Skip the Magento root composer.json update.' + ) + ->addOption( + static::OVERRIDE_OPT, + null, + null, + 'Override conflicting root composer.json customizations with expected Magento project values.' + ) + ->addOption( + static::INTERACTIVE_OPT, + null, + null, + 'Interactive interface to resolve conflicts during the Magento root composer.json update.' + ) + ->addOption( + static::PREVIOUS_PACKAGE_OPT, + null, + InputOption::VALUE_REQUIRED, + 'Use a previously-installed Magento product version as the base for composer.json updates', + static::PREV_OPT_HINT + ); + + $mageHelp = ' +Magento Root Updates: + With ' . PluginDefinition::PACKAGE_NAME . " installed, $origName will also check for and + execute any changes to the root composer.json file that exist between the Magento + project package corresponding to the currently-installed version and the project + for the target Magento product version if the package requirement has changed. + + By default, any changes that would affect values that have been customized in the + existing installation will not be applied. Using --" . static::OVERRIDE_OPT . ' will instead + apply all deltas found between the expected base project and the new version, + overriding any custom values. Use --' . static::INTERACTIVE_OPT . ' to interactively + resolve deltas that conflict with the existing installation. + + To skip the Magento root composer.json update, use --' . static::SKIP_OPT . '. +'; + $this->setHelp($this->getHelp() . $mageHelp); + + $mageDesc = ' If a Magento metapackage change is required, also make any associated composer.json changes.'; + $this->setDescription($this->getDescription() . $mageDesc); + } + + /** + * Look ahead at the target Magento version for root composer.json changes before running composer's native require + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null null or 0 if everything went fine, or an error code + * + * @throws \Exception + */ + public function execute(InputInterface $input, OutputInterface $output) + { + $updater = null; + Console::setIO($this->getIO()); + $fileParsed = $this->parseComposerJsonFile($input); + if ($fileParsed !== 0) { + return $fileParsed; + } + $didUpdate = false; + + $package = null; + $constraint = null; + $requires = $input->getArgument('packages'); + if (!$this->mageNewlyCreated && + !$input->getOption('no-plugins') && + !$input->getOption('dev') && + !$input->getOption(static::SKIP_OPT) + ) { + if (!$requires) { + $requires = $this->getRequirementsInteractive(); + $input->setArgument('packages', $requires); + } + + $requires = $this->normalizeRequirements($requires); + foreach ($requires as $requirement) { + $pkgEdition = PackageUtils::getMagentoProductEdition($requirement['name']); + if ($pkgEdition) { + $edition = $pkgEdition; + $package = "magento/product-$edition-edition"; + $constraint = isset($requirement['version']) ? $requirement['version'] : '*'; + + // Found a Magento product in the command arguments; try to run the updater + try { + $updater = new MagentoRootUpdater($this->getComposer()); + $didUpdate = $this->runUpdate($updater, $input, $edition, $constraint); + } catch (\Exception $e) { + $label = 'Magento ' . ucfirst($edition) . " Edition $constraint"; + $this->revertMageComposerFile("Update of composer.json with $label changes failed"); + Console::log($e->getMessage()); + $didUpdate = false; + } + + break; + } + } + + if ($didUpdate) { + // Update composer.json before the native execute(), as it reads the file instead of an in-memory object + $label = $this->retriever->getTargetLabel(); + Console::info("Updating composer.json for $label ..."); + try { + $updater->writeUpdatedComposerJson(); + } catch (\Exception $e) { + $this->revertMageComposerFile("Update of composer.json with $label changes failed"); + Console::log($e->getMessage()); + $didUpdate = false; + } + } + } + + // Run the native command functionality + $errorCode = 0; + $exception = null; + try { + $errorCode = parent::execute($input, $output); + } catch (\Exception $e) { + $exception = $e; + } + + if ($didUpdate && $errorCode !== 0) { + // If the native execute() didn't succeed, revert the Magento changes to the composer.json file + $this->revertMageComposerFile('The native \'composer ' . $this->commandName . '\' command failed'); + if ($constraint && !PackageUtils::isConstraintStrict($constraint)) { + Console::comment( + "Recommended: Use a specific Magento version constraint instead of \"$package: $constraint\"" + ); + } + } + + if ($exception) { + throw $exception; + } + + return $errorCode; + } + + /** + * Call MagentoRootUpdater::runUpdate() according to CLI options + * + * @see MagentoRootUpdater::runUpdate() + * + * @param MagentoRootUpdater $updater + * @param InputInterface $input + * @param string $targetEdition + * @param string $targetConstraint + * @return boolean Returns true if updates were necessary and prepared successfully + */ + protected function runUpdate($updater, $input, $targetEdition, $targetConstraint) + { + $overrideOriginal = $input->getOption(static::PREVIOUS_PACKAGE_OPT); + $overrideOriginalEdition = null; + $overrideOriginalVersion = null; + if ($overrideOriginal && $overrideOriginal != static::PREV_OPT_HINT) { + $parser = new VersionParser(); + $requirement = $parser->parseNameVersionPairs([$overrideOriginal]); + $opt = '--' . static::PREVIOUS_PACKAGE_OPT; + if (count($requirement) !== 1) { + throw new InvalidOptionException("'$opt' accepts exactly one package requirement"); + } elseif (count($requirement[0]) !== 2) { + throw new InvalidOptionException("'$opt' requires both a package and version"); + } + $requirement = $requirement[0]; + $name = $requirement['name']; + $overrideOriginalEdition = PackageUtils::getMagentoProductEdition($name); + if (!$overrideOriginalEdition) { + throw new InvalidOptionException("'$opt' accepts only Magento product packages; \"$name\" given"); + } + $overrideOriginalVersion = $requirement['version']; + if (!PackageUtils::isConstraintStrict($overrideOriginalVersion)) { + throw new InvalidOptionException("'$opt' does not accept non-strict version constraints"); + } + } + + Console::setInteractive($input->getOption(static::INTERACTIVE_OPT)); + $this->retriever = new RootPackageRetriever( + $this->getComposer(), + $targetEdition, + $targetConstraint, + $overrideOriginalEdition, + $overrideOriginalVersion + ); + return $updater->runUpdate( + $this->retriever, + $input->getOption(static::OVERRIDE_OPT), + $input->getOption('ignore-platform-reqs'), + $this->phpVersion, + $this->preferredStability + ); + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php new file mode 100644 index 0000000..6f2f3e3 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php @@ -0,0 +1,95 @@ + + "List all operations available in the %command.name% namespace. This is equivalent\n". + 'to running %command.full_name% without an operation.', + 'install' => + "Refresh the plugin's installation for the Magento Web Setup Wizard. This may be \n" . + "necessary if the var folder has been cleaned, as the plugin needs to exist there\n" . + 'in order to be functional for the Wizard\'s dependency verification check.' + ]; + + /** + * @inheritdoc + */ + protected function configure() + { + $help = "The %command.name% commands are operations specific to the\n" . + "magento/composer-root-update-plugin functionality that do not belong to any native\n" . + "composer commands.\n\n" . static::describeOperations() . "\n"; + + $this->setName(static::NAME) + ->setDescription('Operations specific to magento/composer-root-update-plugin') + ->setDefinition([new InputArgument('operation', InputArgument::OPTIONAL, 'The operation to execute')]) + ->setHelp($help); + } + + /** + * Install the plugin in var to make it available for composer commands run there by the Web Setup Wizard + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $operation = $input->getArgument('operation'); + Console::setIO($this->getIO()); + if (empty($operation) || $operation == 'list') { + Console::log(static::describeOperations() . "\n"); + return 0; + } + if ($operation == 'install') { + return WebSetupWizardPluginInstaller::doVarInstall(); + } else { + Console::error("'$operation' is not a supported operation for ".static::NAME); + return 1; + } + } + + /** + * Formats the operation definitions into the help/list output + * + * @return string + */ + private static function describeOperations() + { + $output = 'Available operations:'; + foreach (static::$operations as $operation => $description) { + $output = $output . "\n\n php %command.full_name% $operation\n\n$description"; + } + $output = str_replace('%command.name%', static::NAME, $output); + $output = str_replace('%command.full_name%', 'composer ' . static::NAME, $output); + return $output; + } +} diff --git a/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php similarity index 69% rename from src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php rename to src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php index e0ea466..a8538a6 100644 --- a/src/Magento/Composer/Plugin/RootUpdate/RootUpdatePlugin.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php @@ -4,24 +4,24 @@ * See COPYING.txt for license details. */ -namespace Magento\Composer\Plugin\RootUpdate; +namespace Magento\ComposerRootUpdatePlugin\Plugin; use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; use Composer\Installer; use Composer\Installer\PackageEvent; use Composer\IO\IOInterface; -use Composer\Plugin\Capability\CommandProvider; +use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; use Composer\Plugin\Capable; -use Composer\Plugin\PluginInterface; -use Magento\RootUpdatePluginInstaller\WebSetupWizardPluginInstaller; +use Composer\Plugin\PluginInterface;; +use Magento\ComposerRootUpdatePlugin\Setup\WebSetupWizardPluginInstaller; /** - * Class RootUpdatePlugin + * Class PluginDefinition * - * @package Magento\Composer\Plugin\RootUpdate + * Composer's entry point for the plugin, defines the command provider and Web Setup Wizard Installer's event triggers */ -class RootUpdatePlugin implements PluginInterface, Capable, EventSubscriberInterface +class PluginDefinition implements PluginInterface, Capable, EventSubscriberInterface { const PACKAGE_NAME = 'magento/composer-root-update-plugin'; @@ -38,7 +38,7 @@ public function activate(Composer $composer, IOInterface $io) */ public function getCapabilities() { - return [CommandProvider::class => PluginCommandProvider::class]; + return [CommandProviderCapability::class => CommandProvider::class]; } /** @@ -61,7 +61,7 @@ public static function getSubscribedEvents() public function packageUpdate(PackageEvent $event) { // Safeguard against the source file being removed before the event is triggered - if (class_exists('\Magento\RootUpdatePluginInstaller\WebSetupWizardPluginInstaller')) { + if (class_exists('\Magento\ComposerRootUpdatePlugin\Setup\WebSetupWizardPluginInstaller')) { WebSetupWizardPluginInstaller::packageEvent($event); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/README.md b/src/Magento/ComposerRootUpdatePlugin/README.md new file mode 100644 index 0000000..bd0aec3 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/README.md @@ -0,0 +1,52 @@ +# Overview +## Purpose of plugin + +The **magento/composer-root-update-plugin** Composer plugin resolves changes that need to be made to the root project `composer.json` file before updating to a new Magento product requirement. + +This is accomplished by comparing the root `composer.json` file for the Magento project corresponding to the Magento version and edition in the current installation with the Magento project `composer.json` file for the target Magento product package when the `composer require` command runs and applying any deltas found between the two files if they do not conflict with the existing `composer.json` file in the Magento root directory. + +# Getting Started +## System requirements +The **magento/composer-root-update-plugin** package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. + +## Installation +To install the plugin, run `composer require magento/composer-root-update-plugin ~1.0` in the Magento root directory. + +# Usage +The plugin adds functionality to the `composer require` command when a new Magento product package is required, and in most cases will not need additional options or commands run to function. + +If the `composer require` command for the target Magento package fails, one of the following may be necessary. + +## Installations that started with another Magento product +If the local Magento installation has previously been updated from a previous Magento product version or edition, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. + +In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. + + composer require --previous-magento-package = + +## Conflicting custom values +If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. + +To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. + +To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-magento-values` option. + +## Bypassing the plugin +To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root` option. + +## Refreshing the plugin for the Web Setup Wizard +If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. + +To reinstall the plugin in `var`, run the following command in the Magento root directory. + + composer magento-update-plugin install + +# License + +Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license. + +[Open Software License (OSL 3.0)](https://opensource.org/licenses/osl-3.0.php). +Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. + +Subject to Licensee's payment of fees and compliance with the terms and conditions of the MEE License, the MEE License supersedes the OSL 3.0 license for each source file. +Please see LICENSE_EE.txt for the full text of the MEE License or visit https://magento.com/legal/terms/enterprise. diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php new file mode 100644 index 0000000..b1abfd1 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php @@ -0,0 +1,29 @@ +getIO()); + $jobs = $event->getRequest()->getJobs(); + $packageName = PluginDefinition::PACKAGE_NAME; + foreach ($jobs as $job) { + if (key_exists('packageName', $job) && $job['packageName'] === $packageName) { + $pkg = $event->getInstalledRepo()->findPackage($packageName, '*'); + if ($pkg !== null) { + $version = $pkg->getPrettyVersion(); + try { + $composer = $event->getComposer(); + static::updateSetupWizardPlugin( + $composer, + $composer->getConfig()->getConfigSource()->getName(), + $version + ); + } catch (Exception $e) { + Console::error("Web Setup Wizard installation of \"$packageName: $version\" failed.", $e); + } + break; + } + } + } + } + + /** + * Install the plugin in var/vendor on 'bin/magento setup' commands or 'composer magento-update-plugin install' + * + * The plugin is needed there for the Web Setup Wizard's dependencies check and var/* gets cleared out + * when 'bin/magento setup:uninstall' is called, so it needs to be reinstalled + * + * @return int 0 if successful, 1 if failed + */ + public static function doVarInstall() + { + $packageName = PluginDefinition::PACKAGE_NAME; + $rootDir = getcwd(); + $path = "$rootDir/composer.json"; + if (!file_exists($path)) { + Console::error("Web Setup Wizard installation of \"$packageName\" failed; unable to load $path."); + return 1; + } + + $factory = new Factory(); + $composer = $factory->createComposer(Console::getIO(), $path, true, null, true); + $locker = $composer->getLocker(); + if ($locker->isLocked()) { + $pkg = $locker->getLockedRepository()->findPackage(PluginDefinition::PACKAGE_NAME, '*'); + if ($pkg !== null) { + $version = $pkg->getPrettyVersion(); + try { + Console::log("Checking for \"$packageName: $version\" for the Web Setup Wizard...", Console::VERBOSE); + static::updateSetupWizardPlugin($composer, $path, $version); + } catch (Exception $e) { + Console::error("Web Setup Wizard installation of \"$packageName: $version\" failed.", $e); + return 1; + } + } else { + Console::error("Web Setup Wizard installation of \"$packageName\" failed; " . + "package not found in $rootDir/composer.lock."); + return 1; + } + } else { + Console::error("Web Setup Wizard installation of \"$packageName\" failed; " . + "unable to load $rootDir/composer.lock."); + return 1; + } + return 0; + } + + /** + * Update the plugin installation inside the ./var directory used by the Web Setup Wizard + * + * @param Composer $composer + * @param string $filePath + * @param string $pluginVersion + * @return boolean + * @throws Exception + */ + public static function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) + { + $packageName = PluginDefinition::PACKAGE_NAME; + + // If in ./var already or Magento or the plugin is missing from composer.json, do not install in var + if (!preg_match('/\/composer\.json$/', $filePath) || + preg_match('/\/var\/composer\.json$/', $filePath) || + !PackageUtils::findRequire($composer, '/magento\/product-(community|enterprise)-edition/') || + !PackageUtils::findRequire($composer, $packageName)) { + return false; + } + + $rootDir = preg_replace('/\/composer\.json$/', '', $filePath); + $var = "$rootDir/var"; + if (file_exists("$var/vendor/$packageName/composer.json")) { + $varPluginComposer = (new Factory())->createComposer( + Console::getIO(), + "$var/vendor/$packageName/composer.json", + true, + "$var/vendor/$packageName", + false + ); + + // If the current version of the plugin is already the version in this update, noop + if ($varPluginComposer->getPackage()->getPrettyVersion() == $pluginVersion) { + Console::log( + "No Web Setup Wizard update needed for $packageName; version $pluginVersion is already in $var.", + Console::VERBOSE + ); + return false; + } + } + + Console::info("Installing \"$packageName: $pluginVersion\" for the Web Setup Wizard"); + + if (!file_exists($var)) { + mkdir($var); + } + if (!is_writable($var)) { + throw new FilesystemException( + "Could not install \"$packageName: $pluginVersion\" for the Web Setup Wizard; $var is not writable." + ); + } + + $tmpDir = tempnam($var, "composer-plugin_tmp."); + $exception = null; + try { + unlink($tmpDir); + mkdir($tmpDir); + + $tmpComposer = static::createPluginComposer($tmpDir, $pluginVersion, $composer); + $install = Installer::create(Console::getIO(), $tmpComposer); + $install + ->setDumpAutoloader(true) + ->setRunScripts(false) + ->setDryRun(false) + ->disablePlugins(); + $install->run(); + + static::copyAndReplace("$tmpDir/vendor", "$var/vendor"); + } catch (Exception $e) { + $exception = $e; + } + + static::deletePath($tmpDir); + + if ($exception !== null) { + throw $exception; + } + + return true; + } + + /** + * Deletes a file or a directory and all its contents + * + * @param string $path + * @return void + * @throws FilesystemException + */ + private static function deletePath($path) + { + if (!file_exists($path)) { + return; + } + if (!is_link($path) && is_dir($path)) { + $files = array_diff(scandir($path), ['..', '.']); + foreach ($files as $file) { + static::deletePath("$path/$file"); + } + rmdir($path); + } else { + unlink($path); + } + if (file_exists($path)) { + throw new FilesystemException("Failed to delete $path"); + } + } + + /** + * Copies a file or directory and all its contents, replacing anything that exists there beforehand + * + * @param string $source + * @param string $target + * @return void + * @throws FilesystemException + */ + private static function copyAndReplace($source, $target) + { + static::deletePath($target); + if (is_dir($source)) { + mkdir($target); + $files = array_diff(scandir($source), ['..', '.']); + foreach ($files as $file) { + static::copyAndReplace("$source/$file", "$target/$file"); + } + } else { + copy($source, $target); + } + } + + /** + * Create a temporary composer.json file and object requiring only the plugin + * + * @param string $tmpDir + * @param string $pluginVersion + * @param Composer $rootComposer + * @return Composer + * @throws Exception + */ + private static function createPluginComposer($tmpDir, $pluginVersion, $rootComposer) + { + $factory = new Factory(); + $tmpConfig = [ + 'repositories' => $rootComposer->getPackage()->getRepositories(), + 'require' => [PluginDefinition::PACKAGE_NAME => $pluginVersion], + 'prefer-stable' => $rootComposer->getPackage()->getPreferStable() + ]; + if ($rootComposer->getPackage()->getMinimumStability()) { + $tmpConfig['minimum-stability'] = $rootComposer->getPackage()->getMinimumStability(); + } + $tmpJson = new JsonFile("$tmpDir/composer.json"); + $tmpJson->write($tmpConfig); + $tmpComposer = $factory->createComposer(Console::getIO(), "$tmpDir/composer.json", true, $tmpDir); + $tmpConfig = $tmpComposer->getConfig(); + $tmpConfig->setAuthConfigSource($rootComposer->getConfig()->getAuthConfigSource()); + $tmpComposer->setConfig($tmpConfig); + + return $tmpComposer; + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php new file mode 100644 index 0000000..e81d0a2 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php @@ -0,0 +1,450 @@ +overrideUserValues = $overrideUserValues; + $this->retriever = $retriever; + $this->originalMageRootPackage = $retriever->getOriginalRootPackage($overrideUserValues); + $this->targetMageRootPackage = $retriever->getTargetRootPackage(); + $this->userRootPackage = $retriever->getUserRootPackage(); + $this->jsonChanges = []; + } + + /** + * Run conflict resolution between the three root projects and return the json array of changes that need to be made + * + * @return array + */ + public function resolveConflicts() + { + $original = $this->originalMageRootPackage; + $target = $this->targetMageRootPackage; + $user = $this->userRootPackage; + + $this->resolveLinkSection('require', $original->getRequires(), $target->getRequires(), $user->getRequires()); + $this->resolveLinkSection('require-dev', $original->getDevRequires(), $target->getDevRequires(), $user->getDevRequires()); + $this->resolveLinkSection('conflict', $original->getConflicts(), $target->getConflicts(), $user->getConflicts()); + $this->resolveLinkSection('provide', $original->getProvides(), $target->getProvides(), $user->getProvides()); + $this->resolveLinkSection('replace', $original->getReplaces(), $target->getReplaces(), $user->getReplaces()); + + $this->resolveArraySection('autoload', $original->getAutoload(), $target->getAutoload(), $user->getAutoload()); + $this->resolveArraySection('autoload-dev', $original->getDevAutoload(), $target->getDevAutoload(), $user->getDevAutoload()); + $this->resolveArraySection('extra', $original->getExtra(), $target->getExtra(), $user->getExtra()); + $this->resolveArraySection('suggest', $original->getSuggests(), $target->getSuggests(), $user->getSuggests()); + + return $this->jsonChanges; + } + + /** + * Find value deltas from base->target version and resolve any conflicts with overlapping user changes + * + * @param string $field + * @param array|mixed|null $originalMageVal + * @param array|mixed|null $targetMageVal + * @param array|mixed|null $userVal + * @param string|null $prettyOriginalMageVal + * @param string|null $prettyTargetMageVal + * @param string|null $prettyUserVal + * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the existing composer.json file, null for no change + */ + public function findResolution( + $field, + $originalMageVal, + $targetMageVal, + $userVal, + $prettyOriginalMageVal = null, + $prettyTargetMageVal = null, + $prettyUserVal = null + ) { + if ($prettyOriginalMageVal === null) { + $prettyOriginalMageVal = json_encode($originalMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyOriginalMageVal = trim($prettyOriginalMageVal, "'\""); + } + if ($prettyTargetMageVal === null) { + $prettyTargetMageVal = json_encode($targetMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyTargetMageVal = trim($prettyTargetMageVal, "'\""); + } + if ($prettyUserVal === null) { + $prettyUserVal = json_encode($userVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyUserVal = trim($prettyUserVal, "'\""); + } + + $targetLabel = $this->retriever->getTargetLabel(); + $originalLabel = $this->retriever->getOriginalLabel(); + + $action = null; + $conflictDesc = null; + + if ($originalMageVal == $targetMageVal || $userVal == $targetMageVal) { + $action = null; + } elseif ($originalMageVal === null) { + if ($userVal === null) { + $action = static::ADD_VAL; + } else { + $action = static::CHANGE_VAL; + $conflictDesc = "add $field=$prettyTargetMageVal but it is instead $prettyUserVal"; + } + } elseif ($targetMageVal === null) { + $action = static::REMOVE_VAL; + if ($userVal !== $originalMageVal) { + $conflictDesc = "remove the $field=$prettyOriginalMageVal entry in $originalLabel but it is instead " . + $prettyUserVal; + } + } else { + $action = static::CHANGE_VAL; + if ($userVal !== $originalMageVal) { + $conflictDesc = "update $field to $prettyTargetMageVal from $prettyOriginalMageVal in $originalLabel"; + if ($userVal === null) { + $action = static::ADD_VAL; + $conflictDesc = "$conflictDesc but the field has been removed"; + } else { + $conflictDesc = "$conflictDesc but it is instead $prettyUserVal"; + } + } + } + + if ($conflictDesc !== null) { + $conflictDesc = "$targetLabel is trying to $conflictDesc in this installation"; + + $shouldOverride = $this->overrideUserValues; + if ($this->overrideUserValues) { + Console::log($conflictDesc); + Console::log("Overriding local changes due to --" . MageRootRequireCommand::OVERRIDE_OPT . '.'); + } else { + $shouldOverride = Console::ask("$conflictDesc.\nWould you like to override the local changes?"); + } + + if (!$shouldOverride) { + Console::comment("$conflictDesc and will not be changed. Re-run using " . + '--' . MageRootRequireCommand::OVERRIDE_OPT . ' or --' . MageRootRequireCommand::INTERACTIVE_OPT . + ' to override with Magento values.'); + $action = null; + } + } + + return $action; + } + + /** + * Process changes to corresponding sets of package version links + * + * @param string $section + * @param Link[] $originalMageLinks + * @param Link[] $targetMageLinks + * @param Link[] $userLinks + * @return array + */ + public function resolveLinkSection($section, $originalMageLinks, $targetMageLinks, $userLinks) + { + /** @var Link[] $originalLinkMap */ + $originalLinkMap = static::linksToMap($originalMageLinks); + + /** @var Link[] $targetLinkMap */ + $targetLinkMap = static::linksToMap($targetMageLinks); + + /** @var Link[] $userLinkMap */ + $userLinkMap = static::linksToMap($userLinks); + + $adds = []; + $removes = []; + $changes = []; + $magePackages = array_unique(array_merge(array_keys($originalLinkMap), array_keys($targetLinkMap))); + foreach ($magePackages as $pkg) { + if ($section === 'require' && PackageUtils::getMagentoProductEdition($pkg)) { + continue; + } + $field = "$section:$pkg"; + $originalConstraint = key_exists($pkg, $originalLinkMap) ? $originalLinkMap[$pkg]->getConstraint() : null; + $originalMageVal = ($originalConstraint === null) ? null : $originalConstraint->__toString(); + $prettyOriginalMageVal = ($originalConstraint === null) ? null : $originalConstraint->getPrettyString(); + $targetConstraint = key_exists($pkg, $targetLinkMap) ? $targetLinkMap[$pkg]->getConstraint() : null; + $targetMageVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); + $prettyTargetMageVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); + $userConstraint = key_exists($pkg, $userLinkMap) ? $userLinkMap[$pkg]->getConstraint() : null; + $userVal = ($userConstraint === null) ? null : $userConstraint->__toString(); + $prettyUserVal = ($userConstraint === null) ? null : $userConstraint->getPrettyString(); + + $action = $this->findResolution( + $field, + $originalMageVal, + $targetMageVal, + $userVal, + $prettyOriginalMageVal, + $prettyTargetMageVal, + $prettyUserVal + ); + if ($action == static::ADD_VAL) { + $adds[$pkg] = $targetLinkMap[$pkg]; + } elseif ($action == static::REMOVE_VAL) { + $removes[] = $pkg; + } elseif ($action == static::CHANGE_VAL) { + $changes[$pkg] = $targetLinkMap[$pkg]; + } + } + + $changed = false; + if ($adds !== []) { + $changed = true; + $prettyAdds = array_map(function ($package) use ($adds) { + $newVal = $adds[$package]->getConstraint()->getPrettyString(); + return "$package=$newVal"; + }, array_keys($adds)); + Console::labeledVerbose("Adding $section constraints: " . implode(', ', $prettyAdds)); + } + if ($removes !== []) { + $changed = true; + Console::labeledVerbose("Removing $section entries: " . implode(', ', $removes)); + } + if ($changes !== []) { + $changed = true; + $prettyChanges = array_map(function ($package) use ($changes) { + $newVal = $changes[$package]->getConstraint()->getPrettyString(); + return "$package=$newVal"; + }, array_keys($changes)); + Console::labeledVerbose("Updating $section constraints: " . implode(', ', $prettyChanges)); + } + + if ($changed) { + $replacements = array_values($adds); + + /** @var Link $userLink */ + foreach ($userLinkMap as $pkg => $userLink) { + if (in_array($pkg, $removes)) { + continue; + } elseif (key_exists($pkg, $changes)) { + $replacements[] = $changes[$pkg]; + } else { + $replacements[] = $userLink; + } + } + + $newJson = []; + /** @var Link $link */ + foreach ($replacements as $link) { + $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); + } + + $this->jsonChanges[$section] = $newJson; + } + + return $this->jsonChanges; + } + + /** + * Process changes to an array (non-package link) section + * + * @param string $section + * @param array|mixed|null $originalMageVal + * @param array|mixed|null $targetMageVal + * @param array|mixed|null $userVal + * @return array + */ + public function resolveArraySection($section, $originalMageVal, $targetMageVal, $userVal) + { + list($changed, $value) = $this->resolveNestedArray($section, $originalMageVal, $targetMageVal, $userVal); + if ($changed) { + $this->jsonChanges[$section] = $value; + } + + return $this->jsonChanges; + } + + /** + * Process changes to arrays that could be nested + * + * Associative arrays are resolved recursively and non-associative arrays are treated as unordered sets + * + * @param string $field + * @param array|mixed|null $originalMageVal + * @param array|mixed|null $targetMageVal + * @param array|mixed|null $userVal + * @return array [, ], value of null/empty array indicates to remove the entry from parent + */ + public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $userVal) + { + $valChanged = false; + $result = $userVal === null ? [] : $userVal; + + if (is_array($originalMageVal) && is_array($targetMageVal) && is_array($userVal)) { + $originalMageAssociativePart = []; + $originalMageFlatPart = []; + foreach ($originalMageVal as $key => $value) { + if (is_string($key)) { + $originalMageAssociativePart[$key] = $value; + } else { + $originalMageFlatPart[] = $value; + } + } + + $targetMageAssociativePart = []; + $targetMageFlatPart = []; + foreach ($targetMageVal as $key => $value) { + if (is_string($key)) { + $targetMageAssociativePart[$key] = $value; + } else { + $targetMageFlatPart[] = $value; + } + } + + $userAssociativePart = []; + $userFlatPart = []; + foreach ($userVal as $key => $value) { + if (is_string($key)) { + $userAssociativePart[$key] = $value; + } else { + $userFlatPart[] = $value; + } + } + + $associativeResult = array_filter($result, 'is_string', ARRAY_FILTER_USE_KEY); + $mageKeys = array_unique( + array_merge(array_keys($originalMageAssociativePart), array_keys($targetMageAssociativePart)) + ); + foreach ($mageKeys as $key) { + if (key_exists($key, $originalMageAssociativePart)) { + $originalMageNestedVal = $originalMageAssociativePart[$key]; + } else { + $originalMageNestedVal = []; + } + if (key_exists($key, $targetMageAssociativePart)) { + $targetMageNestedVal = $targetMageAssociativePart[$key]; + } else { + $targetMageNestedVal = []; + } + if (key_exists($key, $userAssociativePart)) { + $userNestedVal = $userAssociativePart[$key]; + } else { + $userNestedVal = []; + } + + list($changed, $value) = $this->resolveNestedArray( + "$field.$key", + $originalMageNestedVal, + $targetMageNestedVal, + $userNestedVal + ); + if ($value === null || $value === []) { + if (key_exists($key, $associativeResult)) { + $valChanged = true; + unset($associativeResult[$key]); + } + } else { + $valChanged = $valChanged || $changed; + $associativeResult[$key] = $value; + } + } + + $flatResult = array_filter($result, 'is_int', ARRAY_FILTER_USE_KEY); + $flatAdds = array_diff(array_diff($targetMageFlatPart, $originalMageFlatPart), $flatResult); + if ($flatAdds !== []) { + $valChanged = true; + Console::labeledVerbose("Adding $field entries: " . implode(', ', $flatAdds)); + $flatResult = array_unique(array_merge($flatResult, $flatAdds)); + } + + $flatRemoves = array_intersect(array_diff($originalMageFlatPart, $targetMageFlatPart), $flatResult); + if ($flatRemoves !== []) { + $valChanged = true; + Console::labeledVerbose("Removing $field entries: " . implode(', ', $flatRemoves)); + $flatResult = array_diff($flatResult, $flatRemoves); + } + + $result = array_merge($flatResult, $associativeResult); + } else { + // Some or all of the values aren't arrays so they should all be compared as non-array values + $action = $this->findResolution($field, $originalMageVal, $targetMageVal, $userVal); + $prettyTargetMageVal = json_encode($targetMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($action == static::ADD_VAL) { + $valChanged = true; + Console::labeledVerbose("Adding $field entry: $prettyTargetMageVal"); + $result = $targetMageVal; + } elseif ($action == static::CHANGE_VAL) { + $valChanged = true; + Console::labeledVerbose("Updating $field entry: $prettyTargetMageVal"); + $result = $targetMageVal; + } elseif ($action == static::REMOVE_VAL) { + $valChanged = true; + Console::labeledVerbose("Removing $field entry"); + $result = null; + } + } + + return [$valChanged, $result]; + } + + /** + * Helper function to convert a set of links to an associative array with target package names as keys + * + * @param Link[] $links + * @return array + */ + protected function linksToMap($links) + { + $targets = array_map(function ($link) { + /** @var Link $link */ + return $link->getTarget(); + }, $links); + return array_combine($targets, $links); + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php new file mode 100644 index 0000000..f6b681f --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php @@ -0,0 +1,155 @@ +composer = $composer; + $this->jsonChanges = []; + } + + /** + * Look ahead to the target Magento version and execute any changes to the root composer.json file in-memory + * + * @param RootPackageRetriever $retriever + * @param boolean $overrideOption + * @param boolean $ignorePlatformReqs + * @param string $phpVersion + * @param string $stability + * @return boolean Returns true if updates were necessary and prepared successfully + */ + public function runUpdate( + $retriever, + $overrideOption, + $ignorePlatformReqs, + $phpVersion, + $stability + ) { + $composer = $this->composer; + + if (!PackageUtils::findRequire($composer, PluginDefinition::PACKAGE_NAME)) { + // If the plugin requirement has been removed but we're still trying to run (code still existing in the + // vendor directory), return without executing. + return false; + } + + $originalEdition = $retriever->getOriginalEdition(); + $originalVersion = $retriever->getOriginalVersion(); + $prettyOriginalVersion = $retriever->getPrettyOriginalVersion(); + + if (!$retriever->getTargetRootPackage($ignorePlatformReqs, $phpVersion, $stability)) { + throw new \RuntimeException('Magento root updates cannot run without a valid target package'); + } + + if ($originalEdition == $retriever->getTargetEdition() && $originalVersion == $retriever->getTargetVersion()) { + Console::labeledVerbose( + 'The Magento product requirement matched the current installation; no root updates are required' + ); + return false; + } + + if (!$retriever->getOriginalRootPackage($overrideOption)) { + Console::log('Skipping Magento composer.json update.'); + return false; + } + + Console::setVerboseLabel($retriever->getTargetLabel()); + Console::labeledVerbose( + "Base Magento project package version: magento/project-$originalEdition-edition $prettyOriginalVersion" + ); + + $resolver = new ConflictResolver($overrideOption, $retriever); + + $jsonChanges = $resolver->resolveConflicts(); + + if ($jsonChanges) { + $this->jsonChanges = $jsonChanges; + return true; + } + + return false; + } + + /** + * Write the changed composer.json file + * + * @return void + * @throws FilesystemException if the composer.json read or write failed + */ + public function writeUpdatedComposerJson() + { + if (!$this->jsonChanges) { + return; + } + $filePath = $this->composer->getConfig()->getConfigSource()->getName(); + $json = json_decode(file_get_contents($filePath), true); + if ($json === null) { + throw new FilesystemException('Failed to read ' . $filePath); + } + + foreach ($this->jsonChanges as $section => $newContents) { + if ($newContents === null || $newContents === []) { + if (key_exists($section, $json)) { + unset($json[$section]); + } + } else { + $json[$section] = $newContents; + } + } + + Console::labeledVerbose('Writing changes to the root composer.json...'); + + $retVal = file_put_contents( + $filePath, + json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) + ); + + if ($retVal === false) { + throw new FilesystemException('Failed to write updated Magento root values to ' . $filePath); + } + Console::labeledVerbose("$filePath has been updated"); + } + + /** + * Return the changes to be made in composer.json + * + * @return array + */ + public function getJsonChanges() + { + return $this->jsonChanges; + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php new file mode 100644 index 0000000..5efa975 --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php @@ -0,0 +1,407 @@ +composer = $composer; + + $this->originalRootPackage = null; + $this->fetchedOriginal = false; + $this->targetEdition = $targetEdition; + $this->targetConstraint = $targetConstraint; + $this->targetRootPackage = null; + $this->fetchedTarget = null; + if (!$overrideOriginalEdition || !$overrideOriginalVersion) { + $this->parseOriginalVersionAndEditionFromLock(); + } else { + $this->originalEdition = $overrideOriginalEdition; + $this->originalVersion = $overrideOriginalVersion; + $this->prettyOriginalVersion = $overrideOriginalVersion; + } + } + + /** + * Get the project package that should be used as the basis for Magento root comparisons + * + * @param bool $overrideOption + * @return PackageInterface|boolean + */ + public function getOriginalRootPackage($overrideOption) + { + if ($this->fetchedOriginal) { + return $this->originalRootPackage; + } + + $originalRootPackage = null; + $originalEdition = $this->originalEdition; + $originalVersion = $this->originalVersion; + $prettyOriginalVersion = $this->prettyOriginalVersion; + if ($originalEdition && $originalVersion) { + $originalRootPackage = $this->fetchMageRootFromRepo($originalEdition, $prettyOriginalVersion); + } + + if (!$originalRootPackage) { + if (!$originalEdition || !$originalVersion) { + Console::warning('No Magento product package was found in the current installation.'); + } else { + Console::warning('The Magento project package corresponding to the currently installed ' . + "\"magento/product-$originalEdition-edition: $prettyOriginalVersion\" package is unavailable."); + } + + $overrideRoot = $overrideOption; + if (!$overrideRoot) { + $question = 'Would you like to update the root composer.json file anyway? ' . + 'This will override any changes you have made to the default composer.json file.'; + $overrideRoot = Console::ask($question); + } + + if ($overrideRoot) { + $originalRootPackage = $this->getUserRootPackage(); + } else { + $originalRootPackage = null; + } + } + + $this->originalRootPackage = $originalRootPackage; + $this->fetchedOriginal = true; + return $this->originalRootPackage; + } + + public function getTargetRootPackage( + $ignorePlatformReqs = true, + $phpVersion = null, + $preferredStability = 'stable' + ) { + if ($this->fetchedTarget) { + return $this->targetRootPackage; + } + + $targetRoot = $this->fetchMageRootFromRepo( + $this->targetEdition, + $this->targetConstraint, + $ignorePlatformReqs, + $phpVersion, + $preferredStability + ); + if ($targetRoot) { + $this->targetVersion = $targetRoot->getVersion(); + $this->prettyTargetVersion = $targetRoot->getPrettyVersion(); + if (!$this->prettyTargetVersion) { + $this->prettyTargetVersion = $this->targetVersion; + } + } + + $this->targetRootPackage = $targetRoot; + $this->fetchedTarget = true; + return $this->targetRootPackage; + } + + public function getUserRootPackage() + { + return $this->composer->getPackage(); + } + + /** + * Retrieve the Magento root package for an edition and version constraint from the composer file's repositories + * + * @param string $edition + * @param string $constraint + * @param boolean $ignorePlatformReqs + * @param string $phpVersion + * @param string $preferredStability + * @return PackageInterface|bool Best root package candidate or false if no valid packages found + */ + protected function fetchMageRootFromRepo( + $edition, + $constraint, + $ignorePlatformReqs = true, + $phpVersion = null, + $preferredStability = 'stable' + ) { + $composer = $this->composer; + $packageName = strtolower("magento/project-$edition-edition"); + $versionParser = new VersionParser(); + $parsedConstraint = $versionParser->parseConstraints($constraint); + + $minStability = $composer->getPackage()->getMinimumStability(); + if (!$minStability) { + $minStability = 'stable'; + } + $stabilityFlags = AccessibleRootPackageLoader::extractStabilityFlags($packageName, $constraint, $minStability); + $stability = key_exists($packageName, $stabilityFlags) + ? array_search($stabilityFlags[$packageName], BasePackage::$stabilities) + : $minStability; + Console::comment("Minimum stability for \"$packageName: $constraint\": $stability", IOInterface::DEBUG); + $pool = new Pool( + $stability, + $stabilityFlags, + [$packageName => $parsedConstraint] + ); + $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); + $pool->addRepository($repos); + + if (!PackageUtils::isConstraintStrict($constraint)) { + Console::warning( + "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . + 'the Magento root updater might not accurately determine the version to use according to other ' . + 'requirements in this installation. It is recommended to use an exact version number.' + ); + } + + $phpVersion = $ignorePlatformReqs ? null : $phpVersion; + + $versionSelector = new VersionSelector($pool); + $result = $versionSelector->findBestCandidate($packageName, $constraint, $phpVersion, $preferredStability); + + if (!$result) { + $err = "Could not find a Magento project package matching \"magento/product-$edition-edition $constraint\""; + if ($phpVersion) { + $err = "$err for PHP version $phpVersion"; + } + Console::error($err); + } + + return $result; + } + + /** + * Gets the Magento product package in composer.lock and populates the version and edition in CommonUtils + * + * @return void + */ + protected function parseOriginalVersionAndEditionFromLock() + { + $locker = $this->getRootLocker(); + if (!$locker || !$locker->isLocked()) { + Console::labeledVerbose( + 'No composer.lock file was found in the root project to check for the installed Magento version' + ); + return; + } + + $lockPackages = $locker->getLockedRepository()->getPackages(); + $lockedMageProduct = null; + foreach ($lockPackages as $lockedPackage) { + $pkgEdition = PackageUtils::getMagentoProductEdition($lockedPackage->getName()); + if ($pkgEdition) { + $lockedMageProduct = $lockedPackage; + + // Both editions exist for enterprise, so stop at enterprise to not overwrite with community + if ($pkgEdition == 'enterprise') { + break; + } + } + } + + if ($lockedMageProduct) { + $this->originalEdition = PackageUtils::getMagentoProductEdition($lockedMageProduct->getName()); + $this->originalVersion = $lockedMageProduct->getVersion(); + $this->prettyOriginalVersion = $lockedMageProduct->getPrettyVersion(); + if (!$this->prettyOriginalVersion) { + $this->prettyOriginalVersion = $this->originalVersion; + } + } + } + + /** + * Get the Locker for the root, using the parent if currently in var + * + * @return Locker + */ + protected function getRootLocker() + { + $composer = $this->composer; + + $composerPath = $composer->getConfig()->getConfigSource()->getName(); + $locker = null; + if (preg_match('/\/var\/composer\.json$/', $composerPath)) { + $parentDir = preg_replace('/\/var\/composer\.json$/', '', $composerPath); + if (file_exists("$parentDir/composer.json") && file_exists("$parentDir/composer.lock")) { + $locker = new Locker( + Console::getIO(), + new JsonFile("$parentDir/composer.lock"), + $composer->getRepositoryManager(), + $composer->getInstallationManager(), + file_get_contents("$parentDir/composer.json") + ); + } + } + return $locker !== null ? $locker : $composer->getLocker(); + } + + /** + * Get the pretty label for the target Magento installation version + * + * @return string + */ + public function getTargetLabel() + { + if ($this->targetEdition && $this->prettyTargetVersion) { + return 'Magento ' . ucfirst($this->targetEdition) . " Edition " . $this->prettyTargetVersion; + } elseif ($this->targetEdition && $this->targetConstraint) { + return 'Magento ' . ucfirst($this->targetEdition) . " Edition " . $this->targetConstraint; + } + return static::MISSING_ROOT_LABEL; + } + + /** + * Get the pretty label for the original Magento installation version + * + * @return string + */ + public function getOriginalLabel() + { + if ($this->originalEdition && $this->prettyOriginalVersion) { + return 'Magento ' . ucfirst($this->originalEdition) . " Edition " . $this->prettyOriginalVersion; + } + return static::MISSING_ROOT_LABEL; + } + + /** + * @return string + */ + public function getOriginalEdition() + { + return $this->originalEdition; + } + + /** + * @return string + */ + public function getOriginalVersion() + { + return $this->originalVersion; + } + + /** + * @return string + */ + public function getPrettyOriginalVersion() + { + return $this->prettyOriginalVersion; + } + + /** + * @return string + */ + public function getTargetEdition() + { + return $this->targetEdition; + } + + /** + * @return string + */ + public function getTargetVersion() + { + return $this->targetVersion; + } + + /** + * @return string + */ + public function getPrettyTargetVersion() + { + return $this->prettyTargetVersion; + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php new file mode 100644 index 0000000..b80d3ec --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php @@ -0,0 +1,234 @@ +'; + const FORMAT_COMMENT = ''; + const FORMAT_WARN = ''; + const FORMAT_ERROR = ''; + + /** + * Verbosity levels copied from IOInterface for clarity + */ + const QUIET = IOInterface::QUIET; + const NORMAL = IOInterface::NORMAL; + const VERBOSE = IOInterface::VERBOSE; + const VERY_VERBOSE = IOInterface::VERY_VERBOSE; + const DEBUG = IOInterface::DEBUG; + + /** + * @var IOInterface $io + */ + static protected $io = null; + + /** + * @var string $verboseLabel + */ + static protected $verboseLabel = null; + + /** + * @var bool $interactive + */ + static protected $interactive = false; + + /** + * Get the shared IOInterface instance or a default ConsoleIO if one hasn't been set via setIO() + * + * @return IOInterface + */ + static public function getIO() + { + if (static::$io == null) { + static::$io = new ConsoleIO(new ArrayInput([]), + new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), + new HelperSet([ + new FormatterHelper(), + new DebugFormatterHelper(), + new ProcessHelper(), + new QuestionHelper() + ]) + ); + } + return static::$io; + } + + /** + * Set the shared IOInterface instance + * + * @param IOInterface $io + * @return void + */ + static public function setIO($io) + { + static::$io = $io; + } + + /** + * Whether or not ask() should interactively ask the question or just return the default value + * + * @param bool $interactive + * @return void + */ + public static function setInteractive($interactive) + { + self::$interactive = $interactive; + } + + /** + * Ask the user a yes or no question and return the result + * + * If setInteractive(false) has been called, instead do not ask and just return the default + * + * @param string $question + * @param boolean $default + * @return boolean + */ + static public function ask($question, $default = false) + { + $result = $default; + if (static::$interactive) { + if (!static::getIO()->isInteractive()) { + throw new \InvalidArgumentException( + 'Interactive options cannot be used in non-interactive terminals.' + ); + } + $opts = $default ? 'Y,n' : 'y,N'; + $result = static::getIO()->askConfirmation("$question [$opts]? ", $default); + } + return $result; + } + + /** + * Log the given message with verbosity and formatting + * + * @param $message + * @param int $verbosity + * @param string $format + * @return void + */ + static public function log($message, $verbosity = Console::NORMAL, $format = null) + { + if ($format) { + $formatClose = str_replace('<', 'writeError($message, true, $verbosity); + } + + /** + * Helper method to log the given message with formatting + * + * @param $message + * @param int $verbosity + * @return void + */ + static public function info($message, $verbosity = Console::NORMAL) + { + static::log($message, $verbosity, static::FORMAT_INFO); + } + + /** + * Helper method to log the given message with formatting + * + * @param $message + * @param int $verbosity + * @return void + */ + static public function comment($message, $verbosity = Console::NORMAL) + { + static::log($message, $verbosity, static::FORMAT_COMMENT); + } + + /** + * Helper method to log the given message with formatting + * + * @param $message + * @param int $verbosity + * @return void + */ + static public function warning($message, $verbosity = Console::NORMAL) + { + static::log($message, $verbosity, static::FORMAT_WARN); + } + + /** + * Label and log the given message if output is set to verbose + * + * A null $label will use the globally configured $verboseLabel + * + * @param string $message + * @param null $label + * @param int $verbosity + * @param string $format + * @return void + */ + static public function labeledVerbose( + $message, + $label = null, + $verbosity = Console::VERBOSE, + $format = null + ) { + if ($format) { + $formatClose = str_replace('<', '[$label] $message"; + } + static::log($message, $verbosity); + } + + /** + * Formats with and logs to Console::QUIET followed by the exception's message at Console::NORMAL + * + * @param string $message + * @param \Exception $exception + * @return void + */ + static public function error($message, $exception = null) + { + static::log($message, static::QUIET, static::FORMAT_ERROR); + if ($exception) { + static::log($exception->getMessage()); + } + } + + /** + * Sets the label to apply to logVerbose() messages if not overridden + * + * @param string $verboseLabel + * @return void + */ + static public function setVerboseLabel($verboseLabel) + { + static::$verboseLabel = $verboseLabel; + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php new file mode 100644 index 0000000..17e917a --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php @@ -0,0 +1,90 @@ +product|project)-(community|enterprise)-edition$/'; + if (preg_match($regex, $packageName, $matches)) { + return $matches['type']; + } else { + return null; + } + } + + /** + * Helper function to extract the edition from a package name if it is a Magento product + * + * @param string $packageName + * @return string|null 'community' or 'enterprise' as applicable, null if not matching + */ + static public function getMagentoProductEdition($packageName) + { + $regex = '/^magento\/product-(?community|enterprise)-edition$/'; + if ($packageName && preg_match($regex, $packageName, $matches)) { + return $matches['edition']; + } else { + return null; + } + } + + /** + * Returns the Link from the Composer require section matching the given package name or regex + * + * @param Composer $composer + * @param string $packageMatcher + * @return Link|boolean + */ + static public function findRequire($composer, $packageMatcher) + { + /** @var Link[] $requires */ + $requires = array_values($composer->getPackage()->getRequires()); + if (@preg_match($packageMatcher, null) === false) { + foreach ($requires as $link) { + if ($packageMatcher == $link->getTarget()) { + return $link; + } + } + } else { + foreach ($requires as $link) { + if (preg_match($packageMatcher, $link->getTarget())) { + return $link; + } + } + } + + return false; + } + + /** + * Is the given constraint strict or does it allow multiple versions + * + * @param string $constraint + * @return bool + */ + static public function isConstraintStrict($constraint) + { + $versionParser = new VersionParser(); + $parsedConstraint = $versionParser->parseConstraints($constraint); + return strpbrk($parsedConstraint->__toString(), '[]|<>!') === false; + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/composer.json b/src/Magento/ComposerRootUpdatePlugin/composer.json new file mode 100644 index 0000000..6fc78ce --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/composer.json @@ -0,0 +1,28 @@ +{ + "name": "magento/composer-root-update-plugin", + "description": "Plugin to look ahead for Magento project root changes when running composer update for new Magento versions", + "version": "1.0.0-beta16", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "type": "composer-plugin", + "require": { + "composer/composer": "<=1.8.0", + "composer-plugin-api": "^1.0" + }, + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\ComposerRootUpdatePlugin\\": "" + } + }, + "extra": { + "class": "Magento\\ComposerRootUpdatePlugin\\Plugin\\PluginDefinition" + }, + "suggest": { + "magento/framework": "Enables the Magento Composer Root Update Plugin's functionality for the Web Setup Wizard" + } +} diff --git a/src/Magento/RootUpdatePluginInstaller/etc/module.xml b/src/Magento/ComposerRootUpdatePlugin/etc/module.xml similarity index 78% rename from src/Magento/RootUpdatePluginInstaller/etc/module.xml rename to src/Magento/ComposerRootUpdatePlugin/etc/module.xml index 2f9af5a..404e536 100644 --- a/src/Magento/RootUpdatePluginInstaller/etc/module.xml +++ b/src/Magento/ComposerRootUpdatePlugin/etc/module.xml @@ -6,6 +6,6 @@ */ --> - + diff --git a/src/Magento/RootUpdatePluginInstaller/registration.php b/src/Magento/ComposerRootUpdatePlugin/registration.php similarity index 88% rename from src/Magento/RootUpdatePluginInstaller/registration.php rename to src/Magento/ComposerRootUpdatePlugin/registration.php index feaded9..c6edb52 100644 --- a/src/Magento/RootUpdatePluginInstaller/registration.php +++ b/src/Magento/ComposerRootUpdatePlugin/registration.php @@ -7,7 +7,7 @@ if (class_exists('\Magento\Framework\Component\ComponentRegistrar')) { \Magento\Framework\Component\ComponentRegistrar::register( \Magento\Framework\Component\ComponentRegistrar::MODULE, - 'Magento_RootUpdatePluginInstaller', + 'Magento_ComposerRootUpdatePlugin', __DIR__ ); } diff --git a/src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php b/src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php deleted file mode 100644 index 7d224bf..0000000 --- a/src/Magento/RootUpdatePluginInstaller/Setup/RecurringData.php +++ /dev/null @@ -1,112 +0,0 @@ -doVarInstall(); - } - - /** - * Passthrough Magento upgrade command to check the plugin installation in the var directory - * - * @param ModuleDataSetupInterface $setup - * @param ModuleContextInterface $context - */ - public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $context) - { - $this->doVarInstall(); - } - - /** - * Install magento/composer-root-update-plugin in var/vendor when 'bin/magento setup' commands are called - * - * The plugin is needed there for the Web Setup Wizard's dependencies check and var/* gets cleared out - * when 'bin/magento setup:uninstall' is called, so it needs to be reinstalled - */ - public function doVarInstall() - { - $packageName = RootUpdatePlugin::PACKAGE_NAME; - - $io = new ConsoleIO(new ArrayInput([]), new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), new HelperSet([ - new FormatterHelper(), - new DebugFormatterHelper(), - new ProcessHelper(), - new QuestionHelper(), - ])); - $factory = new Factory(); - $rootDir = preg_split('/vendor/', __DIR__)[0]; - $path = "${rootDir}composer.json"; - $composer = $factory->createComposer($io, $path, true, null, true); - $locker = $composer->getLocker(); - if ($locker->isLocked()) { - $pkg = $locker->getLockedRepository()->findPackage(RootUpdatePlugin::PACKAGE_NAME, '*'); - if ($pkg !== null) { - $version = $pkg->getPrettyVersion(); - try { - $io->writeError( - "Checking for \"$packageName: $version\" for the Web Setup Wizard...", - true, - IOInterface::QUIET - ); - WebSetupWizardPluginInstaller::updateSetupWizardPlugin($io, $composer, $path, $version); - } catch (Exception $e) { - $io->writeError( - "Web Setup Wizard installation of \"$packageName: $version\" failed.", - true, - IOInterface::QUIET - ); - $io->writeError($e->getMessage()); - } - } else { - $io->writeError( - "Web Setup Wizard installation of \"$packageName\" failed; " . - "package not found in ${rootDir}composer.lock.", - true, - IOInterface::QUIET - ); - } - } else { - $io->writeError( - "Web Setup Wizard installation of \"$packageName\" failed; " . - "unable to load ${rootDir}composer.lock.", - true, - IOInterface::QUIET - ); - } - } -} diff --git a/src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php b/src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php deleted file mode 100644 index 861c52c..0000000 --- a/src/Magento/RootUpdatePluginInstaller/WebSetupWizardPluginInstaller.php +++ /dev/null @@ -1,211 +0,0 @@ -getIO(); - $jobs = $event->getRequest()->getJobs(); - $packageName = RootUpdatePlugin::PACKAGE_NAME; - foreach ($jobs as $job) { - if (key_exists('packageName', $job) && $job['packageName'] === $packageName) { - $pkg = $event->getInstalledRepo()->findPackage($packageName, '*'); - if ($pkg !== null) { - $version = $pkg->getPrettyVersion(); - try { - $composer = $event->getComposer(); - static::updateSetupWizardPlugin( - $io, - $composer, - $composer->getConfig()->getConfigSource()->getName(), - $version - ); - } catch (Exception $e) { - $io->writeError( - "Web Setup Wizard installation of \"$packageName: $version\" failed.", - true, - IOInterface::QUIET - ); - $io->writeError($e->getMessage()); - } - break; - } - } - } - } - - /** - * Update the plugin installation inside the ./var directory used by the Web Setup Wizard - * - * @param IOInterface $io - * @param Composer $composer - * @param string $path - * @param string $version - * @return void - * @throws Exception - */ - public static function updateSetupWizardPlugin($io, $composer, $path, $version) - { - $productRegex = '/magento\/product-(community|enterprise)-edition/'; - $packageName = RootUpdatePlugin::PACKAGE_NAME; - $productLinks = array_filter( - array_values($composer->getPackage()->getRequires()), - function ($link) use ($productRegex) { - /** @var Link $link */ - return preg_match($productRegex, $link->getTarget()); - } - ); - $pluginLinks = array_filter( - array_values($composer->getPackage()->getRequires()), - function ($link) { - /** @var Link $link */ - return $link->getTarget() == RootUpdatePlugin::PACKAGE_NAME; - } - ); - if ($productLinks == [] || $pluginLinks == []) { - return; - } - - if (!preg_match('/\/var\/composer\.json$/', $path)) { - $rootDir = preg_replace('/\/composer\.json$/', '', $path); - $varDir = "$rootDir/var"; - $factory = new Factory(); - if (file_exists("$varDir/vendor/$packageName/composer.json")) { - $varPluginComposer = $factory->createComposer( - $io, - "$varDir/vendor/$packageName/composer.json", - true, - "$varDir/vendor/$packageName", - false - ); - // If the current version of the plugin is already the version in this update, noop - if ($varPluginComposer->getPackage()->getPrettyVersion() == $version) { - $io->writeError( - " No Web Setup Wizard update needed for $packageName; version $version is already in $varDir.", - true, - IOInterface::VERBOSE - ); - return; - } - } - - $io->writeError("Installing \"$packageName: $version\" for the Web Setup Wizard"); - if (!file_exists($varDir)) { - mkdir($varDir); - } - $tmpDir = tempnam($varDir, "composer-plugin_tmp."); - $exception = null; - try { - unlink($tmpDir); - mkdir($tmpDir); - if (file_exists("$rootDir/auth.json")) { - static::copyAndReplace("$rootDir/auth.json", "$tmpDir/auth.json"); - } - $tmpConfig = []; - $tmpConfig['repositories'] = $composer->getPackage()->getRepositories(); - $tmpConfig['require'] = [$packageName => $version]; - if ($composer->getPackage()->getMinimumStability()) { - $tmpConfig['minimum-stability'] = $composer->getPackage()->getMinimumStability(); - } - $tmpJson = new JsonFile("$tmpDir/composer.json"); - $tmpJson->write($tmpConfig); - $tmpComposer = $factory->createComposer($io, "$tmpDir/composer.json", true, $tmpDir); - $install = Installer::create($io, $tmpComposer); - $install - ->setDumpAutoloader(true) - ->setRunScripts(false) - ->setDryRun(false) - ->disablePlugins(); - $install->run(); - - if (!file_exists($varDir)) { - mkdir($varDir); - } - static::copyAndReplace("$tmpDir/vendor", "$varDir/vendor"); - } catch (Exception $e) { - $exception = $e; - } finally { - static::deleteFile($tmpDir); - } - if ($exception !== null) { - throw $exception; - } - } - } - - /** - * Deletes a file or a directory and all its contents - * - * @param string $path - * @return void - * @throws FilesystemException - */ - private static function deleteFile($path) - { - if (!file_exists($path)) { - return; - } - if (!is_link($path) && is_dir($path)) { - $files = array_diff(scandir($path), ['..', '.']); - foreach ($files as $file) { - static::deleteFile("$path/$file"); - } - rmdir($path); - } else { - unlink($path); - } - if (file_exists($path)) { - throw new FilesystemException("Failed to delete $path"); - } - } - - /** - * Copies a file or directory and all its contents, replacing anything that exists there beforehand - * - * @param string $source - * @param string $target - * @return void - * @throws FilesystemException - */ - private static function copyAndReplace($source, $target) - { - static::deleteFile($target); - if (is_dir($source)) { - mkdir($target); - $files = array_diff(scandir($source), ['..', '.']); - foreach ($files as $file) { - static::copyAndReplace("$source/$file", "$target/$file"); - } - } else { - copy($source, $target); - } - } -} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php b/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php new file mode 100644 index 0000000..b8b32c0 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php @@ -0,0 +1,215 @@ +assertFileExists(static::$workingDir . '/var/vendor/magento/composer-root-update-plugin/composer.json'); + } + + public function testSetupWizardInstallCommand() + { + static::deletePath(static::$workingDir . '/var/vendor'); + $this->assertFileNotExists(static::$workingDir . '/var/vendor/magento/composer-root-update-plugin/composer.json'); + + static::execComposer(UpdatePluginNamespaceCommands::NAME . ' install'); + + $this->assertFileExists(static::$workingDir . '/var/vendor/magento/composer-root-update-plugin/composer.json'); + } + + public function testUpdateNoOverride() + { + $expectedDir = static::$expectedDir; + static::configureComposerJson(__DIR__ . '/_files/expected_no_override.composer.json', $expectedDir); + + static::execComposer('require magento/product-community-edition=1000.1000.1000 --no-update'); + + $this->assertJsonFileEqualsJsonFile("$expectedDir/composer.json", static::$workingDir . '/composer.json'); + } + + public function testUpdateWithOverride() + { + $expectedDir = static::$expectedDir; + static::configureComposerJson(__DIR__ . '/_files/expected_override.composer.json', $expectedDir); + + static::execComposer( + 'require magento/product-community-edition=1000.1000.1000 --no-update --use-magento-values' + ); + + $this->assertJsonFileEqualsJsonFile("$expectedDir/composer.json", static::$workingDir . '/composer.json'); + } + + /** + * Set file location variables and create the temporary working directory + * + * @throws FilesystemException + */ + public static function setUpBeforeClass() + { + $projectRoot = explode('/tests/', __DIR__); + array_pop($projectRoot); + // Just in case the file path contains another 'tests' directory upstream + $projectRoot = implode('/tests/', $projectRoot); + + static::$workingDir = __DIR__ . '/tmp'; + static::$expectedDir = static::$workingDir . '/expected'; + static::$composerCommand = "$projectRoot/vendor/bin/composer"; + static::$pluginPath = "$projectRoot/src/Magento/ComposerRootUpdatePlugin/"; + static::$testRepoPath = __DIR__ . '/_files/test_repository/*/'; + static::$testComposerJsonSource = __DIR__ . '/_files/test.composer.json'; + + static::deletePath(static::$workingDir); + mkdir(static::$workingDir); + } + + /** + * Reset the composer.json and composer.lock files before each test but leave vendor to not add reinstall delays + * + * @return void + * @throws FilesystemException + */ + protected function setUp() + { + chdir(static::$workingDir); + static::deletePath(static::$workingDir . '/composer.json'); + static::deletePath(static::$workingDir . '/composer.lock'); + static::deletePath(static::$expectedDir); + + static::configureComposerJson(static::$testComposerJsonSource, static::$workingDir); + static::execComposer('create-project'); + } + + /** + * Clear the temporary working directory after all tests have finished + * + * @return void + * @throws FilesystemException + */ + public static function tearDownAfterClass() + { + static::deletePath(static::$workingDir); + } + + /** + * Recursively deletes a file or directory and all its contents, safely handling symlinks + * + * @param string $path + * @return void + * @throws FilesystemException + */ + private static function deletePath($path) + { + if (!$path || !file_exists($path)) { + return; + } + + if (!is_link($path) && is_dir($path)) { + $files = array_diff(scandir($path), ['..', '.']); + foreach ($files as $file) { + static::deletePath("$path/$file"); + } + rmdir($path); + } else { + unlink($path); + } + + if (file_exists($path)) { + throw new FilesystemException("Failed to delete $path"); + } + } + + /** + * Configure repositories in the composer.json file for the plugin source and test package repos + * + * @param string $sourcePath + * @param string $targetDir + * @return void + */ + private static function configureComposerJson($sourcePath, $targetDir) + { + if (!file_exists($targetDir)) { + mkdir($targetDir); + } + copy($sourcePath, "$targetDir/composer.json"); + static::execComposer( + 'config repositories.plugin \'{"type": "path", "url": "' . static::$pluginPath . '"}\'', + $targetDir + ); + static::execComposer( + 'config repositories.test \'{"type": "path", "url": "' . static::$testRepoPath . '"}\'', + $targetDir + ); + } + + /** + * Wrapper to run exec() on the given composer command, treating non-zero return codes as runtime exceptions + * + * If a $dir is supplied, the command will be run in the supplied directory then cwd will be reset to where it was + * + * @param string $command + * @param string $dir + * @return string + */ + private static function execComposer($command, $dir = null) + { + $cwd = getcwd(); + if ($dir) { + chdir($dir); + } + + $fullCommand = static::$composerCommand . " $command"; + $retVal = exec($fullCommand, $output, $errorCode); + if ($dir) { + chdir($cwd); + } + + if ($errorCode !== 0) { + $output = is_array($output) ? implode(PHP_EOL, $output) : $output; + throw new \RuntimeException( + "Composer command '$fullCommand' failed with error code $errorCode\n$output", + $errorCode + ); + } + + return $retVal; + } +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_no_override.composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_no_override.composer.json new file mode 100644 index 0000000..c514c55 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_no_override.composer.json @@ -0,0 +1,49 @@ +{ + "name": "root/pkg", + "version": "1.0.0", + "type": "project", + "require": { + "magento/product-community-edition": "1000.1000.1000", + "magento/composer-root-update-plugin": "*", + "vendor1/package1": "2.0.0" + }, + "require-dev": { + "vendor1/dev-package1": "1.1.0" + }, + "autoload": { + "psr-4": { + "Magento\\": "src/Magento", + "Zend\\Mvc\\Controller\\": "changed/Zend/Controller/" + }, + "files": ["app/etc/Register.php"] + }, + "autoload-dev": { + "psr-4": { + "Magento\\Tools\\": "dev/tools/Magento/Tools2/", + "Magento\\Sniffs\\": "dev/tests/framework/Magento/Sniffs/" + } + }, + "conflict": { + "vendor1/conflicting1": "1.0.0", + "vendor1/conflicting3": "3.0.0", + "vendor1/different-conflicting1": "1.0.0", + "vendor1/different-conflicting2": "2.0.0", + "vendor1/different-conflicting3": "3.0.0" + }, + "extra": { + "extra-key1": "install1", + "extra-key2": "target2", + "extra-key3": { + "a": "b" + } + }, + "suggest": { + "vendor/suggested": "Another Suggested Package" + }, + "replace": { + "replaced/package1": "1.0.0", + "replaced/package2": "2.0.0", + "replaced/package3": "3.0.0" + }, + "minimum-stability": "dev" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_override.composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_override.composer.json new file mode 100644 index 0000000..5ca9e7a --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/expected_override.composer.json @@ -0,0 +1,52 @@ +{ + "name": "root/pkg", + "version": "1.0.0", + "type": "project", + "require": { + "magento/product-community-edition" : "1000.1000.1000", + "magento/composer-root-update-plugin": "*", + "vendor1/package1": "2.0.0" + }, + "require-dev": { + "vendor1/dev-package1": "1.1.0" + }, + "autoload": { + "psr-4": { + "Magento\\": "src/Magento", + "Zend\\Mvc\\Controller\\": "setup/src/Zend/Mvc/Controller/" + }, + "files": ["app/etc/Register.php"] + }, + "autoload-dev": { + "psr-4": { + "Magento\\Sniffs\\": "dev/tests/framework/Magento/Sniffs/" + } + }, + "conflict": { + "vendor1/conflicting1": "1.0.0", + "vendor1/conflicting2": "2.1.0", + "vendor1/conflicting3": "3.0.0", + "vendor1/different-conflicting1": "1.0.0", + "vendor1/different-conflicting2": "2.0.0", + "vendor1/different-conflicting3": "3.0.0" + }, + "extra": { + "extra-key1": "target1", + "extra-key2": "target2", + "extra-key3": { + "a": "b" + } + }, + "provide": { + "magento/sub-package2": "2.1.0" + }, + "suggest": { + "vendor/suggested": "Another Suggested Package" + }, + "replace": { + "replaced/package1": "1.0.0", + "replaced/package2": "2.0.0", + "replaced/package3": "3.0.0" + }, + "minimum-stability": "dev" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test.composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test.composer.json new file mode 100644 index 0000000..e29e39f --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test.composer.json @@ -0,0 +1,41 @@ +{ + "name": "root/pkg", + "version": "1.0.0", + "type": "project", + "require": { + "magento/product-community-edition" : "999.999.999", + "magento/composer-root-update-plugin": "*", + "vendor1/package1": "1.0.0" + }, + "require-dev": { + "vendor1/dev-package1": "1.1.0", + "vendor1/dev-package2": "2.0.0" + }, + "autoload": { + "psr-4": { + "Magento\\": "src/Magento", + "Zend\\Mvc\\Controller\\": "changed/Zend/Controller/" + }, + "files": ["app/etc/Register.php"] + }, + "autoload-dev": { + "psr-4": { + "Magento\\Tools\\": "dev/tools/Magento/Tools2/" + } + }, + "conflict": { + "vendor1/conflicting1": "1.0.0", + "vendor1/different-conflicting1": "1.0.0", + "vendor1/different-conflicting2": "2.0.0", + "vendor1/different-conflicting3": "3.0.0" + }, + "extra": { + "extra-key1": "install1", + "extra-key2": "base2" + }, + "suggest": { + "sample-data": "Suggested Sample Data 1.0.0", + "vendor/suggested": "Another Suggested Package" + }, + "minimum-stability": "dev" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_mage_root/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_mage_root/composer.json new file mode 100644 index 0000000..fa9baa8 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_mage_root/composer.json @@ -0,0 +1,41 @@ +{ + "name": "magento/project-community-edition", + "version": "999.999.999", + "type": "project", + "description": "Test Magento root project composer.json for the default install of the user's pre-upgrade version", + "require": { + "magento/product-community-edition" : "999.999.999", + "magento/composer-root-update-plugin": "*", + "vendor1/package1": "1.0.0" + }, + "require-dev": { + "vendor1/dev-package1": "1.0.0", + "vendor1/dev-package2": "2.0.0" + }, + "autoload": { + "psr-4": { + "Magento\\": "src/Magento" + } + }, + "autoload-dev": { + "psr-4": { + "Magento\\Tools\\": "dev/tools/Magento/Tools/" + } + }, + "conflict": { + "vendor1/conflicting1": "1.0.0", + "vendor1/conflicting2": "2.0.0" + }, + "extra": { + "extra-key1": "base1", + "extra-key2": "base2" + }, + "provide": { + "magento/sub-package1": "1.0.0", + "magento/sub-package2": "2.0.0", + "magento/sub-package3": "3.0.0" + }, + "suggest": { + "sample-data": "Suggested Sample Data 1.0.0" + } +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_product/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_product/composer.json new file mode 100644 index 0000000..c3eab88 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/original_product/composer.json @@ -0,0 +1,5 @@ +{ + "name": "magento/product-community-edition", + "version": "999.999.999", + "description": "Test Magento product dummy composer.json for installed version" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_mage_root/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_mage_root/composer.json new file mode 100644 index 0000000..2556517 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_mage_root/composer.json @@ -0,0 +1,47 @@ +{ + "name": "magento/project-community-edition", + "version": "1000.1000.1000", + "type": "project", + "description": "Test Magento root project composer.json for the target upgrade version", + "require": { + "magento/product-community-edition" : "1000.1000.1000", + "magento/composer-root-update-plugin": "*", + "vendor1/package1": "2.0.0" + }, + "require-dev": { + "vendor1/dev-package1": "1.0.0" + }, + "autoload": { + "psr-4": { + "Magento\\": "src/Magento", + "Zend\\Mvc\\Controller\\": "setup/src/Zend/Mvc/Controller/" + } + }, + "autoload-dev": { + "psr-4": { + "Magento\\Sniffs\\": "dev/tests/framework/Magento/Sniffs/" + } + }, + "conflict": { + "vendor1/conflicting1": "1.0.0", + "vendor1/conflicting2": "2.1.0", + "vendor1/conflicting3": "3.0.0" + }, + "extra": { + "extra-key1": "target1", + "extra-key2": "target2", + "extra-key3": { + "a": "b" + } + }, + "provide": { + "magento/sub-package1": "1.0.0", + "magento/sub-package2": "2.1.0", + "magento/sub-package3": "3.0.0" + }, + "replace": { + "replaced/package1": "1.0.0", + "replaced/package2": "2.0.0", + "replaced/package3": "3.0.0" + } +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_product/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_product/composer.json new file mode 100644 index 0000000..65db9ef --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/target_product/composer.json @@ -0,0 +1,5 @@ +{ + "name": "magento/product-community-edition", + "version": "1000.1000.1000", + "description": "Test Magento product dummy composer.json for target version" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1/composer.json new file mode 100644 index 0000000..ef9634d --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1/composer.json @@ -0,0 +1,5 @@ +{ + "name": "vendor1/dev-package1", + "version": "1.0.0", + "description": "Test dummy composer.json for require-dev library" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1_1/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1_1/composer.json new file mode 100644 index 0000000..4915d57 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage1_1/composer.json @@ -0,0 +1,5 @@ +{ + "name": "vendor1/dev-package1", + "version": "1.1.0", + "description": "Test dummy composer.json for require-dev library" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage2/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage2/composer.json new file mode 100644 index 0000000..6f6ed97 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_devpackage2/composer.json @@ -0,0 +1,5 @@ +{ + "name": "vendor1/dev-package2", + "version": "2.0.0", + "description": "Test dummy composer.json for require-dev library" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_1/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_1/composer.json new file mode 100644 index 0000000..0cd2bd3 --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_1/composer.json @@ -0,0 +1,5 @@ +{ + "name": "vendor1/package1", + "version": "1.0.0", + "description": "Test dummy composer.json for require library pre-upgrade" +} diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_2/composer.json b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_2/composer.json new file mode 100644 index 0000000..160ff9f --- /dev/null +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/_files/test_repository/vendor_package1_2/composer.json @@ -0,0 +1,5 @@ +{ + "name": "vendor1/package1", + "version": "2.0.0", + "description": "Test dummy composer.json for require library post-upgrade" +} diff --git a/tests/Unit/Magento/Composer/Plugin/RootUpdate/ConflictResolverTest.php b/tests/Unit/Magento/Composer/Plugin/RootUpdate/ConflictResolverTest.php deleted file mode 100644 index 2779143..0000000 --- a/tests/Unit/Magento/Composer/Plugin/RootUpdate/ConflictResolverTest.php +++ /dev/null @@ -1,437 +0,0 @@ -io, false, false, '', ''); - $resolution = $resolver->findResolution('field', null, 'newVal', null); - - $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); - } - - public function testFindResolutionRemoveElement() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolution = $resolver->findResolution('field', 'oldVal', null, 'oldVal'); - - $this->assertEquals(ConflictResolver::REMOVE_VAL, $resolution); - } - - public function testFindResolutionChangeElement() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'oldVal'); - - $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); - } - - public function testFindResolutionNoUpdate() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'newVal'); - - $this->assertNull($resolution); - } - - public function testFindResolutionConflictNoOverride() - { - $this->io->expects($this->at(0))->method('writeError') - ->with($this->stringContains('will not be changed')); - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertNull($resolution); - } - - public function testFindResolutionConflictOverride() - { - $resolver = new ConflictResolver($this->io, false, true, '', ''); - - $this->io->expects($this->once())->method('writeError') - ->with($this->stringContains('overriding local changes')); - - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); - } - - public function testFindResolutionConflictOverrideRestoreRemoved() - { - $resolver = new ConflictResolver($this->io, false, true, '', ''); - - $this->io->expects($this->once())->method('writeError') - ->with($this->stringContains('overriding local changes')); - - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', null); - - $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); - } - - public function testFindResolutionInteractiveConfirm() - { - $this->io->method('isInteractive')->willReturn(true); - $resolver = new ConflictResolver($this->io, true, false, '', ''); - $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); - - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); - } - - public function testFindResolutionInteractiveNoConfirm() - { - $resolver = new ConflictResolver($this->io, true, false, '', ''); - $this->io->method('isInteractive')->willReturn(true); - $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); - - $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - - $this->assertNull($resolution); - } - - public function testFindResolutionNonInteractiveEnvironmentError() - { - $resolver = new ConflictResolver($this->io, true, false, '', ''); - $this->io->method('isInteractive')->willReturn(false); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - '--' . RootUpdateCommand::INTERACTIVE_OPT . ' cannot be used in non-interactive terminals.' - ); - $this->io->expects($this->never())->method('askConfirmation'); - - $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - } - - public function testResolveNestedArrayNonArrayAdd() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $result = $resolver->resolveNestedArray('field', null, 'newVal', null); - - $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); - } - - public function testResolveNestedArrayNonArrayRemove() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal'); - - $this->assertEquals(['changed' => true, 'value' => null], $result); - } - - public function testResolveNestedArrayNonArrayChange() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); - - $this->assertEquals(['changed' => true, 'value' => 'newVal'], $result); - } - - public function testResolveArrayMismatchedArray() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - 'oldVal', - ['newVal'], - 'oldVal', - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['newVal'], $this->installRoot->getExtra()); - } - - public function testResolveArrayMismatchedMap() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['oldVal'], - ['key' => 'newVal'], - ['oldVal'], - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['key' => 'newVal'], $this->installRoot->getExtra()); - } - - public function testResolveArrayFlatArrayAddElement() - { - $expected = ['val1', 'val2', 'val3']; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['val1'], - ['val1', 'val3'], - ['val2', 'val1'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff($expected, $result), array_diff($result, $expected))); - } - - public function testResolveArrayFlatArrayRemoveElement() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['val1', 'val2', 'val3'], - ['val2'], - ['val1', 'val2', 'val3', 'val4'], - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['val2', 'val4'], array_values($this->installRoot->getExtra())); - } - - public function testResolveArrayFlatArrayAddAndRemoveElement() - { - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['val1', 'val2', 'val3'], - ['val2', 'val5'], - ['val1', 'val2', 'val3', 'val4'], - [$this->installRoot, 'setExtra'] - ); - - $this->assertEquals(['val2', 'val4', 'val5'], array_values($this->installRoot->getExtra())); - } - - public function testResolveArrayAssociativeAddElement() - { - $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['key1' => 'val1'], - ['key1' => 'val1', 'key3' => 'val3'], - ['key2' => 'val2', 'key1' => 'val1'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); - } - - public function testResolveArrayAssociativeRemoveElement() - { - $expected = ['key2' => 'val2', 'key3' => 'val3']; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['key1' => 'val1', 'key2' => 'val2'], - ['key2' => 'val2'], - ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); - } - - public function testResolveArrayAssociativeAddAndRemoveElement() - { - $expected = ['key3' => 'val3', 'key4' => 'val4']; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['key1' => 'val1', 'key2' => 'val2'], - ['key4' => 'val4'], - ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'], - [$this->installRoot, 'setExtra'] - ); - - $result = $this->installRoot->getExtra(); - $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); - } - - public function testResolveArrayNestedAdd() - { - $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], - ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], - ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], - [$this->installRoot, 'setExtra'] - ); - - $expectedKeys = array_keys($expected); - $actualKeys = array_keys($this->installRoot->getExtra()); - $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); - foreach ($expected as $key => $expectedVal) { - $actualVal = $this->installRoot->getExtra()[$key]; - $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); - } - } - - public function testResolveArrayNestedRemove() - { - $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveArraySection( - 'extra', - ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], - ['key1' => ['k1v1'], 'key2' => ['k2v2']], - ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']], - [$this->installRoot, 'setExtra'] - ); - - $expectedKeys = array_keys($expected); - $actualKeys = array_keys($this->installRoot->getExtra()); - $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); - foreach ($expected as $key => $expectedVal) { - $actualVal = $this->installRoot->getExtra()[$key]; - $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); - } - } - - public function testResolveArrayTracksChanges() - { - $expected = ['val1', 'val2', 'val3']; - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $this->assertEmpty($resolver->getJsonChanges()); - $resolver->resolveArraySection( - 'extra', - ['val1'], - ['val1', 'val3'], - ['val2', 'val1'], - [$this->installRoot, 'setExtra'] - ); - $actual = $resolver->getJsonChanges(); - - $this->assertEquals(['extra'], array_keys($actual)); - $actual = $actual['extra']; - $this->assertEmpty(array_merge(array_diff($expected, $actual), array_diff($actual, $expected))); - } - - public function testResolveLinksAddLink() - { - $installLink = $this->createLinks(1, 'install/link'); - $baseLinks = $this->createLinks(2); - $installLinks = array_merge($baseLinks, $installLink); - $targetLinks = array_merge($baseLinks, $this->createLinks(1, 'target/link')); - $expected = array_merge($targetLinks, $installLink); - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $this->assertLinksEqual($expected, $this->installRoot->getRequires()); - } - - public function testResolveLinksRemoveLink() - { - $installLink = $this->createLinks(1, 'install/link'); - $baseLinks = $this->createLinks(2); - $installLinks = array_merge($baseLinks, $installLink); - $targetLinks = array_slice($baseLinks, 1); - $expected = array_merge($targetLinks, $installLink); - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $this->assertLinksEqual($expected, $this->installRoot->getRequires()); - } - - public function testResolveLinksChangeLink() - { - $installLink = $this->createLinks(1, 'install/link'); - $baseLinks = $this->createLinks(2); - $installLinks = array_merge($baseLinks, $installLink); - $targetLinks = $this->changeLink($baseLinks, 1); - $expected = array_merge($targetLinks, $installLink); - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $resolver->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $this->assertLinksEqual($expected, $this->installRoot->getRequires()); - } - - public function testResolveLinksTracksChanges() - { - $installLink = $this->createLinks(1, 'install/link')[0]; - $baseLinks = $this->createLinks(1); - /** @var Link[] $installLinks */ - $installLinks = array_merge($baseLinks, [$installLink]); - $targetLinks = $this->changeLink($baseLinks, 0); - - $resolver = new ConflictResolver($this->io, false, false, '', ''); - $this->assertEmpty($resolver->getJsonChanges()); - $resolver->resolveLinkSection( - 'require', - $baseLinks, - $targetLinks, - $installLinks, - [$this->installRoot, 'setRequires'] - ); - - $changed = $resolver->getJsonChanges(); - $this->assertEquals(['require'], array_keys($changed)); - $actual = $changed['require']; - $this->assertEquals(2, count($actual)); - $this->assertTrue(key_exists($targetLinks[0]->getTarget(), $actual)); - $this->assertEquals($targetLinks[0]->getConstraint()->getPrettyString(), $actual[$targetLinks[0]->getTarget()]); - $this->assertTrue(key_exists($installLink->getTarget(), $actual)); - $this->assertEquals($installLinks[1]->getConstraint()->getPrettyString(), $actual[$installLink->getTarget()]); - } - - public function setUp() - { - $this->io = $this->getMockForAbstractClass(IOInterface::class); - $this->installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); - } -} diff --git a/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php similarity index 68% rename from tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php rename to tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php index a1a8d2f..586b3c9 100644 --- a/tests/Unit/Magento/Composer/Plugin/RootUpdate/RootUpdateCommandTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php @@ -4,63 +4,62 @@ * See COPYING.txt for license details. */ -namespace Magento\Composer\Plugin\RootUpdate; +namespace Magento\ComposerRootUpdatePlugin\Plugin\Commands; use Composer\Composer; use Composer\Plugin\Capability\Capability; use Composer\Plugin\PluginManager; -use Magento\TestHelper\TestApplication; -use Magento\TestHelper\UpdatePluginTestCase; +use Magento\ComposerRootUpdatePlugin\TestHelpers\TestApplication; +use Magento\ComposerRootUpdatePlugin\UpdatePluginTestCase; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; /** * Class RootUpdateCommandTest - * - * @package Magento\Composer\Plugin\RootUpdate */ -class RootUpdateCommandTest extends UpdatePluginTestCase +class MageRootRequireCommandTest extends UpdatePluginTestCase { /** @var TestApplication */ public $application; - /** @var RootUpdateCommand */ - public $rootUpdateCommand; + /** @var MageRootRequireCommand */ + public $command; /** @var MockObject|InputInterface */ public $input; - public function testOverwriteUpdateCommand() + public function testOverwriteRequireCommand() { /** @var MockObject|OutputInterface $output */ $output = $this->getMockForAbstractClass(OutputInterface::class); + $this->input->method('getFirstArgument')->willReturn('require'); $this->application->doRun($this->input, $output); - $this->assertEquals($this->rootUpdateCommand, $this->application->getCalledCommand()); + $this->assertEquals($this->command, $this->application->getCalledCommand()); } - public function testUpdateCommandNoPlugins() + public function testCommandNoPlugins() { /** @var MockObject|OutputInterface $output */ $output = $this->getMockForAbstractClass(OutputInterface::class); + $this->input->method('getFirstArgument')->willReturn('require'); $this->input->method('hasParameterOption')->willReturnMap([['--no-plugins', false, true]]); $this->application->doRun($this->input, $output); - $this->assertNotEquals($this->rootUpdateCommand, $this->application->getCalledCommand()); + $this->assertNotEquals($this->command, $this->application->getCalledCommand()); } public function setUp() { - $this->rootUpdateCommand = new RootUpdateCommand(); + $this->command = new MageRootRequireCommand(); $capability = $this->createPartialMock(Capability::class, ['getCommands']); - $capability->method('getCommands')->willReturn([$this->rootUpdateCommand]); + $capability->method('getCommands')->willReturn([$this->command]); $pluginManager = $this->createPartialMock(PluginManager::class, ['getPluginCapabilities']); $pluginManager->method('getPluginCapabilities')->willReturn([$capability]); $input = $this->getMockForAbstractClass(InputInterface::class); - $input->method('getFirstArgument')->willReturn('update'); $input->method('getParameterOption')->with(['--working-dir', '-d'])->willReturn(false); $this->input = $input; $composer = $this->createPartialMock(Composer::class, ['getPluginManager']); diff --git a/tests/Unit/Magento/TestHelper/TestApplication.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php similarity index 95% rename from tests/Unit/Magento/TestHelper/TestApplication.php rename to tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php index 083a1fd..1555adc 100644 --- a/tests/Unit/Magento/TestHelper/TestApplication.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php @@ -4,7 +4,7 @@ * See COPYING.txt for license details. */ -namespace Magento\TestHelper; +namespace Magento\ComposerRootUpdatePlugin\TestHelpers; use Composer\Composer; use PHPUnit\Framework\MockObject\MockObject; @@ -14,8 +14,6 @@ /** * Class TestApplication - * - * @package Magento\TestHelper */ class TestApplication extends \Composer\Console\Application { @@ -29,6 +27,7 @@ class TestApplication extends \Composer\Console\Application * Pass in a mock Composer object for unit testing * * @param MockObject|Composer $composer + * @return void */ public function setComposer(Composer $composer) { @@ -39,7 +38,6 @@ public function setComposer(Composer $composer) * Set whether or not doRunCommand should actually be run or not * * @param bool $shouldRun - * * @return void */ public function setShouldRun($shouldRun) @@ -53,9 +51,7 @@ public function setShouldRun($shouldRun) * @param Command $command * @param InputInterface $input * @param OutputInterface $output - * * @return int - * * @throws \Throwable */ protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output) diff --git a/tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php similarity index 73% rename from tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php rename to tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php index 2969c51..a9638a7 100644 --- a/tests/Unit/Magento/TestHelper/UpdatePluginTestCase.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php @@ -4,15 +4,14 @@ * See COPYING.txt for license details. */ -namespace Magento\TestHelper; +namespace Magento\ComposerRootUpdatePlugin; use Composer\Package\Link; use Composer\Semver\Constraint\Constraint; +use ReflectionClass; /** * Class UpdatePluginTestCase - * - * @package Magento\TestHelper */ abstract class UpdatePluginTestCase extends \PHPUnit\Framework\TestCase { @@ -21,7 +20,6 @@ abstract class UpdatePluginTestCase extends \PHPUnit\Framework\TestCase * * @param int $count * @param string $target - * * @return Link[] */ public static function createLinks($count, $target = 'package/name') @@ -38,7 +36,6 @@ public static function createLinks($count, $target = 'package/name') * * @param Link[] $links * @param int $index - * * @return Link[] */ public static function changeLink($links, $index) @@ -59,48 +56,30 @@ public static function changeLink($links, $index) return $result; } - /** - * Callback to capture an argument passed to a mock function in the given variable - * - * @param &$arg - * - * @return \PHPUnit\Framework\Constraint\Callback - */ - public static function captureArg(&$arg) - { - return static::callback(function ($argToMock) use (&$arg) { - $arg = $argToMock; - return true; - }); - } - /** * Assert that two arrays of links are equal without checking order * * @param Link[] $expected - * @param Link[] $actual - * + * @param array $jsonChanges * @return void */ - public static function assertLinksEqual($expected, $actual) + public static function assertLinksEqual($expected, $jsonChanges) { - static::assertEquals(count($expected), count($actual)); + static::assertEquals(count($expected), count($jsonChanges)); while (count($expected) > 0) { $expectedLink = array_shift($expected); - $expectedSource = $expectedLink->getSource(); $expectedTarget = $expectedLink->getTarget(); $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); - $found = -1; - foreach ($actual as $key => $actualLink) { - if ($actualLink->getSource() === $expectedSource && - $actualLink->getTarget() === $expectedTarget && - $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { - $found = $key; + $found = null; + foreach ($jsonChanges as $target => $constraint) { + if ($target === $expectedTarget && + $constraint === $expectedConstraint) { + $found = $target; break; } } - static::assertGreaterThan(-1, $found, "Could not find a link matching $expectedLink"); - unset($actual[$found]); + static::assertNotEmpty($found, "Could not find a link matching $expectedLink"); + unset($jsonChanges[$found]); } } @@ -109,7 +88,6 @@ public static function assertLinksEqual($expected, $actual) * * @param Link[] $expected * @param Link[] $actual - * * @return void */ public static function assertLinksNotEqual($expected, $actual) @@ -141,4 +119,21 @@ public static function assertLinksNotEqual($expected, $actual) } static::fail('Expected Link sets to not be equal'); } + + /** + * Sets a protected property on a given object via reflection + * + * @param $object - instance in which protected value is being modified + * @param $property - property on instance being modified + * @param $value - new value of the property being modified + * @return void + * @throws \ReflectionException + */ + public static function mockProtectedProperty($object, $property, $value) + { + $reflection = new ReflectionClass($object); + $reflection_property = $reflection->getProperty($property); + $reflection_property->setAccessible(true); + $reflection_property->setValue($object, $value); + } } diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php new file mode 100644 index 0000000..b015409 --- /dev/null +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php @@ -0,0 +1,378 @@ +retriever); + $resolution = $resolver->findResolution('field', null, 'newVal', null); + + $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); + } + + public function testFindResolutionRemoveElement() + { + $resolver = new ConflictResolver(false, $this->retriever); + $resolution = $resolver->findResolution('field', 'oldVal', null, 'oldVal'); + + $this->assertEquals(ConflictResolver::REMOVE_VAL, $resolution); + } + + public function testFindResolutionChangeElement() + { + $resolver = new ConflictResolver(false, $this->retriever); + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'oldVal'); + + $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + } + + public function testFindResolutionNoUpdate() + { + $resolver = new ConflictResolver(false, $this->retriever); + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'newVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionConflictNoOverride() + { + $this->io->expects($this->at(0))->method('writeError') + ->with($this->stringContains('will not be changed')); + + $resolver = new ConflictResolver(false, $this->retriever); + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionConflictOverride() + { + $resolver = new ConflictResolver(true, $this->retriever); + + $this->io->expects($this->at(1))->method('writeError') + ->with($this->stringContains('overriding local changes')); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + } + + public function testFindResolutionConflictOverrideRestoreRemoved() + { + $resolver = new ConflictResolver(true, $this->retriever); + + $this->io->expects($this->at(1))->method('writeError') + ->with($this->stringContains('overriding local changes')); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', null); + + $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); + } + + public function testFindResolutionInteractiveConfirm() + { + $resolver = new ConflictResolver(false, $this->retriever); + Console::setInteractive(true); + $this->io->method('isInteractive')->willReturn(true); + $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + } + + public function testFindResolutionInteractiveNoConfirm() + { + $resolver = new ConflictResolver(false, $this->retriever); + Console::setInteractive(true); + $this->io->method('isInteractive')->willReturn(true); + $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); + + $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + + $this->assertNull($resolution); + } + + public function testFindResolutionNonInteractiveEnvironmentError() + { + $resolver = new ConflictResolver(false, $this->retriever); + Console::setInteractive(true); + $this->io->method('isInteractive')->willReturn(false); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Interactive options cannot be used in non-interactive terminals.'); + $this->io->expects($this->never())->method('askConfirmation'); + + $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); + } + + public function testResolveNestedArrayNonArrayAdd() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveNestedArray('field', null, 'newVal', null); + + $this->assertEquals([true, 'newVal'], $result); + } + + public function testResolveNestedArrayNonArrayRemove() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal'); + + $this->assertEquals([true, null], $result); + } + + public function testResolveNestedArrayNonArrayChange() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); + + $this->assertEquals([true, 'newVal'], $result); + } + + public function testResolveArrayMismatchedArray() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + 'oldVal', + ['newVal'], + 'oldVal' + )['extra']; + + $this->assertEquals(['newVal'], $result); + } + + public function testResolveArrayMismatchedMap() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['oldVal'], + ['key' => 'newVal'], + ['oldVal'] + )['extra']; + + $this->assertEquals(['key' => 'newVal'], $result); + } + + public function testResolveArrayFlatArrayAddElement() + { + $expected = ['val1', 'val2', 'val3']; + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['val1'], + ['val1', 'val3'], + ['val2', 'val1'] + )['extra']; + + $this->assertEmpty(array_merge(array_diff($expected, $result), array_diff($result, $expected))); + } + + public function testResolveArrayFlatArrayRemoveElement() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['val1', 'val2', 'val3'], + ['val2'], + ['val1', 'val2', 'val3', 'val4'] + )['extra']; + + $this->assertEquals(['val2', 'val4'], array_values($result)); + } + + public function testResolveArrayFlatArrayAddAndRemoveElement() + { + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['val1', 'val2', 'val3'], + ['val2', 'val5'], + ['val1', 'val2', 'val3', 'val4'] + )['extra']; + + $this->assertEquals(['val2', 'val4', 'val5'], array_values($result)); + } + + public function testResolveArrayAssociativeAddElement() + { + $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['key1' => 'val1'], + ['key1' => 'val1', 'key3' => 'val3'], + ['key2' => 'val2', 'key1' => 'val1'] + )['extra']; + + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayAssociativeRemoveElement() + { + $expected = ['key2' => 'val2', 'key3' => 'val3']; + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['key1' => 'val1', 'key2' => 'val2'], + ['key2' => 'val2'], + ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'] + )['extra']; + + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayAssociativeAddAndRemoveElement() + { + $expected = ['key3' => 'val3', 'key4' => 'val4']; + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['key1' => 'val1', 'key2' => 'val2'], + ['key4' => 'val4'], + ['key2' => 'val2', 'key1' => 'val1', 'key3' => 'val3'] + )['extra']; + + $this->assertEmpty(array_merge(array_diff_assoc($expected, $result), array_diff_assoc($result, $expected))); + } + + public function testResolveArrayNestedAdd() + { + $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']] + )['extra']; + + $expectedKeys = array_keys($expected); + $actualKeys = array_keys($result); + $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); + foreach ($expected as $key => $expectedVal) { + $actualVal = $result[$key]; + $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); + } + } + + public function testResolveArrayNestedRemove() + { + $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveArraySection( + 'extra', + ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], + ['key1' => ['k1v1'], 'key2' => ['k2v2']], + ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']] + )['extra']; + + $expectedKeys = array_keys($expected); + $actualKeys = array_keys($result); + $this->assertEmpty(array_merge(array_diff($expectedKeys, $actualKeys), array_diff($actualKeys, $expectedKeys))); + foreach ($expected as $key => $expectedVal) { + $actualVal = $result[$key]; + $this->assertEmpty(array_merge(array_diff($expectedVal, $actualVal), array_diff($actualVal, $expectedVal))); + } + } + + public function testResolveLinksAddLink() + { + $userLink = $this->createLinks(1, 'user/link'); + $originalMageLinks = $this->createLinks(2); + $userLinks = array_merge($originalMageLinks, $userLink); + $targetMageLinks = array_merge($originalMageLinks, $this->createLinks(1, 'targetMage/link')); + $expected = array_merge($targetMageLinks, $userLink); + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $userLinks + ); + + $this->assertLinksEqual($expected, $result['require']); + } + + public function testResolveLinksRemoveLink() + { + $userLink = $this->createLinks(1, 'user/link'); + $originalMageLinks = $this->createLinks(2); + $userLinks = array_merge($originalMageLinks, $userLink); + $targetMageLinks = array_slice($originalMageLinks, 1); + $expected = array_merge($targetMageLinks, $userLink); + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $userLinks + ); + + $this->assertLinksEqual($expected, $result['require']); + } + + public function testResolveLinksChangeLink() + { + $userLink = $this->createLinks(1, 'user/link'); + $originalMageLinks = $this->createLinks(2); + $userLinks = array_merge($originalMageLinks, $userLink); + $targetMageLinks = $this->changeLink($originalMageLinks, 1); + $expected = array_merge($targetMageLinks, $userLink); + + $resolver = new ConflictResolver(false, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $userLinks + ); + + $this->assertLinksEqual($expected, $result['require']); + } + + public function setUp() + { + $this->io = $this->getMockForAbstractClass(IOInterface::class); + Console::setIO($this->io); + Console::setInteractive(false); + $this->retriever = $this->createPartialMock( + RootPackageRetriever::class, + ['getOriginalRootPackage', 'getTargetRootPackage', 'getUserRootPackage'] + ); + $this->retriever->method('getOriginalRootPackage')->willReturn(null); + $this->retriever->method('getTargetRootPackage')->willReturn(null); + $this->retriever->method('getUserRootPackage')->willReturn(null); + } +} diff --git a/tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php similarity index 63% rename from tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php rename to tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php index 788fab5..2cd1b23 100644 --- a/tests/Unit/Magento/Composer/Plugin/RootUpdate/MagentoRootUpdaterTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php @@ -4,7 +4,7 @@ * See COPYING.txt for license details. */ -namespace Magento\Composer\Plugin\RootUpdate; +namespace Magento\ComposerRootUpdatePlugin\Updater; use Composer\Composer; use Composer\Config; @@ -18,16 +18,15 @@ use Composer\Repository\ComposerRepository; use Composer\Repository\RepositoryInterface; use Composer\Repository\RepositoryManager; -use Composer\Script\ScriptEvents; use Composer\Semver\Constraint\Constraint; -use Magento\TestHelper\UpdatePluginTestCase; +use Magento\ComposerRootUpdatePlugin\Utils\Console; +use Magento\ComposerRootUpdatePlugin\Plugin\PluginDefinition; +use Magento\ComposerRootUpdatePlugin\UpdatePluginTestCase; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Input\InputInterface; /** * Class MagentoRootUpdaterTest - * - * @package Magento\Composer\Plugin\RootUpdate */ class MagentoRootUpdaterTest extends UpdatePluginTestCase { @@ -52,139 +51,52 @@ class MagentoRootUpdaterTest extends UpdatePluginTestCase /** @var MockObject|BaseIO */ public $io; - public function testMagentoUpdateNotMagentoRoot() - { - $links = [ - new Link('root', 'vndr/package', new Constraint('==', '1.0.0.0')), - new Link('root', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')) - ]; - $this->installRoot->setRequires($links); - - $this->composer->expects($this->never())->method('setPackage'); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Magento root updates cannot run without a valid target package'); - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - - $updater->runUpdate(); - } - - public function testMagentoUpdateRegistersPostUpdateWrites() - { - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - - $this->eventDispatcher->expects($this->once())->method('addListener')->with( - ScriptEvents::POST_UPDATE_CMD, - [$updater, 'writeUpdatedRoot'], - PHP_INT_MAX - ); - - $updater->runUpdate(); - } - - public function testMagentoUpdateDryRun() - { - $this->input->method('getOption')->willReturnMap([['dry-run', true]]); - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - - $this->eventDispatcher->expects($this->never())->method('addListener'); - - $updater->runUpdate(); - - $this->assertNotEmpty($updater->getJsonChanges()); - } + /** @var MockObject|RootPackageRetriever */ + public $retriever; public function testMagentoUpdateSetsFieldsNoOverride() { - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - $updater->runUpdate(); - - $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); - $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); - $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); + $updater = new MagentoRootUpdater($this->composer); + $updater->runUpdate($this->retriever, false, true, '7.0', 'stable'); + $result = $updater->getJsonChanges(); + + $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $result['require']); + $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $result['require-dev']); + $this->assertEquals($this->expectedNoOverride->getAutoload(), $result['autoload']); + $this->assertEquals($this->expectedNoOverride->getDevAutoload(), $result['autoload-dev']); + $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $result['conflict']); + $this->assertEquals($this->expectedNoOverride->getExtra(), $result['extra']); + $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $result['provide']); + $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $result['replace']); + $this->assertEquals($this->expectedNoOverride->getSuggests(), $result['suggest']); } public function testMagentoUpdateSetsFieldsWithOverride() { - $this->input->method('getOption')->willReturnMap([[RootUpdateCommand::OVERRIDE_OPT, true]]); - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $updater->runUpdate(); - - $this->assertLinksEqual($this->expectedWithOverride->getRequires(), $newRoot->getRequires()); - $this->assertLinksEqual($this->expectedWithOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertEquals($this->expectedWithOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertEquals($this->expectedWithOverride->getDevAutoload(), $newRoot->getDevAutoload()); - $this->assertLinksEqual($this->expectedWithOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedWithOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedWithOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedWithOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedWithOverride->getSuggests(), $newRoot->getSuggests()); - } - - public function testMagentoUpdateNoDev() - { - $this->input->method('getOption')->willReturnMap([['no-dev', true]]); - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $updater->runUpdate(); - - $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); - $this->assertEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); - - $this->assertLinksNotEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); - } - - public function testMagentoUpdateNoAutoloader() - { - $this->input->method('getOption')->willReturnMap([['no-autoloader', true]]); - $updater = new MagentoRootUpdater($this->io, $this->composer, $this->input); - - /** @var RootPackage $newRoot */ - $this->composer->expects($this->once())->method('setPackage')->with($this->captureArg($newRoot)); - - $updater->runUpdate(); - - $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $newRoot->getRequires()); - $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $newRoot->getDevRequires()); - $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $newRoot->getConflicts()); - $this->assertEquals($this->expectedNoOverride->getExtra(), $newRoot->getExtra()); - $this->assertLinksEqual($this->expectedNoOverride->getProvides(), $newRoot->getProvides()); - $this->assertLinksEqual($this->expectedNoOverride->getReplaces(), $newRoot->getReplaces()); - $this->assertEquals($this->expectedNoOverride->getSuggests(), $newRoot->getSuggests()); - - $this->assertNotEquals($this->expectedNoOverride->getAutoload(), $newRoot->getAutoload()); - $this->assertNotEquals($this->expectedNoOverride->getDevAutoload(), $newRoot->getDevAutoload()); + $updater = new MagentoRootUpdater($this->composer); + $updater->runUpdate($this->retriever, true, true, '7.0', 'stable'); + $result = $updater->getJsonChanges(); + + $this->assertLinksEqual($this->expectedWithOverride->getRequires(), $result['require']); + $this->assertLinksEqual($this->expectedWithOverride->getDevRequires(), $result['require-dev']); + $this->assertEquals($this->expectedWithOverride->getAutoload(), $result['autoload']); + $this->assertEquals($this->expectedWithOverride->getDevAutoload(), $result['autoload-dev']); + $this->assertLinksEqual($this->expectedWithOverride->getConflicts(), $result['conflict']); + $this->assertEquals($this->expectedWithOverride->getExtra(), $result['extra']); + $this->assertLinksEqual($this->expectedWithOverride->getProvides(), $result['provide']); + $this->assertLinksEqual($this->expectedWithOverride->getReplaces(), $result['replace']); + $this->assertEquals($this->expectedWithOverride->getSuggests(), $result['suggest']); } public function setUp() { /** - * Set up input RootPackage objects for runUpdate() + * Setup input RootPackage objects for runUpdate() */ $baseRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); $baseRoot->setRequires([ new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '1.0.0'), null, '1.0.0'), - new Link('root/pkg', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), + new Link('root/pkg', PluginDefinition::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') ]); $baseRoot->setDevRequires($this->createLinks(2, 'vendor/dev-package')); @@ -199,7 +111,7 @@ public function setUp() $targetRoot = new RootPackage('magento/project-community-edition', '2.0.0.0', '2.0.0'); $targetRoot->setRequires([ new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), - new Link('root/pkg', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), + new Link('root/pkg', PluginDefinition::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), new Link('root/pkg', 'vendor/package1', new Constraint('==', '2.0.0'), null, '2.0.0') ]); $targetRoot->setDevRequires($this->createLinks(1, 'vendor/dev-package')); @@ -217,7 +129,7 @@ public function setUp() $installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); $installRoot->setRequires([ new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), - new Link('root/pkg', RootUpdatePlugin::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), + new Link('root/pkg', PluginDefinition::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') ]); $installRoot->setDevRequires($baseRoot->getDevRequires()); @@ -283,13 +195,10 @@ public function setUp() $this->eventDispatcher = $this->createPartialMock(EventDispatcher::class, ['addListener']); /** - * Mock InputInterface for CLI options and IOInterface for interaction + * Mock IOInterface for interaction */ - /** @var InputInterface|MockObject $input */ - $input = $this->getMockForAbstractClass(InputInterface::class); - $input->method('isInteractive')->willReturn(false); - $this->input = $input; $this->io = $this->getMockForAbstractClass(IOInterface::class); + Console::setIO($this->io); /** * Mock package repositories @@ -329,5 +238,24 @@ public function setUp() $composer->method('getPackage')->willReturn($installRoot); $composer->method('getConfig')->willReturn($config); $this->composer = $composer; + + $retriever = $this->createPartialMock(RootPackageRetriever::class, [ + 'getOriginalRootPackage', + 'getOriginalEdition', + 'getOriginalVersion', + 'getTargetRootPackage', + 'getTargetEdition', + 'getTargetVersion', + 'getUserRootPackage' + ]); + $retriever->method('getOriginalRootPackage')->willReturn($baseRoot); + $retriever->method('getOriginalEdition')->willReturn('community'); + $retriever->method('getOriginalVersion')->willReturn('1.0.0.0'); + $retriever->method('getTargetRootPackage')->willReturn($targetRoot); + $retriever->method('getTargetEdition')->willReturn('community'); + $retriever->method('getTargetVersion')->willReturn('2.0.0.0'); + $retriever->method('getUserRootPackage')->willReturn($installRoot); + + $this->retriever = $retriever; } } diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php new file mode 100644 index 0000000..db8692c --- /dev/null +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php @@ -0,0 +1,220 @@ +composer->expects($this->never())->method('getLocker'); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0', 'community', '1.0.0'); + + $this->assertEquals('community', $retriever->getOriginalEdition()); + $this->assertEquals('1.0.0', $retriever->getOriginalVersion()); + $this->assertEquals('1.0.0', $retriever->getPrettyOriginalVersion()); + } + + public function testOriginalRootFromLocker() + { + $this->composer->expects($this->once())->method('getLocker'); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + + $this->assertEquals('enterprise', $retriever->getOriginalEdition()); + $this->assertEquals('1.1.0.0', $retriever->getOriginalVersion()); + $this->assertEquals('1.1.0', $retriever->getPrettyOriginalVersion()); + } + + public function testGetOriginalRootFromRepo() + { + $this->repo->method('whatProvides')->willReturn(['1.1.0.0' => $this->originalRoot, '2.0.0.0' => $this->targetRoot]); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedOriginal = $retriever->getOriginalRootPackage(false); + + $this->assertEquals($this->originalRoot, $retrievedOriginal); + } + + public function testGetOriginalRootNotOnRepo_Override() + { + $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedOriginal = $retriever->getOriginalRootPackage(true); + + $this->assertEquals($this->userRoot, $retrievedOriginal); + } + + public function testGetOriginalRootNotOnRepo_NoOverride() + { + $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedOriginal = $retriever->getOriginalRootPackage(false); + + $this->assertEquals(null, $retrievedOriginal); + } + + public function testGetOriginalRootNotOnRepo_Confirm() + { + $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); + Console::setInteractive('true'); + $this->io->method('isInteractive')->willReturn(true); + $this->io->method('askConfirmation')->willReturn(true); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedOriginal = $retriever->getOriginalRootPackage(false); + + $this->assertEquals($this->userRoot, $retrievedOriginal); + } + + public function testGetOriginalRootNotOnRepo_NoConfirm() + { + $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); + Console::setInteractive('true'); + $this->io->method('isInteractive')->willReturn(true); + $this->io->method('askConfirmation')->willReturn(false); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedOriginal = $retriever->getOriginalRootPackage(false); + + $this->assertEquals(null, $retrievedOriginal); + } + + public function testGetTargetRootFromRepo() + { + $this->repo->method('whatProvides')->willReturn(['1.1.0.0' => $this->originalRoot, '2.0.0.0' => $this->targetRoot]); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedTarget = $retriever->getTargetRootPackage(); + + $this->assertEquals($this->targetRoot, $retrievedTarget); + } + + public function testGetTargetRootNotOnRepo() + { + $this->repo->method('whatProvides')->willReturn(['1.1.0.0' => $this->originalRoot]); + + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedTarget = $retriever->getTargetRootPackage(); + + $this->assertEquals(null, $retrievedTarget); + } + + public function testGetUserRoot() + { + $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retrievedTarget = $retriever->getUserRootPackage(); + + $this->assertEquals($this->userRoot, $retrievedTarget); + } + + protected function setUp() + { + $this->io = $this->getMockForAbstractClass(IOInterface::class); + Console::setIO($this->io); + Console::setInteractive(false); + + $this->composer = $this->createPartialMock(Composer::class, [ + 'getConfig', + 'getLocker', + 'getPackage', + 'getRepositoryManager', + 'getInstallationManager' + ]); + + $config = $this->createPartialMock(Config::class, ['getConfigSource']); + $config->method('getConfigSource')->willReturn( + $this->getMockForAbstractClass(Config\ConfigSourceInterface::class) + ); + $this->composer->method('getConfig')->willReturn($config); + + $locker = $this->createPartialMock(Locker::class, [ + 'isLocked', + 'getLockedRepository' + ]); + $lockedRepo = $this->getMockForAbstractClass(RepositoryInterface::class); + $originalProduct = $this->getMockForAbstractClass(PackageInterface::class); + $originalProduct->method('getName')->willReturn('magento/product-enterprise-edition'); + $originalProduct->method('getVersion')->willReturn('1.1.0.0'); + $originalProduct->method('getPrettyVersion')->willReturn('1.1.0'); + $lockedRepo->method('getPackages')->willReturn([$originalProduct]); + $locker->method('isLocked')->willReturn(true); + $locker->method('getLockedRepository')->willReturn($lockedRepo); + $this->composer->method('getLocker')->willReturn($locker); + + $this->userRoot = $this->getMockForAbstractClass(RootPackageInterface::class); + $this->composer->method('getPackage')->willReturn($this->userRoot); + + $this->originalRoot = $this->createPartialMock(Package::class, ['getName', 'getVersion', 'getStabilityPriority']); + $this->originalRoot->id = 1; + $this->originalRoot->method('getName')->willReturn('magento/project-enterprise-edition'); + $this->originalRoot->method('getVersion')->willReturn('1.1.0.0'); + $this->originalRoot->method('getStabilityPriority')->willReturn(0); + + $this->targetRoot = $this->createPartialMock(Package::class, ['getName', 'getVersion', 'getStabilityPriority']); + $this->targetRoot->id = 2; + $this->targetRoot->method('getName')->willReturn('magento/project-enterprise-edition'); + $this->targetRoot->method('getVersion')->willReturn('2.0.0.0'); + $this->targetRoot->method('getStabilityPriority')->willReturn(0); + + $repoManager = $this->createPartialMock(RepositoryManager::class, ['getRepositories']); + $this->repo = $this->createPartialMock(ComposerRepository::class, ['hasProviders', 'whatProvides', 'loadRootServerFile']); + $this->repo->method('hasProviders')->willReturn(true); + $this->mockProtectedProperty($this->repo, 'rfs', $this->createPartialMock(RemoteFilesystem::class, [])); + $this->repo->method('loadRootServerFile')->willReturn(true); + + $repoManager->method('getRepositories')->willReturn([$this->repo]); + $this->composer->method('getRepositoryManager')->willReturn($repoManager); + } +} From 54c181fe5e27aae6bfeb3e3eff29e7b891ba831f Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Fri, 21 Dec 2018 11:45:18 -0600 Subject: [PATCH 05/15] Resetting beta version number --- src/Magento/ComposerRootUpdatePlugin/composer.json | 2 +- src/Magento/ComposerRootUpdatePlugin/etc/module.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Magento/ComposerRootUpdatePlugin/composer.json b/src/Magento/ComposerRootUpdatePlugin/composer.json index 6fc78ce..7f3e08a 100644 --- a/src/Magento/ComposerRootUpdatePlugin/composer.json +++ b/src/Magento/ComposerRootUpdatePlugin/composer.json @@ -1,7 +1,7 @@ { "name": "magento/composer-root-update-plugin", "description": "Plugin to look ahead for Magento project root changes when running composer update for new Magento versions", - "version": "1.0.0-beta16", + "version": "1.0.0-beta1", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/src/Magento/ComposerRootUpdatePlugin/etc/module.xml b/src/Magento/ComposerRootUpdatePlugin/etc/module.xml index 404e536..d93a4b7 100644 --- a/src/Magento/ComposerRootUpdatePlugin/etc/module.xml +++ b/src/Magento/ComposerRootUpdatePlugin/etc/module.xml @@ -6,6 +6,6 @@ */ --> - + From 58ae308b19d017c6751fde72b1142263b24dad2d Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Thu, 4 Apr 2019 16:07:27 -0500 Subject: [PATCH 06/15] MC-5465: Adding documentation draft --- CONTRIBUTING.md | 48 ++++ README.md | 10 +- docs/class_descriptions.md | 212 ++++++++++++++++++ docs/process_flows.md | 93 ++++++++ docs/resources/explicit_install_flow.png | Bin 0 -> 29281 bytes docs/resources/module_install_flow.png | Bin 0 -> 17758 bytes docs/resources/require_command_flow.png | Bin 0 -> 54987 bytes docs/resources/self_install_flow.png | Bin 0 -> 27460 bytes .../AccessibleRootPackageLoader.php | 7 +- .../ExtendableRequireCommand.php | 6 +- .../Plugin/CommandProvider.php | 2 +- .../Commands/MageRootRequireCommand.php | 55 ++--- .../UpdatePluginNamespaceCommands.php | 2 - .../Plugin/PluginDefinition.php | 2 - .../ComposerRootUpdatePlugin/README.md | 6 +- .../Setup/InstallData.php | 2 +- .../Setup/RecurringData.php | 2 +- .../Setup/UpgradeData.php | 2 +- .../Setup/WebSetupWizardPluginInstaller.php | 4 +- ...ConflictResolver.php => DeltaResolver.php} | 29 ++- .../Updater/MagentoRootUpdater.php | 8 +- .../Updater/RootPackageRetriever.php | 43 +++- .../Utils/Console.php | 4 +- .../Utils/PackageUtils.php | 2 +- .../ComposerRootUpdatePluginTest.php | 2 +- .../Commands/MageRootRequireCommandTest.php | 3 - .../TestHelpers/TestApplication.php | 3 - .../UpdatePluginTestCase.php | 2 +- ...ResolverTest.php => DeltaResolverTest.php} | 69 +++--- .../Updater/MagentoRootUpdaterTest.php | 3 - 30 files changed, 498 insertions(+), 123 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/class_descriptions.md create mode 100644 docs/process_flows.md create mode 100644 docs/resources/explicit_install_flow.png create mode 100644 docs/resources/module_install_flow.png create mode 100644 docs/resources/require_command_flow.png create mode 100644 docs/resources/self_install_flow.png rename src/Magento/ComposerRootUpdatePlugin/Updater/{ConflictResolver.php => DeltaResolver.php} (95%) rename tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/{ConflictResolverTest.php => DeltaResolverTest.php} (83%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cc29794 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ + +# Contributing to Magento code + +## Overview + +Contributions to the Magento codebase are done using the fork & pull model. +This contribution model has contributors maintaining their own copy of the forked codebase (which can easily be synced with the main copy). The forked repository is then used to submit a request to the base repository to “pull” a set of changes (hence the phrase “pull request”). + +Contributions can take the form of new components/features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations or just good suggestions. + +The Magento development team will review all issues and contributions submitted by the community of developers in first in, first out order. During the review we might require clarifications from the contributor. If there is no response from the contributor for two weeks, the issue is closed. + +For large features or changes, please [open an issue](https://github.com/magento/composer-root-update-plugin/issues) for discussion before submitting any code. This will prevent duplicate or unnecessary effort and can also increase the number of people involved in discussing and implementing the change. + +## Contribution requirements + +1. Contributions must adhere to [Magento coding standards](http://devdocs.magento.com/guides/v2.0/coding-standards/bk-coding-standards.html). +2. Pull requests (PRs) must be accompanied by a complete and meaningful description. Comprehensive descriptions make it easier to understand the reasoning behind a change and reduce the amount of time required to get the PR merged. +3. Commits must be accompanied by meaningful commit messages. +4. PRs which include bug fixing must be accompanied with step-by-step descriptions of how to reproduce the issue (including the local composer version reported by `composer --version`). +5. PRs which include new logic or new features must be submitted along with: + * Unit/integration test coverage where applicable. + * Updated documentation in the project's `docs` directory. +6. All automated tests must pass successfully. + +Any contributions that do not meet these requirements will not be accepted. + +### Composer compatibility + +Maintaining compatibility with the Composer versions listed in [composer.json](composer.json) is of particular note for this project. Due to the way Composer works with plugins, the version that is used when the plugin runs is the local `composer.phar` executable version (as reported by `composer --version`) and not the version installed in the project's `vendor` folder or `composer.lock` file. This means that in order to properly verify Composer compatibility, tests must be run against the local `composer.phar` executable, not just the installed `composer/composer` dependency. + +Additionally, because of the way the plugin interacts with the native `composer require` command, some parts of the Composer library sometimes need to be re-implemented in an accessible manner if the original code is in private methods or part of larger functions. Such implementations should be located in the [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation) namespace and documented with the reason for re-implementation and a link to the original method. + +## Contribution process + +If you are a new GitHub user, we recommend that you create your own [free github account](https://github.com/signup/free). By doing so, you will be able to collaborate with the Magento development team, fork the github project and easily send pull requests for any changes you wish to contribute. + +1. Search the current listed issues (open or closed) on the [magento/composer-root-update-plugin](https://github.com/magento/composer-root-update-plugin/issues) and [magento/magento2](https://github.com/magento/magento2/issues) GitHub repositories before starting work on a new contribution. +2. Review the [Contributor License Agreement](https://magento.com/legaldocuments/mca) if this is your first time contributing. +3. Create and test your work. +4. Fork the repository according to the [Fork a repository instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#fork). +5. When you are ready to send us a pull request, follow the [Create a pull request instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#pull_request). The instructions are written for the `https://github.com/magento/magento2` repository, but they also apply to `https://github.com/magento/composer-root-update-plugin`. +6. Once your contribution is received, the Magento 2 development team will review the contribution and collaborate with you as needed if it is accepted. + +## Code of Conduct + +Please note that this project is released with a Contributor Code of Conduct. We expect you to agree to its terms when participating in this project. +The full text is available in the Magento 2 repository [Wiki](https://github.com/magento/magento2/wiki/Magento-Code-of-Conduct). \ No newline at end of file diff --git a/README.md b/README.md index bd0aec3..ecafb85 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,17 @@ If the local Magento installation has previously been updated from a previous Ma In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. - composer require --previous-magento-package = + composer require --base-magento-edition --base-magento-version ## Conflicting custom values If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. -To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-magento-values` option. +To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-default-magento-values` option. ## Bypassing the plugin -To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root` option. +To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root-plugin` option. ## Refreshing the plugin for the Web Setup Wizard If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. @@ -50,3 +50,7 @@ Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/ Subject to Licensee's payment of fees and compliance with the terms and conditions of the MEE License, the MEE License supersedes the OSL 3.0 license for each source file. Please see LICENSE_EE.txt for the full text of the MEE License or visit https://magento.com/legal/terms/enterprise. + +# Developer Documentation + +Class descriptions, process flows, and any other developer documentation can be found in the [docs](docs) directory. diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md new file mode 100644 index 0000000..05e5a5b --- /dev/null +++ b/docs/class_descriptions.md @@ -0,0 +1,212 @@ + +# Class Descriptions by Namespace + + - [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](#namespace-magentocomposerrootupdateplugincomposerreimplementation) + - [AccessibleRootPackageLoader](#accessiblerootpackageloader) + - [ExtendableRequireCommand](#extendablerequirecommand) + - [Magento\ComposerRootUpdatePlugin\Plugin](#namespace-magentocomposerrootupdatepluginplugin) + - [Commands\MageRootRequireCommand](#commandsmagerootrequirecommand) + - [Commands\UpdatePluginNamespaceCommands](#commandsupdatepluginnamespacecommands) + - [CommandProvider](#commandprovider) + - [PluginDefinition](#plugindefinition) + - [Magento\ComposerRootUpdatePlugin\Setup](#namespace-magentocomposerrootupdatepluginsetup) + - [InstallData/RecurringData/UpgradeData](#installdatarecurringdataupgradedata) + - [WebSetupWizardPluginInstaller](#websetupwizardplugininstaller) + - [Magento\ComposerRootUpdatePlugin\Updater](#namespace-magentocomposerrootupdatepluginupdater) + - [DeltaResolver](#deltaresolver) + - [MagentoRootUpdater](#magentorootupdater) + - [RootPackageRetriever](#rootpackageretriever) + - [Magento\ComposerRootUpdatePlugin\Utils](#namespace-magentocomposerrootupdatepluginutils) + - [Console](#console) + - [PackageUtils](#packageutils) + +## Namespace: [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation) + +Because the plugin is hooking into the native `composer require` functionality directly rather than adding script hooks or completely new commands, it needs access to some Composer functionality that is not normally extendable. The classes in this namespace copy the relevant sections of Composer library code into functions that are accessible by the plugin. New releases of Composer may change the library code these classes clone, in which case they should be updated to match. + +#### [**AccessibleRootPackageLoader**](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php) + + **Composer class:** [RootPackageLoader](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) + + - **`extractStabilityFlags()`** -- see [RootPackageLoader::extractStabilityFlags()](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) + - Takes a package name, version, and minimum-stability setting and returns the stability level that should be used to find the package on a repository. + - **Reason for cloning:** The original method is private. + +#### [**ExtendableRequireCommand**](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php) + + **Composer class:** [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) + + - **`parseComposerJsonFile()`** -- see [RequireCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) + - Checks the accessibility of the `composer.json` file and parses out relevant base information that is needed before starting the plugin's processing. + - **Reason for cloning:** The native code exists directly in `RequireCommand::execute()` instead of its own function, but the base information it parses is required by the plugin before it runs as part of the original `RequireCommand` code. + - **`getRequirementsInteractive()`** -- see [InitCommand::determineRequirements()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php) + - Interactively asks for the `composer require` arguments if they're not passed to the CLI command call + - **Reason for cloning:** The native command calls [InitCommand::findBestVersionAndNameForPackage()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php), which would try to validate the target Magento package's requirements before the plugin can process the relevant changes to make it compatible. The `determineRequirements()` call is still made by `RequireCommand::execute()` after the plugin runs, so Composer's validation still happens as normal. + - **`revertMageComposerFile()`** -- see [RequireCommand::revertComposerFile()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) + - Reverts `composer.json` to its original state from before the plugin's changes if the command fails. + - **Reason for cloning:** The plugin makes its changes before `RequireCommand` creates its backup, which means when it runs its own `revertComposerFile()`, the reverted file from the backup still does not match the original state, so this function is needed to also revert the plugin's changes. + +## Namespace: [Magento\ComposerRootUpdatePlugin\Plugin](../src/Magento/ComposerRootUpdatePlugin/Plugin) + +Classes in this namespace tie into the Composer library's code that handles plugin registry and functionality hooks. + +#### [**Commands\MageRootRequireCommand**](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php) + + This class is the entrypoint into the plugin's functionality from the `composer require` CLI command. + + Extends the native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) functionality to add additional processing when run with a Magento product as one of the command's parameters. + + - **`configure()`** + - Add the options and description for the plugin functionality to those already configured in `RequireCommand` and sets the new command's name to a dummy unique value so it passes Composer's command registry check + - **`setApplication()`** + - Overrides the command's name to `require` after the command registry is checked but before the command is actually added to the registry. This allows the command to replace the native `RequireCommand` instance that is normally associated with the `composer require` CLI command. + - **`execute()`** + - Wraps the native `RequireCommand::execute()` function with the Magento project update code if a Magento product package is found in the command's parameters + - **`runUpdate()`** + - Calls [MagentoRootUpdater::runUpdate()](#magentorootupdater) after processing CLI options + +#### [**Commands\UpdatePluginNamespaceCommands**](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php) + + CLI command definition for plugin-specific functionality that isn't attached to other native commands, adding them as sub-commands called through `composer magento-update-plugin `. + + Currently, the only sub-command included is `composer magento-update-plugin install`, which updates the plugin's self-installation inside the project's `var` directory, which is necessary for the Web Setup Wizard (see [WebSetupWizardPluginInstaller](#websetupwizardplugininstaller)). + + - **`configure()`** + - Configure the command definition for Composer's CLI command processing. Sub-command descriptions are included in the command's `help` text. + - **`execute()`** + - Check the sub-command parameter and call the corresponding function. + - **`describeOperations()`** + - Format the sub-command definitions into a readable description for the command's `help` text. + +#### [**CommandProvider**](../src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php) + + This is a Composer boilerplate class to let the Composer plugin library know about the commands implemented by the plugin. + + + - **`getCommands()`** + - Passes instances of the commands provided by the plugin to the Composer library + +#### [**PluginDefinition**](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php) + + This class is Composer's entry point into the plugin's functionality and the definition supplied to the plugin registry + + - **`activate()`** + - Method must exist in any implementation of [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) + - **`getCapabilities()`** + - Tells Composer that the plugin includes CLI commands and defines the [CommandProvider](#commandprovider) that supplies the command objects + - **`getSubscribedEvents()`** + - Subscribes to the `POST_PACKAGE_INSTALL` and `POST_PACKAGE_UPDATE` events, which are triggered whenever a project's dependencies are updated. + - **`packageUpdate()`** + - When one of the package events subscribed in `getSubscribedEvents()` is triggered, this method forwards the event to [WebSetupWizardPluginInstaller::packageEvent()](#websetupwizardplugininstaller) to update the plugin's self-installation inside the Magento project's `var` directory, which is necessary for the Web Setup Wizard. + +*** + +## Namespace: [Magento\ComposerRootUpdatePlugin\Setup](../src/Magento/ComposerRootUpdatePlugin/Setup) + +Classes in this namespace deal with installing the plugin inside the project's `var` directory, which is necessary for the Magento Web Setup Wizard to pass its verification check. + +When the Web Setup Wizard runs an upgrade operation, it first tries to validate the upgrade by copying the `composer.json` file into the `var` directory and attempting a dry-run upgrade. However, because it only copies the `composer.json` file and not any of the other code in the installation (including the plugin's root installation in `vendor`), the plugin will not function for this dry run. In order to enable the plugin, it needs to already be present in `var/vendor`, where the Wizard's `composer require` for the validation will find it. + +#### **[InstallData](../src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php)/[RecurringData](../src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php)/[UpgradeData](../src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php)** + + These are Magento module setup classes to trigger [WebSetupWizardPluginInstaller::doVarInstall()](#websetupwizardplugininstaller) on `bin/magento setup` commands. Specifically, this is necessary when the `bin magento setup:uninstall` and `bin magento setup:install` commands are run, which would otherwise wipe the plugin of the `var` directory without triggering the Composer package events that would normally install the plugin there. + +#### [**WebSetupWizardPluginInstaller**](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) + + This class manages the plugin's self-installation inside the `var` directory to enable it for the Web Setup Wizard. + + - **`packageEvent()`** + - When Composer installs or updates a required package, this method checks whether it was the plugin package that changed and calls `updateSetupWizardPlugin()` with the new version if so. + - Triggered by the events defined in [PluginDefinition::getSubscribedEvents()](#websetupwizardplugininstaller) + - **`doVarInstall()`** + - Checks the `composer.lock` file the plugin and calls `updateSetupWizardPlugin()` with the version found there. + - Called by `composer magento-update-plugin install` and the Magento module setup classes ([InstallData](#installdatarecurringdataupgradedata), [RecurringData](#installdatarecurringdataupgradedata), [UpgradeData](#installdatarecurringdataupgradedata)). + - **`updateSetupWizardPlugin()`** + - Installs the plugin inside `var/vendor` where it can be found by the `composer require` command run by the Web Setup Wizard's validation check. This is accomplished by creating a dummy project directory with a `composer.json` file that requires only the plugin, installing it, then copying the resulting `vendor` directory to `var/vendor`. + - **`deletePath()`** + - Recursively deletes a file or directory and all its contents + - **`copyAndReplace()`** + - Recursively copies a directory and all its contents to a new location, replacing any existing files that exist there beforehand + - **`createPluginComposer()`** + - Creates a temporary `composer.json` file requiring only the plugin's composer package. + +## Namespace: [Magento\ComposerRootUpdatePlugin\Updater](../src/Magento/ComposerRootUpdatePlugin/Updater) + +Classes in this namespace do the work of calculating and executing the changes to the root project `composer.json` file that need to be made when updating to a new Magento package version. + +#### [**DeltaResolver**](../src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php) + +Given the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version, and the currently-installed root project package including all user customizations, this class calculates the new values that need to be updated for the target Magento version. + +This is accomplished by comparing `composer.json` fields between the original Magento root and the target root, and, when a delta is found, checking to see if the user has already made custom changes to that field. If a change has been made, if it doesn't already match the target Magento value, resolve the conflict according to the strategy passed to the CLI command: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used on a case-by-case basis. + + - **`resolveRootDeltas()()`** + - Entry point into the resolution functionality + - Calls the relevant resolve function for each `composer.json` field that can be updated + - **`findResolution()`** + - For an individual field value, compare the original Magento value to the target Magento value, and if a delta is found, check if the user's installation has a customized value for the field. If the user has changed the value, resolve the conflict according to the CLI command options: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used + - **`resolveLinkSection()`** + - For a given `composer.json` section that consists of links to package versions/constraints (such as the `require` and `conflict` sections), call `findResolution()` for each package constraint found in either the original Magento root or the target Magento root + - **`resolveArraySection()`** + - For a given `composer.json` section that consists of data that is not package links (such as the `"autoload"` or `"extra"` sections), call `resolveNestedArray()` and accept the new values if changes were made + - **`resolveNestedArray()`** + - Recursively processes changes to a `composer.json` value that could be a nested array, calling `findResolution()` for each "leaf" value found in either the original Magento root or the target Magento root + - **`linksToMap()`** + - Helper function to convert a set of package links to an associative array for use by `resolveLinkSection()` + +#### [**MagentoRootUpdater**](../src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php) + +This class runs [DeltaResolver::resolveRootDeltas()()](#deltaresolver) if an update is required, tracks the results, and writes the changes out to the `composer.json` file + + - **`runUpdate()`** + - Checks if the target Magento package differs from the original package, and if so runs DeltaResolver and tracks the results + - **`writeUpdatedComposerFile()`** + - Takes the result values from DeltaResolver and overwrite the corresponding values in the root `composer.json` file + +#### [**RootPackageRetriever**](../src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php) + +This class contains methods to retrieve Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version and the currently-installed root project package including all user customizations + + - **`getOriginalRootPackage()`** + - Fetches the original (default) Magento root project package from the composer repository + - **`getTargetRootPackage()`** + - Fetches the target Magento root project package from the composer repository + - **`getUserRootPackage()`** + - Returns the existing root project package, including all user customizations + - **`fetchMageRootFromRepo()`** + - Given a Magento edition and version constraint, fetch the best-fit Magento root project package from the composer repository + - **`parseOriginalVersionAndEditionFromLock()`** + - Inspect the `composer.lock` file for the currently-installed Magento product package and parse out the edition and version for use by `getOriginalRootPackage()` + - **`getRootLocker()`** + - Helper function to get the [Locker](https://github.com/composer/composer/blob/master/src/Composer/Package/Locker.php) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory + +## Namespace: [Magento\ComposerRootUpdatePlugin\Utils](../src/Magento/ComposerRootUpdatePlugin/Utils) + +This namespace contains utility classes shared across the rest of the plugin's codebase + +#### [**Console**](../src/Magento/ComposerRootUpdatePlugin/Utils/Console.php) + + Command-line logger with interaction methods + + - **`getIO()`** + - Returns the [IOInterface](https://github.com/composer/composer/blob/master/src/Composer/IO/IOInterface.php) instance + - **`ask()`** + - Asks the user a yes or no question and return the result. If the console interface has been configured as non-interactive, instead it does not ask and returns the default value + - **`log()`** + - Logs the given message if the verbosity level is appropriate + - **`info()`**/**`comment()`**/**`warning()`**/**`error()`**/**`labeledVerbose()`** + - Helper methods to format and log messages of different types/verbosity levels + +#### [**PackageUtils**](../src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php) + + Common package-related utility functions + + - **`getMagentoPackageType()`** + - Extracts the package type (`product` or `project`) from a Magento package name + - **`getMagentoProductEdition()`** + - Extracts the edition (`community` or `enterprise`) from a Magento product package name + - **`findRequire()`** + - Searches the `"require"` section of a [Composer](https://github.com/composer/composer/blob/master/src/Composer/Composer.php) object for a package link that fits the supplied name or matcher + - **`isConstraintStrict()`** + - Checks if a version constraint is strict or if it allows multiple versions (such as `~1.0` or `>= 1.5.3`) + \ No newline at end of file diff --git a/docs/process_flows.md b/docs/process_flows.md new file mode 100644 index 0000000..04655d5 --- /dev/null +++ b/docs/process_flows.md @@ -0,0 +1,93 @@ +# Plugin Operation Flow Explanations and Diagrams + +There are four paths through the plugin code that cover two main pieces of functionality: + + - Update the root `composer.json` in a Magento installation with values required by the new Magento version during an upgrade + - [composer require ](#composer-require-magento_product_package) + - Ensure the plugin is installed in the `/var` directory, where it needs to exist for Magento's Web Setup Wizard upgrade path + - [composer require/update magento/composer-root-update-plugin](#composer-requireupdate-magentocomposer-root-update-plugin) + - [Magento module-based var installation](#magento-module-based-var-installation) + - [Explicit var installation command](#explicit-var-installation-command) + +## `composer require ` + +**Scenario:** The user has an installed Magento project and wants to upgrade to a new version. They call `composer require ` from the command line or through the Magento Web Setup Wizard upgrade tool. + +![composer require flow](resources/require_command_flow.png) + +1. Composer boilerplate and plugin setup + 1. Composer sees the `"type": "composer-plugin"` value in the [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file for the plugin package + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php)) + 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](../src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php)), which is supplied through `PluginDefinition::getCapabilities()` + 4. `CommandProvider::getCommands()` supplies Composer with an instance of [MageRootRequireCommand](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php) + 5. Composer calls `MageRootRequireCommand::configure()` to obtain the command's name, description, options, and help text + - `MageRootRequireCommand` extends Composer's native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) and adds its own values to those in the existing implementation + - Composer contains a command registry and rejects any new commands that have a conflicting name with a command that is already registered, so `MageRootRequireCommand::configure()` temporarily changes the command's name from `require` to a dummy value to bypass the registry check + 6. Composer calls `MageRootRequireCommand::setApplication()` after checking the for naming conflicts but before adding the command to the registry, at which time the command name changes back to `require` + 7. Composer adds `MageRootRequireCommand` to the registry, overwriting the native `RequireCommand` as the command associated with the name `require` +2. Composer recognizes `require` as the command passed to the executable and finds `MageRootRequireCommand` as the command object registered under that name +3. Composer calls `MageRootRequireCommand::execute()` +4. `MageRootRequireCommand::execute()` backs up the user's `composer.json` file through [ExtendableRequireCommand::parseComposerJsonFile()](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php) +5. `MageRootRequireCommand::execute()` checks the `composer require` arguments for a `magento/product` package, and if it finds one it calls `MageRootRequireCommand::runUpdate()` +6. `MageRootRequireCommand::runUpdate()` calls [MagentoRootUpdater::runUpdate()](../src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php) +7. `MageRootRequireCommand::runUpdate()` calls [DeltaResolver::resolveRootDeltas()()](../src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php) +8. `DeltaResolver::resolveRootDeltas()()` uses [RootPackageRetriever](../src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php) to obtain the Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made +9. `DeltaResolver::resolveRootDeltas()()` iterates over the fields in `composer.json` to determine any values that need to be updated to match the root `composer.json` of the new Magento edition/version + 1. To find these values, it compares the values for each field in the default project for the installed edition/version and the project for the target edition/version (`DeltaResolver::findResolution()`) + 2. If a value has changed in the target, it checks that field in the user's customized root `composer.json` file to see if it has been overwritten with a custom value + 3. If the user customized the value, the conflict will be resolved according to the specified resolution strategy: use the expected Magento value, use the user's custom value, or prompt the user to specify which value should be used +10. If `resolveRootDeltas()()` found values that need to change, `MageRootRequireCommand::execute()` calls `MagentoRootUpdater::writeUpdatedComposerJson()` to apply those changes +11. `MageRootRequireCommand::execute()` calls the native `RequireCommand::execute()` function, which will now use the updated root `composer.json` file if the plugin made changes +12. If the `RequireCommand::execute()` call fails after the plugin had made changes, `MageRootRequireCommand::execute()` calls `ExtendableRequireCommand::revertMageComposerFile()` to restore the `composer.json` file to its original state + +## `composer require/update magento/composer-root-update-plugin` + +**Scenario:** The user wants to install or update the version of the `magento/composer-root-update-plugin` package in their Magento installation. They call `composer require/update magento/composer-root-update-plugin`. The plugin needs to update a copy of itself in the `/var` directory, where it needs to exist in order to function during Web Setup Wizard operations. + +![self install flow](resources/self_install_flow.png) + +1. Composer boilerplate and plugin setup + 1. Composer sees the `"type": "composer-plugin"` value in the `composer.json` file for the plugin package + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php)) + 3. `PluginDefinition` implements [EventSubscriberInterface](https://github.com/composer/composer/blob/master/src/Composer/EventDispatcher/EventSubscriberInterface.php), telling Composer that it subscribes to events triggered by Composer operations + 4. `PluginDefinition::getSubscribedEvents()` tells Composer to call the `PluginDefinition::packageUpdate()` function when the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` events are triggered +2. Composer runs the [RequireCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) or [UpdateCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/UpdateCommand.php) method as relevant, which results in Composer triggering either the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` event +3. Composer checks the listeners registered to the triggered event and calls `PluginDefinition::packageUpdate()` +4. `PluginDefinition::packageUpdate()` calls [WebSetupWizardPluginInstaller::packageEvent()](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) +5. `WebSetupWizardPluginInstaller::packageEvent()` checks the event to see if it was triggered by a change to the `magento/composer-root-update-plugin` package, and if so it calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` +6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there to see if it matches the version in the triggered event +7. If the version doesn't match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the new version of `magento/composer-root-update-plugin` in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation + +## Magento module-based `var` installation + +**Scenario:** The user has called the `bin/magento setup:uninstall` command, which clears the `/var` directory, then runs `bin/magento setup:install`. The plugin needs to reinstall itself in the `/var` directory, where it needs to exist in order to function during Web Setup Wizard operations. + +![module install flow](resources/module_install_flow.png) + +1. The `"autoload"->"files": "registration.php"` value in the plugin's [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file causes [registration.php](../src/Magento/ComposerRootUpdatePlugin/registration.php) to be loaded by Magento +2. `registration.php` registers the plugin as the `Magento_ComposerRootUpdatePlugin` module so it can tie into the `bin/magento setup` module operations +3. Magento searches registered modules for any `Setup\InstallData`, `Setup\RecurringData`, or `Setup\UpgradeData` classes +4. Magento calls [InstallData::install()](../src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php), [RecurringData::install()](../src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php), or [UpgradeData::upgrade()](../src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php) as appropriate (which one depends on the specific `bin/magento setup` command and installed Magento version), which then calls [WebSetupWizardPluginInstaller::doVarInstall()](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) +5. `WebSetupWizardPluginInstaller::doVarInstall()` finds the `magento/composer-root-update-plugin` version in the `composer.lock` file in the root Magento directory and calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` +6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there (if any) to see if it matches the version in the root `composer.lock` file +7. If the version doesn't match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation + +## Explicit `var` installation command + +**Scenario:** The user has cleared the `/var` directory and wants to use the Web Setup Wizard to upgrade their Magento installation. The plugin needs to exist in `/var` in order to function during Web Setup Wizard operations, so the user calls `composer magento-update-plugin install` to restore the plugin installation in the `/var` directory. + +![explicit install flow](resources/explicit_install_flow.png) + +1. Composer boilerplate and plugin setup + 1. Composer sees the `"type": "composer-plugin"` value in the [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file for the plugin package + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php)) + 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](../src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php)), which is supplied through `PluginDefinition::getCapabilities()` + 4. `CommandProvider::getCommands()` supplies Composer with an instance of [UpdatePluginNamespaceCommands](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php) + 5. Composer calls `UpdatePluginNamespaceCommands::configure()` to obtain the command's name, description, options, and help text + 6. Composer adds `UpdatePluginNamespaceCommands` to the registry under the name `magento-update-plugin` +2. Composer recognizes `magento-update-plugin` as the command passed to the executable and finds `UpdatePluginNamespaceCommands` as the command object registered under that name +3. Composer calls `UpdatePluginNamespaceCommands::execute()` +4. `UpdatePluginNamespaceCommands::execute()` checks the first argument supplied to the `composer magento-update-plugin` command and sees `install`, so it calls [WebSetupWizardPluginInstaller::doVarInstall()](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) +5. `WebSetupWizardPluginInstaller::doVarInstall()` finds the `magento/composer-root-update-plugin` version in the `composer.lock` file in the root Magento directory and calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` +6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there (if any) to see if it matches the version in the root `composer.lock` file +7. If the version doesn't match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation diff --git a/docs/resources/explicit_install_flow.png b/docs/resources/explicit_install_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..3883fb851aa52d4917f45e8ae368c3b0ce02bdb0 GIT binary patch literal 29281 zcmdqIc{r5q8#jI<389h{;b~Q5X|ZG(6P3y~Su?ggC0lkP4AUY{36)UxCEGBjFm^^% z(!`K4)-g$zW-zvy!I+u%o_fCD_x-)c?{^%(=(UTjSpo1+4A(&_9r{G z98ifD5&L`~9{SSE@=&;BLeG?QBK}6F#=Y4G zXg*$BKX;^J@;c&Hi?Qc|YFQYPq6qxe4Is6%9=0qzbUOaB?_0Tm3Pp`}nkG}^z)Y=( z-7SVQ+$|P|*zUa*2;6mXLgaN=R{P}>afl-{rMPzPJ-=Mz{2h6Mz{IXNrJJN6G39r= zNoph`u{e&ZYOj&ngmp;Z|Be zfsq@UvT5HMduYXbCemr))jum*z^j6ZZ<(>tGqt(grF=gte=>K3tu)#=e_vnmSJz?@ ze4UY<{4|CuL};4vp0sRQf8Q4rU^w67N7q}a?CZoeGu#D<>SD0EqYJ_7vnivs*elhuQ!aY{LW#vUXoW|EosWm-#RpGmWi8 zsa?odMp>SoVb}T5c<<+J)S=l|*q4WwKal!5c`+N=R!%xPqn|?Nv@(_FI5J%PunhAZ zJLerjDZ0l`iWQASeSi0@-foa=q__E*GOkleh+2~wW!)_@)3N-_8v4Z^vvA6ZhQFmT zTBaA&wj@mGHx9(ej&-vPwcTlE`eufs?KgSMP4^XMq{h*-U+h66O}%LEzt^Pv#~_>f zgX3<4tqT*Kb@tiQyE7@1R{n0a63GT<7dYnCA6X$?Hx{fmsX0|RX_4$Y#9ElVQu(^m zw=XX?U6$vk6|@a<7H-Up8R~6TF`VGb@?`4rz*{#<#bimhV7g@ImKpMRbT*d|C;z1q zkL~G~R0+w^?qk1StGiO;7udQLu+emeul0xNXYHnmELFzm>_xP4CzQ9rOdMsY=Z$i< zl#&(lRMytV>2wOSDN0cRQTbrZKKE-U&Fm2V&Nm6Er+fV3r68?{SuGX>&SJMt)y#|3 z-f=dJG%l5XdeNj@_u*$u>y6QpU85b+?(Q==p`$NFVI?CmRnExB&IeXc{q>bcH_E-AA@ohhr^BhVteP=V{V6IIz?f*Dk%AIPH|_g3wEOK-He#y zHkk6A(ZM^1W{xsL#HxS3Xi^+@uV?lx=Nh>Uh9V<3lxlekhuv2XI0K54R)XxG*O-E3d^M49hS5>;T$N? zHNT()1XCQWcxbF3v0jFl852FzpEkoNr;c7kAr^dSj#vOYHd7w%G{*$6BqrG$*~q$F zmx$F%dsI;rT2oYTGT8a4EJH{g@iis?cffLp}yKg@=vV5oT`N@xCRJ9F@V< zjeC4;J@BxEA zU1mC=QR^Sfo5xP&X|(j3zRA~cTu}Fx(r)g>L6xq_`lT(U)Y>k}>3w;m4S!mhlhLO# ztm0uIVkB!{mxuQ%tKv0)%?XPKWPrxQ&flc&ZK9HR2f2&IBApw?-;R_AiR(0klGFSg zhD77ts6`JdX*v+?J7?eVW+T?t7+RUNF0C>6p>tW1+#kHR7}1qo*lWY|uO*UD%k3Pa z$K3u+UIEXUdvo68M*3b_7+sWikNYWO-Y8}xxixo9XICC|`9&YkYc%+S099t<^epas zu2k*;?o*Vw(b_=tWb20?q}w>hI*+;UFnlbhcpe?vgPVM=px&|13cpv7_(geTyb6}y zoT?9PJS~>kiWp!DF$ga8oG9)zeL>!7a1-frf#%%6xIoL}_Am6uFxw@xgotbGl_|)V zaR*Mo7lc#J{>5Poq~BolT4#Hgu=dkx1J9ekE&f7(Omu3F|N^yn?%$D|YYFSGHq? zEuH+9Fu=^ayTQDs4n=#8e%q*wed{$$++`oO3@w!wejf0TWnTG!L(vWS++^k?cO56s z2yvVrGQN<0olUDzpev=hU68FsSd%_Waw-dyIUy$tc$CdQD@ryS;S64+A}tMU%>;br51 z7R0FGNb|)`DF*kVuYM9vW8k?UA-FTBHMhKWK3yEvqPit)^7(d{FQQH_d?{qVoBw<= z_UlF~!p((n(&rQut-!}+4D!|q+<22$eokQ>6x%=A8Q%(YQA)BcrZMppB zx`-IWRYY!Mf+Ub2d4f1{!Kgs*65Gz}_}Ua+8(dRlZo2AR2t`)%Am@=`Ou^u+$JH&R z`Z-^H$$gL0+^(R2@^1pxMJacUpv}FchU6>+Yv}P1?_tWs6jKYqM z^!~25f|cfK_J(JZPF92uuPCj*wUT=C;>5h;#LxEukSS$p#p$)iBG#px z)eX;Y5V-P-Rt!^mz-RPA5hn<wS44VVt?%$y_IJl zdFO2AhuUMMSBH1odnv44**i1(j!F?ioi?7mN}$ih_eAEea z6{C*@I`o}lLk4I?BQtkK=w7a^vCHZXKEH26%*2nVPQh0V6G~RC6dR7gZus+!r616G z?>7M&BcvM6ekCR?=1|^E&YIrTXo~;pv^!cqWZpz;1|4gO(BCgId(Dn57PrXQQaZqN zi5t-8_FMMf6^G-NFY>-}9Jh^fbkD9k6;Px!aN7}^R2_Iao9$`)nJ%Gb@ZkhY-hmcg z)&~p>c22gRj3==pV+t8(&!Cf3H=A@-LqFOs=vIZXQyEysFh}&g$kGmKC>P^SJtUATs*td8W?t-$8ULZqhxiE%BjHS2kO0yA} z<#tr2B(N^PEy%e+QqrV7WneZZlh_Rl{oPxC1uH?*gxv~QU>D4dTbZx zueR7gQ=xvC#&~#&>E_vevDT+0zm ze-J&o8`;;ErCJj$rL5CKZV*SiGYah=&%xzPJmfE?cCi#7t*Egs? zF&KAYvt=DJ`=d#*Y^chWPQy>?BtEIL!{uLZ2=*@x!3vsl5B(zd1v#`ZQk-L3E>ybZ zug2{-)roFWL1Vh~^bh*DkIdHg_5fkuMXVTp)C24TvVIsVsoG|l>5=Yaqxq(*Eo-cD z`?l1WW4yk|8VUck7Yly+N4i~W&fW@*uYR7a81QgSwrHNY#R z^8C^g?;OM|={-iBPxpPP5B2sy>g|R+fJfL-57{Z-O243{`9t3-O+Ug`9Mb1;K+Nt3 z{-`J{v*{tuS?PRY)>qu2{m06+ax66o+tUeZ+mvUpBw3@b)s+6o4JZSjgI_X3gOQx` zXt(Ucz_)#Tj0m5v4mU%N9A{rPdVHCAj!GEt`Dmp%$)$anXRm6}4y(3?tc=Z0>!-r= z?5X`H)`vIRTk-VTg^wEf8V~i)MwKh$h`m<9lPRuBva!sy^H|cIzRwmsy_3*HjS|b+ zEC$7xF7j@S{!iTw7Jd(wfZ8o*$4z;Z`8zMROtdg__Vx-fm{!f<^c1`DoHv+qS#xC5e8yXC9 zImEE>=P+W;H6#x-#46$*+hG}6j-K3PLk`;0Y4#;x=i#^JnOXOPg}@HR>lpu&EJeRm zf$4cr4yV~xt=UPL@)lY%R{ZUdRCeV}Sy?wJS}NrnnLV@VH>)_)m}nIjHYG^3dKmj6 zONOl3&vT^WuB!WA*gNQerW8d)S&siA^N!QhFnG>Ib~t+S9vs)#xZlko)L?Chq|m5S z7P{ZTcQn~4FOA(Da0bc2m}b^`}`95 zIM~2>S%C*u(o2Ao2*hop`x8p7{0Ag1Lv-urUyjO`vGAI8O@&KqHI2JJ#P%|j)`PP< ztU{4gqi-AAr#?lM@8P67C|?k(_Yp2#I``5|&S9@~Ia|$_0t*cY@3CNgjutnH56XSZ;Z!(8DfCSZBW=c2tmPIc#Mw4#PZEf8KvNtL|Ev zGL#WEO*U#M&(FLx(0Jf7dQa(6;}11_vzB{93@!RDKD*R@R6X(lv_&`CbewbVNu6Fo z9qT94aE5Kg&|>$%q=InwgS>2MI;b&GPc-Lh2|Xl5XC{Fi00P|Se&zPiYvrw96h>c; zwsI*ARxUB{`KTzfA)K3au`p_|h`a9Of7f8>pQho(@0SpU_CEI_V^a5&qCqT$#taA& z;iJYS!lmPsl1+%12x~koKJM>*X7Sl)C z79`vQkD!@;rS=5_Pngte{MA}=pHLqcyj=Gow-X&^sJjvq95yiW?Jd_q3`SdbS+=iq z7+;x{98IP)_sqB~=!`V!Ulsr9gXGy>f$JX14!+ibNQyWn99)9f;_B149 zl`|U_b%c3ZS|;xk;w5A+T4ySTfSRY33zsJP`)(-(dZ#A7#hZxh(62ig-H!1ti+MQE z*uhIck#21eFqAESUnSPL6~0G`oQOeAplfV5ckNf`U5jlAdsfgY5OzFWz3E$WKJPPI z>U-RTsri9;$(Wd%^m5)|YViAug0?Q!a^1;s?rlqU79*ODFGX}fAzv>meSluHRB{nS_a8Z zI2@k4TQEYr*IuMFIW8>#-iecIfAZ7f4ayJGvMaCF*T^vdNu@m@3C;U%_H={zVLZ~6 z*0d3`!ns~8K)t3^TB~;Kd+gZlsc+o`c~EL z{oy~@{=$|U>NQdc4@4-kYu%o<%G~6g2q~pYtM{k^t1>d`7XBVcI4uNB0{m)YPhR9- zQ)jb2tEL@rs>1#@`tIDOYe_j-}F*2;~T4<_~S749O zYDu_I-h%w8sBA# z#PGcwXSNhs>}N6hqr*KmnDMA2i59KjcQTGC?&=UT`2}=C@%EUjyxY894N@}01cX+K zy6hllv>%Qi|HEkx;$AJ^v@00Hm`krd19oAB6jkh_!SD(MYa?*G-NNsJ1CE3fYA=Gh z&2W!;nNj}Qx04D=G~{1g`U+JpJYCUB^Sj6{J@?1#ZPw+FRd=OgUJ z1JLXtz>Z_F&MII0+;J&DVoF*4q|s;j0Hj?1y z-k)bb>Kb(f>NgWpl>;!$`b05ISw(e_koko0_Q6^QPU$%N9ECC%odNfYV>J;g;a0f) zLv-i;lmq&Azm=OkoJ?6WF-{l&bJ({#ec}SBI{$B%St)&FBEl_oJFF$(ykALBxgENr z)vCWMN94L9Kao8V5~5cY|M4p@KZM6;c10jZKG)i*{#9Y`Hjv~OJwoensg}u$%qDJ( z+p;`O!wUwdy6IACG6Uh!(W1@pPajtpy@NPuo@=Wdc5@(@O)kOuqof9PZ0nax2GSQD z*kd5u1I99C4)epPb(PWu%w!0DHNHG{933@VhEK4=aU*#%6>iK*eG1QNmF%k|Uq`jQ zzK=(;%d8`vHS!;b9!M^IboUizv8S&5CKB#VNxhw@gn6)ZhkdD_JbSI~LzBSeZ!%}Z zdN+0KVmV4k(5oV2(s%DxxweX@$=E745f|x!cSCK+H*;wNF@;tMOg!qwy#9~K#V{jC z5?IFS$nj&^BotD-BlMtUimzq!v|^^Zo(#MYyC_rb9Vys*6S-i_P>xI)FS1Rq42ga*Ot9tHz=e$aUEy=Y>gDcrD<&^@YAu$)$G;r!< zQle(4F;zK4@A&PTnaAoD2H1D0>rV$rlaZg2!^xE_KB&=q&F)$r?`A|Nd61(` zXW&K*Vs?(hD37#(_PyogH4|WkWnk^IYkWCgKd%*xBOK6?rC24E@TW?HE=8S*`}-X- zD!+x>S_vgDEfi*~FJy4KSxe)Bu80Au(t;Ru%5);vG^3}k<6`nvUkl2Yu!!c)cZud^ zeCQatOZ$1za}xAriC&XN_@JX8k%`dY=K5l-dt-A3d@i5`ezaB|leW=204;j!9~Ocf zN%T;mhsb-sKR;1dB5$TaZ}Lf9+FDA65#ut#Y59A8QfJ1RHvYl%{#INBR0Mp?p18>w z?$Gm`5otJ03gXm_X=ttB8^Vqk!;RmK2k$a-Cm)1*}MC{%FK8W&}qKOe4E6&RaTO4 zQjEP9_+O=-|0qPnlX2~kN8n_-qJ@hOzUo;{98PO*eZfdJcIUO#TIIvG!32tn}&6K*$wlUN-Y~2V_4Eq4t3bb9``&*xqtU3GmNS>!#*GA`^^Zd0Lh2fV5 zSOPmRY9Vqh3Z&gglkK{>G6^s|cCyhU?s-&_>svJ3 zvF>)@{D#Ma__5~DPyR%?VzAse{4R;L)u{ItsLy3=^UMnR%Q`wh#!x!o2!3$(@5ydYv4Bc6aCFW zEp#d8I(NiK+%|nx!I$NdARDuO@k%keGWYyyjdbJB2%!VQ z>$8Q)_9vvq1Gy0G- zqoz114y}DCOoqV2@6LW&$$^>K;VN>|N&)3l0PS?!hVWYj0RWkkhi4iZwD6uN84O>l{H)KLDWNHfC2{?qGUD`n^rWtds(Z4Wb9>N=h{8u;K3q zh?>1kSBk!U{#In>U?V;IoN?Fh-L{O#^l_$gBYJ za@(iQObM8sV*T7pfB12L(IzY#r?R&4@~@;xOkZ#Qb-!Kl8uajApq+bD`<;^0>4iW zIV~X}Vd0Q}6ad=ZECY<^riynX4u0XtB4$qhJL;2dZN$i6;Qph^A+8 zk*nT^><)wkavGcz@BVxq4|mb7*!Y~1!E-VFIc+@(rT0l(O;dMi%*cQyR}fZH<3!IP z#cH++s30%)*wo)WgiLdc{RxRb{(UTZ;c7Bg&j1mz@+iJJ0hyWJGn6<2KEtN(#SybR z4znFbS;%RSPrV*1kf%mG6?*39CbQM4?)LEVbyfT{-g0=CI;8|F44xITc*j2gf0!TN zhs*O;xCyN*vuSEhie4A){^Uj}!H(%i3Y8rK?d|~%2fF{-A;9;P(PQ6N?MtNs>Zjh@ zZz;_rI5NGw5RGYaTO-pqp4#Rg?L9<0jqnhFim2{M5RZ?KZ#b)l%m8Dc`8M2ZVvBOj z&&Q?AbvSeQjkvk{AL#nT3=dAAa9DK^gO3EX869A zB62)@;&WPLM}NFOk0S*7?S%5D_I@z>zzTtTK-u#+B!~7~by94dA$2DkGKUad!Ms#YNX-)Qfg_fY^K~8T6e#^JNdo`SywuYb}z|KaZk$$-y^OX$L55q1s$ zQL#6ZDrz-poR~klBk&$W{JOp%0C>HEr&K!G?=9P1Or5q7a}u?@H$L;;OKnT(tUc%- zkIyd(f#Kv8aZ=BJd|MvEK)2{9vW%}cW#~OWlqZsL9w~<82qP<@NHKF4uml70vj4rB zGTUJH$`ZhlK#lahZ2(giXcQDs(G1&}S1{P;um=dO{rg>gU_?a3PSA{dKn2zB!k;<5 zL*@78eoT<1+pDDZ99RQ4GpPso)qwjcXiA^2?h@*8Jw{7cyB5N?-_pEb_B}vmzn9RY z&51oTK4j~EuK0l$Xo>I5RU|dlr>oWxdV2~KpXc<{iHB|#v|ZH`v^C!?Xq%_?=+UF% za+ox@=E5LRqdzO#nfG_W3CF6RiSWAL7i9$aWQFeXx(d3!p5lL&&mZ*iN|A}NIMJk3 z0?9c80JA|`O52DRkWXAq-5g)Fg@GIQ5&$=qDe47{@_6 zIyw?cC9ZFRU-1Lk|F^XS0py*7f3AeXUr^yfGaC*NGy*cRtMX z+WN(6eD5X1aO09dCO6cumcaWvs`;d%6;e#H=H8NO>XrB)>Ei&PTPdU|yd&>U-MxGF zxVN?0-Ih+3U|pQKa6eR!!;)?`Py>Z$*O6w3QO@89D>N@v3j4j9=;G!fNR0mh3Yp-! zf_VqXlLt+Q{rCE)fvR|>V3ofVOgye#fxIj4&i#v2&~`Qo0I97AMpl$&R@R;7=ojoI7lU4#-+G>UggK%iBNk^gRG*?X z##Qv2id0R1V_nt&X|Z6LWX3?#b!aW2k89xe7ZaA1wKtEc4i2$hJf?h?f~hUoXzk5g z?5Nwj)#v5XczUWhY^PE}dfy|7a=Sc^i_^lX2$zN1Q`lMqT(80Hk#L{0^GjCf&8xW@ zt{0=YVJ*qU*k6)YPE4kw$GoPFb`z$f(kxcq!_qNtCrZ3?d|bT3Qj%$(AXZ-k!w8o2BVZs|aNw+f0@zWB4|tj;7!DwuLp$<~^Jmm*ct?KEv2&zqIjHcp_vQwJD5F zcue2e8pX-d#KhJvDaSE>`5+urY7OV|8RwI=Tw-H+stkxWhpSE2z^d7n`y%4)C6fk? zqBzeuoJ2^I1RLWu8kLiF&b_Qw!%2i$ZtW&BXeemABXB1_lFZ)pvACFU3dAW(pinO6 zdg}F8-r5T26|S6l8ZF9RJ>4l_avU{tV5p%@=t`W4SZ+ZZRg`QK+NZt$cj58B_+8j#`bX$t~z4BqW76xi?&!*-~?0& z`(QUvZ=8{+JoT%xoyUj72}bnySp)CgMpoDrlH-*#Q40+-l#%9`9|AtxOr#_!GdOA8#&RDUh(KvrGI4^2R=Kih2vv!rUld9L_D8=>o9!i0)S>Ctv4#%zHhCSCh8G zCL6c8Pmp%>DqhDJ>MyOUy0&8iZ4P*xnU}nG9jjMXvpGP@U#x!)grOm^XL4;?K8C&q z!b}X5oRTmo+iS`%)L>SY(Vy^$yC%rUYt8#pLA9^Z6#1kqskI9FJ3Y<5o^0GH;{&2g zTQQhS@lqduzPLuo1ommyE*U zzntjk9#m530Ay0B|sG(|8nXCYtTg8E|yF25iX0Y z4sMOV=JVW(U`4!vm=0Wz(f(?KHX)d+7lx<9iv90Myq~HzF`V0xN3wB~f%uvT`0TAc zQUgE?{N3e0&F{!VVXM?Sj{9KC7i}54Ei$*m%1M{c{%Zz?ZWOtbp<83n>O{M}w%<<3 z7F#i}>`*$10=;XNQqk!%QK5dY#+kbOo~rCn)`~($fr(f1opP{Qy1I*n1u$v)Z&HRG zhf1Wo1RP_@VWDcf#XLWB5Cv@yxa_5bB2k*QXmOK-FY&t)z90A@{^MTbQ@4qqi{nmCS+U-NP?AHQY%W{g&k||yoU7YkLh8%Cl)J+!%Wh*%&5*{ zmlfn&J!{6zuOKsqYMF_HQ>PeqL1cc*5U}MSFysb6Q&5b1s6Ekf#uL?C=X0+&O17sz zr=aa~-KCVW^EtOP%_Jq?)2tuP&pjEK_8?hN?jv z572C0Fl{w=KRcdBLQSQ;YoEdBL+zlR_{~_*(qEd}OilDOGNSXQswPYddb^?`uR zeeF}<3o$n37jeV3hM-fSs1h*jrVhNJ0Cp_;<0r&tlM|Vpp_zkr2WG9yKjXD5R`1&I_VM2vgPKJmNLbk$9a+P=%!=)^CL>D@+>Tak7=)Hhjh8f<^w zBYNy(wN9nmYYk#%0|k0M_ZkDP%R%uUaPIW880WB?@4{0>w*J3Krnn*pl3N=UDi9m_}dG_i z83qtWt}-5l&#p!yQKb7qLPFJ?yI!IZO?3z7x7r+mS`=Ap^i6SslnP&)PbUT?47-B$ zreuu(Ndo{H&w-7%PAB&0T$KinlKJFB1QHcI<1kGhPnw!#00R)p3GzKxo#AgqB<3c2 zY3e8Wui@--BBUTZ=>WsAyfw6AitPYjuWGEG{TTLp+kcS1yqsJ{rLhHAa3#GVm4s`I zdfM#ItF4w%L3V+`($$ooHu9>=wDzfe0SoBPWm1XQ)j_Z#BQDsN0!NhuR3d+4uHSqE zGU%~WuF9o>w_*6LgWtqtX4O>fD_&JvdmHeFAw1j5oH~hx>`1vDXbHTLb&p%Zs@QLrwV0Euh=1j9O<&Qk!)*4;+{P}aO zPQ|FX+A9ZC1XRGPL|JerFsA{8z6nNKb$;ptS8yC_YzyLPpx5$GY?##@famJL^95Uq z;Qw6tgS&y=VGvxmPfSc)AZ|gz`Nd#{4>&<_zKlw5h)P`PZxFgysr~M6G~4@t>W!d! z#fh!v1oNLO_Z&ZENuoqUV#P#8z8_8s69 z+PtZ#4gPFeG=JZn#J?B4mPUCX94kK*8(ld~huCMPr8X7Pdx^Sd_|F}1PUCxlS?705O%C~>B zEOFEw=hx38eG%wBQ8@6P%Y;zGSDZZXs{+RA<{lMJP+zcQxfu>-K(N?yIfS-yIgN&y zUc5+B$;*cw%XX852Yq_E9acR&C)@vPwRK4|iu=e}Jv=v&eM==ZLtj&K(U_fC>t3QI z54E>Zh1&h)Gnu?R6NL1^M_%8*I8skgHgY(c;`oL@`g&?4S_4caE2~4)B1(>%`RBN@ zr(xn=64;|Q<){Ap{7%>Yo2taJZ`OTZ6R}3g)({v5&a%;&k(#M4#@Y7BIWT;;Dm#N^oje5{s_6fh%H)=T%DsDl)bT$BbyUDp0*TfXy1#<3*n>bw(pK-QI&xpkgb^fp-TKjS60k?@6(ICq^$BpmHZltDJCE)Igw8qt16WVHV8 zzjMUkvekC(V!0o7_6Vtg1wX&=uk|fvlof*4Oa8?~*&{x|PNDD$zyo$MsDK@N8n;i| z9|4}C-I+*FtX$$JWg%knY}M+;H23%Sg?ZOkqR(W^+{&E6Z{Brtqg2a09iez0aVuxp ziw`V1=G+m*b+cR=*KJ-|;RT64i~{)!j_VC+$}uex>=!NE2any^QNVTp_?KO z>2qLEp{0sj6^%OOQC~1Z^$gvT!_MOBm|2EV%#hvA)Vo#ZG2{bY`}gmkOX%pm(q6p=B%;D=56Qv7;Ng|H7gGRUV01szUX%0)L@>5#8{T{$0> z%ewPz13V{y?CIs@)hPBa&mvtQ!#C6g&`_#&prJ(IdE>fZ3}_FYtQwx1sd)OP{}+xJ z!e(puxa@1WgEi!>KHYaZr_5}=;|$f55hTyG5Ka=4^W$=un_%s%pP$@o*B2G7vM1?S0bf8!1PPT}Y>s`lm%WoKGP2R%b&{cj2YZ9hR4xi=Vn`*+k1 zO;1&I=t1wvxgxctwDEGk1eHxxbPI?j^cK+}kTC|=TGcl!;v^qovONA1hP{|G0PtyU*s~@wiAlSRxy4J8FgUHXGYUKU8pw~$K3~nf#bZ;~uKT;T} z^q@`wTG6d5?G*8mCGPM1YJQYYj1e=;(O*izfU8+{v;bUSGq}Ljmx}cddEWy~*|gB% zXzfhSHT`tc^kG&oazqrx&#bS^K~Epu1#Zn+dic*%6&PDBF-HT9Map&;?8^K5)Xojy zwi$3#Rwr;v?j=+C{%jLS5;qTPP;Bhz0fcd$X zye+G@`^5pD$aOiACop^J4-x0GVGGAZnnu4mh=oZdl%Byv9N%o9|5d$a2W*^X~? z07OTx+a53Op0`#v@VkT9DrW~`kYmsy`o+AnJ{HCH9{jTfcYS93=Dezy=?KRt*U z;^!9d&!#OxP`TZv#tgd{+DtpG>G}`?o(!@JoadtLpL3Oy$l7Mrrw%ST%Sp{Zfv{nY zF6c_p1#ejm09pfNYe802YnN_OI%fD{#FIPLfpG9Kp@^fP;d-Ed34*a7JZUfRfJM-{ zH!|zEg)qH_|5L+XRKGh;ex}f7(lpFDdE?V*5IxC*Z`CQyRmb`q9H&qjF3gx*O!)dy(1SqutkNrI zmUd$J55EueKK~@IJ0Ny7q+x6Vyt^|t`I2Bc*+cz-^Bc;ryw=)f%tx)D{_+T(Qf9YL zi?4p=9nzB4wG+PzvY{zpzPW{F5pGp0Nu{r0u$@L(MzP$5zIr9e?b}yJ>r-St4I9MY z&f$3_TWxm{tgtNUkHvVi*=#p&g2vmy6E(lo9j<%zw|>cqHBUx6r*z$f%&ye_a?2ph zSiPIj8K&TGH|liKeOD}o>r=HCwziBGPGzKw}&p<`wvX5gB?eW4=G<{AhA*1 zNRHvf^mRH&G4f11<_$&shK9Atkx@7|FF6UR70Q8VtjC&s%bn@3uVeitDL0Y{sPOga z?o5_gR7^&y5vr#mXL-rfNAr2xr+RktTQK`CF9q(6LZjG+QBkp#vxg)MCFBTyp$B>C>G-WE@4*zBbM47$= zAnDDc&95_LK;P$t34f?#6f#oR!!F+d$vq)Z54 z1w-S|1COkJZfxK2J9z5xgC{5+^*w1jE_ro+$yqo6JSA{digD;AP2qGgBgQrNoOUxM z&*b!xw*l-G^P?+wuz5uOwikmC2t;h92aHnQN5j~`9Q_?{kA;cp;aljgQS%H3cUMBy zR8)gvCk7oTUB(``Z*wl+5cjQkT|-I9_>0w%ao$arX(g%ZnG3vTF-`Q-!w@{JgPjb% z8kMH;@V(I`Vncg3M^(*9OB)kq=%seL^2d?QAcSutj1v1I8qqKz-2z6ttPeql#Pfvz z$R1#*L)+a;F}9V7t=xebIWov!V2O8VN>N0;powt&#FFH>Vnbd*oo*_~a~^f!VnAyS z%vG$HGkj{y#ZFbbZ^%xqtn=*3?b%WJoED5V(&vtZ0JXX!%5|Sch++DoGUP@uS3gdD zs~A}IU^t1|Z=`B5ZJoW?> zjg6*CYx{S>2~`7YIXZ`(cA8FJ6e7YI*%3UGFKTW@6RD(`NbxAJJT}7qJi0>wCiS}@ z-UIeSqKe)KnyKQ5-AM2Pu0*rhkD?(sbDf>v&g4rGPxEVnk{K$_7J=9>II04*bIV|^ zReH6G&#g~`Zz*c#`f^@GV=K1n#Q1TB=l@Vk^{KoB zj8;kyH>EQ?QhhpTj=7>RfxM{g|7zlV86M2de;?GiYj0-bQtNVTx4J`Y$%N&6E=ykW zGKg{>8pBk5(VL$i7Jx*BsYI1;kFtF`Al@sGd^i$y_bw6z(;Vx)5}xh0 z&p%_p7O4WpP9^!6r1p#0aV#PIie+J=`PRqaMFbCfO?GspzhVqE(YVtV>rw4a8R%|p zXXD1kX?Hk@pOpTd#MDkW3@-+pXK3CLjm2)%x_8*eluTJPx6TDh*7O~(e-388lmt+d z1ltc*_lfd(9W{{G5%_a4&+&u$XJL|BUWl4&b41Z9Cq%Pf^;dQDRkw3TR-qr3QYS3*u#Zd`@lya zllWbqlO{+peiIG|6$QaScRhMr44+m@Ns!m&gBei8?Ue{_80*@|1atAPyx*|v4CovH z8?0t~m%xK%?gOQ+uzXmAS@7EC`iff?eoC@B*aMP_BNv}Nl-V6qItalbT077u&|29D zCI>CqD(hOUlF}88#EQ&HJUjpldGc4ohZj~LSpQoudt68H7J>2%KebRDL@_V24iVY9 z)zvGuUwiJ1s>tfM8H^4T>#hDTw^ImmJ6}hHR=2kA_@Aa7Q3gfj-0gg+79&$(`JCW( z8ahv>$CeFrO+6k&gVl|OyM$n)+y9$=p&?*xDTw{&>&%LSW5d@kjw%q4^xFSsLc2Rr zdw$TbS6$AZfO)qJU5iRoH>*3<39>Yke!LIG`d2XGbT&5PjUmNr3JX@2vfABY%vO5M zU8$kTT=;sSAt4KVXuDG>{>1)+y2JT7~wXTHb-w*neL9d zu-Q6+u*6omx=3y3YkkQeFV6LCKQ&gwdMrc)>ED^*$&@**@F7aV9D*rMugf(wI?0WW zL_=(vaBQ#!c$I201p3FT{lBjes9xWjK6Yjfv_ZCFN&hcf_4KBJP7e@zmJ)ihbHps9 zU|zG6^Glc?drCnjxI=sH37qcTzd7{fedHJjG*3iDKZI9boYZkTYfJaVYg^=(r4GpH zgiL?M*^667QCN>y&Np)>ZpCFzVB@_T7>@0pYo*1>(};SDrl^|cnZx7ZAAd{3R^hds z_<06JGHfNCn$hOEa>tMoLNMf!K5;<8Ju3QsY_WHLv7rfh&%?WlwbM0`&=%}?%ki9Fx8O$e*`B<+^xekaUws;` zGLY*Ik7r>Ct;r(oV#)J{gd{hmIw+X8-7`E565*dlP9Zc;-j0o;SnD%W!As}+-zF+B zvYY@b)U#j%0QFyLCl-=xk7l?(EBXdsl`G#+UiHL}>e?Y7DMLTn_Z^*q}WpxdE$xhXgnF?jun4N;B@GtzV*F!;Q zNGK=>W^yXw4)C+5{Lhu)*_>cjF!=7`-aB!r6IU>zYA~fM+sCmN#ffQKN|}vM4I!~T zS!W4@x-KZU0HhCn`%;P4&6vL>i0FF^+*@2TiPNw@_S3l2{Sg}yJ2#b>!qoF5tOry z!)YR=V&_30oYWa!q|Ck(yg{~AxjK_x8Zc;&i2tn*w72eh70u5dSN~_BxecWMLDqo~ zkMGg>Rmo^viB?G+!FR@IYPz8Iq>4c}k8BF?#Ca^3aw)e?y=JWiGBQ!}nvM;mFMjqt zW~)MtX7x{9Co%Sv{H0T6Q@*M~w%r=e?HGBJY#%~EpXWQm#vXQ*XVL#?@5|$%+~WTq zB1;#Ql&mecl>KIzWSeNY*~^w~kZdC)yRl4Vsf2qg%jII^V#p9?$gUJ2ON^{TOvpO+ zv5cAT8LHd;{(gV|{`&dHYs~YU=RD_p&gXnS@Avz2P-AuxoyM(_*os0UZPf@be!R#VZQvq>Q)2kBW0}^&2+@j&et6{Z;I^{+=m% zVmlSDGXc;E0ZGsjx`5BfErPY;tx{kQ68dU(yrI4epmefd_J`WtV6t(y(|>!uO;TOy zeq;oHox8!MH$)JEDz2VUbMLO%U)MG1B+qShNDfgYOaQX5S-Ed`MKTY&ni>xbj896T z(F>@;w0s^fu&D!$?Tdu$(!t6*hfv6XMoyy<*8oo8XUl~-^JD80_B;g-;Kn4H>@iH~ zGiSvXT$h#=0g=}QeF)3J0=n%I4c_1U^0)h?)ZA~{UID{d_m+9I4#>4!cJ9x&1~r+u zc^|b+9ppg>{Rt?e+6h-X*QO%Zo|_oQ5pRLhL!+Z@Ml~nXYT==Xk*k%-VUW zw%&0Z{?q|($2@`AU0eXL``39q+ySYWUp6O<9d88j96y@=3)8VI>!I^pS5?RF70I86 z*<`FjFAIN#vnK8 zo7Wm9HMq|JDamTUxGHno`ZPT|Qb5Qi{ZOI}1rFfFJE}~oo@<)HoOzU~4?=qkFr>+TDE)1KANkhTIfb@({H^J2q8U2d|Z>hfSyYb4rE@heR@7 z9BGS8J&<6-GNp}Mi@jCOn-BZEr-jhnJmT1(zYZy-8q~g?`m7Ps12Tt#5Q-eW)>YUq zIeu5rWXXPv!H1gSA*~n|s3Qmr^^OV}*m`L=YinuVwFh};(j32rL#D4C!1F((z$gMi zwTBg)W) zqk?ZG4U5lEl+0rJN(Q>esqU!I)Hj_0hXI(tgg^2QU?K}R>9Uq>{%;fe2WT2M+QXd3 z%%l$WE<5)Po7%6NS*o1XD|;`-H&D#mH$3bT=-wOluElXl zevGhaiAuNqx->u=%B?7C)FdokM$xm@N(QQi{%Ln8EI%*f<&I!Xk%`5|>8%Z<{EAXn z#?Y_>bBF7mF!}t#v1p<_Zt81hhO9>q^$+Z{tMXir;ax&lEYI+@_H(Wj z>OzN1y(P}y@=&8OY@-H*crRaurix5`agpkg`fQ@x`_;ReI3Dtdr?!cXa4JN3e~<3c z-EZHXPY6u)G+?pFEq7HOm@@P3I3IYPA7L zx~7JrQ2Ji-*B4XvVj|nGDg)I`yzTSQ>-ANdxztC9N^Nvr``e$q>OZq?ZiDYu#?z*Vivgy-H~{obAwClt@m+5~W>ZU~LBxio2b1#|wUUf2va#UDZir9S% zMSnSdxSGNp(U!-Ls#4=k04hhkb&Y!OW@rrLI|_U`fbzjl_5(1EPq$2_F2Ti4_LD0C zL^`BV(0_u)Z7~}n9+O}n1rdx+Raq}Z+w#A8_0cS2sK>?Uc&@O(d|xp=@H&9dOl*+i zwf;;ABEP*;G5b{0!kxZi#GLP54lD@P=jBU5V@(@I*C)>V280&1PB0~Dv}*yIn_FL~ z&g6FEfV}5H^L6sSwenIjC@RFRk|$k~(&m6cRMB{nfB)-}nRUW^g_3W69Q7^EOIwuD zuNijS_bIJrCQzBz!IWYzO3E56#zwq|YHOl)`_J9IeciUKp)iYGGJ~(Klnv7ZAxe+_=Py;2XoanR-l z?%OuYpSV!=Ju7XIN#GXTQ-)H>Th1kTy^U&i)`uZIi+SgO`f-r zS)v-`l}aO1g`(b$eO2Wq$*U=d6(ppYJ&O6HzqrYLl;%sfCaHhjs_Mp8-0i5q>dvpk zv6JTWbkKPkf(5ZtYs~?FaT!U_*kV(z@6azh33TdS%=n#hx$EVpRMeK20*f@|4q+sp zl}wIDtgB&k^FlxoVWh_ScB+Q5_HYWI!aBHp+_M}#6yXHN;7&OyzI4YZ4I|&bURfGS z&Xm-q>k!*tfiUfsVYl`xYZ`Vw)O0%YHMC+P6NaN$jFfKpt+dRx;Nhg>!L}7@A!nCQ zOpQ7<#x$wJ|oR9iNjH@ngSe=N57skj#F1N7RL(Bbzuvy5tjFKfg(Ohw5t4v z3EMaN&)XJUm4@UPa>g)ycC7uHOW4b+XiI{I5EqxWASyW9>-&;3 zUy0qSI3LEx7p?urE1hABq?`(|Ud<}KyHnhJMk}N|$L!V#_m7()xu1%|D-0*SnFei5 z*yxUVuf@n1=4!!e7_~3gSahf7Zu}-{NCHP`P3^wM#Pl@wEF%CQY)>PEYX3&KN$5$} z2cfMzD5(L^0{Wz{4gg5IQ=7Web2j)*_tdZPE{@6oH+b#*Q;$LN(ZsLWnj=prD;p^~ zS%~M2s_vfeuzTlBghQjZ)Mc!nhvn1))mr22re*zAji|NTsL=3xr`b)FgWpziVin*d z!PcYEB#D& z+M`Q)0JfW|x%lZ_0$&eu2ZPEem2Y?$V5xvR+y-{sH3`43=oVh|&oB?ePSqDDGF8$( z3K2Qy?t+JSGjYI^v(&^CCJqE-S^c4tJ{xk{VYr^ixjk(wHD)M_#V0v7{7#kZ4qFgxyd^hesVg8_bz5t;ZA$SSh_kARtp6}pJPioSu4JD?V1 z@EP=U_u$)oAVXmXfwf{R2ybG34sV#~S`0wxEVX5tT=W4(2UtcB%fZR7!uPuH&<`^s zU62j!bPB*^oF1d!fC|y<{3I{MY!Pa1lRTQQ>NR{;|9UB2S>Lx zls=AH+%w}`Z}4~`hYpsG|DCGW3%k+%z~mnQ5ed-FWw1b)$JVmcOn^BY4)chsbsG~6 z64C0wA;(mhYBChf?p!K8QJ&;X9R6x>^gX_jVxP*npfJ;thgXK0$6*|8P>3`aGd2+Y;&`prg8p9A_Fz8ga4Oag4s5m<+n zyHLbaZZ%v!AgpzR12ocy&xX-yBp_4A!?(^ytcMtc6g1Q$32@vhBE$9*nQTABZ@QKH zZR91{ux>5v2F#)v(0ZZ4x7Le^9LV5IETG1AOL=yZwKC)lVTr|{00O{0R*9Q@yu7Qf z)|^_GZ}r#yQn+~tMfXzQ7C86fseLcYCfB!FzY3y;9#xqn(mYP}U-fZDQTPUyTE#H^ zYm=+AyC98GD@mg!GZk{THlp{ZPwrnC?w=iQeIy{2{Q}I9-7!!)#av$oE)Yce6#&Hc zW0q3pQdpwnlKq+zD8{MFDe1MbA1~;Nr zVwy~R(jB?=x*^pqZWtNY4xDurMRLUp8CL6H=#N`LD9@TQIq@V9v#Q4=o!67^-@iW@ z4-}(~!jR>8+>*;fP;7cqR033E>80ILM%l?GZf?Uh0D9#o(f>-2v#l8b_auYl)Xyqw zZ#;RMm9dHD2PLK3YhI34jWO3S#_fF7a2fW;=*eqPhtuC3VDB`;9(;bM3W$)_I;cRI ziViY#L9}v#Ca@XXsveAh%fqPL2f(V%LCij2;+rF|z|ffDg4Fz4K+!%ku#~;`tKP+Y zD%8QkTtg5pVOuVGyXtN%;OB>uTE=8^o%=zBN|Zih^$vM)omQDYc&5#94J}hoN#2r1 z%@j4J_f-uUQ(3l+toG0r*#08!|+Rm^5<@&IxvAyOQ+iI)w$Zizti{ z!;e08KgR{M-9Qkbb=dG@5gUtSg%Z5gsl*n)_>`Mv%0fB4y-WcvDk%81Y;4m>iq%Be z-!;Q1X<~^mafts{7SLfh+VV0w=49h7~~9wj%pq zrp!|81k;8i#d>$=a^ar0OTT*fsnG;UdlaL7#}<&q*yh~l04Kvx)l~j zaAhFEaI~T9&HG!`4b2P5pBnn=0bkJXyGpg*3@&^yZmbL^09Qyh&Rvw7$2ENP z-;JUJ=@n3MXRJ9w#1q5+bw3Tr9>3yUyV5eYWddRtChXcGR@e?SD&5`@DXTPARBIz` z1sJ|j*V`q0;Gjcc)5a25wl@p^%4%fF$Y)AMvq{Guxm7A$SRc%%+t`rSfj zWi+(yf?~v?(Ejz?Vu^9ahKmXv*2mhNtsYFLtye}-*I$B7K*$_)dW2&j_|e045u~G+ zL~s=TEf{0>*Gy}5$+{WPys-P+c|b0e%M#+E-pLqg*9F{B1O79YYeIiPp1=((gVI6n zuw3d#QryUeOBe+XuYh*;DolnCF)Ue-WOrBD}0m?Bl*I#}Xzm)Thy_geinPt86_;abD#$fY0BX*Dj$QMHYl5LD@ z1P+UC%xUAlVvjtZ*D|{D%IFmB4JhrX%?A77MQ(-ei|DQD4*Jj`4s4SL3pk-E2qfGZ zsv9CxPM=p_souQnq`pJ*0=Yp+BvWpoNg5UdZ1zu@L{w>u@_k`$|>ptyNI`QX(Z;x z6vV7`aqg0nR7fY^T9cSvQN?q1@%(dZ5FFapAop@21xO7n1JgXT#*P!s^EMoLmMm^| zr5;Wo`zojN^(~ox4AwL?MCS?XPwuvS*p7PG>TUU=Hf~#6IbzL@1xpw>ERGYvVIBUa z1{(|5m8x|&Ll$RVn_bZo!nsFoVFARcj;l00(8fp0qCniku<-y#kK3o|mt0MJFVQ&J zV1kUus~n2@VAMfit0is=9d`rXby(f3t@JhxzPt^XAQ`I)uIPAskHgs_5POKvmcwy)BT8g`wx3 zhuQ#5RoQYcz2L$9elv^jK>TuCB0$48c>xU#(Zf*wtjBvodRWvRa$`lf6XBfAhgo={ z+lEGwJx) z?Yk$rWqjD5i5f)=)b8uvZp>Ex$Co*7sV{-@S2mO8%HzNT5r=%MuPJ8%L* z2kkl)U4Q6HSmB2$m6KdoV?;shCJ!7wQTPO^>kO(}SRU38fgP8;-KB4lfb&0>%A?L= zbe}8cIU8i;33Vw;UQqG_dy*n(NxPcGyYQ#a4nVA1;-|v;jANq8MrSrH&}02!6K|_` zw2@a@!$bR{dF9-YQRQ}1Nxs!yo{YP#oJNZJU89;NA54?{EfQ8TC%MqcdM==!bun1j zct}e8|(}7WZ;`FsE;+wri4(X|4n?0RDVTtsb6I&sD|4hqDOV7BM4cMy3 zP+xft*I0FVGu%r0tc4coj8ml0p!c)2$!QFM$(^38OZ`kZbjYO7vGde|^zm9*}*7Da@mQ*1TRz_Efk7ktq( zEh=;ZC)QcbPad^?r z>0^v?_8P#m5;+^X98yzn_NmUjB9l888Gj<`yB2H8CHRtAm=*`jiG1<)IY~8=t3#t1 zE77lVEAHuTY^&1}JZR8g?P1T#hU;$Z(g}+27d&ZD~0kGU=wqi zdxnLq;*?Wb6}-r=?{lR8S)jT9E`m<|bNOt#098pSrttBEVf)gmUuB3&sbZ^V9QkJG z5%nQ+(-rORigj-qYQcKaH+b_QzXWfin`B{nfq%4&L}N8IA|k>triZ^J{!^H=X6(dt zmp2VQxw_fu@VA_etzi#!B9Wn{_2K!uiG6q7=oVG-rJXVG-)!iUH?{)WdM|45IWdlv zz7?ffn1E9OYa-dPlZz=`-k~-{gH2B*G=D=_O``6cky&c=I)x_E6$!8l(VLy*5M!_M z0G#VO1(?upl*aT^EAI>iDk+JGu1!9Xv-4N1w*3Eaw9ri6$5IDU1tU)^44oSz8i+MJ zjJY(Qd zDGzVGPdW4sb)|jf<44a0#8P8m{pjcohtJ*tx+Ze7OE((K~S$(PvmLdo^U&bX9rp6UCWF7+fYj3=G5YXRP;bI3$lWN8V%Pi zGPMtn$pls0TXJ0vj0QX177>xvnt|EX_AFn7Qd312r%?+<{Y&Mv=1`cI8ga3YywtO* zy>3==h$GEe9v<^luiDq%e&l*!xl_ry(xg|I3dvvTP1o1PydLGb5^R4&nBDrrGPr)? z@IkBf@$`kJuhX5=Vuvve3HZrU)KcgM=`~fflIA(_tJS|w8Oql1!w<|dWc*NkWLDzY zP?vDG^)ZV0eqIFM?6LXiF5vJx$G-);#<2L%GjDOV>NTru0n}FbH1h z@03}Iu3>86-{X(sU#r{^=9metBE`O0& z3skF$=2yBnuXKBVF5^mB`h#gNr*)rU+e3{?F{Ik#Ceb(*a!^U1+8>3s;mMR(oFe(o zjE=a(1xXHUkyK#NV>vm4BLbD~jb3`uGAfj+rM5Upzn1p;IxvCJ{taG=U1w~C3vryZ zOSqK6u+y>5LY38x8yAlxA(pi0h}dz7%5$nsD7g#o4_9X7G_HSq-G0K495?ZRT+2Hc zst(tmPL0Kxa`fm^8Nq)CoURS>7NPif^diwhmA#GAIxU*B1(kCfNj~dJyR0xZ znUY@yL3MNjC`{=sIGQG9kgYW7AKK~k4BCk%&_xbb4wq*V#Ev47l*cmMR^Xo@@Oj)7 zl7Mu=#A7h>u>=c5?MIhE(MI-G!@Vn`z)0?0LvPSH`xiHD|D0pA-9ZbrG=e43L+89i zF2&QY-Re&A*vtu@ZkRaOlH+Kb;1Oob7E+{(+IR3EcxlAM!dZvl@PFPN%$O|ojJ07n z99ln{=1SLSH`o}`zSWeRZZx_2dh-}sTC2VI8ahZfKc%2HXK8oLe(k?y2$DYPD3aMRFru&ntvM-!k+hShh=Pb>SY7 zt1cypCNANEsf9S)?89;De)v^z2+0uSH`99*E`Z^ED!ym6X3-sNRhmyctU~sOkXp(@ z5z(LV!xYa8FSxI6Ay|_@{y)XDvU~nnoG7aa3o@PvlZ&a}^Y#jU^rpoaxmCl0PHywcqK3A>)|5)j#hPaEm?F)NUO)~xRhAG3wnX7q3vB8nq1xU%V zEX;oCL%(BfBJ^ngcBlnqYx*reJ@%jzUs1~W^IOTU6lD%m3VURvqj7w0FC%W2uT10F zk)Yk`uCgf^&$}DAiQzBQ{Xd3WSi$e@+=7>N)1pP2y~JrxCDEwM^C+&Jcn~-}p2Mn- z=JCDF3I?AtFZW&9U2hC}@jV6{{G&)apxFQ>t+w*hEgu?rM^3BSmxFIl+v-FhgJ<6%MN&Qv=cKEpEfU$zx}6 z+=f5R9(Yf3MwD2;bm>$Vp48EnZ?pPS4}2P80i4fG!rOG;a6|soVLkVmSDUo>=@H|< z|GC^gLfsGTQe@iCW6`W4?FWxQ*M8|>Mt|~tj|Ejnzo==M&=9P4XaF1wql+?c%n^e#p_{Q(U$Lgp|*? z`V4|f3Se`ynnx5n6=#E{|5qaMBkP#c2pbCu=rmjw+wPDv@Z{TzAwaA8Z2FH^TEEl)5YUcJVzzB@ALnDpP+>&=hfG}_62X$T8eK~cTL{<%7wNu zdiHI6SnU8F@c> z%8%UXzzLM@MNrHI^&+c_%sE*_JjvdR0^F_>3kPpOpn1uEP#*6#wT9DWv$JrRDS&Tm}usj@ma5)z%G2(K<rfPbt7=$9k#2$|*|gCWF*E&4#3D6~#zTj8(ffb=Sd_TB@G8URg+H?+ET1|4nj zNjT^i!G?qY2=0DTQOjiejQ~D^llbxBL#1@f#>Ztgxc*OnQ)r~K_py zae+Xd)O&(IdB=9aK;ox)p!54{s1Pwoi~lH6QJow5kT(R4rySuLMnT_`JnWI*-%!PV zEO*IY_BCX|X71YXOS)?-vWcI0m;7Kd9^rGUyfyk1|<~9ajJa+~Z7S zy8?at1VO#7{K;2R#CAcYR-Q}-2W=TR?k2v4$A#y#S5Q83tO!9bO@T2%cIFQi7`Xg= z>rQ?!^?-$QZ8hHAZpjOn7b6s_iJj0Rg(uU@B0W2Ez*2SXa58=;ByeomK#B#;2!ee6 za@W82o=a_uOaq#{J9KAIQU^d5rn3C|?#_l}!fBi;EfsENDh!skTH^}jx8Z7tDS=ny6l#@xT z{r74o_rSnFLq&D)4Uq`}B@sZby-y!J1R^k1^hBoKPoP+Oy71dr7j?=4wYKN{=LTJOnwHUG-t?cl&=yt*#1Q|#&vL?n^?Cse8U&wx%k|Ip|aFQJA` zLQzz@Kmvp+(n*4p1PCE#N1yloKj-WJ%lUHl&kr)$GqYyRnzgQV&Du{*Z|aK(Necl0 zAYyRisyP5~6953BA;1T=6h|;|0C4TN!BxFm!NZhM!Q_K(DeMPLmO<8zBYgoR)?)30 zfu-=T|GqHzs(#MkhQLqAiQ5vq*CYZhRrdXpdu~KvuM6fI0O1xq<}mpf5IpwihN^?0 zH6}S0?(6Q`vBqXlLQ-cEGPV-uQ|G5vQ4OgiqabT*YfI~(fvU93v2_(ti2uvy0?gan z`zCNLoAts(1{?#Ub4~4v_hcVuUFpZTmS@?^l2PXGE(4qfT_9Tw1L3>{7##t)!T$h| z4}X8cd>15eds9y#S+(B}= z3*NG!jkonzN}ulxN^3Qj9%7G0DkIBMijiJ^2Be?je`XTHgq{J?RyJx|OGOJ$+5oAW z3%J&b_$v8qd)u-HL5japHh*npoU+I+Lzw9Q>>}az{OOiH%)MMqww9k=U9DLZmd3Qq zs+}7AP>;qcujyU0$@ZUim&}+ZGq8VGp_|1sL8bd?4@6y!>U5atO#uqaf=7#g-N7eT zeVk~2ci(n=q%2ymgtMOXw_GKsZ2ICpG=%9Z-g0Da&-AZpo*qrCMR(F0p0bhth@UuX zTw^AlDtZWJaL~1ho)CxYN3L4Yj9%0ST#MoI>03=fK3GqzMb2jsTengu-g)b< zF4S6qjdvfSw;u1VkS9_Hlj>RPp;kfggrCo{W1AW--PjM=&zTugEycC`N?SIs?X-RW zf$IZoBEJRixCeSr)iXP^C@{3txNcb3j{9KT^U>9=RQyN#b_r{uZLXEa%tZpHzv(1o zqd6*t+hiW=rS$2c40%{w;nK)k4bS|V-`w6_CZ6QcHxIkI8f1Crxx;Xa++pF$q#wjl z$Ha?ldY;IrUAD*O$D*Xq!qv4^X)YSRJ^C2>Dr`qeVmCL)d!` z9@IgGtkB^}feJU1%2j^9ud_NHSjjjAq{$rMefa&ky*ct-OifV0!f$`bdGgt7&ta$m ze06_|w>Q5_V-VyR{yBr{c+ZW?Jrn5RZ{m9L4=`)h&r^MLZYrN$#SgJjFRV2|cZc0Z z{>&TDiX{;(Zw#=4YT#4E({ssQ#6|L3^}^_}#bN%O;9Jkykv)&#ye^|VZ+R!M zb^wtb!wa#ga<4B_f6)G;=o=h@uXqr&()eoPB0<9GcABcRJcDAT&L`^8M8n%aw9btb*HdQ{LeTSE+Jzggv6jdw*=$54W z=oUX>-6FM#VBg3zq|hqE%Z(T1y6Lq$fg>wd+oiIe{zc$1)wbPK(n01TW zC!1Dp#F$-?{jlhtwKF>2M=|Yi>%@&{+cw_DI^(E&qrzBWb&u8tP)S8_VbBr<2-jpB z=Hg8XV~}AZ6eSD(nA78x&tikQRHMv^kd5`u6z)&=*C@-Vm%o%}uT$~s@4J{-^aI12 zi&C0)K{j6Ho0R)B2Mx!@edDH30_QOpAJzluQG zk9K|!E}!xKN4IW&b0-uw<|6arfUr^F6p|y#KZKM%slPxxUeV07_FAlG*NGO+KL8rZ zFR85UEyt(KvBA{(ckwS0YR=Bm@OS)dUO9XhmGd%a4D|Q^T^G6MYYWr8_D<74H{vF5 zA)-zzUju*xwPCOHb$~^FF-qiQ;LgjaiOYNwTF#5fjHB&AYYzdP-)|}}cuR5i5W`dh zo)?=*>tx54nL6{kq#XPDWWHUH;MSNHcolZp|3^r4O2zvh(_vI!g*VP838=LBw+7?F zDhd5b)5EhGj4g&axYxWlhV~_UENb7Zh8sqA=hYyt{gc*x4F{Y`qC{n?7uR%(1vl*3$rv?qCp^o_wn|OH(5mXSDk3-SJ zBizdj3bHS2z~7*HY&T)c>}vdjqhL6KRDa@LRY+_>6x!G72~cQPaUa)cH+$SGWx7LwG&>-a zAy$bYpZYad?)Wm&rLstcYflJ2gXswn3mHyhq7;C#A1*$Es!4TLgj#*q>c1#^*>@?P+LZC8@59Fj5R2v@l6dal)U(1^^)gO>(e$i%20 zV}qtID^OC%PRz zY|&Zp{IdX)_F-JR@4&XxV2M%)nV(SSC_-$Y*3ptTWomhPjmBMt2wUMTbw}p3wqHb@ zGr|5&`ARp@^kz>v->59#LhXj?HwKbVv-{}C{OP%TTNvT)OQgz8TeA|5T^FXzoEoApWTI?=%hG~#Xtwo zxM7((%gSd~L_9wjgUTV{kCZA_$qyMtmcjtQIS^i$qw$;wa-8E___HLc8)b z)n7(q2`?)`7|JHqR@5*DGW7K55{#6+=J*g^3nXpRig(Mkx9ZeaNsQkG=O@&^+c|p> zgPgD|KRJpn7jDh5Pk+fbvCp1yO!U&l(h$#Uvrm8V3Xkhx5GAoGeM=!1gTL=X55L=` zz5^5w>xk3_^ckkaF!gjVsVnzE0wcBVU!*q0-NpZdew!~YyHDGl`aLDjRh7Q2ax-kb=?mbtrJqGgrc*ment zzn4@6)58W-mOZ4cvw}8K3VekK8PnC@8>8hOG_kFun!oUkI{4f7ZAve-jRw^vMk{SR zhSp!@E{XVbTKZNyn!li?!dah{{tG&#^Jr@-%A;<3uyGa}XuMh(14HQ~2WSHAFLBB_ z)l+Z0=lvrVxr=nOr${foLYY9}dQXM5?c542YK*j8DY4Wt^+q&A$bY9T%9h$7z+K|h z?rnj0TooapeaB^npH%{K{*-)7-8#-E6YSp_Y8oMg8#{Lzhi^@ucP zDluo?WydlIV5lG3iT8CDU(VIq{NcA|Soi6bhkaLw^pC|=T+f>GvDy6x7I*Ys+l=aFa@ljDRoevv!J#KoW*J1dUZC#6A`TmDH z73py{NcHkgtz86^P7UA01HITq4~u)_ykAHtrtS`$aWp(Jn%SLzPVoSCL%H7;dzXk_ za_^>XMK-?M5TpPhf;Vuz1a`osJ-Lun1o{G@fNJ!KzkVjhdpvn9b9*A0<#T{1Y zSJwX5UI&3FTDb!wIt;Xo3~UIU?3TqGGnB%xP(Ewgj#c+-frFY2&NpRaGFc?`)NoP4 zP@;VSpOyvCHC-+_8uTX`cWD7JfvoyeUsm&W)|(874Qoo0&$y+zS3ZE65xJ~M`Q(h> zh*miKp!36Lxr$7M7%7#dOHZ7uRds-Zyz;rLXCIc3hlMj^_hN)K1>(FTf^Vowdu0zx z41C`}T1IBhKWf+P?l=wv#sa8Bi?#D^q|wuiE&yyrDy@oy%dwBXlWQU|Of2!+b1@bVAcM|2-$-w_GLXIO!Cz68 z!ISirv}?RA{*XbEk-olkBSH3bcHNYo13;&(|Ca3ErXP~_fAB;joG5ym92n%kX{O`| zr0!Yeey`a|R4RnLf;YVJ2IzX`MVVn6jQEQ0k{DLHcm7c4aX>$ZQ8}H8e`j3_6b>^i z->~eNrrAI;&HW{?3*edX;+wCtJF_TI^l%wd=JAY&Wx| z!G}dO7bP^}3+g2vX(1J&n%m{3nX=i5qpD&jjzwiYV_Inn9U7XEWsEUtt;09ODgKb) zC&C|GeO4>`d-s%BI*qOc`iMZ&dVwYH)6aK(noE*}4-M@7=^)kdgVzP^9IC%1?c&^C zDA&xAWKI(}3FXi&P3~(vEeiVO2 zfP2|{H0z1~N^WeOzQc*%5Sz#sx!tc4yHD~Q6i;^85LZq)mKRcvQYTiBE>&H~0hYL* z!tQQEh$KFb!>0yZ>Z!1Ro7tj1wmdE=)m!2Y=(5-%jn7rLZjhqY^uD9wFt&jjcyqQG!(x4#w zW)=kfGx}!4P%2Zb;6f$atc~HlPD>Cq8=9f;8}&6#!)=l0V3jX=E>=`aa`ys-EWM@< z{LJw;kjQ%I_1ae(Eam!bGtgF<1$K8fGUTTlp(S<~a_cano(Wtfc%5QYYi}(xAW?Ry zuTjIyxP1Le|8A*ff214qr6{5H3u_ZcVc{Nj@OJn)hxUrqzAFzeRru;|Ak95#8p2H!t9&bfYdky3OZ;kQunS zxF5@~fm&5mONX)}s}EVHIm)t;x>mxF1{$iQ#S?OVe3kbPq^^_2O#NFSxb~0rog8sM zJKO9qOEAa*dM#yQ)46n?5o_9QTkst=pj{HPzPNHv^LEZn|Lv8IyApF{y9urn$gjK| zo|Pvr8c=S0!a{b#?>#&U=rcFwuU=TXlce$|#l2|%(pC*{hrijQY_}6u>}dB5a(+ZK zlO=!*epa__hNGztjBMU(Sx7?N>oIW%KT>FEIk5}==v=3L+CI6%>duuyJ;=u65zVAc zmAm@fB{WifUBhZy``pFOpvsR@*S4b2ed^!F56nx+@J(b!$dz1{-Zo{Hcz?m-cYG}p zxR#|T=ML?hSM3eh0|wNH)QFGXsfBXV&BW`Ip>zCBZ!f%&Hd>Eo9No28K3jMF7?ztF zQ7tE@3%&S=+$5LQEGgw&>t`7+mw8Kx)qX?si33OB8ryouKZ)6Q0EN?Qea~S7Exo^J zyKzZ&RmpOva_G+NqYpV}V3X@lju_$?s!8@JtGSs82?-lN<)~@?u_-O=*WQ2o92q6a zScW0cr3{EB(dk*&*4ojyVF|qRJdfEC+xUWz-u3b|$Q6YO9aBjyfH{%Z8(>M!(g|_R z{T?Xm0u{T7lP>pAcEL#=aQ)3vYRN7fID|QeteSqP+&{L{_QT zuF-ibDI??F)4Iq+wDa?HGe_2m@6xKDMa3+Zq+P~6H6#!_`?E~tUR_Qq`f{pRQq|)5 zi@AW8NO4~-pMZCuevijd`6hf%G|D{rg71lGmxdFn8Wp1@O%Hl=<~O^Rd8O9sd!9UM zQcx!fNQM`gn;2GPpBx{4=~?;DBm(;SJ>R{)fU^T~-TZy7UOl+#?2(#Np$;O}DwUXC zrdl&y&$41%93x10FO5Mmp89;cyZ$`&yf=57^`G-_l0y?bcMijA4h-gxCnhQuoOV5L zt{eq8JacqVtjyi?t}NDy;)D)iA^yAczKk?aX87anbgQ5SoU$d;@CaaWLIUJPKuXd_ z_a3yU`YnxMu*6vE2AK#>FBeWP3n8X9^IX(r4$BAm&PdzN%y@tKe{q^)*eO3Gq)k;SOq!lHXw0zX&|!Ok07x^*9{c_i-!f< zCKV&C8k51oVA^GI{&2hxK&*_=x*azwDv1fetN57k4**|BTDB z7w>Z#OANk0MeVOm-MmUAE%#UA0&0%aLmPum>Mb{%^gDb{=0QC#;PcP*NsN(*&uJ3S z9Ts38&kPtk27TzSLRuIIaGaj?BbAS@HW&)F8z;b{hOUFc1IbU!Kq&$ME9fxwRsF#R zD-Ru{FAFaD4^S|d*tAj*B2(5i+uugY;_|t$epDV@LV5Bs^ZvJH+(fV;a2;!h9QyF_ zf!jdTQJpVD2uD*1tco1S#=JV>DgD*e3{=yXt8n|!l~M(3N|@HEF57sU8iY3trNsz5 z)_yFsG12UK;$G}%spk5__+i#!P+(UkC-2kc}sG*2k07 zLm7>w+GjT6rfs=FIy5OZW|aP&cuRa1WV8%A^%skn>%sjT`m~XHP8pHMoEh?cSs%a) z`9Mp`SDsA?aw;oPK0me>W9AQ6f&MB{kNu*7t@920$HMm9{gJXbgDLsOKx#O%bih;u zvL9sfq~wA2BuPo^R$q22$aWm_9$$#~gkm$r;i9^no~yW zf(}FAaiXJj%<-0&pxW9(Vg_8)Q`17cdQG{QYJg4_8odWSsA+}jkTPZB10JtM!iw=9OgN|vz5dszDb<>?wvN=a~xoj8*{S* z?k`eR6igGpXhN+Jz78Ph?7K81b9?cPq1;5dqnu08cyqczd2XPVBVZ*y$3HfbIygfd zWeez9T=hwYU?QT&A1E-z>gL0r50i0TBXEf3Y#Hl)8zVn^Q1f}V{Qikn9-fI!6&oi; z^GrW-yw`*VdVwjQdr8j@LzfHjbo0x%qe%J5s5* z$qmM(gYM_MfD@|fq4de1_H$LZS@AGX-3K4JfQWBj@yCy@wn!vW>5sxTX4=ahGRInl zWdgIj#y5V%_514pN(U8#x9s{19d00oN;OOUr>L!1${Ak6s9__>hAZi=Ez%eLyLIbs z?poi*;e{l<@%1g7O1MMifW3o==hRQ*Z!>-QrKyWs>yIHeBp}tx%|O+KXloM4XV(xX z%4Rd>hFgz%C4>R=cw=*@+xn_3EpD-9y>4$CwEyjgC9bpovx36|{rN*>m*I#p;;j5F3lgaatWme|Cz1!;)$AmUn(Q>r7^5vAK+CKe7) z4wJ1~`CNGKLYLp~+)V>SHJD944z87KD#;+=2X>RyvGQ<_IjrrW#MdJhxm;Y!qy2}633TQc0zaTh=@q1qNgC2&l&ELXIsL|Jz3XCTQ4;4F0YaADj)I-n?W8{g>wf;tLO| z6ANv>as3e}*R=MgcbVw&%=$G!Leimz+c7eNTX%69rXkYO(s2VMQdQY;!cz_4)j8nb zRNlU!Z)WN7I>YzE142KxZ{~?ZT9$V;11yL4{~P`y*V-Gefa~ zHN)d>o~{@I@b24FjYtY`&>gtN4c$+CoP@3jnvD~&Kh+vo=V=?(y?dMy--HEj8Uet> zdG7R2g|$AdZQIA+5FJCWw&6%=R>sRSVc)xy7d5NK&`m0GD0PCQy(H+AfIphJ+3ps= ztTK|uf8`%EHc_jZmPS=!gYPdsPBIxu62fzi*>kSs4*)=Tz^D7|$MmyP^<8UvR+UXf zWXUXGl4VZqp#w|rhdvvOtk9Ye17vlu0(gIhHPPB-r=rKMDS zS}(U&nbuc*C(B2MfC|j-Pc)diNzc48YbTLB@Gh~4|5mm`v$xIQ?TM>0x+Z7N%VNev zn&ahs2NMnMB=tqPilkl0=IbN!iM&O7z^0NQTSni`!FDm(c|nO z$W8OOfC@gWx$7oBN72A8iP@`3O@WxbEut2j4dm=0mnvo<>&=G%k+Cop`&$cHZa+4z z)vBDKsc@_SM8t;$2zQc{PHtykUIsO_O6B6*je;{jlr>I=aH&G}zkk*g$k}Z0UC`E_ zYG8DlCj9?1I!D4k?ckgL5ssVF9~>L}#}WFSBwr5q-#?%EWpuh|={}u|KKWPnKlA*r zn7}&&KNUWZ~G&98Uf6Tjsb>S=aiNd}jUE<;>+ z24YxHi@FE+U9?KalT`qFkIfI-sB4prx-;9<8%?*lJjk8c@gM61r^|IPR&OX{-s~Se{J#2l!)n zrhJgW@9UBhJ6&Fl_TgV|uF*uemd8}TDoWS{vp$7V1zl}+&1}A94QZ~_+X^9AOa>aO zz4PHRmbc8LdR~FKd`Ismbcvk?zgSljqs@@Ef6OO!u`#!26zhy<)R?YWgl<2c2%wp! zktPDXOMKsnnhL$bEk!TjcvnuJ2;Q2w%@BP_Y#lz!zD&M1K?p}r&S*Ay;iao&DWy}C z(zhtM4sN_&KEH^VrsO^Tx_vQ(!S zqolnzKhFekY{+HBQWT4r(zw%?X!_ubC^7h z6u=?W0Bp=D#x=zR;t>_V{b5=e?*zB#rRCF^k+Gihnci&?0leZiF}5u2SoIFVGX zqkXf79Z&I6C?t|TBF-7VxO41$Tjp6K~OjN|fPL1GzrR_oh%v7)@INz#2I zmF=r)>E`Ul7qHBb>k213V$+*IcB@Jlj{2g;JVZB;x_wtD93AA8@b=;jf- zW*!7Ph*`d@CK|gcm#L8K@?ywwFcnH-r_LftlkB_iAE1so{FBQDKs3h#QX@ZRrC+t!PXrv_%@h zr3(7T?|UNqAh{sJJF(be)d(5nt-NriNp%)j+JdEGL+W1pJ`$Cu)`;KY&LMovuHQjP zQNg^o8)WK3!x}mntCPri80JcG2vud>uoLHS--$7~tnQl&q0Sx}&&dIAG-ZE+cc_e$ z_5iL;@;X1hYRoaW8;Bx5}3CF1Z)b~HrBEbSc?1v|8Z%+%XusXSc3g}MKJ*9*h$Z> zrhY^&?PROkoWv|_mEI>#zgjN4xNxp8WOgqGQOi>AiG_tB@#ZA{jYH!kt4OQ+ue1F1 zh$KQSB;VZl?Kx%Zk;QylY;%e>yPHq+&^8HXen^C{qivy_Gge+QR5QAh(l9m%(uZ*Y zAMRsAYU3PJJ*jjX%VA;>bA zzaQ)O(Y_V998vc+6^X9mGqh-rOcz``p$J1Myd?fKt zfElr5(aC3g%m=QwU%^JgHbDRK#Ry^(!w(7mR(BhAWL1!m|G;wGLEX^uhMC-O37<{; zl-YhWaqCdZ>=G@YF|QzW`>fJw*fE!vXA6M@Lm;p!XZR8+m+!ZuH)+>SYYZ2KuY4RUiu0F2 zSip(#rs{U`FO%lDOTOm|_c^0-B&v0NP-mR4blydl#D;V8@4V!Ft>lkZr^tM+4)pUj zMU-*5X)>^bsQRwDeGgmWAeEJs+@4#bS+&a2GBS+v<+{1r#bU!x!cBich_0n6g*I#u zje88K{!mGnz8o*QLZYsR|H}%eG`0v3-YZ)Gfh?l40QPth@}I(9m$LPpB^a08!G#h-cLVDlCv^?>TG6VJ06cqd_>AU4g{5zFcIsbL7iz*_BFal zKSN~0*{Hvvx`)gO)mULTdf%gfw5SQUelG6!CmjXkfk<$0K+(yDxu}o!stH@-frW=J z2!S$lEJgR)^8(hokfrd*$o;_g?-H!!t*#_o3R&S%x>E4*#3`#Q0>y9#FeBhNtXsv^ z!{hyTc=LI2;)`w^+%+%<7m zgZ1a$SrMq#99n}0bTd z8O}R2xiuaK|1_GKws=YZ#s1$l|L!I{#CnFNm~r3Q#Z)Y-o6$GZ-!Ff>M!eMbT} zd0faEN(e{j-jN+-vHCBLvA!-({i`=O*$9PC!uaV!809c!8SRH_x-4(BH>k9NN~XO1 z3FOd@&NTea=R@UnUpmU$oXVMUKUS9-y@!yeZRUN@&daV=$Y2V+fFAVGI5Zdsc&=U? zK2&$1EUb@GhkT@9{TRRL>IKvR2QrwL!TL; z!{1w1sMvYikMScCr}~c#c|q?(Sf-&%Cc|D{T04U~Y4Encx}>92Q0dhR zc#bsP_}-lm^nKL7c=cc@VrEJ&%-s^ zIJ-gPn+|;h?uimn8EG6pM81m+dE_PM-=l|y$BZv>m)H}fz&)g!X|e>yc^t^*5N!uh zMr14U^KhDe3;a6h%gGe8~YR%pE89r!+X2Esz(}u!#f9A<`1<{9n)0hXWG$@ZUkE%`7L9RDX+w z7_A@;vGsXlQtrCl7Q<*B44w!_Cs5bb2_KC8@NB)NROXGlw&oyu8B|V0|N7l#DZJE7 zHJ-dv3nl6N;BiS`X|3$&A=o?!KXTDVvhR%JapPz%rK{jMbpp>M&%{|F!i=^Gfc_fQ z*{_u>e|QpPDLB37v^tvNMe+~?li|ulHRD=VeJNYwL{hWCagy} zp^0u>ib!Z&WLp;%!*?!S6d){SV(-VGKdZByJ}txB*%;9}SA?$`Kpy`*EZKW|lHsD{ ze5-unMs@M}YL~u+McpL#rz z)wKA_oO_$%p`6p4vvr_NnYri}dp+HRH3dQPc~&b@xYgG>8gBnWKS{RnkE(WI;7J8*;yO{$)_ zDJ`Is#;7YTeE=24KUd2SsRe@+2Z86Co`IpYVc`(4f{w>UJ%|pmsZ8>!n6l1fc@0Pm zWjo$MV}rWuY}DfdnPyRryx^s$%@TVS28Z)kPxlf`o}W8am#9IDMaYic7$;>?^LKnSlj`!jb~KEBj6zO$a|aLJ?^^@bJhLFxdZb@t<>9#+^xaAm#AFMsfpn zuZaqiT2AcuJnwZ;N}`)KzxY`_{MDl+rt-#4JidsKr&~Bo9vjmDV|=7%h?9RiE5)dN zG*+x*XV*J^5wTVmT|g+~!(XSq4q|_ST^D-4woz##xA9h>-*aUcuOFpX4cxL*Z+gJW z2%YDTxTm>)XuUDd|072v0HbKp0Y*=_sqn5_j)1)CyHrp=oh9lS3JLI9l78lQe)FT;8LJ+Ar6Zx-C8WbIqdf_tZFKTe5e2 zuCBd~YW2>YzN#5N87l3bp0oIalK~FZzxsA`qId=Bd9CeuPkNyAfiYuE%b4;i0F0Vb_r$mKc`!QN=;pyRbhZy6J00k= zimE;n1bm6AW~bxMVyOpURb5FA%D+%v+$~#kH8Eo1#qwS!s*a;vn4Q=Ywj=9az`Po> zuIKXnq~4Q8N75_~Pnz?E^UH-TmLCrn+LC_Utism~ixzn(=v)q_&HW z86D#|v4^klUDekus>oV@=oX!k1-n73#u ze768Z=v7~fmz1en(1&@XV^LCqHJi`Q_*~|aA2?P;~<`il9`a6 zbUitL<3eaU;EBaMs<-x%o(dB_{+?<>4`vU&R|XdCrUIGOUq$)HtJ-7)xB$=s#4v)p zPOL7s?>NYwQbT>aJpL0ryIf<()ZKT>$$^0%TGz9kdU#iA@x9L~x1e{c(2_dtAYw@~ z-ES!lXEGnLMkT=FobmX}+K|FEjcAvvXy~0~exSI)h&KB2% zMK<;c64tG{=GyNV7e{H!{srG(#ZW_O1(8ub-2Gwa!Mh=mk|@0Gn;wUPM%*Ow597sa zu5Lzj9zSS%>eEh*O8ngz5s`?Qx0m@`Ot1yLIfyOqHeMGd`kl7-D`lE~#B`T5MjbjH zef|Ue9$9i6By5nG|9elv1=@jbOv`De-@2)vn{X(!hn=5SS;BZCN+{0zZ6{pfOhi~S znbpQ<-;S{CK^C-fHmK&*Q$9jjn-%{N|N7A}hg`sOT2B2*99kC{HE?>UbM|f$^-$Hp zw5rX6Soj9ts`l}CpfJ2l4Y0Y3O#9(Mib6pX6BFsylQ!VzHsI{xHWRXmCpjA}%B!Pp zY=w7vS+>;d1e5R(j!nangKEsuXYD<fD@T~h-V zDF;M|I$2dyQ)7ADLSUv`@Q*M%M}jZcf(Ee5KMU0<`4AhZS95aLaMX{%!RoZA@oQVI zivRmAz&vMq3${bq=~5=bf9;Lf^yK=cL*U$k0Ebn9hFYkcOW#4l&aO2(ff*2?38`@y zTe^F?;$_t|V@{sL!-ruV$bBZ(;VusPc_17z*TUKa9oa>_G{=u_+3%mew;9p(xS~{g z;*u4NGX~fn?6)?#6K1>G{|H~WH|p|9uU^rc2jRMaJf|RFKR7~-Iec%?L90AAmpw^g^+V$jZb)?G?5Qfm zY8rUfz+D2D_rBMysw`^UcQ-*})InH_{d%g9Q8^!PW3yG7dM<2Z*<^_Fjm{3g@VL-f z>a73p_Mwv34CZnpXky)p1lt7h@}?L)%dwG!!>Fb zA;ha~%G2I)!8=AqlbVK*DgNIXOqaW}6I&j4zE*y5&`r++Cv40{#6UkL8rHF$)U|PE zPlh#e#Tuj2Kz2M^pKG~MNHdUWJ1}N9c)>F0P}p)4`*xMH>0v*#bIIm*(xLL#TgMw3 z8@k#hU%TgJlSZ+q3|S3)OCKWIzdHb1I)IN;ZeyJC5a~O6t=T0LThoD~f+3h~(w;dV zF?;#}%f`Emv~k~u{azym9zkwjqU$o)=Y&2s&aq5g0%@OPEDeiwJ=o45=qKG0zav%1 z{*<`vNxKH}bnDN+z`~xQCpxe8y_AFQwAkTXpa;DBI;<^VO2S2#a(`Z?oY_I+3pH2_ z#8&iZVEy?)No(dzchlJ-nX`Al>VpXIkJ&;b*2?EEn+=21p9dpFhZCbsOUXgDn>E78 zv?{iyE#|`Is)1xn*WPHAI<{6Ib8*X=bx&DTZJt*8}Wt|q7PUt-=gG3 zz(;U>W$=_pH*z{+Psh2b&`E9|RRN+F1Ym+*E$B)y0mn0@r|7vQ#Uh^o81gRM(Qf~5&{4TCet zpo&2?{q2zfdY9lwXOq^6P>YG zB;VI?<_3s;2>{0l(uXQadZ|ewAfq~fwwnCPV#j{)i5GGd@=VkTUiy9t%$>A#j*!-^ zoXy#GL#Z+%1j!PG)XzqEj6@S-3*3sx58{)d15eF#R-qODgPf^(@o0_TS1+TjP#6 z53l{00SVH~?`u#XF0mnB4vRIARrBcKN0#4{n#m>+iM!3A=1DUJb{cYnXSGS%=FFrMR7& z7qY>_dz`TmMc?@C3J}*8z?zv}A)JcXORJ)c(JJDA%AtKc3XZqu%y`w+1?jG*1)T1u z!P_rfKPsv8YfIX;j%E+LSS0^tp?i_ITIfJ>ycN5|_h>;*9OJ$AQQa6Av+fSa)W~_p zM=%#Dz~#jmyAJNKjlj}yN`om7coa1&f!Q}qp{pSbMLM$Fg!IFSfxelDs)cCC%Uap` z=nMQVF9nLt(jt*R-_6~a`kzPyJlh#ElB&qpQ^$sC6tmaXcW~Fg;(E435%8*x8ilZ*a&YwKfi*1 zYsET#3;fT?j|GeX>b~0Rr7|fGpVl2(F^EbGx^rg=C5e)93>po13MTg8$YPv;jp>$8 zGrsl6^T-F}A>jN;$Y2^oCkYJm`~Ghls*4}&VP5)=$YB~vtL1ri_;0ZaqRN?Pn<9o= z&nm{-Bsl&&egI=Bos@G7q+&mVLjTXK3D6|TV~NPu>89T+Pw@C%;`-|#mWM#TCH}qr zpc%#aK)>nZteRvPM!_UC%r}pe4_;KYIjx>(JNh@w18JSx&?J~sA?p1Dl0EOgdV?^% z&h`t3Ab(HJhUW}xXl;^2Dub0A!JYW`!nW^!&B81<*tmA#vGXha|3nU*q!6TrApQht zx&8m>_S|f@{G`)&;`2VBbd&KVSW^8fFzZ=&MJem-k zZ$ABBvc5=bzu-4>>^o%`FY#}-FRbs>f6Yx(LvHzB7SGCfW_Ql#|A_e_U`^f6Vp1=1 zSLrkX6pJ9G271w3OYX5^yTIN+V>$ytLY@U3gFn$W!IWE_i=ia zKp8%0`Xp&UO&R9tITBMO^zKXP^&>V>MS@*e00`6L)*eX{!~<>8={?M_;wWW4Ao(58 zfP;#oAg|OpH4x`dPJZIqR|Az3Tk3w~jJYGyBh#fttA8Y9&&4r{t-EOXZLWiGPIZKj zd9#6_A_@(X`=FZtUyTpZI+gdWnw`y~E`OB3&z);4<>OQXfG|8=)06^jRg_L)y&s4r6(egZhZp8@(2E^FC&8HRJ{@e^@VR7)(h;> zLy3Lco|v<@_=KAoc+7m>ih!zk+xK4!ck?y(1+7SYml)5@GRX1}4**y`FmFClGjU7? zNHYdMfL90Qa{+sT?g9LeKQVUiW!3ZF_+Hc8&ykuXuN;T{Ek6Qu6ZrTptAf*l37*$} zUTHw_u&P)DSU#gG1`N_5=`gM<`y(jqkDivG>cn^ekT=O4B&3TKHcRq?lIj62cz*Dd z_ZTwIv35cJG6ILe3m}j@+H$yZrKN0ZYs(790}cfe1?iZL&$8gofNKO520ol^2ImH! zKMU6t1woKnLCJBj0kZ$Z5_om<3ON3St-4RS*R+sq)aL*GtSrD$9r(@ZziVxZEX-$r zBkPlWAYvkbeAjLQnA34mCQb$i0MGVe{FFVC^9Pj10CR$rlg_@Fy5(ENKtEnnJ2Z1g_ zKp>LvE0=*s=m&|LAkbKfnxfnb@2U0ds~=32G7hiHSSk11wpz{{aBb7Ec6pN(Eqpsf zRvxN`X^n0!<B&(!N0+_Lkk2frIk}5LZ%Q4`x;NuD&Q5bma{Q2HC7Rivb@!`$F%VB+5dXh^P0SOpxoo${Z0MHQ zX7BuWD7QkL+-+|DACLU-VreJTP+SdM?{ZF*!F7DH%uEI# zonEBu>|Jv?;toUE)t0A!wO}81-fe^=93g0$v(sj(SJUv;>TG$^bN{|elBOd{kc6|g z7a0!jZC>P)mr$Y@O7CbE32fu6Q)cqhlBxIDn)b_O;m@gk{bJ}aq^JH`rSEEE8gjSQ z_{lYC8|=To&c9iKR;1|TB-y`&r|^kyQD#SyYEN9}$?HXu z{WI{8j^>fO1zpizPArmtH@PpTR|I+55Jc^BocyL35JnHP+VEodMFn4 ziY(V(3XWQs-AzBYtrUtJyW=D0>90;am6^F{=uv*G0d*B#{4~EaL_+k@@Fi4;Y#!$a zSu@V6>%F_68=eDn94%dX$) zw)K*dKFX3vK9P&!W1AAG*~pyS)KiDI{zJd|;imCuU+xhP!yqKgm*_6(NoJjz_#N7&yYtAW zqS3SCWuTX}k0Z78tyuquX-iNa*1U)qJ?iU^$xdL~J-IVb@0qU59!qYk3YSw(v~pP4 z_HO9PMrATjW>ed?htD7`KX~;EO?T;EmB&p&0dr-Y^;yjVTK)M2VhhD%%taQnHus$5 z3-6sD*2iaxAMJS9t7Hj{ca-*vAnunKz6l&d>T8IhEbgR6&p!No*1mS4VwNN-I@Ry~ zn)#WFcjl4-*ZY!HZ7K;|lOAjfzhV&7_{7r>BThrpv$7#D;pF_Oa+k+xwT6f*Do6Xy z;G5h;7xTI6uCaP{HR=3bGq0{;+%;a0%PL*!Qe=e4I|c1!bh>S|+}&rCCqQ=je34+zA9cYfz#@65~fj5Y;+B#t)=?LFJ-!bmbnxzS#IF4E3=)QIi4t%j;JuaHr}`=v02xn>|l@u6A+4Fhkin$v$cidOPVxE za**dJ$9_>K`tgHmug@9o#EhqHQVN;w4y(b+XvZ_+@yDB)k7D6{deIZz)%tOLelN5d z?o}1pQ^bw#QW0jFtS`&B`q(e!F&+_vYMrLj)@+Z}al@pF@EaBgA^muq$#YAb3bbN& zkEI}ts@&vb=|ebfP>TA#N7`o&Uvwcl>z*B#=~#in2T`VI^op0v!y9`DsTJt8&6>pE znsGhSLDAz;S_94!RKlz_zYaxTXL1W(#Y$7=H2*3mRHNq6LrvIVuO42+qiYx)`8x*- z{W>*z{baG)m^&Qja{f!;8ye}#8LFZS8x$VH%`=;FM?z864kx9OIuv#w<5 z-3sVzG^_tWp(Aol#*1Y8roduRw8_PJmGfwU&UgPnvoX(ZN)S@p?+)>K_Y6#(!PuF8 zrrHiff$7$U%rN7BYcDICEX{VG27NmX*v}^7+ijMp+Wa0jX@5bVoNS#XHa6Y4K{8@n zv6Rb?zW3UH{-1MtF6`alqTqR?MLBJmj?9PlE=~s(tJk+_z zJaSuJ4@5P1q~IPfd8{d3hZxz`cSnO!{==$heGImLdc6+w#fJ%%AFBxWlxergq3oB7 z=x3Wg+oTEeGX7S) z!@IvyittTa)re*4PpeeV&XLbyS9X;-T{9VeIxgR)q0UzLlEBvz5Rhkv*UZgTAEGrb{_}%DDXmmvX?&RqJr{M z*p)SCd*$Q~mu2fM7lVGA^w?}C1 zk`?(+6tVhdm++PEHy}UoNTcy_uU`V?^&V5_5Ax&mUT%a}OOfi5b2mE2InOi*9yFnd zJAr}=;Uwp+Zl#%%d2YXu9|w1^Re`WAD@LtEMv6kLFPzzhLq{?DvbPO{T}7^|cws#97K#Jy zDJzj{J}tp!0Pb5X#5dseQTeHErei_{0j$7T0E*MI7heK@=8PPpYLX=x4#o>Su?q{@ zz%p$7D!L9~^4MdXv0l}xt|pJcJs7%{qG7M71h;A0{Kh4IIy~{ydGD{@{IJ27+jRjZ zNT-HM*LYz$_F8E~n}rlM7Uf-DXn50Q&m?}a-sx+zIiIb8j9zkQNAB;bIG}mJ@ zS6m$9Gr)#1;9M_AjXIK4$Xse9E)P* z>9oh8^lAP)^iTRmd=%Ig-<^&He#RFYeyn>`pwQ%LEQR6_#Eg`;|DCF9o8FLeS3=Kr zX~RmF%DMWT_oDAW-=3Z<%n};h27~@SCKC_T`Jmpyv@t3Z zM@^$MVe2b_GcyV)h3&oxJK@l-o9C9yb&usoaa1Lt(M8mnT?(*U;`00L0yMzpkQ?9H zA?D0EJRtTxtqaSgiVQEKmz5*%2|Dw%`($=c;rZn5e%RY)lICN>zu9Q|&M5e{sUz3g zaPGmQ7%#+2B%#2(#C~*P5=)`mnrXY)I;;f$#!o*C#f|jDD0}!a&z6Te&Qr`4R%Ypv z9=6I6$`ZJRm!Ry^A$?zo7KDy+0zJIT@}hTKhdCz_iS?^=yF26>_%_HO`4^G!YxyB z9~Ni^IUlI3uzY-Ybq(5jZtg-7a>d$VwC`_iUG8n+9U~u+8<0Jn3hTeQx;CtWD}>{O zpn(p$N2g00OXZ}>2eyR;zaaLxw^>~`CCvA8M*8~I#*v@>ZGQfCN?TI+_^3&r@IXh2 zB3|)@-R2Tg=p)gF(aG!j+>Nsw8B;^w`=plKJ@^TDQ4e0*%$>>YH1qwI`pem?zGKGO zy?HGq*v7}S3w!Y`quu6~gfZEztp&M?HKNySkKumO>OCnxf&M>XZLxbnmUF{${l}VG zO_i7+@4E12q0@1{+Q%QL3k>Ovq`7DzH7&S=c!m8@ebI&InBC*LStkD~MP$*AtNm8M zmqYpfrpP|kpxj+r$nyX`FZI^N{=a;r)qdO!84u>g3lELTzYKh?e0tm_E+&?%LU>St zODcZGGUnrpEG!617(GlCDsM#=7}<`g8{bCddo~N)PlOjJJZ~%03sOPX`KkoX<&2xI zgCD?JkJTxv^ENL`EeFa-Cof)1*a=H6CYp55rp)#050^Rthi`J6m3 zEQU1}3w@0Z(lkrmpIRElU$Y9|H+wq$C)s(n!8CZ=F#*8AgQ));wAH=)?S3uGfwPt6 zyz-Cx0^W>z9r%s7=-gdmE*G7ECkrL{%h5s$hgEVg=DpxdWkbn%GgXf?yBqJl=nBiM zF)p63EKAHI&`SClzA7;39N)72&SIK-7@y_8`RS(AH!w+dN-)~bUlYBuSrvviDrld% z?WO-|)?Mje5Yv&VZ?w#NpsYwCv#O0?lkw>Om~Soi zHgiIcLCr_OQ--nWr1dVYp=y77Rpw~c_GSyy*_o6M>GI|A!9jRAuRUh#=ufJBq{0gz z@%TUdqI&x6crGdj84KQ@9S}L3T`A~#nI3J+0{g6>w)6pN;}0)qvs^+}ywQt1TB&ZE z_nTWnXGeTq-yV;lhtvo-oZvE!{fx2F8hZM+pVE=#t6W1pCC=7)!T>R-IQ4Ruk*!>xU4qQm+8Ey&QrjxjBNb{b=Kn#4__RozY%Et9AVC zHp7nwJJ~#+onL@IyFVV#pupT;!#{;IvAJq~%UDS=gW37I>zz}cC zoe@Qz8D=V(8mtkO@s;N}IzC^{KdUg^grrRU++S@u6`b;H$w}5|J@(SRW6>&NUsn-_ zOPY2nPwi0vCWIOJ-2RTmsN6CCNj|10sJc90jY8dIZZ&1U(p+v^$Lz3La5bO{UJ!Pk z0PXb9=Sz&-So_k3;tu6p!R?lhBP#2GLvo1J!oVnJpNHb>KkFsn2u=LbdjqACyc}}- zPCQWWbM?>7t!1S~9ubGNWp4s$QkFU!ggQiEq4dK!J&~VBoUS0gF((9;Bthk4b54tO?Gpg0sr_1Z!k&c?J z${!vCrL>G6^UAECJFG@G1xJhZe>jyqUj>20v&Q_-W>?^TAHB~4yO(NwY!jckx~jsw z+J26sazyq-gEahurz`X~{VUN|BR$pl?uGTyiRbsVlLJmKgj@E;?2DL$?G>(fveH4vl7(JVUi@&+ z$hD(=pYo;Sa%IJRD#%n@_)s?u+Rt!Zl0N3oy^x z61EnS@fy2zeZ%i-E5VQ{omgfDs;S4grx zuDX%#Y&VLrkbEz6qOs~i)_@c`9@`mj{}bR0C+H*njTW2B^;r{-VOP3og9f@xn>ov! z_qO?=BYuHu8|%L4zq5o-n!X+h<@8d5ITqpzNH(mOkc0bKXLRj(whO|hJ^gYsKl*Dx zBs~0@5M|9&WSIe?d*6p^Y2a(sC$pB8eflLI^F))y&H8ufN|0mK%huNh<#H=3oCKXp zi{kVtro(*i`b?&p`pkvJjV}Qsyb}#H{NiQ1oH+>B<;(KARDpXlMTq35rGx6V-!S+BJaUx<_6eLxq;ER!HM3V4a$z31zmn7N@Y6Q zOcq$?SJg8VL9H$GqX%9MmyMawd9z&4SlSdSbWj{n=vN(7{%mu`@~ng@4EO%gx3MeF z$>VDRxgN#qXCJyQWwBN(S48SMmrx9Ay|aRHc}ywdD|%9Ok6&Fo+|^a&tNgYXocVf9 z;!aC1Z45ZjIAwU$7q7$=B$3SyRXy2*(Bz6D`vLY@nm=9P#3r!W_p=7&@$jz9hTmAQI^!Csy%01x|G!diDM z3Sz*9aFGJiOC|Au6cp#tdP5*FzlF|c(wrNU05$BjJQK*p2(TAyiZZC8+Hcz~ExfqLxdFUv9>!&G3 zcD5y!F@0!pyA(Hf+UmdeeYA^MM2NIoWDaP_G>GvF+@G4XJ?^!O2J=$9oAOj!>TRrS zJ)V5UAol{cbrfQ0d)(@uB_h#vXny36vSThb6E}DYi(3A7Z?xem%cZ$wsN^>yHJzJD z;?%oW&GQB;bG;rE-k+^i4mr{+)R&m>4>OKXV!Anw9@!_V3ml|2LLV-$tkfFAw$#fv zIh_0o6Ltzb=>o{j#jzHO@OuLsBlM6TecY?V93oT&lsLZ-wNQX`1hAZpc_dIbtbDQi zU_)k~qKic_Kt5)y3_2Dll)R6wh2C_gDgLxtIGU)b$q7A@+&zocf3m3Bl!i;PdjH~s z!3mF(j4ls!xHI!Wbv3Vi-wYOu%bVLER9#W73(uC=UyVy_x?GOs?$7ZKA$>W@Xu-6R zrafMkMWuoq|0lLU*xi74uJ@*i%4$kMASwomRrKi&7L=3$$<4;|rU zTTMmn_faTbe8ac-E}}b93c#>0jz+?8lllf>p zQ>O%G)K#rO;lBwWM;TVC7t~Yh*xEZ6uyN$fPaUpt9Jx*UrNxIampich9RKBAHQNk2aS_$jd{ z#q-sC)`lWeh`lwJM21(XvwVw}thM#Ehy3re#y>Q@EWs>|M}4CP6Ya2w5Gd)3SL)fL zaMOpsCZ7qQr(x0L^t@kT@eOhJ@KHJyea!#&% zDaFxxem%mPuMv*FPo(R%CI9W0yGq6SjQkK{1JKj`z8U^LxMvKww(s98aS4?lP#kk5 z3JheSILp=-TCgq5)W_`&bijV!6#r_IFGjS@EG5h|-f91EeYh{ZtSyH&*K1#`_26v{ zLPF>{)^u~o+>u`V?tZrS8S?|d0V78~t+%Lrv*rq*V8no33B~%Skny^WtIFDIQ$~2S z?IUzyU1cQ}9YDwQ9&bsU4TAUPK5dm`y$Lz-TRb(y%?|l1a6m@_|H&G4J*~U@ig2T) zsBjQ|Sk9{bZm{&Szphe0l?;zcfWiJMrFm5;n=+<_?njGd4FA-F+P=oY!)W}jDkpR# zHa#@xo&}q3NxkVM<%-v-eUOj*L&PF*?$<)?crTf|FW6GeDV2qupgZe+w{AUZ-pSN#bo__4n_;0)I2#S}NRCzWe-k!2EC&j`pq^Ea}dA_V|5Gyoaj> zuOv`g7%!V2!G~99A>VI8MC8Y0p6Y#UBgG(LTZLNkCn_e@1qxBTIfG7tw~+Cbb>l?0 z*;cJO$Ko(Hotsl8aX>g`2e$730m%4Jtc#Mky$?A$>k^E|<$e7ro~~avq*Cr>G8%)M z+f8&1F75O;`BVB~cRC=KHtIT1nP5$yy>`Av4UZb(l|-Y#t{is13iPvGt^SDk78xK2 z)H?9rpG)er$n}qXzlm&yod(O!9k4Cc?s&Iu@cISJ)OIrQe5YpShVmR)uOwFKZI%i5 zC-&(Q=maQ6Umds(;q+v>#Vc^y_+!KOG#fV{cBwSBeK?~fQlPS+Ec|1^Zw!S-=^-P@ zOZ}DuuXeY~^v108#z%>5;xe?P4CENmkEq)*)O2kA*w=fRcgrq_ViTJTy|G6Q^m72A z${gdf-hS!b(3jM@9YqGnPz^d|$NL2=s$aBr75}NO!b>o)zB*CJ?;u}|dBEtov3}vV zQRzJ&HNwKyocMU(U)O@J@hWp7Nl&9;V4)Tnier;7YG++C$$E$wr-!Vja!zdqpm>O? zvV&5J{Zs+W#7E|&0{}x*A@h;?Ni_>_!<4?puYucuN~Q?+-G0tPA>4r8He-i&G?cxx z7x(D@(P*;QJ>~eG3Cw%48qXW6>GK(TLkQtxT39$?C*DUjAYM9b)gt=w`yZt3kc<*hGcg+Iw}mu5$+T86hV`xn3?E$hBNGk^ozj(hRcBReQBj_0B5v7I zX>VnYp^kwXyj82&?P?`>w=X9qW z)WcaNxMo$l(SU^ExQ8Kj$qM}w)EKn5>K=r$r$3_WUsf@Ne;e+gq;(BQhb{<>bS-J4~s-I}I2EO|;)I2;@3Hs}% zXfVcm=uMOB{$yL^86C>PA(Sop&XPH0z4DOVr{0Z+g%Ve0P;=Icd=@oIaQ-khi((qj z_&pTNCRODls(|JwdXHr|3LG9i^ANr6*N7OX+3EKkccNBKva3Pc9)LA#u%`C+%5n#a zGla9ZTB~$Y=X>21DOT@J{iLswp3BU2rr9yVk_{+sRVUw7wO9U-dpBJ$7eF%?k!^%jsIj8M13S=^%jJQO0W-K1xAA(c<{$&$B!Bn z;v4ZL$_?#BYrrwR zusi2WR|oM|oHNZlBWwNlW1e;03$uf8%Upgs?r=!hYW6^0+XPI(>nmK<8nX_|->H${ zO?O0BSpS%eghLa`{O^Y%^dIqnqZGX`8p$?G-1+J=Bz2ppSgwCy1#CI)t>}3HF_(#f zWX}(9spB?Dwl)smG=X$vv>`Ec65Cwe!m<0D>aju4zj2qhig+nbrOGg z+x3s-?@=p7vrV+}3j72NNfslsW}nP>)~lBvbu9z?TYphx;Rsu{QwV^9WX%Xx8LAkx^^) z`s9zeMa!s1jrrn<_KGB?!D2>Rr8P^`E)#mz8dbn9*nt0iy&!CGJy?wUPkanmep}+K zo*ACbXDuVya#|1Qi*$_d5o}G~qk~OU=`xSs(%B9aI~K$+8vlfHtMUZqw!I#hd!yxS ze1M2K-6ei^SM>p;U%J1s|KBcn&3>!oMx`UsJDAT1HY{ddvaD(UNZ{Z08e_&f^EzbotoXs)d zY;u|POKhDj$FC_bJ|2j=#SonpT-{Mr&GI~Q#Xpo@LLFENr}o@3zyIATm$evQ-ZDqI z@J7)8Z}xr^cxAIEu{UU(kQjGMJm{1&lRi-#ON0J%(z}>!?gdC(R-7is9lGBNx@Md% zch7209}!y7hWK&s+R-HN$?Cjh@n;yuAQ`PRm>}Q;_Q0H^=tRy_+zFn8W89OwBC*5 zstf(e^S&~-y4Y>Rq?1shLMSs@`c*in9Th2d1OfbLHX-EV!txl!$r`xR$RTqrur9=( z&L+1fTZ_UYi)Omfb)wT9xkB?;0NP77T<>fo<9$4Xh7K={7!d=x7KZ0F@WU4SPjbOV zV+3jUrH}bwe+)_}zoSPtH2M#t=^JbdLj1LpA}pyY{J{-`Q8I2=&UH^%d66iOji!eP zdKKp^!WZ}$Fn(-)Pf~Gn!wQPo=4;|BlECt|)vjE*2TY;Yes41QB99qSQn0!oYv;X^ zhvmuN1J1Y2dv<7&*Hyr`X-n5~nuH$B{pAdx?yU;Wb!1;mBE0X2L!yy>Z+nxgRfui) zAE}%}WjZK}{9Oa~$^h@T)=gHgzrcRLe^QGl9xSJc6k4>pe?7@^e0PNo5!m3ff8br| z{klJi9R;BB3(Sc7HYOgV|IxQ((6u1UO4J*RI!=9=tPIoMB2ltgSOsw_haV>5Wao@C z6JG6zTr|PtLN2m;Wtq#*>nhjCDeoy#@V?ygu{=U1>QUz+FI9OV-< z-U7`gM`OjDA0(dvJuE0|l^lxR(yy7NMMe+t7%R&!oJzOw7Lt`c%L^(?TTExX!FVO!o2(27NWJ%RsRHN`$`l9o`)V zy`RXE+Cja|4}1Z2`*Oye&(_OVRMt5M1UK5qbT7*WkjUm--r)gSrnEUo2~@!?RF1{W zN)#;L-pJf8)dKYn%d(OKPx8KH54x1(l}{=M>$omaK&qV!@?pwzFV!#a#|~uHhr9;` zS9VcWiBo=_y4rUB{u+#pQmJ5N@-R z;u^?M_#Z!_P#MY43kM5$-T113gHYot-@|eZlLTSeg6SeoQ94e+i$eUW8uv zN(_W*n^c9%GYMj=E85CP6wT1c5XHyUAU#0jGW(zo4VTt=^7Q1Vs( zI74hVO`XKdIyCWUZR*^Q-^779V7CN4U^$yXRD0I!Mih+)OXVgu6McJgu*^QjpjRfq zFypvCkFIu{5O$IG?*7w?_T6%Gy7Ao&)vLIBXS)r}6q#2IP1|4c2lDaKPBh(5RksW~ zWwmY>UO&`huu4}ivgbERUC6?0Y;4RF&ISU*@-NE9Pw3-2qrqAP%V;n|!qEAQ7v>a6 z6(Qf9Ha>~Jw4nh|okkNUw$t1vj#HSIQ>XP6=+7b!2hcYh(DgdB)49RQgUFAUK)Z!C|E$bbe?UM$)z>}K>-9nbo}j^6 z?chtGPhknhd6g6&ydQjL4n8JpVivmd>8>WsrJB}>KfS-b)}=b)>CKY^`FxkCvHr!x zHI2Y;W9Xf1h8&l6uc<(`-`HJ0IOIsjt#NwKrCH8qZHv&X?LpzPbh8+#RkvgL(v`&*@K&MSx6W9^Uik z!^6KGbx343p@^BE<<_RTy`M=_BFTCmz`z_%hBuVH)^mILp`ItI!N@M@np zpz)SNeLu!$9&##D7zb7hV4Sy!1_J0^-rsTWghM=1&G9+JlIccP*otS_si0c2(AjvV zK4s28;c)RYKk+3&P#m+!^9Yh_py0?SNnV%O&x|vYXFwy|gRMZ`DXlikMjhuOvF@vH z5705)3+W~*)p%{`2e4}n=T%^p_3}a^2uf``TgvYM^ZRWQ^mt~Qd4mb7`u3~X`sx^U zEO?bRJtKc^Vrhpl-;x_D$@tT0( z#TeWF!g!WzS8MZjj`KLt$YuTPm|BU#I$>2VNZM-vZ{OOC)SB8!-8_=oIIr@Yv`m-r zL`yr5ON&2iOA@mui$98YfRV%zr>NFcj9mlGLftPv=XblT}~_l28Yc zw1Ws5{EW*3pFA&v8F*_F-fW$Z`sDO4&E9YbOjhouK&!xV?4QU9pgIpu(HBZjXtPH? z?yJdbg|8d@18IZjVyI5Uxo`Uyu)ndFy}aZ|e$clwz}iqJjYrgDuy;)+8V`2Rrr49) z^MsM#mA9|h*7X!+8Xv08br>N&Ie&G(`EKYr@|k>8mIz;^E72Rc4nbE`V!>1ms|!U- zBOCmVlKkBi5$={B{VmW=LmKZ@O@dxy`Lr)4_APXoia}ak9W+QAikKTzpqVlBiA7b) zPG0%X%%t8v1QeXu9m0vmX?oe+_>be~Wx6G2MPwubDhe*^Q3&^|Bz2p=KOOa*q$uMk zdJ<_Vr<=54V%=v!`sa`7f256TX;^RYjq);Tt1WE2wkyGG{r#bNZc zf!5>4^zAbTrmxo8=zkenWxdxw@G1fPiJ^fCzYw1y+N+A5o6fkG#M7cv>3b+S`A{o5 zE*8#jugW7Zy)SfO%`+Gg@xN(40@0KVbqzH$N>(3E_^*h!9o)-YU83@9OE+w>iWpDM zrIFEn`{gvLgUoUtMNs2Q6nvI=xq{mR*ygm<95yt&%LAE}EqWFDRa}pQzh4hNqzFE| zlqbT6XA@XgPv|k;sM>cVM(bDZyRVRAI|R`?wV%EOUPEOZdGo&8v~}3TF;^( z8nQZ_=VyQsuoqvdaf2->r#UuQu_5Q-Kcqz!hqdh0FU(*rXl!qC?N2hIl$fM*g@68< zx6IAXvvU5*^Bc>CiWD{ybT{B z1uvib&Uh#-6VMkJ^wuI{JvV|pbbaJ^S-b|-ijn3D)o42;`-gXl``m78Z!90PZM`@J zl-theBf92Y?78W4Aq5H7m_k02_PkJZnhb60xmb_X=ETdA2bCSuTH9WSlR~u-OxrkvBhL)eKWNBqY2Ve<#$4Q?}U3DqP94GK>af<=8)nhfgVj zovDJawZWJ74iDKB)n!GKQ5tYRf4Iy0*WZrxs7O~&#=oDoO(7*+dU725&9fwZcAonA z`ku+Jy^T>V+!__)8dh_=yrBUxtq3gXtz8>{y<89ARI~*^sLiYSH%`N`SxHIiFqgHo z&xso6hg0@dGQ)AP>P0)A0?k01V`&iIz`-Y2`CVvlbd96=ZAnJ)^I4oV&BVF+#D3dC zJ-Sw!JLsB@%Fs^z9PyfE2p8;{fJJ94Se<^K5FspSw-j3yxL>|R3KpA_*qqAUJ9`wK zdHWl*Uy@4uogY?f)@MJoty(3DD)wNe>1as8H_v`LkHUpG6dgM>G%<-KTZ?@5kxdj1!42U0YuftteN6ya6nNaet5B41w=?kTWc zu5?}-ry~^+89993(b_An_~F0ZcG!xa|C=atHmMZLAmwC{a;&MqMo%aF){PekN>Y>+U$`*y z6O*lq3ay!-wOyp-uEy&@&kfPn%L z7HYJM%7Bq5k!xUHaqlK`q?N z!ayMcgG+I$(|E_L47!Ei^^62Nmou9n?p(GVuMy^=#$ ziB2;$JBP+OKFb-!VF}|tS&8s2qiBPau2f%y`Nta&hJM#t<1=}&X1OOLiyw@}(}Zsm zw3FhWI99(oGt(?RZR3S)_y%k?xGTU#>H@XQZ;9|hN8A|R5`!jwJd1fJ(Drh*gzVib z5kh9XaZ1-soqoboCcwqGv0)8;*2Wdu}I;)aTSYav=ld%I$GZBNcrV2M%uTN#J-;WQOHHgyKKo}(aV_pq3uH9zkm4R z84Ca>xC19}OY24X=n_Ev1_v**<4Ly8KwHwF!4Rr!Re|{sNve!$t4?WVF0iBrMu3j$ zkpgzu$Ky;>3us42l{pMwo>kA|AOjfVjvxq6gQo%ENnv~go^zjtKgix1c<`zw#o7nW zk(R2GUQ8pNC{0i>&IO`dQVoVyA*$A3ZcxCg)3l7mfkeVVz2>sB8D8}$017!p68%R@1g z`JVwLO}(hQ@lv)2B>Q*Jl?v2R53pw#O@}hifcmfrPX9Eu^M6I4>EGpC1~0A zqK^OMt6P)QpuvrrLeg<6FoFy5T%7z~5r;r68QgQp@I!|;YJQtp)7SF?O??MLg$hUk zQ^vc*r~>>aSLWQ|IZ#+YAa>(rM7D%l$opWYJ8NJQG76gr-%tgK9exd$$N;FxPPRa% zsB<9iTHfx$@FiN{UNq1>R=T%0Rj&eDmOLZl=Li>!+0b+Dj+iXpr|T@=5!E1xFOp3n zrUe~ScXNH)AO&HF^4&G4?SgLEiv)n2RbhX_*bb%-gcxG+wncLV9VDPzzw_a&P42Zu zt#tIvgYmC?!FfKw%mAHX=9xaE;R<+=(v}>~zsuk8fR6me6RG1tT`if@y?2J>*~nap zXOdO}ANTP^&sPH59nRVAr}tx?P5~_bG;Cv6J6~Ju)tBMHg{=@xzsl~{+bmSMw<^{e zv4_q2l!BD}py1>`!1S7!Ch1sJsc}zmTqHJSC)WS&>4%1}f2MU2Jzt|vj&ez=2ce@> z$B5bG@%cq-pnn}EjYV>#X72)^XhuGN>v20js`JO#ZDFeu`yXdLlLrG3cIdjz_RBF_ zq(_Ysg^pmlP%U;l&58=RG)^C2{BA%3{(c#Csp&Q}8sr@W@@9KgFl0v|7)ts!8BhxiXhwk#(vYvlLXtltN2nMDs&nsW@&E&M_Lo6p`Nlbc$16R?)X+qC_H4Vi zR_5jyKJ}uZRhMiwIk<<+|*0u!n~W&|6nQtZ{6vG!d-&dd?l*GZC2k-Lc-# zdvL=Ypt`P^UPEVf{^=2ziE|_wR+1Z_u@0xNC6=9tr>Ul1k#)ZlLl#PI-`+e#%!ysS z*IxJ41p7hUeVOm?rm0o*dQ1DCCAVS6_Sp4vQU(UH=5?F3pgiyUfu3ye@E&89Svz#q zS4Tbp69=qcb)pv!bfWH!z^qD>ecM>2YjFiJ>~z+F*eVcpSnhDTQg>!XY*kqKlxIC( zWxZDQ!f1jZ_~GH3Uu&U$lX|WWbr;8tNgl*mre6+k%mBlanf^=3AT>Nt{u1G=2QdX* zk@~y*vUkc85n4NEjI7Ozz1E^KZI$=G$@Q#w2MK124(!KvFT4kssX_xH791fzKXY)d z+%BDqJS-zMJ6Ax-S$%LgP4}Ad7?=6p%{VEZD3so>=k+Itj_MyqJoX*3>obsYrSk6e zzfa}-H4ikCM}3^f8>Q3xTYqi%z2Xs6<&Q$ZzMi(fUeo;e$g=P0wD@vqn9SyEwoo{? zL#~ir5V^6m*5Sg!F$em<>@*hu?Fi3JV+Wr)^cbIO$EI^iGjjY8Anixqeiy_wa)JJ* zbLEi9xu2h3$<$8qKl;(ksi{N%dBu6PHS$a%`W{11(AalGgM{Lw?iKc_--f@m`6Es0aUYc2Ik1p-%}fT@FA>kd zr-=wyRF=EBN8VcdT7wM2*&T9f(B$6Ic~%w8A~B2%)yB4lUv77-YCSj>9&q^D(5lhz zTAEg8i|$*D&cfIXNRD>I{AOdAAmKmh&9 zY=@xD&me<~AiI7O!lf)$E%&rG_a|kAm(|D3!+Qx1r}YkmwM`c1bemT_*=*EK7{KxR z_KqHM_M_V|9QSJN+z8FBm2)z*75KL8_m37_E1+a}B4$Q?*jV_s=jf~}SEgiP4Z5Mv zOg=VwV@ezL4gj~yWH)za9bRrZ^*fM_N)fL6mZ@)p+g;6Gr0a)cvy@;qKiH4Lwz_CC zG?s)4ck7~pyaLjtZ2dbu3c(+wFykC@RmFSBEn9Ql@SaK;+ue7EGkc06Hm!`zEptg@ zpPs8q=LXd(M!1;rK%-uN6JrmepkqG)REn>sM8{lbG$y--zgh#}*F8v-YJf)y8s8b% zy0&MYbE95aiGne`cD7!gc6>w_!oSIex<=v^FtBr^>auxV7~+8Dezzd-O+GZ()Gtg! z$hK93{eH#iI=NTQdk3(A^c6 z{Egj1r`*#eKaR~QMDm{dj^2DFw+f=ag^i*p%<)|G)S#rzD%=|-SIsi=8(-i5;ysHM z(c5qM>5di$_Y~cYMMWdclGFVqwjXJtmxUn=e!F@)4w%qP(`{ve{*f0pPC!}Q;It_D zCu~5-PT2o%B<=CP!dKe=nJo?T{TNPUskM{Cq*8_qx-1R)qI0@EfK>m@ZHt8;nDfFF zC5i$5q<5AX`YE7%@R3V#n7?xG&y=6>04p#iN%UBQg|>Hd*xAc{f>4G$;nzT*lk)x8%5+1SI-PPDnv)i2))Dd&jMA zKLobrT=z9|0hhWPyF2^#{b`_9HDtY^JWF?DI5Y!Hv{Q~=_~hQF_^$GHRu4n00S{z< z4dT??ki#q}YUHBx>Jq3W$*$J-M#86j=FUA9+1>w$%bA}4b2uZn3KYh$sgqnDP4%1z z04tQ|SS2O^8T1FOEBvfBL%V*W+M7iR&0 z!x8{^qmG~;%4ENf)I?T;%4`9-N2H4#NY!Q3SgA&B9{*{f!@a#04Xa>>8RK72DG4>0 z%a4#h+@}@hB_fp8aa6_f{crE)|6^j+hhVTAEqe~0K5MItHGY0bL!sO?5X+2 zJN&Y|KvQE472y%!WAupaDm~JJr5;W`7=uk{P+S#Zdw))4ZM5P^g-OOoqIep3z5+5E zh(Oib4Ek4D%ntuzX_I7G_QJ?!o)K|v|L#uo{pzs`Z9a-sfkF_*EKT!-NmaZz&glju zPnc4W*YJPf_dmd0R`h}U@*jgV^&%yPC-&^+WRXCw{&-PB*F~-S*{Zi3(QSwyx9c|J z&t580;GWPz*wg-_!vLg_{Q+y}rpjUW@PN&ZTxVB+W4Cpp#T%;DOiXv>HHs;>ZEd;P~(YI|9O& z3$XZ(^Ovbgj2aLX#&6~)0F}YOn+2$z!mEF^+3&u3AqNW*uZZt~_5J{5`V!fkqnM3qqQ8Ar6xS1c-Xe>a{# zh)Wgj!4T*;pV_?Gw8mEJogKC=X&vX;H1!zYhuw94?@T8yQ38y{svsK7RMzgjy|7Yv zWBJ;(Yb;OH=Xt0M>Kn%lHovg4tcVj;#XD( zerPOJol`kkOq^hIVYahj!w)Ge=E&0T*ZJ}gc28eWgrOj;2y49Yk&)uit*ra6!29OG zZ*jG_2d|D04)R{WXCVCF2TYeYRa2wcxg;9AqjG&?$N$z3>A=65}7?)-|2z~zYu07Pr2U)|hqOh(iF3%~fQOHs(KWGQRLzLStOYxaF9`!;rCNkaA_#xBdm*v1lLW`57;y?wsF z&mZ6G`u0cHRm|)4Eay4rKKHrLGig-kp$SnI!S#x3weE^{7*8p_%{qd3EWwX~qJ-2q;^oPjZ%_bn5mdOyb&TUF_ipLq}^}8a}mx2M- zd1-^YMW0Z6)dv*zJ9p_N_i%E|Set`0NY@(MM!pw5+}4d=%yp}_yJOCdUj8dWFGq`g zsqT?W&J@@lNHiwW?GavoX=$8*XBfCCTh#v4ZcB3o5U9)hTY{i#m?V!h%qU4-OCo5w z+Ii+-a<^;9dRw2K*d1gl+pF{-EFI_lvR9ko>u_{g*eA5WwE2qOCJM59+A`;hIgpYJ z-hd2BeShI>ia$EAjJ*JXco{$-tv|q7&EC-n~g8BgYEl)btY3QYUEH^qNn_YnA+!*H=*A1%`w6-hYI}qYNvE`CA&kz zC32t;Gi9GR&fE7Wdf)_quf6|N?{!IweGw$u)uTCy>gHw^|c8niaMTrH7`D=OCLy%#bqX*k)ZqT0JiXZC2X^-;l=gupdlfu`Np30TOEq z!}lEbd}!BxXkJTtu$>)c8sfXj9 zy)7%Hjn&ny`E~JfrY)PqoePcu@>k>effwZs2Tl@j2fq16yatDVc@?21Qy;mF$zd0Rn+<{NR;_BctH(UXLcH!B2Jdwl?Nw%P zdDNan<nn>osk>>2R1wavSPjv^aQ!o3vQ zZ;*@As=QnyEN3IHMXpG}abxtQyV-%#DTKW$zPPbe>%8nnL=L|pqq;VM5B*~qZP_(@ zQ!;2khF&zg>AJTDfF^G!yO4=wWP0H+6=McT%Um6w+A)eCtZS1jjfdoTKdx4mIKkr z-3|^M(-<3Ewg@tX1}cJV@83cL1G|!>1$G8-x>c{6ghfW$l1v-=upSPM4IDdFH2t7P zhIqN!|9xv37Hd%Ugqrl$yoaw?&Rslo?a54HwQM$NxOfQF`MiYLLFMQ7{ZB%KE=bsB zrThn;NyGyC_})4>piemyjx19bhOVQ~Xk0C(qw(1vRkjCVv7DMd z)~BwxGf~inGl6nQADy@)m1j-F8-Fez?Uxhr_r{)!}H}z$!NHg z3RSVQx;@Xw^c7P_DfOkW$@wr2QvkN}Gi(lSNNz~hG>sX1;*w~&6o11WEF&;Dp}v`d zPRvYzEioIFVds$e)Y)mYL9;<h%tyYQ+7@h8AXXnhZGU`cXXnVd1Z{f(+G2CW%(pPF@F8eu}#uDG?_`1 zH?rmPx5D01tuwPePHnO$w{^|W(3#j3h$UThAb23J6@$Y+6KmsG0NSGF`IH83mT-`? z;}M{mwk~tMmn`Nh7s_M$q867#9nNB6FZdR=;sm1kf(^{zolP(qo}5p~7vX;5j2 z_mTVBZ{LUdJ=V2BCMuH@=|fnKC9-{wbv$;%cjXv?6>fr?m!45&lPbLX5zQecqU8|L zb)u-Yc3<|9xYAhTTM(OT8{aORc+7_m4|^O*5V$36-T25Qthae@YbUBDpwzl28_3@3ON}FquOgBu&4l2ryI=J7YsnzHkmEN(2h*Xk%K@cge@vKX-9t>*+ zp4=Zuo<&T2Rbg+iABEO2g>FcE4vw~`aONorY@{8|A6XbH`C|Dxa%D0zPHZ(GYgR(ptlEsur}L;!q#0=y}rt}tNTr6KI?@2 z^oJ30IIqC20(n6>>t!7cV_12e5BtS?A_43kCR*#x_WU}9h`kXeej|%+Gdd`#&^vZ} zk^=@%=r+9~Y(L$E(ty>)eDkYN%=w)oXYocvS@3w&c4YXdE4Xte>aPr?CSV>%4Ppb* zgGi+0cfpsCqEn@c>gLz7{L}2-bc4R7G?jMHVGz!}-=EPWoK1Q>*wok$G-;SUxmV4f zLWUv%SYW@t-jOZ;6|NnV`k>}KV(i67)1MNEcULsKAZm}k7~H|11kmXH7vM?@w1E`L zdJa0YYkRY7=Xug@g+ron9wtdcHB2MQMD(e(*uDIM=ka&1zyBG_{vjiuW}b*U8MSrz z3(ta&=fE0Q*a|?SM;W&bz1`?etHaw&Z&T*%-O>}k{YyM5iX1lLw6E-Y7Cd^c?q5aF z9Ba&9yVPI}tK@h49)3v}|J^YUU~50 zkwu-STWk9Xgpox&Y<)J+Ob=F02-_u@!_Ux6CsIq?PMuGT6If?f5=#lH^+djN8NiMc zG8AO=`^%rr-2XG-!M-nR%(qb_Tm+Jvg_=~7N8m!AyQ+7Bugs9^-a=B}B^L;%dpO6A zaNu^Q|NQ2-DkCVyWtH^EUj$4L=w&u$l{JEYW_H=fI8VxG1PkX-69MJo1*0B-icvzE_N9YmW?6rrTYm=?9rHSgMFTl6A@77ABA zTi%)Gg0!tAyzCwOu{~Bj6-qU5ax9b%I=|jq(g?Z{YliPxB%7=Dur5R8QAcoFx~>lXf`o$=#SFu|n!Bzp58KnPQSVPSLhkxU$KuE^5O;b<6-tE(^Db zi%y7}uzLMG3IcKn?$!sTMjTm_nb_&ZkD7_@-VLoz7 zg8-+<^U%qL0?V)Ow$LE5xVbHl z!;j(%%P9N228%;AK!*zHAAQ~(%-1wY*O(LBB+x<8%|4+o4=ZcJX2w-zfuQG$Ulx_= z3A5t%nlWRLisb}mES3W%W!tvbyEh39u|~Oo&=SA)Y~GwwSMPV01pg5$DfPVWGZIA7wDfl*L&yIB9NP zilp^opd3&eZ^v8QnTVW3wc6&hANCfFnvTL))aEJ@K$eIhYh)MhnNsC?C@4PB&soulXqHvzA4im z`E#}V2|6c%h%5ey_@IbIB6&~OeB3{b_%OQTqU5a)qP=EVA27lRAcdGxj@i89bBbvk zf+EV1D^#fO98eGP=p=qcZEVPf#Q9a^KjN9XWW;c*d#(E;DMK4c<#i2*FEjSBEK^Fzp z6Ho(a$W2=;_x81rymJ#gL6nw`Wt%cFhNx1`ECHk>BvY6Rm>O5SYOmoBrlb8i3d;N0 zT{RQLxuEYpm4D>$>$NY%mJ=yom>U52qWv)!?Rb)vp}WrL866)xZ8U) zq(le4dxlS4aHrpkL^0MOx7(-7B-s5L<1KUj9Q_hw_m!;Rn`ul9RDjtNp$181p=KtA zi#|-7Sjg4KZLF?(2JutJspf5bLL*<0mwug5P|7B)rUkM(-ac@6eWbwp9|RQX_%EU_ zd04jO@`Tf8&1O=|dd0O`U1^hxSaZiLsdL`SL}tdG{=1Z%rUWP_o29R|fi{a>fX89E zKGZhKN;@SQMx^)|)z3pSZL4n{+nQD0tSV2rLnhg?d&K3IB^3dkTQFhR1(IX7v(+uz zP+(>izives!|sB*Gxd;n)vwljZhk{2fcI1^s$zy`1dme<8n)5tnzDd|nDRVQbV zFo7b2z~ifziAC-~6|z6v>q028BABO@WQHKu*JCb5@Hf~efTB0G5EFn+249{g1QMTR z(?-W%exBL1Sos38e)j;dcK;*FKPB72X0RPvd0Osuk9eeGfY>$H8L-zl_$2F;D5&Va zCbtp|p&F#GOkOOD1DWG>Fcoy<3$orFe*)=hd*bJ4&RwpHFNg&rj+6Cfw zH^6JgKt;dDO&?}YDPM*D4E$5pX67ERCRrs|p_`nmPpFUsy*A`l#k0dQ2SNAAX_!s< zhLj9rW(Tc&@`1@g{49>EO3M!c=rrbuhwJdf8?5br77{_)+2rH&%KFX>o(-Xj=0?9i zc5@>74$#V#0J*Xsw5QXU<8(jxf%61`ObkrcJ8^PHUl1Av)+7n)nEPKUSzqs6g`N4s zXout(5jQIX1cR1JX%ABh=)@nppUa_vWKsdn=gwbxqYo-y)$ejX0F*&b!=ERB*Iz=X z{&~ID_=^l&B*N}|m$H!YwlJMg^_evIV4rC$%YV%;iPx9v2^=`kyX0^=YVD*V6D`u@ zr$hlP@i|*n(DTsi0ue65ZzA6T*$*(R2b7@h{ki=+eI%p3HE2bce!9O~G@Gu;pV+%< zq&K&Y?Las9ROlVWc$*q!-1f-{GQzw|61K!!axPn{LcqTMG=W%M{5xEr2YhPU(EH(5 zR`4lhER942D2T-M7<%0&xzjbG#%ZfMH`m#3J+c^dH)|LO|D*OuHcfybk;CnV+*CV& zGP?cYp{)D%&%`CnfizIcd%J9q*Df3;lx|e5dQ00LDQ@LZ50O}zlSccJ9BUaMiaAC; z548UCy1bsrKSvXinPUx)g{NTx$qZdqKV-*c0b_mSV!tJa%KiO9U_8ItRFoQqk z4h>Qulb{164x?+lt>HC;`);7tF#wS1)ar3##AuR?;=sRuDmwltJwz_(?dx*o)gq za6I*%XBB!MzS-b1ooZ`fgIpioa_PV1gC#~$P++g!%v9q9*Z!Fscej_|k6hHsa4co0 z3{RkiCyaPBT4a4EQqEc+x_EEros-{il^KHCf-$+gWRxDi5IOn5jAN2r6BX~&3?gY zo4PqGQB(U-zG<`uY^AOZBKDg~>2r!xzvA|g-%0GShC-mOff#olGa`IBP!#hVEq+2M zMqfC|cce@J0@7Q61mB4CMo19Q`(f(~&jf92qauA@jis+jR@hDfvH#D7)l$!9D2B;3 zjr5v6*;a#c3-?7%V7QAL-!DLSvYz$!uGI#iKNl>R7=}&+|Ene zVGuLQ$6cb&G3Xai&p|ihdJ~+t=2*A+-vS+S%Of~OKtO9prY_a7Th*y%a zn`c&$EloOcxKgo*w9SaZqD!9TH}EGM->%%&lgRULN-BTG?`3MW$tt4Bsu_hqQhIl_y9hW^m1I zm-6-MH=XZ?7RZQdR^SzrIf^v&DX7o*Jb}*Ce4QgwO*MP(auyw$CC0yxuj8Xuj%pX} z50cKUlg^ji?8_y9OX1w!^_M-lAeZ{>zyyXRIMo~C-&kn=*bqeTSAe_8esqUY`OcNP zdonJIEX7~hXOt#T^&e?p+xD-{$Pa2D_^t+}5~WC~fveQu+O&B~gpxts;GV4P*=ZVQ z!^vOL@)S~z$g4~77e)*$%2T(%yOFTDl-`oRubbTSAbhMc@ZbKxsz zNkyTshvfs@E+v7fnqk!Bf0kEMMxgx?t@XUlvUpiUzYYcbTqww>Bwyh==I0ZuWw>Pv z&H>>m@lW5?#`z!g!5Vk)G&xn9X`1c4AWH?sISqLkDCpOMthh~Fldw`~kvQNt6bNoT zrd4t4PXrPfzPu~nbCiJ!tPwN*eYtXk7XumVIa?a&&&zDgXw&61+uqBdXMbG^fmw~(CRO3JQ!D9#uu0J! z)cLC+%siGuhh-wsD>s0vPMd<;|MF=joL)Zb`4)MbLn2psW1oENF!FB6uJtxxg}^QI zfETcm{VOH>M(^BmQI?(e1k<;I+b#l$$bxa7^PtivBD8fHVk9rwH)N$4sP^j-h{it= zZ88wMm`ki1FufmLt;-zDdU|$qhBF8J`gBn91kUcJm{i`EJIy7HJ)_>!{oeW@SzQKf zvBYqi_NwfcqbR4;shAbEog29i8GmJt@x2oZ)>ZAbTJ{77o>Ewv3!Yg&v@d^IGH73p6GHj~mk>%yWUng+n_*PJaUb0^s8ipHU}kM($<)pqp|>iB zsHy$Y5T=ECiS@ko`^vDY8;I3Mfvtk{7=q*7c1i{UVf6xuj^EDTt!99*QWpssPC7j7 z|C%IhqLM@jc?5q{DMUrg5`gCMzlqV73Xl+)faq&l*Pnnumq1^`0)eU{B3^Y7-S!ds z+`p|Iac}>-cklL{QOD?d3)jA^Sv4!cRAO1YvMI7yR3Sn3f}vAdRpges{b>CY^KrSQ zT1AYwL=p3TBlggIr8f2^1LX6q<1%HYBbD^dU|M_`BkIiGQoSZ>p}n}?MH7JUoA?ZC zk11+32Bm%UsUf+gyhYy|dNny->XGo3*&~CNuY$-LjdJH&3%5&CW0ej;pdD>LWp*5& zBHcx4Glk;MM-_%qxioJ~;Jnlc&jTjc&8oa?wU$n_){%plewsw2zsVhw&uj9y=sT~< zxl9|f`mUKU6%;?qxa}Gd_X@|?zEw$2|M4fyExh%%egVR zRs-%9j<)XwOkT5;o{ZxD*xG@4r@y~FWRQ-HSa5Vk3iiyALQm zq9i_OhXE0gKfPU*^Zvv}Xabe*>TUE-3!ce54R0(ViA{ifyurs{|0LE(AXbUkTpU&B zOkgx5RP~b$zj9tv!qIcs*1DlL)*Xuwx(ta7AyhH~8n7P^QF-1lLDjA=PQFF9h<12M zwL=9IYrzl&q*%6+a6ojR`U(2?%Y<#GSa;NH5Va?_eB&*i)a^8N$SP4>$9ur>ss^sL z7Upgk?c-j^-6C*LBSli-e8fpVN9sSdM=_#IlQ+I>X&}S*_6L$by0UP7P`00_dBjsv z*j86J1F2W^8RR%Ps2E%`Fs|%y$&$O zL%+?Hzy`y~h)8LjCKY37Br3LUPPsAU8%niD5Obad$ZD)k1PZzgJ7)F{%&e9QwLW-t zCTRYpEL<*JkU_gNj_!B}L)Q*{?UxTKO0c0wJiFw8r+2CilK+C+B-}WZG{Gg-umRCh zR#w(!f|YJ%XW*jz5GgaF59e%ykUF#CVrj zV0}ty+RKgY<1}}X=7(IbN>*(V`vv2FmnFM=iuSw+90Db_*r=xWS1P!>U&k;&Cv5^L zUrNhP6*1Uw+*Cf+2D&vm;YE_K!*NW~G~zlpN?w z-us(GOw#~G^Hu_D38?UP=0tkq#*~j<)qAzzJrSq%Bj3#aC|nT|aca*H{M7I5Ue2HKke~nvBx0Dr2zhj!Sdy9Z6KL0{G12u?J{4zAUXfCsA?UDImyw7ryG7 zoJ8d)MilpsIaI!#A8Kn|b#{!2y1&IZ!z%L=Lvt6CLSA9575kVr4BGKC zEQ$H_!Oi?@*2pr-va2_gx9mg&52Sur&GwP|-2lKGR3PU;FJ@Gw10y&1M+qC@&hOI! zc5G`$P~i6S<5MYec+C^InE3@s*neT!F3f=Q1N`Jo0|4(#oSz~OS{RH39p#_Cwz#Wj zh&Q^r7h9^U33I@%!!ULpe@c zjfT3uDY7?)ra=QnRiS~%voQ-DD?P#p_n z2KktXeK0g?10;X55m-h_AjkCwicbQ!odNLiAA?AotzJHXe)AD&mfW*3z-L=f5TR4h zXkV&0M)o_(eie~N02GU0n!GTR2(29U3&H=0={Ke@aHfB1x!>JROk3{i)-B$<1Q6lv z5a2SS09Mq7*KNbX)dr{aGIZPSOS{C4lj41&-X1fQpl*cR3lSAw{7PrE41{*C%3n?| z*Y+02soB3__qUNkde=h710{`kileZs~oaj-S|X2+u<44E&Fcbo^|;M zTqwxlKhnt0;Lvjm9#H|V)ckdJpkxHcbWy$-~B$o87tlE)ErwNv`vh=rRF`<6aG# z+G_MoKR2ygG2#_W;~AA!n>AeEoE~;(m1RqVWPy``K>V z%nXMJTlH~OV475}fJ>MCd7yw6z^O`dP!(c|CcLeQygj++YxhWyi;~_lG{^*rK_FCA~7L5UeKljG-%^OKGC#02bkpybydg%JZW`1h~$4a zb`}WJ%RnK?*Lb9UG6-B7IB~}ntPo_7`D-=_Uk9 z`SP!?%TXqBcaR2Z01y?k+dCSZSHDz220_mQ(YFEbCj75=)5-4mPf_GPD+>fBbF5^e zjUuuTK^=eflO%td*blEPtuTIA0Et5Q*Z8%=CiBQUz((+aQq7)&I-y0Z}m-`IqFv*=XnV zlaQ6dzh4{9`jgE2FO3nAi2F}jY*5|&`yV=+Q_MWc63B2$i4XRx$bH0>SWPAjP#Ih2R>d-Fn4vR#< zLB<6k0rPPNxf}oh!_bxs!Nqr`RtPcepdmL@XZkXuKT9xKAe^phtqh_8#*8?N8_$@U+~Be7%fn8B+Z0$g=!ze79M zTdK?YeZ(Of5r_Oqn~&b|R@Ed-dtz|e9^j|3X&m9vz=Fvgcdj!rvs=m z{S-`pmKCioBp+n3*1Ntc7&dx5Wi(~vyy~5?Eai>ZC>hzG*4{t*m5%Y7_o>1)-N-9b zta^amLu2t?co5t)&AT?^e`wxV5xRBDc|+ug+&WD#55cO2t!G2doU}eV*Eztj`q^^g z7ol0z;B(S#lo|S{@^T!fnxga<)m`?XBlUg3A+xxFCw}jm9qY~7wzIRnEi9JUZy1*7 z@J1*ugdH?_G~Cgv9B1hBnDO5H3Xq5Ft#y-+gf2ohVne9zMe*XHq zH-cfc*Uw-ONyFDFW?(kRD}&L)H8(cass`}=B3>NmeaCSb>fC|NF`DyVMw>+^C7#GD@O7i^SsZ#VxIxbu;k z#?5rHA564~I8kWd*RMwoO)3vkM_<%vJi7a-3h8}rQ)@^PPjtxX2quDx8j<-tTSl<~nI zEc=?+U^Sl1=5j6NJv*pvchQ`%KU|~k4dkM6P{Dd>I^w_zJ{g=F{$b&m`LDdKYh=t_f~RnorSXMy3r;k91B z?4$c=w9Czvr`S_#kX_=wfX=)iCwgHyyFPy%wFSRBhxS{~?1(tHd}{o*ae6a_V{oRt zSHe@a=ioY_g3jfTRz>*Hda-o@b*BLpH_p>cc1kYTDh#DP-jw*f?>-%7U15-Yx-|qM zf5_}h*LU}xu*MDPa|C@?R)(!5`KE|DV~Uqxu?=NsJ?=<2!7ErFbJW%yp;jA&m%L~{4EpyVozHp-)YcUxgi5i&wx$R3d8)& z$d2x3gOfDX%E*lFLs>Ul__XuT0p^lrs$;F5sAt&hZV=k;(coP76IQwdmvl+1LH7JE zmwDSr8$@GH2U=?7yxi;GeB+y%1t`{txzm*jUBgE1__>v$r+S2rj$ms0sGr8&94MH? zeR67+CLN8|UmwhakTpm&j*^m+{3qG-V)7?_u3rA+pV6r&8AC58UZ&?O8?tKRdU;_* zj|s1K=;@uOCC-_V;r!|7-S0&oGRkcYaQ7N}+$hdG8Na1pviHNwV~3O`O{f6or0Sb8 zjBPl)Rd;{%(@@{|VIqAL_jEa<#UML85nP9!CXcug;ma;_|FAep*%#3?>=PyzY=dO@ zl)`M?9Cfe!+G@wVfn2Z%I@B5`+b84nb0qK#5@$X!IzC>pp^A~cQA{u)yF4u1R-xQ{ zf_gn3@{0-OrDlf zi0KXQ6{jrE6BQ8Us{@tFy>GK?Q4?Ri5+Nni~MUId+o&v4W zh03ILQ>y;t`>Imq5b=1eOsTemv;7bKSRUn)q}tTC26|p&(SwXp*ReRr1Ug+bg#_y4 z8oB*GTzE*rsS+a+%DwI%K;>piLOe{Z$Dforbxy(%4y$>qq!M%eHdMbsYmkJR&u^97 zkXkImtQRJcmXqv8_PM=0`E2Nt(%ZDs6X8{P9Mocb0s`w}O&6|tnLd$l+nlX=bwWoo z@8V?Xl--7jo}L&Rr-Ls!M1xWBo<7;->?zFCf+EPmN6A^CV$W-k#Z0X;nW&repj3#o1`_m7=A^)m4qv{v$IngcY^9=y>8DF#Wd9`dXj%F zG8jto+v;CgSG!I!`&EXT%9r_dyx0KV)UEIoalOH6LTFzOzOAODHu04r+$!f#8P3hC zBR?ZCo5ghOZI^{1gI$?&!MzEkI$K;S!=Liy@9X~09}m1pK3hNgT{OMWFDz1bk!9AH zd!^M4%BN|#9M&*L`YtfmSU`>00Wd*}T2dUl*l%rHN9vuNJ^9+#3_LT|^&@?m-78{!5% z&m`JDaqYAI7|NT6?{v;e<<3bLjr)>;`}~(?loqkCFyQ_8>aVhPI)hnSAEfw>VM5y8 zL8=HExRG(F{6uHk$exUL>Uq#5j&-~3k@%wKu=R9hu65Z&IKlTYTu?n@qhPcmv@!P? z?tv;n(mO$?reJFmxx#RV>1DmOOO>aJ;ZcGt9ni%43xL^ zK}K()G`MLGA(;G%$7@Nf-)OvoIb^p=R4Q^wUGCm_j)<9PQSta~EI#PyAga2o3X99o z?MbwmW*zA7@6UA~`$S-~brP_}AoAx#&HHW{r9{plK2-_}3sb8uhs!RIqUntxxo6PJ(H8E)$QZByYhu{7<4FEwh!J0)N z4!;0vC8g*$YuzO+B~KlI++oeR02AA2!uSZi>{&r{?o{WRc z6NjH|4=`qk@Ex0qQ6`8{jh{_ol4^3bHpT}mN8QGm)qq`G1(H&;_|Yx z&t>xf@N(aN7U#@?qeg9vRf%4pcR^1$m$e_g9Ckc<<%qRzg4(lxaws>Td5+eeyy~M2l>*1AuT&~|u$Y{?05!CWB%- z(*T1C+#As_>S9vguYNm{ZJi^63)I%uo}op~)H^Ltg<*2GrB2Y^nA}hgc6)XO8;`F) zINBL!rCBY8P12Zh-M$p3X`nPXmt1P={j}b@V8Gk>Heu8Yw|F*2a4Mc)ebjl7a_$L> z5=MhHbZ#9CR@Fv!I))2e+se0VbHJ+6g_T`EhZYB}`xPpU6&Il!e02~*~#soRr9dZ(yedTxe5UczSZ?q=wDd_NwzWy?<(RHImGhFGp(~Xt$ zBw@t|S^K#=i0l~Y42Hh*bl3}eOL}knc&Eg9HbyF>8---2VQzMgX@z0~GyNewn@!dQ z?G@D9`5j%9SU3G0!3QNe2x+X;!)~AIt{aGYEIO<-vx6Y8r&94Iv*U7(crFVTA$17X zvztB!%W~V#a{{Qg=oLo4eM@g|(xe8!zISb9A}W&8YapaS^h$tLni6OXvgFF!blkXWfH*4n1c|TWB?YDK`MnXg=!OT05N@qLGs$p5H`x zUF@Fa+s|83)j~NfLCk<{@C(D+zG?P?t7UT;^SXo~-MjEUNf<|(GZT(HXu(69jO+~3 z-RjXNON`HryeJE7(|!H*57rq3PHKr<+h!5y*hb#L486T)?woG%^_A$8?sh%cijeCe zZ~OQ=kz5bES=%C3W+emU9KBGCKAaMEEF31cc~;J+DHqI@U4&mw3-@)3&Xk9zrCgP* z!r>i8{p-MdA?8aidRh1&h_B;C$V8^N(EcJ*%zdequ#cvkexq+O^u37s;ZUj7i>Y5 z0}f}T(udV9bRo2bZayNa-No}1v=X&j+>T4IVU6PMj}m3drpSq7W$iu8|7~@|x*eH= zUF`J;q}p=Vd11^g;Tu^CzRW;2v{q+c>K7d>#CB0G7==P*e37WZ8^SEooKyK%AaQpK zM(RcK!WPLOt2BM;FJ8q|o>w6CV;}#O*&)=4Bz^K6U#pPkS~Y%j5I22Gg)s7TkmLvB zP_ZKT*j#p)fP}i8P-L|V^~(aSGjW`MEM6rkfNI=;6k(7`ug;Taov1cUcr-|I+n9`b zom=3}mUv1rPoK{3}9Y$?e>GvO?TsP}2npBK$LyeRYBer%C*t$AJ-- zUjh*8og3Rw(6Kb+Y+UEj(GjYJm8A9jX$eTNF|7TviiS}B@5y!LOsLCJ-r;;)~`q*qu__8yI`pv zs=pNayj+$^S{$gV^H&r)4}h~BmtI5 zw4k6sGx4g>lYcxKacvedb&C2#nIaI%s4Ai-8_X7_|7c~>HpSfjoG=r7<`5>c3Q+BNPpZ7czm%F6kt9#gV z!jGN!Sw;}BT1o(^kUwN~^nCQ}aM-rH=l*)LFO#nmu;sgl*tWRzgn^>(FUW^~gE*Xg z-&ZzySlxO;`J&wBUMBC+DAjUf^Ik2N>e=+9+g7I9h)6 z6|e!lG9uQus5+qT?dz2=Xvr5V(fXjEj$5ugAa7W%)h~%!lnWW;U5UmUzgv>Np25%- z9w!hvM=}F*dp34dkhc?T)FqqUNfcxYh07*Po#k$SL zgnGadyjxtlmC`p8x)k+xq_URWPCc+dfB_bwdQ{z)!5v=42(zlu*Hr7U519kdZ0goa z{SWDAOW@7-TLkZ;O1731Z*)q0f3cgjm7wT&AW{+&iB4NE9OYdwbAF`ize++(9|0)< zS&iTI->g|&>P(?3(OEs5uP!XKzn$Wn)@K6XVy?TO`hxvibS_$&CqqV3($fyFc+YGq zT`qV{J@mQc6OAtKnHA-Rmj?y8wtSIJPs+lBzdz(YSsRxB*^s&pK?>odA==)R^MfyH zeE3<*zP}iFi>K)a7-=I!=E883(x;s@{^0$4LB8;9sVYQX~A^bF1WtI`rPzFrBSVFnoO{SlK3;H!-9rL)R-xq0U9 zChYdO=|SVQ93-Jd3wSEXq(7KvNAyXC(igW=OhWn-Fp=3nfi5kq-YAf=pY`+_j!iCj z#mWQVFo2>(2(dI=%Dx1F!$4(J?HR0(Ve)=xT%^JS4%_AW<^U=)5068oc{lCDr>d+P zt4-zQ8@(eQ0|Mc`(kHl6(q?FE^z`*5=s%du$_@H|R~_r&tz6v`9ocngU=~kO<@S5^ zq85o|pr@z$@|T%^ev&ifrUcI5mY38C2+eDdrZtK_^#!Gr)arqhXJY6Dir$m_w>z@r zgEb7mqhEE>=`wFHxSfYFQlq?*HJ3+Ba(Bk38eL~Biz1%^I$RFLD3bxzhLQRH@Le|l zgI$O&Ahi=oJLD=a!(z|<=`uCIhx?O--ijI2;7x77tT{od*in`fC!PG6H3hmsTIq9NCSw=~uktR9R7FlB^e2sX z+AbhrrzVNPL4%buPUSz2iqRJkiUCv|LpHZ(Vnih`X2)*-mIU}Ns<2QK!*Xe1In8yN zTJfIGbdtxTBPGifu;6Alz-hv2)0l_cxz|!+)ozv7KRmLtT(Gm<-o%S>4Aeu3XF2M!P%L_<;jAO_K}OTQVyoCYp>Cq$-f#3%E^8G@u z!?m@w`h&}KfdGAqUQ0;8@}cTBE<;sZ`3Z5=5uKGsg5U_%*_jFr;hR0Ie9VekgBqb%j%cAmjL;(O>mbMoae%)bPyuoj3?+UmuseWK7o1!{ z18;i$Z8gTnu_H#kd zt&P%0M!j)ywk@zE;ujM2F3RgCG98n;EOi6tjm4zzH+7X+OgT3C=$rv=u+?;Fd08`x zgA)Q_th>@4NCyco^&ttC>p29h>nLjPbivGF$&3X4C#USe?;KX#ihW*z%Y0OS7u3i?J=wL^upLGd1bX_oc5(@ z7*`wex@a5+Fv&rZQ#lJpJsbg6L|`#0@sr={-MBim*KAl|qvP>RnfUM+dVm7T zf4x0TtpjGSF#a;3oe)b;%ns7Q4c zR?IIzdW=R*Ky{+WLZv}^#la?eRdJBSjuxC%unvWyFmWYq3_mMVO>&_Di4Ax zz4+%Cfi|vbO8AMjbbP@H>fe!kD-hh*zCK%E(Ojg*8%O?e@?=@dR^3~22$u;ks_kk< zw=aQ=vkkyXxpG_@n|;m9=KNA2e&>8Ld1Uk`q#$<=dJcW!3uxRzI5-{u+6b)wM{Jv_ zHYMaiX{GTZH*kI>{~ny*{x9mudHul)G@q1hUo;dg*ZH6E{<`#ok{#1)n-It%*XrZ+ zfYno@s$GqLhu9ypO_XYX*n4@VS|4-%Um;{ysd1UC1#hI>Nt)s)RqhA*$E+z4&oyUX@dkwe&xDjQD-i*845H%YSgGeZr;L%2 z5!nmKK1EO*IXEtkc+^S?3u8Krx(IJ{igrCaB-X2&H47HJOeY2chd29J3eAp)OG8}J z6_6$S?YN4U2I`6Nj!8>;*l*bbH-N=ZAOdQZSf-$aQxoBsp80CG z%j{5W&Kt+C*5HD=?Rn^dn*`o9&sKJl^j0{-*m+|4ko0(Y%8fyialiK@6cixvz|Rw_ zDoJj%X790UR1#-VPEr+;D?)1A)TOFOLcN#N^jF3ugNw)el)|6PVv)&^g@yUuSO~4`u)TkB<~8SxTiCiZ&#aEMpH@%2JfF(_KsmWnYFUq|jn1yR4Nh z*%C$zio0wfWQhjT#AKZqX1?c|Nq6`A{`~&=`QyGHT{G8fIj`lM=Q+=FCh$PE1bARS zlOu^lpY2c6N+$;!rzViNNzbBL)@%Yy(RI3IqnJR@iQ4{BT5V|>B@1g~Ff))cf5_s~ z*j3rUod#*m^MNLb^B-T0Ek7y6Q^!V#hc4Yz?*C4|g6uyPMzHE%_+_W~Yk{bS?&+ol zdlPN!ViFR;nVFJZs0mgH`5)8w9Oj0I! z&i98AR;{J4%&9vlI<}$c7;%jZ9zfo5pk4JT9@1ab)c0y;iL{c^Nn+1zZtj%8v`f~+ za?7MVx_wF0w{@rgxsgL{do^*lh=-OT2=6eKCbLQmyJY7pbadeB)iBv5GO^{+LV3p+ z%?P^?bY=d;p3K&>i$_hw))*W9dAsi#N2y0dPUXERI{hSWcouf#Ie;JW!=bMpo8!F#&QIDj`V<_p*zvP*Ny z(yh%4V`=ojdVM-hBYc*UC`(Ux5&a15di9MPP&N)*El{3v9MKBiIWRh|8s;-%55}ao zCtI{!N;dZ;KFqN}$KOzCM}nuv(kPf9-bC`t`#`x7;>AzK$n#X0)DBz*>?;{ow8g|U zy^AzAwk1If%s?z%e$@`x)x%LCd08=EAdGuLCV9;PTF2$Ir>CDx7|y_KG^gS<=jUHm zaPq(pY2P)s^7Yb4q`h?RLqcLWku!G*grF=n?*o6UmQ~WrFabY*%*q+B6 zv*}d%t6Aw7yIjj}jkXtVn^wt0k;tZ3KzjO_KTUTmA^;NzcIjGdZ{LfA=ES{K&x@@o zNn8$Z%G-y;V0`UfX|XvqQeo6;;*YZGqOgau`1sKpHL|=2$;ke&Mz~X?#y#hx3E5>g zuK<-$2oudrZJY7swY;G!+BJiZw0#Xs2L^p2go~8*G{T5C5wV!|M^TbVFWpT3t_e=e^P8D>Tv!O9)qxae#?PqLuy( zE=Yx5p&8DVQeIl>@ikJSP+>sgN9&X7w5)^H4#azHXRGwZJ}ECMWUT0T#M#L6@CpG0 zyqLdgW)sNvZ2bbZ_xx1+k)=c>$%y9WJi~E!uJC9N& zUGOz%I1qaj^PYLcos>3ZnjlIAIU653>X=^$2g6ab)H03hIU&(Z{G9{svd348N2Zer zVRWxEeG`Z)q4lgEoK?5H*i}s5F}o`UReWlX%(!t;ciP?dL+uV!;WnLm>56`dA3Mg~ z1X8kN92|%i3uxc6cPS})zbwgN);Q=YNs_eQ{$U80fJ-)l;NN+~)yi z2P*v!v~e$#>uIzNhmomYr95hnxbHn|NxMwkR0x;&G5Pop?cv>L7mHVMxsxplpLv3L zgbJ4A1Un&9QY6B+ZfBz5Ca5NdIVOC`fk^6n@@%dQr{jf|A^x^>&djlPMw{XG?B+g+ zlKxi3%C+3&AD1nl<3P-GXhj$}oO>{LC&9B$SbaV{W+RG!BS=}@cmGzmakGKOm~~6V z!FmUgwfwK$ANyLLB^*bnIhd;VQ3_T770T;@P(&E6ty_5Ol@B|SV4&{Gj*H?u zm33L_#z~AWY!K$o%uUx@`rQHz%2vvgV$1aTTmIWCNDjzKv2q?|g5^_jJVIG0+fwb_ zuJ~Y0{4}j3`$^1L-|byQim5kJkTD{%*tWwHQGJQv7Ls@^1tg-~T>cYqm|;;JwzG@0ZaNN6zl=)_Rc6 zTK5(EkDu&QojwQ1)R;Dn%RZ>et?(CqNp+*kV6$G+g>2Omu#r$Gx%7>%Litd!`05y< zQdN@=3;Q08BEwH>(6-XQ&9VH&t4+vvo>l(>GQjkCvJn0K#ld?A{Xwmj zTFXtjNdPLIgLl@TQBD8Es8LXJAa7AcSG#zpwNA_~u+m3N2DUc)Ab`jFFUBc3tZF>x zce{y|>+G8PJIMXI#{n>26Ls>SHG*SojsIMNi`ndAERb|hp2j^#KJUH-J5S`_^8d>I zm>`t3S`f-gH&f%pkF61Md;dT13qZpJglP9E>jD!?U9pL39hY5Sr@4JVbgw(|-8&4= zz3qE$(|SgK`;2@Ci%Miyao_nWQn*cneLROL)KT`w0g5c)?>6Eu#_idg?X+l*eb~OC z{cF}74E!!$v`FrqqG>xq(NQ)o5E*y-@`V&LX0Truxp8DwA?nmMk3QSV;&*eNe5xHN zV&GhX(QAYt?yiF23$O0lup3!Uljv*p2PPlngY6@pZ7cfg6fih#XIy6V>$!F(Y*gex zjaUt=$JBj;Yj|JoAK1&&{FqKV03hSkY3o*&(>lx`9Ne5<9;b9Ulu>m*<0!ok*=_It zzKRs=BwaCIR|}zRtDF2`jl#rM1iV`@jluyGHJ<#v<2;NnIK~Kd=`=o1Kc10HnO8{Q z7E<;p_>2nnGM|L{(JPUz3LB+Jer*FAGU;BO#bZ#)R|nn)#xKKKW>Ln z-kt>?j(9}f;njug3UGi?oNdJ+TsskY!~Qz3S!-xJ=$2$>fRpN5JSt5uK`3v-A-KhF zsUn|+*xf*42C*6tMpamkvD_~Yz9B?K3=2Juxd2W*n9}6EGa=-cpcnPuB%Nvn! zGr(_D@XLRgwoo1x=@W%IbMs$8!Qgl8M2~nRX4WOqTL%>lft0%=f(@mKD9C%-{~63&;~f;DPRm*BQWQe8xC_nR12Ss{4q&G$6GX9CfOL?Q&#VJvuq@+5&Le%#%8$O z1javt`IiWTAm-&8y>9v2A!|wig0444_1dc&bFr>1jUCnNfgF*RjZHI{u}Y6qCqMlWEh9>Uf>pRQh2JwpSps?3J-TgzgM-{bsByq|+K;Iw-;?q6=O9pd(%L@xW+i_OH`^UP^bS&nk_0H}=5xG5Sq?k%bS7KRx9ui@qcSNegi|h{9S4JIQXZ;LC-S zZDM{1-J)b2t)jtVeLYud$4@gxLc){Oc0lB+Z0(ov8(jtVSG1I^M*B@X0cgIffO@Q+ zka>FRpR!WZBB^>~=lNr6r8UC z$&MYiVyesV(d)R?YfqZzcV3)Xm<4bb1>BGGN>j}P9zS1iE~mU2*LlJcYf`u4s(3Y$D^w~HBgj-{Am@we@4UF4jHVm8x zF{ifibujj+^%mtb`pr21@`1pPpG6HnfIB!D7r)#nJK=C{2|WD^(T;3l7LyK{q2r}B zWWCJXtUxuDsGvg-uU_cu_^CGW`T5Lb=yxabKn=HUb5JqqP@uwMCja#%6$Utby`LOM z70?-bO~^R8?owutg1(QT@RcBTX1re%=G=U;N-^6=n}CzW#v0&(EBb!4IolV1J16j= zH0_q7m)JnOF0l$>A%2~K2+9YLNg;(oGIK1U{k6qE?foP z2y*W8^TCsq3)unUVI7075h#`EW*N?f#<*ay#J8KxEZqZcBY>gH3(S%Oyr-IFXkpW1Sh8Je5-y0!p!y+ zMX<@pRu6^pL_~Q*?A?iFOcqsqAkhqLv0l@bGED1?r!*}`WzKw=20B1Ro3r3< z<=mL(cvc2+MqM$V&+UwXZ_(#L1!N?MnwVx~0R({v0K$)4PsA9zLE94<|1XM;^uXd8 zK^T_=MLrr44sJ6Ss;&g~uWi7FjVsNCaH;n=%0(Z1KK}5g;jyuqRD`|5RK3a@w*T`j z!WYJ6w4n9O*Im^~m7A$-8(LuOsT!=z{zI{4=0#B0!a;6()L?RIs%qeEPty-^;Wpl( z)a`^0qQ`;g8AVzX*bNB3gWrhPnSU2B=%;b_nwOR2litG>InsX!PW4E~tWlxoYq;so zi=hSo0w-Y)8oX+}p_lxT05`TTE#?cvj%Q^9d~VNqs9Fk%o4+hmUqGy19)Rq@)3_%g zT<~^R0eJ5MYbwnPRQ+0AatSu(PdafbVJ!+Hw_ao~FOiTVN!Xtx$b4N;+2=>ELfY8a znD7CR!9DSvAglBx!tySlC|))z*ny3s{7FYEs8U7oa>21`{ZTexGXq;cTPY1gmy}ZjiE`!U1tiKu zA7#1c`&B@xDG<;Pay6%Yi>*y=SBl(BN1VEMa%xnvi?TcnAREbrybZh5P?~~2_PXd@ zZZ1|nd)BCDFll<|eXT~v+;*4VDHTYPMN9F3J7T{2<$ zU>@j;OWIhPg*`|Y=f9oa2n&+OuSUv$R;yG%FINUSB9C=3LK|+7$?`2SBzdjt!?{iw zgy+A@vZz|MA?+Ax**IWccIoBt(;QC$-XG%>wN}syh^=4*Ngit9HxE>e%RXm8C7G_> z3DyijicDRDo}cKG?>t=K+RBJh#sH%rJR5lNQHA2;#-9y)$5fsL4vH=Znw!&9tQyPg zXY_e(Bjk6lYfk0!h?oEpPCY{9vg~a%DM7tHRnO!=CC+Ey=&YMNkv$G}hi2Y21lj_C zS`Oi=kEA>IUJT_r;Y5EFpqyx+Ka=|+Mjogj*nt;|->LKqn`u(Vn(@_619}?aAcD80 zHEQ}D(OPvoCFvhtl|XJ<^GQFaYP}Q3r@XYyCo|eUpLDmunrBzm6pu_NQNH>rIQsn{ zq}6_WuUBziFhtQg_9jb;MC^TjJg4Q7<6h);pN;aXIh zQrdDzWati?(IbRegA=TZo`dAKWh>o2f4%L>hZ{1t<-h{Uk{^gkN+!f$pHq+Ov|BYU zB4KL6w13$$<;SxqtjBb3%yB$-p2@I&WnBdyOE+h3a$)jnT zHq+X^T$wEZX;&}vyhZ}el9*USDo(q6@X$E{-wvZPa~E$f!NGNytT}7pt5=;(+KS$M z0s3tjcF7m}!aL?d5`(opo{uq5QnT3;vJtdt`I4kN=R!4yqn1Pa>kqC+6C%Ybqb`i> z+oU0j`V<1d_ca3lYt5dg9lq{y63Na;Mqdq&rR9KWaX@nB9=HK17!daDE=Wq4B=Y2{p9XNNc7t&T^vZhEU7wx|vl7tB5Q_IwNe=oZwQd`e#@k~-%w*9(G zqReQN+3suMcF4l1JtojDyPkE9^AQk5x4f6JgsY;pztm*gbkcz7aYO+eLP;Y&2F9eU zTkWLVR+~b(MK?@gcg~2PZO{MtJ)CLtG9l6H?x6GL%_g9Uz;x&QlJV)aG;-6JO>&R^ z`0^T3naJ<5U`QRmV^ozDtSBeHee=?s$O($0E^8OtfxBA;vrT>>Mv#v)TAeeLucrN(akb~E>+-Y$D z-fuRj=NrlDyjb%PI0-$DvbZb2zM8eWWYK!{pgSOyKi?=Law(f~qwfsv&auBJ(hpgC zIMHlja_Wg9V8i4DT`mx~B)c8BaYH+rAvt6cC_^#)9({~lkUm-fC!`r6EX~3-5UZ+t z`+FqM83|b8t=VO{Z#6Z-FQ1e-d}s2rqwJ;rZ$KC8z6n9PO!j^+b6Pf7G~MTZ-a7Q5 z(xSJ8N#b-;{D4jvtlo<^+mI*}u#hX|`E6)`3k6;|PS(2B36$qxB5YAOmM zqx*@vqP)O7w<=3y9R2pm{ug8u{vhpw)Bf##O?Nu?;an>NcNm1 z1gQz$Hy+*J9@xeQg*%_yKzPhXUuOo3qygNM0%n%*z0rPS%Um!5bFi+*iBAx+Um$fp zFi6gSC|%Rpfe{Uh$78aka%ZFM>*+|kb{5vvh$sP}X|xC2C`Y&BWn9L{?LVBRA(z@f zY}sUXz2-Yf@3j`L%wI zm#b&kJGcd_OX4|4VVplN010R$)(cXcotty|dx6eE(n@3JmDbJ7)glCBvjYaOKOsbldJiM4Q{+D z*}Dd9?=QA&=bm!<$>D_gDJJ)G05mi?EhkV{aTTgg*YwEGb`IvzeDch)P6PM4i&Xft zC8t1coTqF)t*J4yCrdV{%HIa7)B-WYPKQCd{F5I+XU%B@s~N*}n2#6BbaEz^Pb7Nn zTE&H3@ZJ&+Axley8f6e^A6X&MewjOJG(M85-yvQueQMX|u-T5>^l*mVfOhj$ z-noV#P6HGZ1OdZkb=6u>Jm&O%>{9z=p!RMYVEBo+a+v1P)yfb@LM->T7|rgdsT`Or z{CcdvlwN3VgOBX%k3fDMRXLQMO_<&x>5ULWNoUgTmOsrCysDJQ^gV)i^7KFR4JB@N zgPSGS{^D!_kH=4Su!;;hb*=|grxe5f?_!xPzzRr3f03mp=lzJ`O3QS;KI}C-5zapV zSuNPM43A!lM9FgMw!XJXILIM<@oePh>DHYEltm8Czmg(xySX4wqtIZahzieMA z5*^oe=*0FeEF9#GjZj6G5L|DZdpoABE{eHleV~O)ShZT^?&WKGoy;o>ReU3kr&e|q z_;>vYxFCdl;s?U=UO(OF?$sv${5F2}&gh_1UGe;`|`LA7IAYQJDJ0sEXmtV%KP9y41l5FZ~O zt!*y$1N;R&R+r!HZbWF~pWghj($c_86=UvczMB1%`1;c~DrDNHJ_{mHY;C`R5&@tX zGnbhor;yLb&E|Ct8pZGT&)Jp26OHQmYHk4+Oly-d9Ddr zY`Fc(=CZ+cq(zGPoh`)Xy0=OEe}19K8KMu=?QpNvwF*ZDVplU2K*2jQ8ry@O_IJ*^ z|L}YKdgXmOXJWvK6Y0a62ORcMxS)ucKDSmP%XCSY?*I0^N5gw4!*4u-+ItY8$wQi_ zMOaylUyU{RG0bvZXr~sb#8{dwIFv4&=}SvYTr4k*G0U02$<5zsPphYuw3H}C(LK#a z*%w{*qC29Y_Xv%7Mc`7Tm&UX>P<{NitA1a{isG&Z%gOdglRu$$Nr-*w2?6VEp;3DdD$}!@_oCdu_n&iL7%0?wE2N?#l1SI&i99~D5y?AdU zKPqg=IREyVUxI8V=l$I*K>?(CR7QJ9L-BHn-5O-G;VOZ9sMBlhBpOrE<`@u&5S!OE z28oOpH%(X`hFSrEg;Em(HB{B>Q)XJVQAZRZ*#u?r@0Jma`$*9?(#FP>P=vRcN$d^~ zZ|7gq4}53Ax!eKZkj1rre_Es~W@-2d{jdC{JBZ`O$0Qxx08-hRJzq@#Za9@(xT%^N zmi9nO4atgfY}zOOwquYR2q^2PeX%|^=JP7pMYE12ZzK~qz#5{Vqd|EuL{t~0bxGya z3AKUMx*Y=pn&Vt{q zA!*YcxH~v7TF>*R-PZ|P&kV!@MQpqVYXb*` z7fvs6Vv`WXHeJJkQs$HRi^X;oW7Tu;zjB-Z7O{_G7kv1r(EUXbxL{m?BcNu8?S@Q{ za_8Qp_MbB|=b;wJNx$QAON+AfH8Rx*K!RG-7ddr;)?pVNzz@TEPWj0vdV(zdJHHeg z+FrqB%*Cp4s%+_fh&9z{rtlr>YKaGliB&qLL%GY!2OCZ zpW4uIS9kC&67x-keL=WI<>0&iow!Byn^I_WrRG+zVeB0wuwC+%q_LV_x{eSRoQxi z7OCNYu+HxNyPGdp+ijNt)soWzVRoQN$3!=)wy0}_Lu&Pxpz%9+u?v$Llf?d?{`%3nIk#tv| zv~*FH%x9oOc(0m$UTC}PIS+!(?p1uXTUmm`?XT}g9ajhIEn#qW%z(Y=Vs`Lrg@Nv> zP_DDrm)bPCqMBv{dcGTkk8K%iOoFV4FGx--BQYDhMDCC)*Fl|YU2c4O^v4eFllvk6 z6HM7|?*cQRdy!tLLQRs0mbRLL!lgj6K3Xrg<;PHIO3=;atn-rs%YVTrW)+|2{Y!Gl zy$<&Dx6N++jZ|~+EhNq7G@D> zATfgQtU_=g!S%|0jqu^Iefz2ThdwBbThMb4>boUS2XdxW+MG}R4Z52`X&8%8 z-B)tnRj)ba^v`4|;-!*{pMI(9?Wm(m8E1B`XIfCxNwn4IeM!ju3r?{S9~&pRq``46 z%(c8=BjRYR>Zzjcgbz&W78t-cuqkTqMsC*E+oxTp0(}O>K&+kRX!3GL294J^DK+-Ns!ssV&1sVt;Gb0 zrX{RVENq&eds+SZ+joWS*u}k>V%g&_M9|~`$Ae-~^z(vdLj3PkX9&EyVo1`+9)UT0 z%r9n*#2TU44wSSAV^;%^Wb6K#K>VT|l_<=+{5QB2X6?Wp@*Pjk72m0vWxI4=-j?5SIIxMf_`oaNGYP#=<*+lrm)~yf*2-3qy_T;I#0r(*P&2=q%yH?_DWi zduY;W6_;}u+(}JX6n}cZ#Xhr+P`KC@xpTmU)4#X@9E!x26b287gttR=9>L?L`k#*&%fRsVSG81 z9lcuc({YAhTe>-~j=x!+g;s?TWo?{5__=WSl(@FObDVIZn?1{L~ z?RnWyheSM=#r6pv**~@iC2sN;EJg3p7kNi008a_E_gj)#WX`4OrU9QBjp$*2UerEk z*ds$<5+y&7s|{v#S8+o&It!(&%yXCIk6^MkD!usB+f~8gPDgj)JzrrkP~~PNU$tz2 zQid*C`Wp@6<^E~C8nw;>Hl$DGg_|=LP=BQ(5XzGb-bAU}J-NLG#og_E0=FLOw(Qqg zE%XC4IA1xe!VkCiY@?Lu^V1e}cCzo8%{T!_(Icve}1KH z$!M3NQu5N|C2PI0$C(B*`jthG7`%PX4CY`+w<+>GdP8;!3pJJqjUe+F2=_3Pc(U`C z2ns?+qMFGJNlse~*~$JBc%F1h6^0{BYnl=Q)8B0qTa4!EK>n^YFTCI#OM=a=S8rZYm-o5AJ4=OuK-i*TR%5O*(9 zY|nGSHa1lT6cOJUz=R_IanjgtRQWmGB6!xNZfDn>E4OYU$sHZOK&e^rjRijajEAy2 zLf!6Dql60b*%1h2|KX7XJ_2#YZ$~~rxryGoUfDSS*Zh16;fsT3Fx;N$8N@xmY%^qc z1-y)7Ovkj(iW|>NoU%qpP9D$F@bY&O_F)x1f2Q}WrYZ0$h|D$k#MYF_Z$4Wg14(V= zv*ll(9ldAc{Te)P;DV?Uid5lbMQBVKP0RK}J2#N;ybwqlyd{L64C@1TJh-HPkEr#s z8nTJMkA9WU)Yg`$gj(7SIxuR%BI z{d(ufKi)=WNNaO}Thi}a6^I%L86zFsny$(aNJJQn%z!TQ(Qj8hLb+!ce^aZ`!6gsU z($s2z?so?%U4R5PVv(2{5-xoIGL*e2ike8fh(akK!N`3`xXh?EL)l{;R|Qnil7AEU zz7MD(f4*icgjo~L6aW%vG)4kxsUO0dHBoB~KX*shm$|iRZ%x?);Wh=}>>-^6@+YUW zA~Hc}^uHcNo_2#D1ZRXYnVtR3N5L%F44IgJgPKl)m``pq zFACKKsX)xR-YFmsTs?-up8dQ{|N5$f*{f~ktB_NUZ zu1x3*7zjR?a&GPtFx9>@5Nz2biBaM3ry%yn$Klyh`puJzy80Dav-Aq@z3O-SJ<)Zn zGUg(tsHxLCHkNX1N%CAHD3_GduUO^p7s0_7!g5-Y>(06L*Y<^{ZyGaTTYp_wH_9$& zt0)_RqkGl5N*>m0&Got=f+Zn&JelX#eJs&`Mhohp4EGrt33sU6`%Y@f{btkAPoA3_ zoLq+AHktRQbLW;TA4n^keW2@F;XZR(t>xp#kFVQjmI7(6wC^6io$`=FCnt2 zSw#6p>*B?<8o(4g5hvx=ZirfyJ$v?Suy3rNoZ5w-`@n^6zhY&{WyrQUL$f-jgQn)8 zpkjqTP{y6p-Iko3y!2((8YiyO;?!{7IN-&R@SjWBsjK-YY|n^ zMG#^^8;ua47pA1y;AoKq=Bkboy_w=r@Zh=&PQX~lhMEtpHdn;TAHJeFVH=CdCFPItf zNOL-=w56hcury$JX{LpK$73OkKcIy^P#i?3vDaf@x-oj)FP#yr!Tr zStM7;vF?z`OP-v0K1<1j4P;V3edERr^6?5f_M%Oc$+og^E+VZZDpR`s98S^-7wGO! zGsmB;9<{H-Ebg!hGE1)7JDD&*vFszgP%xB?>$Su zu*st0MEl2Br)W)N`2m3FqaSHvk~R@RX&ws(3&h021FupP>W%$ug!P&!In)|?E1Ler zgt#<`Mdkjnl&AZbm(N^m5toWFQOlmM|5#`=LCB`MeBw?~<<6#fogC+BN$9o7r7l@5 z`sWEA&+Z0Bj;!g_SwDYY>oA|_@r2UvF1~8$)U-5u#+fZaBjx2m2{w8a1+(A$b=+7` z(jRwRd&!QS{^^X7V7!%I=xl-q#WWRkj%Ese7I^1ury<8YsvgFiFLvl4! zrGi{JleczvlnhE92@e=r+PzTnBHOb_w>d$-XyG+M6g}NB``MJUcr3KfMxWwuTT5#e z-Bmdhvc6V|T#>sqLz8A%(a^@-PXp5Wml>jOkO;lP=ZxRq^!=|Qeeg1MwGJ(*>yUouUM6VV#q>k%Q!KWw(( zLROU6ke4=#>mVF!(s9h9P_A)s4PO?*K-t7IY63cTUr7zCn_Uk`87<3Ma85Q-Bh~i` zNYX|7h~h|Bgg+K&3* zO4^Rml9ra2r%cYD=Im{3TjxHjNp&)aS$J6beL0(yawMEmkMhAS=8&43do{y<#s&FR zSLF0XA00AIy^^6h?uNZc#sz0wf8+8aOcxIaz;4fSs6qkaYY&g}!jw>0P;)bQR_*WX{`a=mFX>O7k` zEUS%88Fa#{utksX>1C6=IneefrT!orKW?Qy_WtG!&BYh@!}mITZBA{K^oYRe7L>I) zMVoAkGur!cr-zSKb&(+@%47?H^yUti)J6XmnZ|~TxfCVGah@wCZ+Lev5F8pGB{nsm zb5k8Pa|{ZEi*zifkFI2x@li+3nIH4UDaLnCuWL;4o>y54cX!*6M~7#vvRkw&MA1zd zug}Wd@vsn9J!Gg-_J9mTQ4Tap9c~Ldl)#2kImm==Oy;OxUE}_N9uP8eZx8$AUXQmi zmOiF3H$2Qw-?W+AdS&Z(@Qhj$IwZ5_a0k^64h>f-1LsePtxD8*AKUm$;|-0UW$)g- zb8Rld7b|QqrC}}s@eD7RaBd)UX)Z|Fh7xnrWL1iny>)PBdFz*N_qliC+=X=x9zTBk zv-}=c!wM_o{7o5JG6@>AV@usxH**`S7QS{>z!B!)-E=TW+K`1$=Z;p|8(#K$r|5Tg zMs%;W3vPBd&vD&6gXY|h$!c6oyMG^JL%AwrqqRuQde|}&YfSW1l`9<6QMBCj<)mfI z+Ps_ZYR*kw^O2I_)+)j|(v{y-bH@0*5v^6?E>X3a_4UgqOBvloXB?R_pHV~)Y$AP) zp?|#0-!AF#HeoQ9*8gGuT!%!TkwvFo{CV~*TeosA+OVL44gOJ>!hU`GkW{WBNGH^3 zs>mhQ1)DGaXdH zi)ouO_^-uWUHV8<3<doir8PXtn>JaSaw31zRXy|hq^W8dR}Ddf zjk&wU_Z;V1SX!+!y!_@4FF}!8?n;&>&Z8-6;Yx~!sqWH?dg?p6OQ8h!-;He|ZNkL) zmiiKr!9(1vq^{ky!fd*H~Q7L zZ{MOFYcAe^MuP8f9{J?6nqkfe`&pbh^NH8M$apue9K;#s90k^JFMr%P2c~ugD=Lrj zO_g&;bIF{eUx@hGvu72bbzVfPc#OABMTNj`J)=;f=xfFB8%~HpraD_*T@B=z)bxvf zYv3oXQK+LB4!aRv)MFcRXW+QOxO$xDdZBAzUf=TIdDS3_GQ|%(p|L)$1{}c&83VnhW!PHdt5b$SriiEP5SKqM>~h>RU}!P&Xed&dJHizf7qH4KC55 zeZA)stmcW`wew~2@va@^*;XmNye@rxE*YAoab0nZQsyf8IJnyqH8=!fP{zxCHW3=x z_P6yqWDPo+UWP6og?SuYnaBEs^1_3aURxdAF`MuuwZ}^1GdU-h%FD~=4@Fn?&`zti ze@Q6(dAmab%#W&-2Gx%D$LJl+B)UAlWM^lmBdem(soi^eHJ5v$%qBfnlyv*b7x6t+ z!giXpCrh3$Sx+UL@9~4L#+9*iEq86CM*X^-gdb0Cy{d`Bq-V-3JQd1qicd(0(dpP^ zdMdXrW6<0l4?uyjZb^mIRqPFSTCa-t?QB`CVg8U9g*dIH(xVX4h{JTNc&cLgzBZlMZr?fv6pBQR&m3%tL!BmUXT2gHti=mQa za|tU1NZIFBU%Y|HEsTF_K+BlBVe4~^$w z6Xj}MvOTkr9on|x*AAsxd#Zhyv*J{(zr*bp?_OG5Tr8THs?O-UZjZd6i}I>EpN~}B zx7wkr?o5$gWk?~|;F3ZwZ1Sl*%5qoWOV$x*U*82ES(X2M8u}6WRDV2}`P$CkZEBE^ zHg|da>g={aw44wmw0v8>!01f$|M-A}Q}oeIpnP+GeEh%d*?q&U=5CvnLss@G_@+bs zR+YWMi9!b3XSY%~Ln9~Pn4tVR@8S{%R-uZ&IQ}}q!5dQi7ov2KgEPAe5&{szizc~$ z|M!TTz5L#z^6--kqQ8#ntQ7O8y6=VW{qwX%UE(L0Fs;`?w1^F}=#aiSU@M%NBg`|y zy?m&3T`eGWkDV*+Z*#o9sbs$rI1YV`jZi4JjVnDE!7G2X#B@nm{ZgDP{2!ruL$sqG zl>fc=`0&BtnV6+x8L87fopB>>(#MWh+^edu=j_iS2H#wUSjITQMMX94{NV!>A` ztEi~N71~s!NPYUZDAJUg!N}?fy~YI;8MQ8FIW?$7M4FE}`)CC2yP?k!jt6!23q zt7^GUESHZHmKyceB`ITHV2lyLONVH1ghC3?GDy)3q4($j0Csm*v7){Hn983pGc?qt zkZ21bTpDq@0-9`T>h^d4JI&2p^=H}vHHk8_Mgo{4{yEL^5Jla~*DYH7w!2T-Bcd55 z?B5sTF+OieBp+*^&Yw_`%$;}Y>kLQk@nBxg-}o2b3$6xDK&=LZKInil#=2DTj{WE0 zCLPJxy*`HBc$q_ou3RD(dM70%QT;4+Nh*7z<;G`|lh&fERG_L^(D^EIM>DDMO*?j> z-D~dLiq)V6IXj#(484Wui{*qqp}Bc9C?$FSW$A90DldBGah( zH}})HK{TTG>LS>8euu2TIZ{oPO`uG!rfaCa|N5o_fhYz;Y16TQ$S7x(j|WF>4=ojPy%c z)mZpVI^>cn_%bxjYelW-qXp@otRiP%mt)Fma$_0;_>zb*ot>TNHfISKl=IAv zYiKbh;%!7~YRi@hm{6So0f|LT_qhVWq@&&s+jFnOr8}WeP2TVYpU$zY`9ffphHq_# z#qLUMbzYq$;RiCH*a`lTr}6?Rj~A_ zFJsJujd7RaZX+r^nN)k<(Tsd@H=)kX#bqM+e6xOe7%0N4ZM~qi4{pXblPn#k2 zxPpR$iHR-$+mB5l^_hfD4D?mSAt1L-kh!bAtV~tP5|Po=dN%_B%9cU)z9zVlM1@wy za{ZWv**52{(&~cJ!qNixg;nWqE8~ZDw_3YTOoSH7^b}gZ@op?X2rCJzW81Mw52Uw- z`{yKM%E-!cT9TDIK_}NLG2{rR48___jRK|1GKgmM32PmE^;=*?m6T82A8qZ*(Bx4b zdT|a})jHUCc@)vL=p#&xY!%?mm<+5ZY7W=osTLCM=(pvc_871)m~4nQh=HB!l?B>1 z9kEX5UWkGkLh-xlGz~G$;n{Mg4FXTc83t8N758#;iL**#>c1XYSX-+K8G!CYc_5mM zp|wHTp-@!-rI}g~QR%1^-I)=@xM~j^-G<38ES!wUF=H9}m`^#v&aiAzt@U|F!Z`$h zw(ckUk`BoWU^sU44EaW+#H{wDRbt;XQLr-vOa;%;i2um zB*k_Ou2tbvC1CfO38cl>b`-TXJVv)QeNL;_+c%0^QMMTQE7R(SGw$u z{`OFmaf+W5V{Bn14{l_w;YPWA>!hYI$MIxN0Q22hAb>|1r9St2S{I@{_HhWI(#;VG z!5`=O`;fv44=}$Ud}2P>{`&z)ZXY{0aoYOd5<4}{BjUqYiNdpFb{9Wnz4+hm(_}{t z6@>xS;d;3WV z#)d(azn`9-oL}%P*;~LOs@DO7aC1w#t-4A8dmi&|2`t zvoMS;SLBIcS)pwEes11{bP2sPz6XzXF}56xS{HO8@)vvuaqJ*}qIY2UD?RkCm~qU{ z{`qfS#QtV3tqusSz+auQ{YESTVURz~O)Cu{7a#I>)E;4&gYgG*`~UN2I3M)vf!C=5 TIybodQ7AoaL#@Iiwjuuyn)sZg literal 0 HcmV?d00001 diff --git a/docs/resources/self_install_flow.png b/docs/resources/self_install_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..19e12ca14b1fe4ddebdeec092dd8c0ab7ed48445 GIT binary patch literal 27460 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 V$bAr_5U7G`t14?L6)2ed{~xM^X}16X literal 0 HcmV?d00001 diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php index 5ba44d8..0373de8 100644 --- a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php @@ -7,14 +7,15 @@ namespace Magento\ComposerRootUpdatePlugin\ComposerReimplementation; use Composer\Package\BasePackage; +use Composer\Package\Loader\RootPackageLoader; use Composer\Package\Version\VersionParser; /** - * Class AccessibleRootPackageLoader - * - * Copy and expose necessary private methods + * Copy and expose necessary private methods of Composer's RootPackageLoader implementation * * Functions here may need to be updated to match future versions of Composer + * + * @see RootPackageLoader */ class AccessibleRootPackageLoader { diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php index e51b40b..7b03d5a 100644 --- a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php @@ -16,11 +16,11 @@ use Symfony\Component\Console\Input\InputInterface; /** - * Class ExtendableRequireCommand - * * Necessary functionality from Composer's native RequireCommand class split out of the larger original methods * * Functions here may need to be updated to match future versions of Composer + * + * @see RequireCommand */ abstract class ExtendableRequireCommand extends RequireCommand { @@ -256,6 +256,8 @@ protected function getRequirementsInteractive() * Copied from RequireCommand::revertComposerFile() in Composer 1.8.0, it needs to be separate to use the plugin's * file backup rather than the one that RequireCommand natively picks up, which will contain the plugin's changes * + * @see RequireCommand::revertComposerFile() + * * @param string $message * @return void */ diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php index 20b68b0..4f17a2d 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php @@ -11,7 +11,7 @@ use Magento\ComposerRootUpdatePlugin\Plugin\Commands\UpdatePluginNamespaceCommands; /** - * Class CommandProvider + * Composer boilerplate to supply the plugin's commands to the command registry */ class CommandProvider implements CommandProviderCapability { diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php index 74a4e09..a3ad86a 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php @@ -6,7 +6,6 @@ namespace Magento\ComposerRootUpdatePlugin\Plugin\Commands; -use Composer\Package\Version\VersionParser; use Magento\ComposerRootUpdatePlugin\ComposerReimplementation\ExtendableRequireCommand; use Magento\ComposerRootUpdatePlugin\Utils\PackageUtils; use Magento\ComposerRootUpdatePlugin\Utils\Console; @@ -20,19 +19,18 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Class RootUpdateCommand + * Extend composer's native `require` command and attach plugin functionality to the original process */ class MageRootRequireCommand extends ExtendableRequireCommand { /** * CLI Options */ - const SKIP_OPT = 'skip-magento-root'; - const OVERRIDE_OPT = 'use-magento-values'; + const SKIP_OPT = 'skip-magento-root-plugin'; + const OVERRIDE_OPT = 'use-default-magento-values'; const INTERACTIVE_OPT = 'interactive-magento-conflicts'; - const PREVIOUS_PACKAGE_OPT = 'previous-magento-package'; - - const PREV_OPT_HINT = 'magento/product--edition='; + const BASE_EDITION_OPT = 'base-magento-edition'; + const BASE_VERSION_OPT = 'base-magento-version'; /** * @var string $commandName @@ -92,11 +90,17 @@ public function configure() 'Interactive interface to resolve conflicts during the Magento root composer.json update.' ) ->addOption( - static::PREVIOUS_PACKAGE_OPT, + static::BASE_EDITION_OPT, null, InputOption::VALUE_REQUIRED, - 'Use a previously-installed Magento product version as the base for composer.json updates', - static::PREV_OPT_HINT + 'Edition of the initially-installed Magento product to use as the base for composer.json updates. ' . + 'Valid values: community, enterprise' + ) + ->addOption( + static::BASE_VERSION_OPT, + null, + InputOption::VALUE_REQUIRED, + 'Version of the initially-installed Magento product to use as the base for composer.json updates.' ); $mageHelp = ' @@ -137,8 +141,9 @@ public function execute(InputInterface $input, OutputInterface $output) if ($fileParsed !== 0) { return $fileParsed; } - $didUpdate = false; + $updater = null; + $didUpdate = false; $package = null; $constraint = null; $requires = $input->getArgument('packages'); @@ -228,27 +233,13 @@ public function execute(InputInterface $input, OutputInterface $output) */ protected function runUpdate($updater, $input, $targetEdition, $targetConstraint) { - $overrideOriginal = $input->getOption(static::PREVIOUS_PACKAGE_OPT); - $overrideOriginalEdition = null; - $overrideOriginalVersion = null; - if ($overrideOriginal && $overrideOriginal != static::PREV_OPT_HINT) { - $parser = new VersionParser(); - $requirement = $parser->parseNameVersionPairs([$overrideOriginal]); - $opt = '--' . static::PREVIOUS_PACKAGE_OPT; - if (count($requirement) !== 1) { - throw new InvalidOptionException("'$opt' accepts exactly one package requirement"); - } elseif (count($requirement[0]) !== 2) { - throw new InvalidOptionException("'$opt' requires both a package and version"); - } - $requirement = $requirement[0]; - $name = $requirement['name']; - $overrideOriginalEdition = PackageUtils::getMagentoProductEdition($name); - if (!$overrideOriginalEdition) { - throw new InvalidOptionException("'$opt' accepts only Magento product packages; \"$name\" given"); - } - $overrideOriginalVersion = $requirement['version']; - if (!PackageUtils::isConstraintStrict($overrideOriginalVersion)) { - throw new InvalidOptionException("'$opt' does not accept non-strict version constraints"); + $overrideOriginalEdition = $input->getOption(static::BASE_EDITION_OPT); + $overrideOriginalVersion = $input->getOption(static::BASE_VERSION_OPT); + if ($overrideOriginalEdition) { + $overrideOriginalEdition = strtolower($overrideOriginalEdition); + if ($overrideOriginalEdition !== 'community' && $overrideOriginalEdition !== 'enterprise') { + $opt = '--' . static::BASE_EDITION_OPT; + throw new InvalidOptionException("'$opt' accepts only 'community' or 'enterprise'"); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php index 6f2f3e3..27aab42 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php @@ -14,8 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Class WebSetupWizardInstallCommand - * * Namespace for any plugin-specific operations that do not fit under other commands * * Checks the first argument for the actual function to run diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php index a8538a6..7871ddf 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php @@ -17,8 +17,6 @@ use Magento\ComposerRootUpdatePlugin\Setup\WebSetupWizardPluginInstaller; /** - * Class PluginDefinition - * * Composer's entry point for the plugin, defines the command provider and Web Setup Wizard Installer's event triggers */ class PluginDefinition implements PluginInterface, Capable, EventSubscriberInterface diff --git a/src/Magento/ComposerRootUpdatePlugin/README.md b/src/Magento/ComposerRootUpdatePlugin/README.md index bd0aec3..802813f 100644 --- a/src/Magento/ComposerRootUpdatePlugin/README.md +++ b/src/Magento/ComposerRootUpdatePlugin/README.md @@ -22,17 +22,17 @@ If the local Magento installation has previously been updated from a previous Ma In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. - composer require --previous-magento-package = + composer require --base-magento-edition --base-magento-version ## Conflicting custom values If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. -To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-magento-values` option. +To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-default-magento-values` option. ## Bypassing the plugin -To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root` option. +To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root-plugin` option. ## Refreshing the plugin for the Web Setup Wizard If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php index b1abfd1..317fcb0 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php @@ -11,7 +11,7 @@ use Magento\Framework\Setup\ModuleDataSetupInterface; /** - * Class InstallData + * Magento module hook to attach plugin installation functionality to `magento setup` operations */ class InstallData implements InstallDataInterface { diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php index ea31a63..a61dee8 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php @@ -11,7 +11,7 @@ use Magento\Framework\Setup\ModuleDataSetupInterface; /** - * Class RecurringData + * Magento module hook to attach plugin installation functionality to `magento setup` operations */ class RecurringData implements InstallDataInterface { diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php index 5703caf..f091ef3 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php @@ -11,7 +11,7 @@ use Magento\Framework\Setup\UpgradeDataInterface; /** - * Class UpgradeData + * Magento module hook to attach plugin installation functionality to `magento setup` operations */ class UpgradeData implements UpgradeDataInterface { diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php index acd727f..b39f578 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php @@ -18,9 +18,9 @@ use Magento\ComposerRootUpdatePlugin\Plugin\PluginDefinition; /** - * Class WebSetupWizardPluginInstaller + * Handles plugin installation in the `var` directory, where it needs to be present for the Web Setup Wizard */ -abstract class WebSetupWizardPluginInstaller +class WebSetupWizardPluginInstaller { /** * Process a package event and look for changes in the plugin package version diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php similarity index 95% rename from src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php rename to src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php index e81d0a2..05ae2fa 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php @@ -13,9 +13,9 @@ use Magento\ComposerRootUpdatePlugin\Utils\Console; /** - * Class ConflictResolver + * Calculates updated values based on the deltas between original version, target version, and user customizations */ -class ConflictResolver +class DeltaResolver { /** * Types of action to take on individual values when a delta is found; returned by findResolution() @@ -55,7 +55,7 @@ class ConflictResolver protected $userRootPackage; /** - * ConflictResolver constructor. + * DeltaResolver constructor. * * @param boolean $overrideUserValues * @param RootPackageRetriever $retriever @@ -76,20 +76,35 @@ public function __construct($overrideUserValues, $retriever) * * @return array */ - public function resolveConflicts() + public function resolveRootDeltas() { $original = $this->originalMageRootPackage; $target = $this->targetMageRootPackage; $user = $this->userRootPackage; $this->resolveLinkSection('require', $original->getRequires(), $target->getRequires(), $user->getRequires()); - $this->resolveLinkSection('require-dev', $original->getDevRequires(), $target->getDevRequires(), $user->getDevRequires()); - $this->resolveLinkSection('conflict', $original->getConflicts(), $target->getConflicts(), $user->getConflicts()); + $this->resolveLinkSection( + 'require-dev', + $original->getDevRequires(), + $target->getDevRequires(), + $user->getDevRequires() + ); + $this->resolveLinkSection( + 'conflict', + $original->getConflicts(), + $target->getConflicts(), + $user->getConflicts() + ); $this->resolveLinkSection('provide', $original->getProvides(), $target->getProvides(), $user->getProvides()); $this->resolveLinkSection('replace', $original->getReplaces(), $target->getReplaces(), $user->getReplaces()); $this->resolveArraySection('autoload', $original->getAutoload(), $target->getAutoload(), $user->getAutoload()); - $this->resolveArraySection('autoload-dev', $original->getDevAutoload(), $target->getDevAutoload(), $user->getDevAutoload()); + $this->resolveArraySection( + 'autoload-dev', + $original->getDevAutoload(), + $target->getDevAutoload(), + $user->getDevAutoload() + ); $this->resolveArraySection('extra', $original->getExtra(), $target->getExtra(), $user->getExtra()); $this->resolveArraySection('suggest', $original->getSuggests(), $target->getSuggests(), $user->getSuggests()); diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php index f6b681f..47d1350 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php @@ -13,9 +13,7 @@ use Magento\ComposerRootUpdatePlugin\Plugin\PluginDefinition; /** - * Class MagentoRootUpdater - * - * + * Handles updates of the Magento root project composer.json file based on necessary changes for the target version */ class MagentoRootUpdater { @@ -91,9 +89,9 @@ public function runUpdate( "Base Magento project package version: magento/project-$originalEdition-edition $prettyOriginalVersion" ); - $resolver = new ConflictResolver($overrideOption, $retriever); + $resolver = new DeltaResolver($overrideOption, $retriever); - $jsonChanges = $resolver->resolveConflicts(); + $jsonChanges = $resolver->resolveRootDeltas(); if ($jsonChanges) { $this->jsonChanges = $jsonChanges; diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php index 5efa975..f146da6 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php @@ -13,6 +13,7 @@ use Composer\Package\BasePackage; use Composer\Package\Locker; use Composer\Package\PackageInterface; +use Composer\Package\RootPackageInterface; use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionSelector; use Composer\Repository\CompositeRepository; @@ -20,6 +21,9 @@ use Magento\ComposerRootUpdatePlugin\Utils\PackageUtils; use Magento\ComposerRootUpdatePlugin\Utils\Console; +/** + * Contains methods to retrieve composer Package objects for the relevant Magento project root packages + */ class RootPackageRetriever { /** @@ -112,7 +116,7 @@ public function __construct( $this->targetRootPackage = null; $this->fetchedTarget = null; if (!$overrideOriginalEdition || !$overrideOriginalVersion) { - $this->parseOriginalVersionAndEditionFromLock(); + $this->parseOriginalVersionAndEditionFromLock($overrideOriginalEdition, $overrideOriginalVersion); } else { $this->originalEdition = $overrideOriginalEdition; $this->originalVersion = $overrideOriginalVersion; @@ -124,7 +128,7 @@ public function __construct( * Get the project package that should be used as the basis for Magento root comparisons * * @param bool $overrideOption - * @return PackageInterface|boolean + * @return PackageInterface|bool */ public function getOriginalRootPackage($overrideOption) { @@ -167,6 +171,14 @@ public function getOriginalRootPackage($overrideOption) return $this->originalRootPackage; } + /** + * Get the project package that should be used as the target for Magento root comparisons + * + * @param bool $ignorePlatformReqs + * @param string $phpVersion + * @param string $preferredStability + * @return PackageInterface|bool + */ public function getTargetRootPackage( $ignorePlatformReqs = true, $phpVersion = null, @@ -196,6 +208,11 @@ public function getTargetRootPackage( return $this->targetRootPackage; } + /** + * Get the currently installed root package + * + * @return RootPackageInterface + */ public function getUserRootPackage() { return $this->composer->getPackage(); @@ -267,9 +284,11 @@ protected function fetchMageRootFromRepo( /** * Gets the Magento product package in composer.lock and populates the version and edition in CommonUtils * + * @param string $overrideEdition + * @param string $overrideVersion * @return void */ - protected function parseOriginalVersionAndEditionFromLock() + protected function parseOriginalVersionAndEditionFromLock($overrideEdition = null, $overrideVersion = null) { $locker = $this->getRootLocker(); if (!$locker || !$locker->isLocked()) { @@ -294,11 +313,21 @@ protected function parseOriginalVersionAndEditionFromLock() } if ($lockedMageProduct) { - $this->originalEdition = PackageUtils::getMagentoProductEdition($lockedMageProduct->getName()); - $this->originalVersion = $lockedMageProduct->getVersion(); - $this->prettyOriginalVersion = $lockedMageProduct->getPrettyVersion(); - if (!$this->prettyOriginalVersion) { + if ($overrideEdition) { + $this->originalEdition = $overrideEdition; + } else { + $this->originalEdition = PackageUtils::getMagentoProductEdition($lockedMageProduct->getName()); + } + + if ($overrideVersion) { + $this->originalVersion = $overrideVersion; $this->prettyOriginalVersion = $this->originalVersion; + } else { + $this->originalVersion = $lockedMageProduct->getVersion(); + $this->prettyOriginalVersion = $lockedMageProduct->getPrettyVersion(); + if (!$this->prettyOriginalVersion) { + $this->prettyOriginalVersion = $this->originalVersion; + } } } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php index b80d3ec..7678989 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php @@ -18,9 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Class Console - * - * Shared static logger and interaction methods + * Singleton logger with console interaction methods */ class Console { diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php index 17e917a..53b9424 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php @@ -11,7 +11,7 @@ use Composer\Package\Version\VersionParser; /** - * Class PackageUtils + * Common package-related utility functions */ class PackageUtils { diff --git a/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php b/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php index b8b32c0..88f2772 100644 --- a/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php +++ b/tests/Integration/Magento/ComposerRootUpdatePlugin/ComposerRootUpdatePluginTest.php @@ -72,7 +72,7 @@ public function testUpdateWithOverride() static::configureComposerJson(__DIR__ . '/_files/expected_override.composer.json', $expectedDir); static::execComposer( - 'require magento/product-community-edition=1000.1000.1000 --no-update --use-magento-values' + 'require magento/product-community-edition=1000.1000.1000 --no-update --use-default-magento-values' ); $this->assertJsonFileEqualsJsonFile("$expectedDir/composer.json", static::$workingDir . '/composer.json'); diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php index 586b3c9..8eb8f7c 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php @@ -15,9 +15,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -/** - * Class RootUpdateCommandTest - */ class MageRootRequireCommandTest extends UpdatePluginTestCase { /** @var TestApplication */ diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php index 1555adc..85d174c 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php @@ -12,9 +12,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -/** - * Class TestApplication - */ class TestApplication extends \Composer\Console\Application { /** @var bool */ diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php index a9638a7..11847af 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php @@ -11,7 +11,7 @@ use ReflectionClass; /** - * Class UpdatePluginTestCase + * Helper functions for common test data creation and assertion operations */ abstract class UpdatePluginTestCase extends \PHPUnit\Framework\TestCase { diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php similarity index 83% rename from tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php rename to tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php index b015409..5f9aacb 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php @@ -12,10 +12,7 @@ use Magento\ComposerRootUpdatePlugin\UpdatePluginTestCase; use PHPUnit\Framework\MockObject\MockObject; -/** - * Class ConflictResolverTest - */ -class ConflictResolverTest extends UpdatePluginTestCase +class DeltaResolverTest extends UpdatePluginTestCase { /** @var MockObject|BaseIO */ public $io; @@ -25,31 +22,31 @@ class ConflictResolverTest extends UpdatePluginTestCase public function testFindResolutionAddElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $resolution = $resolver->findResolution('field', null, 'newVal', null); - $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); + $this->assertEquals(DeltaResolver::ADD_VAL, $resolution); } public function testFindResolutionRemoveElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', null, 'oldVal'); - $this->assertEquals(ConflictResolver::REMOVE_VAL, $resolution); + $this->assertEquals(DeltaResolver::REMOVE_VAL, $resolution); } public function testFindResolutionChangeElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'oldVal'); - $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + $this->assertEquals(DeltaResolver::CHANGE_VAL, $resolution); } public function testFindResolutionNoUpdate() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'newVal'); $this->assertNull($resolution); @@ -60,7 +57,7 @@ public function testFindResolutionConflictNoOverride() $this->io->expects($this->at(0))->method('writeError') ->with($this->stringContains('will not be changed')); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); $this->assertNull($resolution); @@ -68,43 +65,43 @@ public function testFindResolutionConflictNoOverride() public function testFindResolutionConflictOverride() { - $resolver = new ConflictResolver(true, $this->retriever); + $resolver = new DeltaResolver(true, $this->retriever); $this->io->expects($this->at(1))->method('writeError') ->with($this->stringContains('overriding local changes')); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + $this->assertEquals(DeltaResolver::CHANGE_VAL, $resolution); } public function testFindResolutionConflictOverrideRestoreRemoved() { - $resolver = new ConflictResolver(true, $this->retriever); + $resolver = new DeltaResolver(true, $this->retriever); $this->io->expects($this->at(1))->method('writeError') ->with($this->stringContains('overriding local changes')); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', null); - $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); + $this->assertEquals(DeltaResolver::ADD_VAL, $resolution); } public function testFindResolutionInteractiveConfirm() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); Console::setInteractive(true); $this->io->method('isInteractive')->willReturn(true); $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); - $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); + $this->assertEquals(DeltaResolver::CHANGE_VAL, $resolution); } public function testFindResolutionInteractiveNoConfirm() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); Console::setInteractive(true); $this->io->method('isInteractive')->willReturn(true); $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); @@ -116,7 +113,7 @@ public function testFindResolutionInteractiveNoConfirm() public function testFindResolutionNonInteractiveEnvironmentError() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); Console::setInteractive(true); $this->io->method('isInteractive')->willReturn(false); @@ -129,7 +126,7 @@ public function testFindResolutionNonInteractiveEnvironmentError() public function testResolveNestedArrayNonArrayAdd() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveNestedArray('field', null, 'newVal', null); $this->assertEquals([true, 'newVal'], $result); @@ -137,7 +134,7 @@ public function testResolveNestedArrayNonArrayAdd() public function testResolveNestedArrayNonArrayRemove() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal'); $this->assertEquals([true, null], $result); @@ -145,7 +142,7 @@ public function testResolveNestedArrayNonArrayRemove() public function testResolveNestedArrayNonArrayChange() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); $this->assertEquals([true, 'newVal'], $result); @@ -153,7 +150,7 @@ public function testResolveNestedArrayNonArrayChange() public function testResolveArrayMismatchedArray() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', 'oldVal', @@ -166,7 +163,7 @@ public function testResolveArrayMismatchedArray() public function testResolveArrayMismatchedMap() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['oldVal'], @@ -181,7 +178,7 @@ public function testResolveArrayFlatArrayAddElement() { $expected = ['val1', 'val2', 'val3']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['val1'], @@ -194,7 +191,7 @@ public function testResolveArrayFlatArrayAddElement() public function testResolveArrayFlatArrayRemoveElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['val1', 'val2', 'val3'], @@ -207,7 +204,7 @@ public function testResolveArrayFlatArrayRemoveElement() public function testResolveArrayFlatArrayAddAndRemoveElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['val1', 'val2', 'val3'], @@ -222,7 +219,7 @@ public function testResolveArrayAssociativeAddElement() { $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => 'val1'], @@ -237,7 +234,7 @@ public function testResolveArrayAssociativeRemoveElement() { $expected = ['key2' => 'val2', 'key3' => 'val3']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => 'val1', 'key2' => 'val2'], @@ -252,7 +249,7 @@ public function testResolveArrayAssociativeAddAndRemoveElement() { $expected = ['key3' => 'val3', 'key4' => 'val4']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => 'val1', 'key2' => 'val2'], @@ -267,7 +264,7 @@ public function testResolveArrayNestedAdd() { $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], @@ -288,7 +285,7 @@ public function testResolveArrayNestedRemove() { $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], @@ -313,7 +310,7 @@ public function testResolveLinksAddLink() $targetMageLinks = array_merge($originalMageLinks, $this->createLinks(1, 'targetMage/link')); $expected = array_merge($targetMageLinks, $userLink); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveLinkSection( 'require', $originalMageLinks, @@ -332,7 +329,7 @@ public function testResolveLinksRemoveLink() $targetMageLinks = array_slice($originalMageLinks, 1); $expected = array_merge($targetMageLinks, $userLink); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveLinkSection( 'require', $originalMageLinks, @@ -351,7 +348,7 @@ public function testResolveLinksChangeLink() $targetMageLinks = $this->changeLink($originalMageLinks, 1); $expected = array_merge($targetMageLinks, $userLink); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new DeltaResolver(false, $this->retriever); $result = $resolver->resolveLinkSection( 'require', $originalMageLinks, diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php index 2cd1b23..e043100 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php @@ -25,9 +25,6 @@ use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Input\InputInterface; -/** - * Class MagentoRootUpdaterTest - */ class MagentoRootUpdaterTest extends UpdatePluginTestCase { /** @var MockObject|Composer */ From 8648dc55a03d7bdb65e8bddc84a48742483f5c96 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Tue, 9 Apr 2019 12:06:30 -0500 Subject: [PATCH 07/15] MC-5465: Changing Console and WebSetupPluginInstaller classes to not be static --- .../ExtendableRequireCommand.php | 2 +- .../Commands/MageRootRequireCommand.php | 22 ++-- .../UpdatePluginNamespaceCommands.php | 16 ++- .../Plugin/PluginDefinition.php | 4 +- .../Setup/InstallData.php | 22 +++- .../Setup/RecurringData.php | 22 +++- .../Setup/UpgradeData.php | 22 +++- .../Setup/WebSetupWizardPluginInstaller.php | 74 ++++++++----- .../Updater/ConflictResolver.php | 35 +++--- .../Updater/MagentoRootUpdater.php | 23 ++-- .../Updater/RootPackageRetriever.php | 32 ++++-- .../Utils/Console.php | 103 ++++++++---------- .../Utils/PackageUtils.php | 2 +- .../Commands/MageRootRequireCommandTest.php | 12 +- .../TestHelpers/TestApplication.php | 10 +- .../Updater/ConflictResolverTest.php | 74 +++++++------ .../Updater/MagentoRootUpdaterTest.php | 43 ++++++-- .../Updater/RootPackageRetrieverTest.php | 43 +++++--- 18 files changed, 357 insertions(+), 204 deletions(-) diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php index e51b40b..e941ae4 100644 --- a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php @@ -40,7 +40,7 @@ abstract class ExtendableRequireCommand extends RequireCommand protected $mageNewlyCreated; /** - * @var bool|string $mageComposerBackup + * @var boolean|string $mageComposerBackup */ protected $mageComposerBackup; diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php index 74a4e09..f17d490 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php @@ -42,7 +42,12 @@ class MageRootRequireCommand extends ExtendableRequireCommand /** * @var RootPackageRetriever $retriever */ - private $retriever; + protected $retriever; + + /** + * @var Console $console + */ + protected $console; /** * Call the parent setApplication method but also change the command's name to update @@ -57,7 +62,6 @@ public function setApplication(Application $application = null) // added to the command registry $this->setName($this->commandName); parent::setApplication($application); - Console::setIO($this->getIO()); } /** @@ -132,7 +136,7 @@ public function configure() public function execute(InputInterface $input, OutputInterface $output) { $updater = null; - Console::setIO($this->getIO()); + $this->console = new Console($this->getIO(), $input->getOption(static::INTERACTIVE_OPT)); $fileParsed = $this->parseComposerJsonFile($input); if ($fileParsed !== 0) { return $fileParsed; @@ -162,12 +166,12 @@ public function execute(InputInterface $input, OutputInterface $output) // Found a Magento product in the command arguments; try to run the updater try { - $updater = new MagentoRootUpdater($this->getComposer()); + $updater = new MagentoRootUpdater($this->console, $this->getComposer()); $didUpdate = $this->runUpdate($updater, $input, $edition, $constraint); } catch (\Exception $e) { $label = 'Magento ' . ucfirst($edition) . " Edition $constraint"; $this->revertMageComposerFile("Update of composer.json with $label changes failed"); - Console::log($e->getMessage()); + $this->console->log($e->getMessage()); $didUpdate = false; } @@ -178,12 +182,12 @@ public function execute(InputInterface $input, OutputInterface $output) if ($didUpdate) { // Update composer.json before the native execute(), as it reads the file instead of an in-memory object $label = $this->retriever->getTargetLabel(); - Console::info("Updating composer.json for $label ..."); + $this->console->info("Updating composer.json for $label ..."); try { $updater->writeUpdatedComposerJson(); } catch (\Exception $e) { $this->revertMageComposerFile("Update of composer.json with $label changes failed"); - Console::log($e->getMessage()); + $this->console->log($e->getMessage()); $didUpdate = false; } } @@ -202,7 +206,7 @@ public function execute(InputInterface $input, OutputInterface $output) // If the native execute() didn't succeed, revert the Magento changes to the composer.json file $this->revertMageComposerFile('The native \'composer ' . $this->commandName . '\' command failed'); if ($constraint && !PackageUtils::isConstraintStrict($constraint)) { - Console::comment( + $this->console->comment( "Recommended: Use a specific Magento version constraint instead of \"$package: $constraint\"" ); } @@ -252,8 +256,8 @@ protected function runUpdate($updater, $input, $targetEdition, $targetConstraint } } - Console::setInteractive($input->getOption(static::INTERACTIVE_OPT)); $this->retriever = new RootPackageRetriever( + $this->console, $this->getComposer(), $targetEdition, $targetConstraint, diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php index 6f2f3e3..6f48f56 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php @@ -24,12 +24,17 @@ class UpdatePluginNamespaceCommands extends BaseCommand { const NAME = 'magento-update-plugin'; + /** + * @var Console $console + */ + protected $console; + /** * Map of operation command to description * * @var array $operations */ - private static $operations = [ + protected static $operations = [ 'list' => "List all operations available in the %command.name% namespace. This is equivalent\n". 'to running %command.full_name% without an operation.', @@ -63,16 +68,17 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { + $this->console = new Console($this->getIO()); $operation = $input->getArgument('operation'); - Console::setIO($this->getIO()); if (empty($operation) || $operation == 'list') { - Console::log(static::describeOperations() . "\n"); + $this->console->log(static::describeOperations() . "\n"); return 0; } if ($operation == 'install') { - return WebSetupWizardPluginInstaller::doVarInstall(); + $setupWizardInstaller = new WebSetupWizardPluginInstaller($this->console); + return $setupWizardInstaller->doVarInstall(); } else { - Console::error("'$operation' is not a supported operation for ".static::NAME); + $this->console->error("'$operation' is not a supported operation for ".static::NAME); return 1; } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php index a8538a6..7758f97 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php @@ -15,6 +15,7 @@ use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface;; use Magento\ComposerRootUpdatePlugin\Setup\WebSetupWizardPluginInstaller; +use Magento\ComposerRootUpdatePlugin\Utils\Console; /** * Class PluginDefinition @@ -62,7 +63,8 @@ public function packageUpdate(PackageEvent $event) { // Safeguard against the source file being removed before the event is triggered if (class_exists('\Magento\ComposerRootUpdatePlugin\Setup\WebSetupWizardPluginInstaller')) { - WebSetupWizardPluginInstaller::packageEvent($event); + $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($event->getIO())); + $setupWizardInstaller->packageEvent($event); } } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php index b1abfd1..558a07e 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php @@ -6,9 +6,19 @@ namespace Magento\ComposerRootUpdatePlugin\Setup; +use Composer\IO\ConsoleIO; +use Magento\ComposerRootUpdatePlugin\Utils\Console; use Magento\Framework\Setup\InstallDataInterface; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\ModuleDataSetupInterface; +use Symfony\Component\Console\Helper\DebugFormatterHelper; +use Symfony\Component\Console\Helper\FormatterHelper; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\ProcessHelper; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; /** * Class InstallData @@ -24,6 +34,16 @@ class InstallData implements InstallDataInterface */ public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - WebSetupWizardPluginInstaller::doVarInstall(); + $io = new ConsoleIO(new ArrayInput([]), + new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), + new HelperSet([ + new FormatterHelper(), + new DebugFormatterHelper(), + new ProcessHelper(), + new QuestionHelper() + ]) + ); + $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); + $setupWizardInstaller->doVarInstall(); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php index ea31a63..dc64eda 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php @@ -6,9 +6,19 @@ namespace Magento\ComposerRootUpdatePlugin\Setup; +use Composer\IO\ConsoleIO; +use Magento\ComposerRootUpdatePlugin\Utils\Console; use Magento\Framework\Setup\InstallDataInterface; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\ModuleDataSetupInterface; +use Symfony\Component\Console\Helper\DebugFormatterHelper; +use Symfony\Component\Console\Helper\FormatterHelper; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\ProcessHelper; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; /** * Class RecurringData @@ -24,6 +34,16 @@ class RecurringData implements InstallDataInterface */ public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - WebSetupWizardPluginInstaller::doVarInstall(); + $io = new ConsoleIO(new ArrayInput([]), + new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), + new HelperSet([ + new FormatterHelper(), + new DebugFormatterHelper(), + new ProcessHelper(), + new QuestionHelper() + ]) + ); + $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); + $setupWizardInstaller->doVarInstall(); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php index 5703caf..0dce5ac 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php @@ -6,9 +6,19 @@ namespace Magento\ComposerRootUpdatePlugin\Setup; +use Composer\IO\ConsoleIO; +use Magento\ComposerRootUpdatePlugin\Utils\Console; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\Setup\UpgradeDataInterface; +use Symfony\Component\Console\Helper\DebugFormatterHelper; +use Symfony\Component\Console\Helper\FormatterHelper; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\ProcessHelper; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; /** * Class UpgradeData @@ -24,6 +34,16 @@ class UpgradeData implements UpgradeDataInterface */ public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - WebSetupWizardPluginInstaller::doVarInstall(); + $io = new ConsoleIO(new ArrayInput([]), + new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), + new HelperSet([ + new FormatterHelper(), + new DebugFormatterHelper(), + new ProcessHelper(), + new QuestionHelper() + ]) + ); + $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); + $setupWizardInstaller->doVarInstall(); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php index acd727f..c85ebf6 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php @@ -20,17 +20,32 @@ /** * Class WebSetupWizardPluginInstaller */ -abstract class WebSetupWizardPluginInstaller +class WebSetupWizardPluginInstaller { + /** + * @var Console $console + */ + protected $console; + + /** + * WebSetupWizardPluginInstaller constructor. + * + * @param Console $console + * @return void + */ + public function __construct($console) + { + $this->console = $console; + } + /** * Process a package event and look for changes in the plugin package version * * @param PackageEvent $event * @return void */ - public static function packageEvent($event) + public function packageEvent($event) { - Console::setIO($event->getIO()); $jobs = $event->getRequest()->getJobs(); $packageName = PluginDefinition::PACKAGE_NAME; foreach ($jobs as $job) { @@ -40,13 +55,13 @@ public static function packageEvent($event) $version = $pkg->getPrettyVersion(); try { $composer = $event->getComposer(); - static::updateSetupWizardPlugin( + $this->updateSetupWizardPlugin( $composer, $composer->getConfig()->getConfigSource()->getName(), $version ); } catch (Exception $e) { - Console::error("Web Setup Wizard installation of \"$packageName: $version\" failed.", $e); + $this->console->error("Web Setup Wizard installation of \"$packageName: $version\" failed", $e); } break; } @@ -62,37 +77,40 @@ public static function packageEvent($event) * * @return int 0 if successful, 1 if failed */ - public static function doVarInstall() + public function doVarInstall() { $packageName = PluginDefinition::PACKAGE_NAME; $rootDir = getcwd(); $path = "$rootDir/composer.json"; if (!file_exists($path)) { - Console::error("Web Setup Wizard installation of \"$packageName\" failed; unable to load $path."); + $this->console->error("Web Setup Wizard installation of \"$packageName\" failed; unable to load $path."); return 1; } $factory = new Factory(); - $composer = $factory->createComposer(Console::getIO(), $path, true, null, true); + $composer = $factory->createComposer($this->console->getIO(), $path, true, null, true); $locker = $composer->getLocker(); if ($locker->isLocked()) { $pkg = $locker->getLockedRepository()->findPackage(PluginDefinition::PACKAGE_NAME, '*'); if ($pkg !== null) { $version = $pkg->getPrettyVersion(); try { - Console::log("Checking for \"$packageName: $version\" for the Web Setup Wizard...", Console::VERBOSE); - static::updateSetupWizardPlugin($composer, $path, $version); + $this->console->log( + "Checking for \"$packageName: $version\" for the Web Setup Wizard...", + Console::VERBOSE + ); + $this->updateSetupWizardPlugin($composer, $path, $version); } catch (Exception $e) { - Console::error("Web Setup Wizard installation of \"$packageName: $version\" failed.", $e); + $this->console->error("Web Setup Wizard installation of \"$packageName: $version\" failed.", $e); return 1; } } else { - Console::error("Web Setup Wizard installation of \"$packageName\" failed; " . + $this->console->error("Web Setup Wizard installation of \"$packageName\" failed; " . "package not found in $rootDir/composer.lock."); return 1; } } else { - Console::error("Web Setup Wizard installation of \"$packageName\" failed; " . + $this->console->error("Web Setup Wizard installation of \"$packageName\" failed; " . "unable to load $rootDir/composer.lock."); return 1; } @@ -108,7 +126,7 @@ public static function doVarInstall() * @return boolean * @throws Exception */ - public static function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) + public function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) { $packageName = PluginDefinition::PACKAGE_NAME; @@ -124,7 +142,7 @@ public static function updateSetupWizardPlugin($composer, $filePath, $pluginVers $var = "$rootDir/var"; if (file_exists("$var/vendor/$packageName/composer.json")) { $varPluginComposer = (new Factory())->createComposer( - Console::getIO(), + $this->console->getIO(), "$var/vendor/$packageName/composer.json", true, "$var/vendor/$packageName", @@ -133,7 +151,7 @@ public static function updateSetupWizardPlugin($composer, $filePath, $pluginVers // If the current version of the plugin is already the version in this update, noop if ($varPluginComposer->getPackage()->getPrettyVersion() == $pluginVersion) { - Console::log( + $this->console->log( "No Web Setup Wizard update needed for $packageName; version $pluginVersion is already in $var.", Console::VERBOSE ); @@ -141,7 +159,7 @@ public static function updateSetupWizardPlugin($composer, $filePath, $pluginVers } } - Console::info("Installing \"$packageName: $pluginVersion\" for the Web Setup Wizard"); + $this->console->info("Installing \"$packageName: $pluginVersion\" for the Web Setup Wizard"); if (!file_exists($var)) { mkdir($var); @@ -158,8 +176,8 @@ public static function updateSetupWizardPlugin($composer, $filePath, $pluginVers unlink($tmpDir); mkdir($tmpDir); - $tmpComposer = static::createPluginComposer($tmpDir, $pluginVersion, $composer); - $install = Installer::create(Console::getIO(), $tmpComposer); + $tmpComposer = $this->createPluginComposer($tmpDir, $pluginVersion, $composer); + $install = Installer::create($this->console->getIO(), $tmpComposer); $install ->setDumpAutoloader(true) ->setRunScripts(false) @@ -167,12 +185,12 @@ public static function updateSetupWizardPlugin($composer, $filePath, $pluginVers ->disablePlugins(); $install->run(); - static::copyAndReplace("$tmpDir/vendor", "$var/vendor"); + $this->copyAndReplace("$tmpDir/vendor", "$var/vendor"); } catch (Exception $e) { $exception = $e; } - static::deletePath($tmpDir); + $this->deletePath($tmpDir); if ($exception !== null) { throw $exception; @@ -188,7 +206,7 @@ public static function updateSetupWizardPlugin($composer, $filePath, $pluginVers * @return void * @throws FilesystemException */ - private static function deletePath($path) + private function deletePath($path) { if (!file_exists($path)) { return; @@ -196,7 +214,7 @@ private static function deletePath($path) if (!is_link($path) && is_dir($path)) { $files = array_diff(scandir($path), ['..', '.']); foreach ($files as $file) { - static::deletePath("$path/$file"); + $this->deletePath("$path/$file"); } rmdir($path); } else { @@ -215,14 +233,14 @@ private static function deletePath($path) * @return void * @throws FilesystemException */ - private static function copyAndReplace($source, $target) + private function copyAndReplace($source, $target) { - static::deletePath($target); + $this->deletePath($target); if (is_dir($source)) { mkdir($target); $files = array_diff(scandir($source), ['..', '.']); foreach ($files as $file) { - static::copyAndReplace("$source/$file", "$target/$file"); + $this->copyAndReplace("$source/$file", "$target/$file"); } } else { copy($source, $target); @@ -238,7 +256,7 @@ private static function copyAndReplace($source, $target) * @return Composer * @throws Exception */ - private static function createPluginComposer($tmpDir, $pluginVersion, $rootComposer) + private function createPluginComposer($tmpDir, $pluginVersion, $rootComposer) { $factory = new Factory(); $tmpConfig = [ @@ -251,7 +269,7 @@ private static function createPluginComposer($tmpDir, $pluginVersion, $rootCompo } $tmpJson = new JsonFile("$tmpDir/composer.json"); $tmpJson->write($tmpConfig); - $tmpComposer = $factory->createComposer(Console::getIO(), "$tmpDir/composer.json", true, $tmpDir); + $tmpComposer = $factory->createComposer($this->console->getIO(), "$tmpDir/composer.json", true, $tmpDir); $tmpConfig = $tmpComposer->getConfig(); $tmpConfig->setAuthConfigSource($rootComposer->getConfig()->getAuthConfigSource()); $tmpComposer->setConfig($tmpConfig); diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php index e81d0a2..ef49496 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolver.php @@ -25,7 +25,12 @@ class ConflictResolver const CHANGE_VAL = 'change_value'; /** - * @var bool $overrideUserValues + * @var Console $console + */ + protected $console; + + /** + * @var boolean $overrideUserValues */ protected $overrideUserValues; @@ -57,12 +62,14 @@ class ConflictResolver /** * ConflictResolver constructor. * + * @param Console $console * @param boolean $overrideUserValues * @param RootPackageRetriever $retriever * @return void */ - public function __construct($overrideUserValues, $retriever) + public function __construct($console, $overrideUserValues, $retriever) { + $this->console = $console; $this->overrideUserValues = $overrideUserValues; $this->retriever = $retriever; $this->originalMageRootPackage = $retriever->getOriginalRootPackage($overrideUserValues); @@ -169,14 +176,14 @@ public function findResolution( $shouldOverride = $this->overrideUserValues; if ($this->overrideUserValues) { - Console::log($conflictDesc); - Console::log("Overriding local changes due to --" . MageRootRequireCommand::OVERRIDE_OPT . '.'); + $this->console->log($conflictDesc); + $this->console->log("Overriding local changes due to --" . MageRootRequireCommand::OVERRIDE_OPT . '.'); } else { - $shouldOverride = Console::ask("$conflictDesc.\nWould you like to override the local changes?"); + $shouldOverride = $this->console->ask("$conflictDesc.\nWould you like to override the local changes?"); } if (!$shouldOverride) { - Console::comment("$conflictDesc and will not be changed. Re-run using " . + $this->console->comment("$conflictDesc and will not be changed. Re-run using " . '--' . MageRootRequireCommand::OVERRIDE_OPT . ' or --' . MageRootRequireCommand::INTERACTIVE_OPT . ' to override with Magento values.'); $action = null; @@ -250,11 +257,11 @@ public function resolveLinkSection($section, $originalMageLinks, $targetMageLink $newVal = $adds[$package]->getConstraint()->getPrettyString(); return "$package=$newVal"; }, array_keys($adds)); - Console::labeledVerbose("Adding $section constraints: " . implode(', ', $prettyAdds)); + $this->console->labeledVerbose("Adding $section constraints: " . implode(', ', $prettyAdds)); } if ($removes !== []) { $changed = true; - Console::labeledVerbose("Removing $section entries: " . implode(', ', $removes)); + $this->console->labeledVerbose("Removing $section entries: " . implode(', ', $removes)); } if ($changes !== []) { $changed = true; @@ -262,7 +269,7 @@ public function resolveLinkSection($section, $originalMageLinks, $targetMageLink $newVal = $changes[$package]->getConstraint()->getPrettyString(); return "$package=$newVal"; }, array_keys($changes)); - Console::labeledVerbose("Updating $section constraints: " . implode(', ', $prettyChanges)); + $this->console->labeledVerbose("Updating $section constraints: " . implode(', ', $prettyChanges)); } if ($changed) { @@ -399,14 +406,14 @@ public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $us $flatAdds = array_diff(array_diff($targetMageFlatPart, $originalMageFlatPart), $flatResult); if ($flatAdds !== []) { $valChanged = true; - Console::labeledVerbose("Adding $field entries: " . implode(', ', $flatAdds)); + $this->console->labeledVerbose("Adding $field entries: " . implode(', ', $flatAdds)); $flatResult = array_unique(array_merge($flatResult, $flatAdds)); } $flatRemoves = array_intersect(array_diff($originalMageFlatPart, $targetMageFlatPart), $flatResult); if ($flatRemoves !== []) { $valChanged = true; - Console::labeledVerbose("Removing $field entries: " . implode(', ', $flatRemoves)); + $this->console->labeledVerbose("Removing $field entries: " . implode(', ', $flatRemoves)); $flatResult = array_diff($flatResult, $flatRemoves); } @@ -417,15 +424,15 @@ public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $us $prettyTargetMageVal = json_encode($targetMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($action == static::ADD_VAL) { $valChanged = true; - Console::labeledVerbose("Adding $field entry: $prettyTargetMageVal"); + $this->console->labeledVerbose("Adding $field entry: $prettyTargetMageVal"); $result = $targetMageVal; } elseif ($action == static::CHANGE_VAL) { $valChanged = true; - Console::labeledVerbose("Updating $field entry: $prettyTargetMageVal"); + $this->console->labeledVerbose("Updating $field entry: $prettyTargetMageVal"); $result = $targetMageVal; } elseif ($action == static::REMOVE_VAL) { $valChanged = true; - Console::labeledVerbose("Removing $field entry"); + $this->console->labeledVerbose("Removing $field entry"); $result = null; } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php index f6b681f..0046e02 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php @@ -19,6 +19,11 @@ */ class MagentoRootUpdater { + /** + * @var Console $console + */ + protected $console; + /** * @var Composer $composer */ @@ -32,11 +37,13 @@ class MagentoRootUpdater /** * MagentoRootUpdater constructor. * + * @param Console $console * @param Composer $composer * @return void */ - public function __construct($composer) + public function __construct($console, $composer) { + $this->console = $console; $this->composer = $composer; $this->jsonChanges = []; } @@ -75,23 +82,23 @@ public function runUpdate( } if ($originalEdition == $retriever->getTargetEdition() && $originalVersion == $retriever->getTargetVersion()) { - Console::labeledVerbose( + $this->console->labeledVerbose( 'The Magento product requirement matched the current installation; no root updates are required' ); return false; } if (!$retriever->getOriginalRootPackage($overrideOption)) { - Console::log('Skipping Magento composer.json update.'); + $this->console->log('Skipping Magento composer.json update.'); return false; } - Console::setVerboseLabel($retriever->getTargetLabel()); - Console::labeledVerbose( + $this->console->setVerboseLabel($retriever->getTargetLabel()); + $this->console->labeledVerbose( "Base Magento project package version: magento/project-$originalEdition-edition $prettyOriginalVersion" ); - $resolver = new ConflictResolver($overrideOption, $retriever); + $resolver = new ConflictResolver($this->console, $overrideOption, $retriever); $jsonChanges = $resolver->resolveConflicts(); @@ -130,7 +137,7 @@ public function writeUpdatedComposerJson() } } - Console::labeledVerbose('Writing changes to the root composer.json...'); + $this->console->labeledVerbose('Writing changes to the root composer.json...'); $retVal = file_put_contents( $filePath, @@ -140,7 +147,7 @@ public function writeUpdatedComposerJson() if ($retVal === false) { throw new FilesystemException('Failed to write updated Magento root values to ' . $filePath); } - Console::labeledVerbose("$filePath has been updated"); + $this->console->labeledVerbose("$filePath has been updated"); } /** diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php index 5efa975..e7e5bc9 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php @@ -27,6 +27,11 @@ class RootPackageRetriever */ const MISSING_ROOT_LABEL = '(unknown Magento root)'; + /** + * @var Console $console + */ + protected $console; + /** * @var Composer $composer */ @@ -38,7 +43,7 @@ class RootPackageRetriever protected $originalRootPackage; /** - * @var bool $fetchedOriginal + * @var boolean $fetchedOriginal */ protected $fetchedOriginal; @@ -48,7 +53,7 @@ class RootPackageRetriever protected $targetRootPackage; /** - * @var bool $fetchedTarget + * @var boolean $fetchedTarget */ protected $fetchedTarget; @@ -90,6 +95,7 @@ class RootPackageRetriever /** * RootPackageRetriever constructor. * + * @param Console $console * @param Composer $composer * @param string $targetEdition * @param string $targetConstraint @@ -97,12 +103,14 @@ class RootPackageRetriever * @param string $overrideOriginalVersion */ public function __construct( + $console, $composer, $targetEdition, $targetConstraint, $overrideOriginalEdition = null, $overrideOriginalVersion = null ) { + $this->console = $console; $this->composer = $composer; $this->originalRootPackage = null; @@ -123,7 +131,7 @@ public function __construct( /** * Get the project package that should be used as the basis for Magento root comparisons * - * @param bool $overrideOption + * @param boolean $overrideOption * @return PackageInterface|boolean */ public function getOriginalRootPackage($overrideOption) @@ -142,9 +150,9 @@ public function getOriginalRootPackage($overrideOption) if (!$originalRootPackage) { if (!$originalEdition || !$originalVersion) { - Console::warning('No Magento product package was found in the current installation.'); + $this->console->warning('No Magento product package was found in the current installation.'); } else { - Console::warning('The Magento project package corresponding to the currently installed ' . + $this->console->warning('The Magento project package corresponding to the currently installed ' . "\"magento/product-$originalEdition-edition: $prettyOriginalVersion\" package is unavailable."); } @@ -152,7 +160,7 @@ public function getOriginalRootPackage($overrideOption) if (!$overrideRoot) { $question = 'Would you like to update the root composer.json file anyway? ' . 'This will override any changes you have made to the default composer.json file.'; - $overrideRoot = Console::ask($question); + $overrideRoot = $this->console->ask($question); } if ($overrideRoot) { @@ -209,7 +217,7 @@ public function getUserRootPackage() * @param boolean $ignorePlatformReqs * @param string $phpVersion * @param string $preferredStability - * @return PackageInterface|bool Best root package candidate or false if no valid packages found + * @return PackageInterface|boolean Best root package candidate or false if no valid packages found */ protected function fetchMageRootFromRepo( $edition, @@ -231,7 +239,7 @@ protected function fetchMageRootFromRepo( $stability = key_exists($packageName, $stabilityFlags) ? array_search($stabilityFlags[$packageName], BasePackage::$stabilities) : $minStability; - Console::comment("Minimum stability for \"$packageName: $constraint\": $stability", IOInterface::DEBUG); + $this->console->comment("Minimum stability for \"$packageName: $constraint\": $stability", IOInterface::DEBUG); $pool = new Pool( $stability, $stabilityFlags, @@ -241,7 +249,7 @@ protected function fetchMageRootFromRepo( $pool->addRepository($repos); if (!PackageUtils::isConstraintStrict($constraint)) { - Console::warning( + $this->console->warning( "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . 'the Magento root updater might not accurately determine the version to use according to other ' . 'requirements in this installation. It is recommended to use an exact version number.' @@ -258,7 +266,7 @@ protected function fetchMageRootFromRepo( if ($phpVersion) { $err = "$err for PHP version $phpVersion"; } - Console::error($err); + $this->console->error($err); } return $result; @@ -273,7 +281,7 @@ protected function parseOriginalVersionAndEditionFromLock() { $locker = $this->getRootLocker(); if (!$locker || !$locker->isLocked()) { - Console::labeledVerbose( + $this->console->labeledVerbose( 'No composer.lock file was found in the root project to check for the installed Magento version' ); return; @@ -318,7 +326,7 @@ protected function getRootLocker() $parentDir = preg_replace('/\/var\/composer\.json$/', '', $composerPath); if (file_exists("$parentDir/composer.json") && file_exists("$parentDir/composer.lock")) { $locker = new Locker( - Console::getIO(), + $this->console->getIO(), new JsonFile("$parentDir/composer.lock"), $composer->getRepositoryManager(), $composer->getInstallationManager(), diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php index b80d3ec..f22adcf 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php @@ -6,16 +6,8 @@ namespace Magento\ComposerRootUpdatePlugin\Utils; -use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; -use Symfony\Component\Console\Helper\DebugFormatterHelper; -use Symfony\Component\Console\Helper\FormatterHelper; -use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\ProcessHelper; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Console\Output\OutputInterface; +use Composer\IO\NullIO; /** * Class Console @@ -44,81 +36,78 @@ class Console /** * @var IOInterface $io */ - static protected $io = null; + protected $io; /** * @var string $verboseLabel */ - static protected $verboseLabel = null; + protected $verboseLabel; /** - * @var bool $interactive + * @var boolean $interactive */ - static protected $interactive = false; + protected $interactive; /** - * Get the shared IOInterface instance or a default ConsoleIO if one hasn't been set via setIO() + * Console constructor. * - * @return IOInterface + * @param IOInterface $io + * @param boolean $interactive + * @param string $verboseLabel + * @return void */ - static public function getIO() + public function __construct($io, $interactive = false, $verboseLabel = null) { - if (static::$io == null) { - static::$io = new ConsoleIO(new ArrayInput([]), - new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), - new HelperSet([ - new FormatterHelper(), - new DebugFormatterHelper(), - new ProcessHelper(), - new QuestionHelper() - ]) - ); + if ($io === null) { + $this->io = new NullIO(); + } else { + $this->io = $io; } - return static::$io; + $this->verboseLabel = $verboseLabel; + $this->interactive = $interactive; } /** - * Set the shared IOInterface instance + * Get the Composer IOInterface instance * - * @param IOInterface $io - * @return void + * @return IOInterface */ - static public function setIO($io) + public function getIO() { - static::$io = $io; + return $this->io; } /** * Whether or not ask() should interactively ask the question or just return the default value * - * @param bool $interactive + * @param boolean $interactive * @return void */ - public static function setInteractive($interactive) + public function setInteractive($interactive) { - self::$interactive = $interactive; + $this->interactive = $interactive; } /** * Ask the user a yes or no question and return the result * - * If setInteractive(false) has been called, instead do not ask and just return the default + * If the console is not interactive, instead do not ask and just return the default * * @param string $question * @param boolean $default * @return boolean */ - static public function ask($question, $default = false) + public function ask($question, $default = false) { $result = $default; - if (static::$interactive) { - if (!static::getIO()->isInteractive()) { + if ($this->interactive) { + if (!$this->getIO()->isInteractive()) { throw new \InvalidArgumentException( 'Interactive options cannot be used in non-interactive terminals.' ); } $opts = $default ? 'Y,n' : 'y,N'; - $result = static::getIO()->askConfirmation("$question [$opts]? ", $default); + $result = $this->getIO()->askConfirmation("$question [$opts]? ", $default); } return $result; } @@ -131,13 +120,13 @@ static public function ask($question, $default = false) * @param string $format * @return void */ - static public function log($message, $verbosity = Console::NORMAL, $format = null) + public function log($message, $verbosity = Console::NORMAL, $format = null) { if ($format) { $formatClose = str_replace('<', 'writeError($message, true, $verbosity); + $this->getIO()->writeError($message, true, $verbosity); } /** @@ -147,9 +136,9 @@ static public function log($message, $verbosity = Console::NORMAL, $format = nul * @param int $verbosity * @return void */ - static public function info($message, $verbosity = Console::NORMAL) + public function info($message, $verbosity = Console::NORMAL) { - static::log($message, $verbosity, static::FORMAT_INFO); + $this->log($message, $verbosity, static::FORMAT_INFO); } /** @@ -159,9 +148,9 @@ static public function info($message, $verbosity = Console::NORMAL) * @param int $verbosity * @return void */ - static public function comment($message, $verbosity = Console::NORMAL) + public function comment($message, $verbosity = Console::NORMAL) { - static::log($message, $verbosity, static::FORMAT_COMMENT); + $this->log($message, $verbosity, static::FORMAT_COMMENT); } /** @@ -171,9 +160,9 @@ static public function comment($message, $verbosity = Console::NORMAL) * @param int $verbosity * @return void */ - static public function warning($message, $verbosity = Console::NORMAL) + public function warning($message, $verbosity = Console::NORMAL) { - static::log($message, $verbosity, static::FORMAT_WARN); + $this->log($message, $verbosity, static::FORMAT_WARN); } /** @@ -187,7 +176,7 @@ static public function warning($message, $verbosity = Console::NORMAL) * @param string $format * @return void */ - static public function labeledVerbose( + public function labeledVerbose( $message, $label = null, $verbosity = Console::VERBOSE, @@ -198,12 +187,12 @@ static public function labeledVerbose( $message = "${format}${message}${formatClose}"; } if ($label === null) { - $label = static::$verboseLabel; + $label = $this->verboseLabel; } if ($label) { $message = " [$label] $message"; } - static::log($message, $verbosity); + $this->log($message, $verbosity); } /** @@ -213,22 +202,22 @@ static public function labeledVerbose( * @param \Exception $exception * @return void */ - static public function error($message, $exception = null) + public function error($message, $exception = null) { - static::log($message, static::QUIET, static::FORMAT_ERROR); + $this->log($message, static::QUIET, static::FORMAT_ERROR); if ($exception) { - static::log($exception->getMessage()); + $this->log($exception->getMessage()); } } /** - * Sets the label to apply to logVerbose() messages if not overridden + * Sets the label to apply to labeledVerbose() messages if not overridden * * @param string $verboseLabel * @return void */ - static public function setVerboseLabel($verboseLabel) + public function setVerboseLabel($verboseLabel) { - static::$verboseLabel = $verboseLabel; + $this->verboseLabel = $verboseLabel; } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php index 17e917a..6f6ef79 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php @@ -79,7 +79,7 @@ static public function findRequire($composer, $packageMatcher) * Is the given constraint strict or does it allow multiple versions * * @param string $constraint - * @return bool + * @return boolean */ static public function isConstraintStrict($constraint) { diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php index 586b3c9..0cd683f 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommandTest.php @@ -20,13 +20,19 @@ */ class MageRootRequireCommandTest extends UpdatePluginTestCase { - /** @var TestApplication */ + /** + * @var TestApplication + */ public $application; - /** @var MageRootRequireCommand */ + /** + * @var MageRootRequireCommand + */ public $command; - /** @var MockObject|InputInterface */ + /** + * @var MockObject|InputInterface + */ public $input; public function testOverwriteRequireCommand() diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php index 1555adc..ab7fa85 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php @@ -17,10 +17,14 @@ */ class TestApplication extends \Composer\Console\Application { - /** @var bool */ + /** + * @var boolean + */ private $shouldRun = false; - /** @var Command */ + /** + * @var Command + */ private $command = null; /** @@ -37,7 +41,7 @@ public function setComposer(Composer $composer) /** * Set whether or not doRunCommand should actually be run or not * - * @param bool $shouldRun + * @param boolean $shouldRun * @return void */ public function setShouldRun($shouldRun) diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php index b015409..45c7ddd 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/ConflictResolverTest.php @@ -17,15 +17,24 @@ */ class ConflictResolverTest extends UpdatePluginTestCase { - /** @var MockObject|BaseIO */ + /** + * @var MockObject|BaseIO + */ public $io; - /** @var MockObject|RootPackageRetriever */ + /** + * @var MockObject|RootPackageRetriever + */ public $retriever; + /** + * @var Console + */ + public $console; + public function testFindResolutionAddElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $resolution = $resolver->findResolution('field', null, 'newVal', null); $this->assertEquals(ConflictResolver::ADD_VAL, $resolution); @@ -33,7 +42,7 @@ public function testFindResolutionAddElement() public function testFindResolutionRemoveElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', null, 'oldVal'); $this->assertEquals(ConflictResolver::REMOVE_VAL, $resolution); @@ -41,7 +50,7 @@ public function testFindResolutionRemoveElement() public function testFindResolutionChangeElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'oldVal'); $this->assertEquals(ConflictResolver::CHANGE_VAL, $resolution); @@ -49,7 +58,7 @@ public function testFindResolutionChangeElement() public function testFindResolutionNoUpdate() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'newVal'); $this->assertNull($resolution); @@ -60,7 +69,7 @@ public function testFindResolutionConflictNoOverride() $this->io->expects($this->at(0))->method('writeError') ->with($this->stringContains('will not be changed')); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $resolution = $resolver->findResolution('field', 'oldVal', 'newVal', 'conflictVal'); $this->assertNull($resolution); @@ -68,7 +77,7 @@ public function testFindResolutionConflictNoOverride() public function testFindResolutionConflictOverride() { - $resolver = new ConflictResolver(true, $this->retriever); + $resolver = new ConflictResolver($this->console, true, $this->retriever); $this->io->expects($this->at(1))->method('writeError') ->with($this->stringContains('overriding local changes')); @@ -80,7 +89,7 @@ public function testFindResolutionConflictOverride() public function testFindResolutionConflictOverrideRestoreRemoved() { - $resolver = new ConflictResolver(true, $this->retriever); + $resolver = new ConflictResolver($this->console, true, $this->retriever); $this->io->expects($this->at(1))->method('writeError') ->with($this->stringContains('overriding local changes')); @@ -92,8 +101,8 @@ public function testFindResolutionConflictOverrideRestoreRemoved() public function testFindResolutionInteractiveConfirm() { - $resolver = new ConflictResolver(false, $this->retriever); - Console::setInteractive(true); + $resolver = new ConflictResolver($this->console, false, $this->retriever); + $this->console->setInteractive(true); $this->io->method('isInteractive')->willReturn(true); $this->io->expects($this->once())->method('askConfirmation')->willReturn(true); @@ -104,8 +113,8 @@ public function testFindResolutionInteractiveConfirm() public function testFindResolutionInteractiveNoConfirm() { - $resolver = new ConflictResolver(false, $this->retriever); - Console::setInteractive(true); + $resolver = new ConflictResolver($this->console, false, $this->retriever); + $this->console->setInteractive(true); $this->io->method('isInteractive')->willReturn(true); $this->io->expects($this->once())->method('askConfirmation')->willReturn(false); @@ -116,8 +125,8 @@ public function testFindResolutionInteractiveNoConfirm() public function testFindResolutionNonInteractiveEnvironmentError() { - $resolver = new ConflictResolver(false, $this->retriever); - Console::setInteractive(true); + $resolver = new ConflictResolver($this->console, false, $this->retriever); + $this->console->setInteractive(true); $this->io->method('isInteractive')->willReturn(false); $this->expectException(\InvalidArgumentException::class); @@ -129,7 +138,7 @@ public function testFindResolutionNonInteractiveEnvironmentError() public function testResolveNestedArrayNonArrayAdd() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveNestedArray('field', null, 'newVal', null); $this->assertEquals([true, 'newVal'], $result); @@ -137,7 +146,7 @@ public function testResolveNestedArrayNonArrayAdd() public function testResolveNestedArrayNonArrayRemove() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal'); $this->assertEquals([true, null], $result); @@ -145,7 +154,7 @@ public function testResolveNestedArrayNonArrayRemove() public function testResolveNestedArrayNonArrayChange() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); $this->assertEquals([true, 'newVal'], $result); @@ -153,7 +162,7 @@ public function testResolveNestedArrayNonArrayChange() public function testResolveArrayMismatchedArray() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', 'oldVal', @@ -166,7 +175,7 @@ public function testResolveArrayMismatchedArray() public function testResolveArrayMismatchedMap() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['oldVal'], @@ -181,7 +190,7 @@ public function testResolveArrayFlatArrayAddElement() { $expected = ['val1', 'val2', 'val3']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['val1'], @@ -194,7 +203,7 @@ public function testResolveArrayFlatArrayAddElement() public function testResolveArrayFlatArrayRemoveElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['val1', 'val2', 'val3'], @@ -207,7 +216,7 @@ public function testResolveArrayFlatArrayRemoveElement() public function testResolveArrayFlatArrayAddAndRemoveElement() { - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['val1', 'val2', 'val3'], @@ -222,7 +231,7 @@ public function testResolveArrayAssociativeAddElement() { $expected = ['key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => 'val1'], @@ -237,7 +246,7 @@ public function testResolveArrayAssociativeRemoveElement() { $expected = ['key2' => 'val2', 'key3' => 'val3']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => 'val1', 'key2' => 'val2'], @@ -252,7 +261,7 @@ public function testResolveArrayAssociativeAddAndRemoveElement() { $expected = ['key3' => 'val3', 'key4' => 'val4']; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => 'val1', 'key2' => 'val2'], @@ -267,7 +276,7 @@ public function testResolveArrayNestedAdd() { $expected = ['key1' => ['k1v1', 'k1v2', 'k1v3'], 'key2' => ['k2v1', 'k2v2'], 'key3' => ['k3v1']]; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => ['k1v1'], 'key2' => ['k2v1', 'k2v2']], @@ -288,7 +297,7 @@ public function testResolveArrayNestedRemove() { $expected = ['key1' => ['k1v1', 'k1v3'], 'key2' => ['k2v2'], 'key3' => ['k3v1']]; - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveArraySection( 'extra', ['key1' => ['k1v1', 'k1v2'], 'key2' => ['k2v1', 'k2v2']], @@ -313,7 +322,7 @@ public function testResolveLinksAddLink() $targetMageLinks = array_merge($originalMageLinks, $this->createLinks(1, 'targetMage/link')); $expected = array_merge($targetMageLinks, $userLink); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveLinkSection( 'require', $originalMageLinks, @@ -332,7 +341,7 @@ public function testResolveLinksRemoveLink() $targetMageLinks = array_slice($originalMageLinks, 1); $expected = array_merge($targetMageLinks, $userLink); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveLinkSection( 'require', $originalMageLinks, @@ -351,7 +360,7 @@ public function testResolveLinksChangeLink() $targetMageLinks = $this->changeLink($originalMageLinks, 1); $expected = array_merge($targetMageLinks, $userLink); - $resolver = new ConflictResolver(false, $this->retriever); + $resolver = new ConflictResolver($this->console, false, $this->retriever); $result = $resolver->resolveLinkSection( 'require', $originalMageLinks, @@ -365,8 +374,7 @@ public function testResolveLinksChangeLink() public function setUp() { $this->io = $this->getMockForAbstractClass(IOInterface::class); - Console::setIO($this->io); - Console::setInteractive(false); + $this->console = new Console($this->io); $this->retriever = $this->createPartialMock( RootPackageRetriever::class, ['getOriginalRootPackage', 'getTargetRootPackage', 'getUserRootPackage'] diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php index 2cd1b23..e858f69 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php @@ -30,33 +30,54 @@ */ class MagentoRootUpdaterTest extends UpdatePluginTestCase { - /** @var MockObject|Composer */ + /** + * @var MockObject|Composer + */ public $composer; - /** @var RootPackage */ + /** + * @var RootPackage + */ public $installRoot; - /** @var RootPackage */ + /** + * @var RootPackage + */ public $expectedNoOverride; - /** @var RootPackage */ + /** + * @var RootPackage + */ public $expectedWithOverride; - /** @var MockObject|EventDispatcher */ + /** + * @var MockObject|EventDispatcher + */ public $eventDispatcher; - /** @var MockObject|InputInterface */ + /** + * @var MockObject|InputInterface + */ public $input; - /** @var MockObject|BaseIO */ + /** + * @var MockObject|BaseIO + */ public $io; - /** @var MockObject|RootPackageRetriever */ + /** + * @var Console + */ + public $console; + + /** + * @var MockObject|RootPackageRetriever + */ public $retriever; public function testMagentoUpdateSetsFieldsNoOverride() { - $updater = new MagentoRootUpdater($this->composer); + $updater = new MagentoRootUpdater($this->console, $this->composer); $updater->runUpdate($this->retriever, false, true, '7.0', 'stable'); $result = $updater->getJsonChanges(); @@ -73,7 +94,7 @@ public function testMagentoUpdateSetsFieldsNoOverride() public function testMagentoUpdateSetsFieldsWithOverride() { - $updater = new MagentoRootUpdater($this->composer); + $updater = new MagentoRootUpdater($this->console, $this->composer); $updater->runUpdate($this->retriever, true, true, '7.0', 'stable'); $result = $updater->getJsonChanges(); @@ -198,7 +219,7 @@ public function setUp() * Mock IOInterface for interaction */ $this->io = $this->getMockForAbstractClass(IOInterface::class); - Console::setIO($this->io); + $this->console = new Console($this->io); /** * Mock package repositories diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php index db8692c..1ca9360 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetrieverTest.php @@ -33,6 +33,11 @@ class RootPackageRetrieverTest extends UpdatePluginTestCase */ public $io; + /** + * @var Console $console + */ + public $console; + /** * @var MockObject|PackageInterface $originalRoot */ @@ -57,7 +62,14 @@ public function testOverrideOriginalRoot() { $this->composer->expects($this->never())->method('getLocker'); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0', 'community', '1.0.0'); + $retriever = new RootPackageRetriever( + $this->console, + $this->composer, + 'enterprise', + '2.0.0', + 'community', + '1.0.0' + ); $this->assertEquals('community', $retriever->getOriginalEdition()); $this->assertEquals('1.0.0', $retriever->getOriginalVersion()); @@ -68,7 +80,7 @@ public function testOriginalRootFromLocker() { $this->composer->expects($this->once())->method('getLocker'); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $this->assertEquals('enterprise', $retriever->getOriginalEdition()); $this->assertEquals('1.1.0.0', $retriever->getOriginalVersion()); @@ -79,7 +91,7 @@ public function testGetOriginalRootFromRepo() { $this->repo->method('whatProvides')->willReturn(['1.1.0.0' => $this->originalRoot, '2.0.0.0' => $this->targetRoot]); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedOriginal = $retriever->getOriginalRootPackage(false); $this->assertEquals($this->originalRoot, $retrievedOriginal); @@ -89,7 +101,7 @@ public function testGetOriginalRootNotOnRepo_Override() { $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedOriginal = $retriever->getOriginalRootPackage(true); $this->assertEquals($this->userRoot, $retrievedOriginal); @@ -99,7 +111,7 @@ public function testGetOriginalRootNotOnRepo_NoOverride() { $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedOriginal = $retriever->getOriginalRootPackage(false); $this->assertEquals(null, $retrievedOriginal); @@ -108,11 +120,11 @@ public function testGetOriginalRootNotOnRepo_NoOverride() public function testGetOriginalRootNotOnRepo_Confirm() { $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); - Console::setInteractive('true'); + $this->console->setInteractive(true); $this->io->method('isInteractive')->willReturn(true); $this->io->method('askConfirmation')->willReturn(true); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedOriginal = $retriever->getOriginalRootPackage(false); $this->assertEquals($this->userRoot, $retrievedOriginal); @@ -121,11 +133,11 @@ public function testGetOriginalRootNotOnRepo_Confirm() public function testGetOriginalRootNotOnRepo_NoConfirm() { $this->repo->method('whatProvides')->willReturn(['2.0.0.0' => $this->targetRoot]); - Console::setInteractive('true'); + $this->console->setInteractive(true); $this->io->method('isInteractive')->willReturn(true); $this->io->method('askConfirmation')->willReturn(false); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedOriginal = $retriever->getOriginalRootPackage(false); $this->assertEquals(null, $retrievedOriginal); @@ -133,9 +145,11 @@ public function testGetOriginalRootNotOnRepo_NoConfirm() public function testGetTargetRootFromRepo() { - $this->repo->method('whatProvides')->willReturn(['1.1.0.0' => $this->originalRoot, '2.0.0.0' => $this->targetRoot]); + $this->repo->method('whatProvides')->willReturn( + ['1.1.0.0' => $this->originalRoot, '2.0.0.0' => $this->targetRoot] + ); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedTarget = $retriever->getTargetRootPackage(); $this->assertEquals($this->targetRoot, $retrievedTarget); @@ -145,7 +159,7 @@ public function testGetTargetRootNotOnRepo() { $this->repo->method('whatProvides')->willReturn(['1.1.0.0' => $this->originalRoot]); - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedTarget = $retriever->getTargetRootPackage(); $this->assertEquals(null, $retrievedTarget); @@ -153,7 +167,7 @@ public function testGetTargetRootNotOnRepo() public function testGetUserRoot() { - $retriever = new RootPackageRetriever($this->composer, 'enterprise', '2.0.0'); + $retriever = new RootPackageRetriever($this->console, $this->composer, 'enterprise', '2.0.0'); $retrievedTarget = $retriever->getUserRootPackage(); $this->assertEquals($this->userRoot, $retrievedTarget); @@ -162,8 +176,7 @@ public function testGetUserRoot() protected function setUp() { $this->io = $this->getMockForAbstractClass(IOInterface::class); - Console::setIO($this->io); - Console::setInteractive(false); + $this->console = new Console($this->io); $this->composer = $this->createPartialMock(Composer::class, [ 'getConfig', From 075a1417a33055a2142a40c4bb60b41367db9f52 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Tue, 16 Apr 2019 16:12:30 -0500 Subject: [PATCH 08/15] Correcting formatting and adding use case example --- CONTRIBUTING.md | 19 +- README.md | 173 ++++++++++++- docs/class_descriptions.md | 237 +++++++++--------- docs/process_flows.md | 70 +++--- .../ComposerRootUpdatePlugin/README.md | 171 ++++++++++++- 5 files changed, 499 insertions(+), 171 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc29794..97b9bac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,23 +1,20 @@ - # Contributing to Magento code -## Overview - Contributions to the Magento codebase are done using the fork & pull model. This contribution model has contributors maintaining their own copy of the forked codebase (which can easily be synced with the main copy). The forked repository is then used to submit a request to the base repository to “pull” a set of changes (hence the phrase “pull request”). -Contributions can take the form of new components/features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations or just good suggestions. +Contributions can take the form of new components/features, changes to existing features, tests, documentation (such as developer guides, user guides, examples, or specifications), bug fixes, optimizations, or just good suggestions. -The Magento development team will review all issues and contributions submitted by the community of developers in first in, first out order. During the review we might require clarifications from the contributor. If there is no response from the contributor for two weeks, the issue is closed. +The Magento development team will review all issues and contributions submitted by the community of developers in first-in, first-out order. During the review we might require clarifications from the contributor. If there is no response from the contributor for two weeks, the issue is closed. For large features or changes, please [open an issue](https://github.com/magento/composer-root-update-plugin/issues) for discussion before submitting any code. This will prevent duplicate or unnecessary effort and can also increase the number of people involved in discussing and implementing the change. ## Contribution requirements -1. Contributions must adhere to [Magento coding standards](http://devdocs.magento.com/guides/v2.0/coding-standards/bk-coding-standards.html). +1. Contributions must adhere to [Magento coding standards](http://devdocs.magento.com/guides/v2.3/coding-standards/bk-coding-standards.html). 2. Pull requests (PRs) must be accompanied by a complete and meaningful description. Comprehensive descriptions make it easier to understand the reasoning behind a change and reduce the amount of time required to get the PR merged. 3. Commits must be accompanied by meaningful commit messages. -4. PRs which include bug fixing must be accompanied with step-by-step descriptions of how to reproduce the issue (including the local composer version reported by `composer --version`). +4. PRs which include bug fixing must be accompanied by step-by-step instructions how to reproduce the issue (including the local composer version reported by `composer --version`). 5. PRs which include new logic or new features must be submitted along with: * Unit/integration test coverage where applicable. * Updated documentation in the project's `docs` directory. @@ -27,19 +24,19 @@ Any contributions that do not meet these requirements will not be accepted. ### Composer compatibility -Maintaining compatibility with the Composer versions listed in [composer.json](composer.json) is of particular note for this project. Due to the way Composer works with plugins, the version that is used when the plugin runs is the local `composer.phar` executable version (as reported by `composer --version`) and not the version installed in the project's `vendor` folder or `composer.lock` file. This means that in order to properly verify Composer compatibility, tests must be run against the local `composer.phar` executable, not just the installed `composer/composer` dependency. +Maintaining compatibility with the Composer versions listed in the [composer.json](composer.json) file is important for this project. Due to the way Composer works with plugins, the version that is used when the plugin runs is the local `composer.phar` executable version (as reported by `composer --version`) and not the version installed in the project's `vendor` folder or `composer.lock` file This means that in order to properly verify Composer compatibility, tests must be run against the local `composer.phar` executable, not just the installed `composer/composer` dependency. Additionally, because of the way the plugin interacts with the native `composer require` command, some parts of the Composer library sometimes need to be re-implemented in an accessible manner if the original code is in private methods or part of larger functions. Such implementations should be located in the [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation) namespace and documented with the reason for re-implementation and a link to the original method. ## Contribution process -If you are a new GitHub user, we recommend that you create your own [free github account](https://github.com/signup/free). By doing so, you will be able to collaborate with the Magento development team, fork the github project and easily send pull requests for any changes you wish to contribute. +If you are a new GitHub user, we recommend that you create your own [free GitHub account](https://github.com/signup/free). By doing so, you will be able to collaborate with the Magento development team, fork the GitHub project and easily send pull requests for any changes you wish to contribute. 1. Search the current listed issues (open or closed) on the [magento/composer-root-update-plugin](https://github.com/magento/composer-root-update-plugin/issues) and [magento/magento2](https://github.com/magento/magento2/issues) GitHub repositories before starting work on a new contribution. 2. Review the [Contributor License Agreement](https://magento.com/legaldocuments/mca) if this is your first time contributing. 3. Create and test your work. -4. Fork the repository according to the [Fork a repository instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#fork). -5. When you are ready to send us a pull request, follow the [Create a pull request instructions](http://devdocs.magento.com/guides/v2.0/contributor-guide/contributing.html#pull_request). The instructions are written for the `https://github.com/magento/magento2` repository, but they also apply to `https://github.com/magento/composer-root-update-plugin`. +4. Fork the repository according to the [Fork a repository instructions](http://devdocs.magento.com/guides/v2.3/contributor-guide/contributing.html#fork). +5. When you are ready to send us a pull request, follow the [Create a pull request instructions](http://devdocs.magento.com/guides/v2.3/contributor-guide/contributing.html#pull_request). The instructions are written for the `https://github.com/magento/magento2` repository, but they also apply to `https://github.com/magento/composer-root-update-plugin`. 6. Once your contribution is received, the Magento 2 development team will review the contribution and collaborate with you as needed if it is accepted. ## Code of Conduct diff --git a/README.md b/README.md index ecafb85..e636738 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,29 @@ # Overview + ## Purpose of plugin -The **magento/composer-root-update-plugin** Composer plugin resolves changes that need to be made to the root project `composer.json` file before updating to a new Magento product requirement. +The `magento/composer-root-update-plugin` Composer plugin resolves changes that need to be made to the root project `composer.json` file before updating to a new Magento product requirement. This is accomplished by comparing the root `composer.json` file for the Magento project corresponding to the Magento version and edition in the current installation with the Magento project `composer.json` file for the target Magento product package when the `composer require` command runs and applying any deltas found between the two files if they do not conflict with the existing `composer.json` file in the Magento root directory. # Getting Started + ## System requirements -The **magento/composer-root-update-plugin** package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. + +The `magento/composer-root-update-plugin` package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. ## Installation + To install the plugin, run `composer require magento/composer-root-update-plugin ~1.0` in the Magento root directory. # Usage + The plugin adds functionality to the `composer require` command when a new Magento product package is required, and in most cases will not need additional options or commands run to function. If the `composer require` command for the target Magento package fails, one of the following may be necessary. ## Installations that started with another Magento product + If the local Magento installation has previously been updated from a previous Magento product version or edition, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. @@ -25,6 +31,7 @@ In this case, run the following command with the appropriate values to correct t composer require --base-magento-edition --base-magento-version ## Conflicting custom values + If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. @@ -32,25 +39,177 @@ To resolve these conflicts interactively, re-run the `composer require` command To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-default-magento-values` option. ## Bypassing the plugin + To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root-plugin` option. ## Refreshing the plugin for the Web Setup Wizard + If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. To reinstall the plugin in `var`, run the following command in the Magento root directory. composer magento-update-plugin install +## Example use case: Upgrading from Magento 2.2.8 to Magento 2.3.1 + +### Without `magento/composer-root-update-plugin`: + +In the project directory for a Magento Community Edition 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Community Edition 2.3.1 with these results: + +``` +$ composer require magento/product-community-edition 2.3.1 --no-update +./composer.json has been updated +$ composer update +Loading composer repositories with package information +Updating dependencies (including require-dev) +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Installation request for magento/product-community-edition 2.3.1 -> satisfiable by magento/product-community-edition[2.3.1]. + - magento/product-community-edition 2.3.1 requires magento/magento2-base 2.3.1 -> satisfiable by magento/magento2-base[2.3.1]. + ... + - sebastian/phpcpd 2.0.4 requires symfony/console ~2.7|^3.0 + ... + - magento/magento2-base 2.3.1 requires symfony/console ~4.1.0 -> satisfiable by symfony/console[v4.1.0, v4.1.1, v4.1.10, v4.1.11, v4.1.2, v4.1.3, v4.1.4, v4.1.5, v4.1.6, v4.1.7, v4.1.8, v4.1.9]. + - Conclusion: don't install symfony/console v4.1.11|install symfony/console v2.8.38 + - Installation request for sebastian/phpcpd 2.0.4 -> satisfiable by sebastian/phpcpd[2.0.4]. +``` + +This error occurs because the `"require-dev"` section in the `composer.json` file for `magento/project-community-edition` 2.2.8 conflicts with the dependencies for the new 2.3.1 version of `magento/product-community-edition`. The 2.2.8 `composer.json` file has a `"require-dev"` entry for `sebastian/phpcpd: 2.0.4`, which depends on `symfony/console: ~2.7|^3.0`, but the `magento/magento2-base` package required by `magento/product-community-edition` 2.3.1 depends on `symfony/console: ~4.1.0`, which does not overlap with the versions allowed by the `~2.7|^3.0` constraint. + +Because the `sebastian/phpcpd` requirement exists in the root `composer.json` file instead of one of the child dependencies of `magento/product-community-edition` 2.2.8, it does not get updated by Composer when the `magento/product-community-edition` version changes. + +In the `composer.json` file for `magento/project-community-edition` 2.3.1, that `sebastian/phpcpd` entry in `"require-dev"` has changed to `~3.0.0`, which is compatible with the `symfony/console` versions allowed by `magento/magento2-base` 2.3.1. However, without this plugin, Composer does not know that the value needs to change because the commands to upgrade Magento use the `magento/product-community-edition` metapackage and not the root `magento/project-community-edition` project package. + +This is only one of the changes to the root project `composer.json` file between Magento 2.2.8 and 2.3.1. There are several others, and future Magento versions can (and likely will) require further updates to the file. + +The changes to the root project `composer.json` files can be done manually by the user without the plugin, but the values that need to change can differ depending on the Magento versions involved and user-customized values may already override the Magento defaults. This means the exact upgrade steps necessary can be different for every user and determining the correct changes to make manually for a given user's configuration may be error-prone. + +For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Community Edition versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. + + - **2.2.8** + ``` + "require": { + "magento/product-community-edition": "2.2.8", + "composer/composer": "@alpha" + }, + "require-dev": { + "magento/magento2-functional-testing-framework": "2.3.13", + "phpunit/phpunit": "~6.2.0", + "squizlabs/php_codesniffer": "3.2.2", + "phpmd/phpmd": "@stable", + "pdepend/pdepend": "2.5.2", + "friendsofphp/php-cs-fixer": "~2.2.1", + "lusitanian/oauth": "~0.8.10", + "sebastian/phpcpd": "2.0.4" + } + ``` + + - **2.3.1** + ``` + "require": { + "magento/product-community-edition": "2.3.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.13.0", + "lusitanian/oauth": "~0.8.10", + "magento/magento2-functional-testing-framework": "~2.3.13", + "pdepend/pdepend": "2.5.2", + "phpmd/phpmd": "@stable", + "phpunit/phpunit": "~6.5.0", + "sebastian/phpcpd": "~3.0.0", + "squizlabs/php_codesniffer": "3.3.1", + "allure-framework/allure-phpunit": "~1.2.0" + } + ``` + +### With `magento/composer-root-update-plugin`: + +In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0` before the Magento Community Edition 2.3.1 upgrade commands. + +``` +$ composer require magento/composer-root-update-plugin ~1.0 +./composer.json has been updated +Loading composer repositories with package information +Updating dependencies (including require-dev) +Package operations: 1 install, 0 updates, 0 removals + - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) +Installing "magento/composer-root-update-plugin: 1.0.0" for the Web Setup Wizard +Loading composer repositories with package information +Updating dependencies +Package operations: 18 installs, 0 updates, 0 removals + - Installing ... + ... + - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) +Writing lock file +Generating autoload files +Writing lock file +Generating autoload files +``` + +As is normal for `composer require`, `magento/composer-root-update-plugin` is added to the `composer.json` file. The plugin also installs itself in the directory used by the Magento Web Setup Wizard during dependency validation. + +With the plugin installed, the user proceeds with the `composer require` command for Magento Community Edition 2.3.1 (`--verbose` mode used here for demonstration). + +``` +$ composer require magento/product-community-edition 2.3.1 --no-update --verbose + [Magento Community Edition 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 + [Magento Community Edition 2.3.1] Removing require entries: composer/composer + [Magento Community Edition 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 + [Magento Community Edition 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 + [Magento Community Edition 2.3.1] Adding conflict constraints: gene/bluefoot=* + [Magento Community Edition 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" +Updating composer.json for Magento Community Edition 2.3.1 ... + [Magento Community Edition 2.3.1] Writing changes to the root composer.json... + [Magento Community Edition 2.3.1] /composer.json has been updated +./composer.json has been updated +``` + +The plugin detects the user's request for the 2.3.1 version of `magento/product-community-edition` and looks up the `composer.json` file for the corresponding `magento/project-community-edition` 2.3.1 root project package. It finds the values that are different between 2.2.8 and 2.3.1 and updates the local `composer.json` file accordingly, then lets Composer proceed with the normal `composer require` functionality. + +With the root `composer.json` file updated for Magento Community Edition 2.3.1, the user proceeds with the `composer update` command: + +``` +$ composer update +Loading composer repositories with package information +Updating dependencies (including require-dev) +Package operations: 118 installs, 246 updates, 5 removals + - Removing symfony/polyfill-php55 (v1.11.0) + ... +Writing lock file +Generating autoload files +``` + +With the updated values from Magento Community Edition 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. + +For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Community Edition 2.2.8 installation. They contain exactly the same entries as the default Magento Community Edition 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). + + ``` + "require": { + "magento/product-community-edition": "2.3.1", + "magento/composer-root-update-plugin": "~1.0" + }, + "require-dev": { + "allure-framework/allure-phpunit": "~1.2.0", + "magento/magento2-functional-testing-framework": "~2.3.13", + "phpunit/phpunit": "~6.5.0", + "squizlabs/php_codesniffer": "3.3.1", + "phpmd/phpmd": "@stable", + "pdepend/pdepend": "2.5.2", + "friendsofphp/php-cs-fixer": "~2.13.0", + "lusitanian/oauth": "~0.8.10", + "sebastian/phpcpd": "~3.0.0" + } + ``` + # License -Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license. +Each Magento source file included in this distribution is licensed under OSL 3.0. [Open Software License (OSL 3.0)](https://opensource.org/licenses/osl-3.0.php). -Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. -Subject to Licensee's payment of fees and compliance with the terms and conditions of the MEE License, the MEE License supersedes the OSL 3.0 license for each source file. -Please see LICENSE_EE.txt for the full text of the MEE License or visit https://magento.com/legal/terms/enterprise. +Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. -# Developer Documentation +# Developer documentation Class descriptions, process flows, and any other developer documentation can be found in the [docs](docs) directory. diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md index 05e5a5b..b10807a 100644 --- a/docs/class_descriptions.md +++ b/docs/class_descriptions.md @@ -1,5 +1,4 @@ - -# Class Descriptions by Namespace +# Class descriptions by namespace - [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](#namespace-magentocomposerrootupdateplugincomposerreimplementation) - [AccessibleRootPackageLoader](#accessiblerootpackageloader) @@ -20,31 +19,35 @@ - [Console](#console) - [PackageUtils](#packageutils) +*** + ## Namespace: [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation) -Because the plugin is hooking into the native `composer require` functionality directly rather than adding script hooks or completely new commands, it needs access to some Composer functionality that is not normally extendable. The classes in this namespace copy the relevant sections of Composer library code into functions that are accessible by the plugin. New releases of Composer may change the library code these classes clone, in which case they should be updated to match. +Because the plugin is hooking into the native `composer require` functionality directly rather than adding script hooks or completely new commands, it needs access to some Composer functionality that is not normally extendable. The classes in this namespace copy the relevant sections of Composer library code into functions that are accessible by the plugin. New releases of Composer may change the library code these classes clone, in which case they should be updated to match. #### [**AccessibleRootPackageLoader**](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php) - **Composer class:** [RootPackageLoader](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) +**Composer class:** [RootPackageLoader](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) - **`extractStabilityFlags()`** -- see [RootPackageLoader::extractStabilityFlags()](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) - - Takes a package name, version, and minimum-stability setting and returns the stability level that should be used to find the package on a repository. - - **Reason for cloning:** The original method is private. + - Takes a package name, version, and minimum-stability setting and returns the stability level that should be used to find the package on a repository + - **Reason for cloning:** The original method is private #### [**ExtendableRequireCommand**](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php) - **Composer class:** [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) +**Composer class:** [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) - **`parseComposerJsonFile()`** -- see [RequireCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) - - Checks the accessibility of the `composer.json` file and parses out relevant base information that is needed before starting the plugin's processing. - - **Reason for cloning:** The native code exists directly in `RequireCommand::execute()` instead of its own function, but the base information it parses is required by the plugin before it runs as part of the original `RequireCommand` code. + - Checks the accessibility of the `composer.json` file and parses out relevant base information that is needed before starting the plugin's processing + - **Reason for cloning:** The native code exists directly in `RequireCommand::execute()` instead of its own function, but the base information it parses is required by the plugin before it runs as part of the original `RequireCommand` code - **`getRequirementsInteractive()`** -- see [InitCommand::determineRequirements()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php) - - Interactively asks for the `composer require` arguments if they're not passed to the CLI command call - - **Reason for cloning:** The native command calls [InitCommand::findBestVersionAndNameForPackage()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php), which would try to validate the target Magento package's requirements before the plugin can process the relevant changes to make it compatible. The `determineRequirements()` call is still made by `RequireCommand::execute()` after the plugin runs, so Composer's validation still happens as normal. + - Interactively asks for the `composer require` arguments if they are not passed to the CLI command call + - **Reason for cloning:** The native command calls [InitCommand::findBestVersionAndNameForPackage()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php), which would try to validate the target Magento package's requirements before the plugin can process the relevant changes to make it compatible. The original `determineRequirements()` call is still made by `RequireCommand::execute()` after the plugin runs, so Composer's validation still happens as normal. - **`revertMageComposerFile()`** -- see [RequireCommand::revertComposerFile()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) - - Reverts `composer.json` to its original state from before the plugin's changes if the command fails. - - **Reason for cloning:** The plugin makes its changes before `RequireCommand` creates its backup, which means when it runs its own `revertComposerFile()`, the reverted file from the backup still does not match the original state, so this function is needed to also revert the plugin's changes. + - Reverts the `composer.json` file to its original state from before the plugin's changes if the command fails + - **Reason for cloning:** The plugin makes changes before `RequireCommand` creates a backup, which means when it runs `revertComposerFile()`, the reverted file from the backup does not match the original state, so this function is needed to also revert the plugin's changes + +*** ## Namespace: [Magento\ComposerRootUpdatePlugin\Plugin](../src/Magento/ComposerRootUpdatePlugin/Plugin) @@ -52,52 +55,51 @@ Classes in this namespace tie into the Composer library's code that handles plug #### [**Commands\MageRootRequireCommand**](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php) - This class is the entrypoint into the plugin's functionality from the `composer require` CLI command. +This class is the entry point into the plugin's functionality from the `composer require` CLI command. - Extends the native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) functionality to add additional processing when run with a Magento product as one of the command's parameters. +Extends the native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) functionality to add additional processing when run with a Magento product as one of the command's parameters. - - **`configure()`** - - Add the options and description for the plugin functionality to those already configured in `RequireCommand` and sets the new command's name to a dummy unique value so it passes Composer's command registry check - - **`setApplication()`** - - Overrides the command's name to `require` after the command registry is checked but before the command is actually added to the registry. This allows the command to replace the native `RequireCommand` instance that is normally associated with the `composer require` CLI command. - - **`execute()`** - - Wraps the native `RequireCommand::execute()` function with the Magento project update code if a Magento product package is found in the command's parameters - - **`runUpdate()`** - - Calls [MagentoRootUpdater::runUpdate()](#magentorootupdater) after processing CLI options + - **`configure()`** + - Add the options and description for the plugin functionality to those already configured in `RequireCommand` and sets the new command's name to a dummy unique value so it passes Composer's command registry check + - **`setApplication()`** + - Overrides the command's name to `require` after the command registry is checked but before the command is actually added to the registry. This allows the command to replace the native `RequireCommand` instance that is normally associated with the `composer require` CLI command + - **`execute()`** + - Wraps the native `RequireCommand::execute()` function with the Magento project update code if a Magento product package is found in the command's parameters + - **`runUpdate()`** + - Calls [MagentoRootUpdater::runUpdate()](#magentorootupdater) after processing CLI options #### [**Commands\UpdatePluginNamespaceCommands**](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php) - CLI command definition for plugin-specific functionality that isn't attached to other native commands, adding them as sub-commands called through `composer magento-update-plugin `. +CLI command definition for plugin-specific functionality that is not attached to other native commands, adding them as sub-commands called through `composer magento-update-plugin `. - Currently, the only sub-command included is `composer magento-update-plugin install`, which updates the plugin's self-installation inside the project's `var` directory, which is necessary for the Web Setup Wizard (see [WebSetupWizardPluginInstaller](#websetupwizardplugininstaller)). +Currently, the only sub-command included is `composer magento-update-plugin install`, which updates the plugin's self-installation inside the project's `var` directory, which is necessary for the Web Setup Wizard (see [WebSetupWizardPluginInstaller](#websetupwizardplugininstaller)). - - **`configure()`** - - Configure the command definition for Composer's CLI command processing. Sub-command descriptions are included in the command's `help` text. - - **`execute()`** - - Check the sub-command parameter and call the corresponding function. - - **`describeOperations()`** - - Format the sub-command definitions into a readable description for the command's `help` text. + - **`configure()`** + - Configure the command definition for Composer's CLI command processing. Sub-command descriptions are included in the command's `help` text + - **`execute()`** + - Check the sub-command parameter and call the corresponding function + - **`describeOperations()`** + - Format the sub-command definitions into a readable description for the command's `help` text #### [**CommandProvider**](../src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php) - This is a Composer boilerplate class to let the Composer plugin library know about the commands implemented by the plugin. - - - - **`getCommands()`** +This is a Composer boilerplate class to let the Composer plugin library know about the commands implemented by the plugin. + + - **`getCommands()`** - Passes instances of the commands provided by the plugin to the Composer library #### [**PluginDefinition**](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php) - This class is Composer's entry point into the plugin's functionality and the definition supplied to the plugin registry +This class is Composer's entry point into the plugin's functionality and the definition supplied to the plugin registry. - - **`activate()`** - - Method must exist in any implementation of [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) - - **`getCapabilities()`** - - Tells Composer that the plugin includes CLI commands and defines the [CommandProvider](#commandprovider) that supplies the command objects - - **`getSubscribedEvents()`** - - Subscribes to the `POST_PACKAGE_INSTALL` and `POST_PACKAGE_UPDATE` events, which are triggered whenever a project's dependencies are updated. - - **`packageUpdate()`** - - When one of the package events subscribed in `getSubscribedEvents()` is triggered, this method forwards the event to [WebSetupWizardPluginInstaller::packageEvent()](#websetupwizardplugininstaller) to update the plugin's self-installation inside the Magento project's `var` directory, which is necessary for the Web Setup Wizard. + - **`activate()`** + - Method must exist in any implementation of [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) + - **`getCapabilities()`** + - Tells Composer that the plugin includes CLI commands and defines the [CommandProvider](#commandprovider) that supplies the command objects + - **`getSubscribedEvents()`** + - Subscribes to the `POST_PACKAGE_INSTALL` and `POST_PACKAGE_UPDATE` events, which are triggered whenever a project's dependencies are updated + - **`packageUpdate()`** + - When one of the package events subscribed in `getSubscribedEvents()` is triggered, this method forwards the event to [WebSetupWizardPluginInstaller::packageEvent()](#websetupwizardplugininstaller) to update the plugin's self-installation inside the Magento project's `var` directory, which is necessary for the Web Setup Wizard *** @@ -105,30 +107,32 @@ Classes in this namespace tie into the Composer library's code that handles plug Classes in this namespace deal with installing the plugin inside the project's `var` directory, which is necessary for the Magento Web Setup Wizard to pass its verification check. -When the Web Setup Wizard runs an upgrade operation, it first tries to validate the upgrade by copying the `composer.json` file into the `var` directory and attempting a dry-run upgrade. However, because it only copies the `composer.json` file and not any of the other code in the installation (including the plugin's root installation in `vendor`), the plugin will not function for this dry run. In order to enable the plugin, it needs to already be present in `var/vendor`, where the Wizard's `composer require` for the validation will find it. +When the Web Setup Wizard runs an upgrade operation, it first tries to validate the upgrade by copying the `composer.json` file into the `var` directory and attempting a dry-run upgrade. However, because it only copies the `composer.json` file and not any of the other code in the installation (including the plugin's root installation in `vendor`), the plugin will not function for this dry run. In order to enable the plugin, it needs to already be present in `var/vendor`, where the Wizard's `composer require` for the validation will find it. #### **[InstallData](../src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php)/[RecurringData](../src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php)/[UpgradeData](../src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php)** - These are Magento module setup classes to trigger [WebSetupWizardPluginInstaller::doVarInstall()](#websetupwizardplugininstaller) on `bin/magento setup` commands. Specifically, this is necessary when the `bin magento setup:uninstall` and `bin magento setup:install` commands are run, which would otherwise wipe the plugin of the `var` directory without triggering the Composer package events that would normally install the plugin there. +These are Magento module setup classes to trigger [WebSetupWizardPluginInstaller::doVarInstall()](#websetupwizardplugininstaller) on `bin/magento setup` commands. Specifically, this is necessary when the `bin/magento setup:uninstall` and `bin/magento setup:install` commands are run, which would otherwise remove the plugin from the `var` directory without triggering the Composer package events that would normally install the plugin there. #### [**WebSetupWizardPluginInstaller**](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) - This class manages the plugin's self-installation inside the `var` directory to enable it for the Web Setup Wizard. - - - **`packageEvent()`** - - When Composer installs or updates a required package, this method checks whether it was the plugin package that changed and calls `updateSetupWizardPlugin()` with the new version if so. - - Triggered by the events defined in [PluginDefinition::getSubscribedEvents()](#websetupwizardplugininstaller) - - **`doVarInstall()`** - - Checks the `composer.lock` file the plugin and calls `updateSetupWizardPlugin()` with the version found there. - - Called by `composer magento-update-plugin install` and the Magento module setup classes ([InstallData](#installdatarecurringdataupgradedata), [RecurringData](#installdatarecurringdataupgradedata), [UpgradeData](#installdatarecurringdataupgradedata)). - - **`updateSetupWizardPlugin()`** - - Installs the plugin inside `var/vendor` where it can be found by the `composer require` command run by the Web Setup Wizard's validation check. This is accomplished by creating a dummy project directory with a `composer.json` file that requires only the plugin, installing it, then copying the resulting `vendor` directory to `var/vendor`. - - **`deletePath()`** - - Recursively deletes a file or directory and all its contents - - **`copyAndReplace()`** - - Recursively copies a directory and all its contents to a new location, replacing any existing files that exist there beforehand - - **`createPluginComposer()`** - - Creates a temporary `composer.json` file requiring only the plugin's composer package. +This class manages the plugin's self-installation inside the `var` directory to enable it for the Web Setup Wizard. + + - **`packageEvent()`** + - When Composer installs or updates a required package, this method checks whether it was the plugin package that changed and calls `updateSetupWizardPlugin()` with the new version if so + - Triggered by the events defined in [PluginDefinition::getSubscribedEvents()](#plugindefinition) + - **`doVarInstall()`** + - Checks the `composer.lock` file the plugin and calls `updateSetupWizardPlugin()` with the version found there + - Called by `composer magento-update-plugin install` and the Magento module setup classes ([InstallData](#installdatarecurringdataupgradedata), [RecurringData](#installdatarecurringdataupgradedata), [UpgradeData](#installdatarecurringdataupgradedata)) + - **`updateSetupWizardPlugin()`** + - Installs the plugin inside `var/vendor` where it can be found by the `composer require` command run by the Web Setup Wizard's validation check. This is accomplished by creating a dummy project directory with a `composer.json` file that requires only the plugin, installing it, then copying the resulting `vendor` directory to `var/vendor` + - **`deletePath()`** + - Recursively deletes a file or directory and all its contents + - **`copyAndReplace()`** + - Recursively copies a directory and all its contents to a new location + - **`createPluginComposer()`** + - Creates a temporary `composer.json` file requiring only the plugin's Composer package + +*** ## Namespace: [Magento\ComposerRootUpdatePlugin\Updater](../src/Magento/ComposerRootUpdatePlugin/Updater) @@ -138,75 +142,76 @@ Classes in this namespace do the work of calculating and executing the changes t Given the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version, and the currently-installed root project package including all user customizations, this class calculates the new values that need to be updated for the target Magento version. -This is accomplished by comparing `composer.json` fields between the original Magento root and the target root, and, when a delta is found, checking to see if the user has already made custom changes to that field. If a change has been made, if it doesn't already match the target Magento value, resolve the conflict according to the strategy passed to the CLI command: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used on a case-by-case basis. - - - **`resolveRootDeltas()()`** - - Entry point into the resolution functionality - - Calls the relevant resolve function for each `composer.json` field that can be updated - - **`findResolution()`** - - For an individual field value, compare the original Magento value to the target Magento value, and if a delta is found, check if the user's installation has a customized value for the field. If the user has changed the value, resolve the conflict according to the CLI command options: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used - - **`resolveLinkSection()`** - - For a given `composer.json` section that consists of links to package versions/constraints (such as the `require` and `conflict` sections), call `findResolution()` for each package constraint found in either the original Magento root or the target Magento root - - **`resolveArraySection()`** - - For a given `composer.json` section that consists of data that is not package links (such as the `"autoload"` or `"extra"` sections), call `resolveNestedArray()` and accept the new values if changes were made - - **`resolveNestedArray()`** - - Recursively processes changes to a `composer.json` value that could be a nested array, calling `findResolution()` for each "leaf" value found in either the original Magento root or the target Magento root - - **`linksToMap()`** - - Helper function to convert a set of package links to an associative array for use by `resolveLinkSection()` +This is accomplished by comparing `composer.json` fields between the original Magento root and the target root, and, when a delta is found, checking to see if the user has already made custom changes to that field. If a change has been made, if it does not already match the target Magento value, resolve the conflict according to the strategy passed to the CLI command: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used on a case-by-case basis. + + - **`resolveRootDeltas()`** + - Entry point into the resolution functionality + - Calls the relevant resolve function for each `composer.json` field that can be updated + - **`findResolution()`** + - For an individual field value, compare the original Magento value to the target Magento value, and if a delta is found, check if the user's installation has a customized value for the field. If the user has changed the value, resolve the conflict according to the CLI command options: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used + - **`resolveLinkSection()`** + - For a given `composer.json` section that consists of links to package versions/constraints (such as the `require` and `conflict` sections), call `findResolution()` for each package constraint found in either the original Magento root or the target Magento root + - **`resolveArraySection()`** + - For a given `composer.json` section that consists of data that is not package links (such as the `"autoload"` or `"extra"` sections), call `resolveNestedArray()` and accept the new values if changes were made + - **`resolveNestedArray()`** + - Recursively processes changes to a `composer.json` value that could be a nested array, calling `findResolution()` for each "leaf" value found in either the original Magento root or the target Magento root + - **`linksToMap()`** + - Helper function to convert a set of package links to an associative array for use by `resolveLinkSection()` #### [**MagentoRootUpdater**](../src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php) -This class runs [DeltaResolver::resolveRootDeltas()()](#deltaresolver) if an update is required, tracks the results, and writes the changes out to the `composer.json` file +This class runs [DeltaResolver::resolveRootDeltas()](#deltaresolver) if an update is required, tracks the results, and writes the changes out to the `composer.json` file. - - **`runUpdate()`** - - Checks if the target Magento package differs from the original package, and if so runs DeltaResolver and tracks the results - - **`writeUpdatedComposerFile()`** - - Takes the result values from DeltaResolver and overwrite the corresponding values in the root `composer.json` file + - **`runUpdate()`** + - Checks if the target Magento package differs from the original package, and if so runs DeltaResolver and tracks the results + - **`writeUpdatedComposerFile()`** + - Takes the result values from DeltaResolver and overwrites the corresponding values in the root `composer.json` file #### [**RootPackageRetriever**](../src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php) -This class contains methods to retrieve Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version and the currently-installed root project package including all user customizations - - - **`getOriginalRootPackage()`** - - Fetches the original (default) Magento root project package from the composer repository - - **`getTargetRootPackage()`** - - Fetches the target Magento root project package from the composer repository - - **`getUserRootPackage()`** - - Returns the existing root project package, including all user customizations - - **`fetchMageRootFromRepo()`** - - Given a Magento edition and version constraint, fetch the best-fit Magento root project package from the composer repository - - **`parseOriginalVersionAndEditionFromLock()`** - - Inspect the `composer.lock` file for the currently-installed Magento product package and parse out the edition and version for use by `getOriginalRootPackage()` - - **`getRootLocker()`** - - Helper function to get the [Locker](https://github.com/composer/composer/blob/master/src/Composer/Package/Locker.php) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory +This class contains methods to retrieve Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version, and the currently-installed root project package (including all user customizations). + + - **`getOriginalRootPackage()`** + - Fetches the original (default) Magento root project package from the Composer repository + - **`getTargetRootPackage()`** + - Fetches the target Magento root project package from the Composer repository + - **`getUserRootPackage()`** + - Returns the existing root project package, including all user customizations + - **`fetchMageRootFromRepo()`** + - Given a Magento edition and version constraint, fetch the best-fit Magento root project package from the Composer repository + - **`parseOriginalVersionAndEditionFromLock()`** + - Inspect the `composer.lock` file for the currently-installed Magento product package and parse out the edition and version for use by `getOriginalRootPackage()` + - **`getRootLocker()`** + - Helper function to get the [Locker](https://github.com/composer/composer/blob/master/src/Composer/Package/Locker.php) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory + +*** ## Namespace: [Magento\ComposerRootUpdatePlugin\Utils](../src/Magento/ComposerRootUpdatePlugin/Utils) -This namespace contains utility classes shared across the rest of the plugin's codebase +This namespace contains utility classes shared across the rest of the plugin's codebase. #### [**Console**](../src/Magento/ComposerRootUpdatePlugin/Utils/Console.php) - Command-line logger with interaction methods +Command-line logger with interaction methods. - - **`getIO()`** - - Returns the [IOInterface](https://github.com/composer/composer/blob/master/src/Composer/IO/IOInterface.php) instance - - **`ask()`** - - Asks the user a yes or no question and return the result. If the console interface has been configured as non-interactive, instead it does not ask and returns the default value - - **`log()`** - - Logs the given message if the verbosity level is appropriate - - **`info()`**/**`comment()`**/**`warning()`**/**`error()`**/**`labeledVerbose()`** - - Helper methods to format and log messages of different types/verbosity levels + - **`getIO()`** + - Returns the [IOInterface](https://github.com/composer/composer/blob/master/src/Composer/IO/IOInterface.php) instance + - **`ask()`** + - Asks the user a yes or no question and return the result. If the console interface has been configured as non-interactive, it does not ask and returns the default value + - **`log()`** + - Logs the given message if the verbosity level is appropriate + - **`info()`**/**`comment()`**/**`warning()`**/**`error()`**/**`labeledVerbose()`** + - Helper methods to format and log messages of different types/verbosity levels #### [**PackageUtils**](../src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php) - Common package-related utility functions +Common package-related utility functions. - - **`getMagentoPackageType()`** - - Extracts the package type (`product` or `project`) from a Magento package name - - **`getMagentoProductEdition()`** - - Extracts the edition (`community` or `enterprise`) from a Magento product package name - - **`findRequire()`** - - Searches the `"require"` section of a [Composer](https://github.com/composer/composer/blob/master/src/Composer/Composer.php) object for a package link that fits the supplied name or matcher - - **`isConstraintStrict()`** - - Checks if a version constraint is strict or if it allows multiple versions (such as `~1.0` or `>= 1.5.3`) - \ No newline at end of file + - **`getMagentoPackageType()`** + - Extracts the package type (`product` or `project`) from a Magento package name + - **`getMagentoProductEdition()`** + - Extracts the edition (`community` or `enterprise`) from a Magento product package name + - **`findRequire()`** + - Searches the `"require"` section of a [Composer](https://github.com/composer/composer/blob/master/src/Composer/Composer.php) object for a package link that fits the supplied name or matcher + - **`isConstraintStrict()`** + - Checks if a version constraint is strict or if it allows multiple versions (such as `~1.0` or `>= 1.5.3`) diff --git a/docs/process_flows.md b/docs/process_flows.md index 04655d5..2d5c641 100644 --- a/docs/process_flows.md +++ b/docs/process_flows.md @@ -1,93 +1,101 @@ -# Plugin Operation Flow Explanations and Diagrams +# Plugin operation flow explanations and diagrams There are four paths through the plugin code that cover two main pieces of functionality: - - Update the root `composer.json` in a Magento installation with values required by the new Magento version during an upgrade + - Update the root `composer.json` file in a Magento installation with values required by the new Magento version during an upgrade - [composer require ](#composer-require-magento_product_package) - - Ensure the plugin is installed in the `/var` directory, where it needs to exist for Magento's Web Setup Wizard upgrade path + - Ensure the plugin is installed in the `/var` directory, which is required for Magento's Web Setup Wizard upgrade path - [composer require/update magento/composer-root-update-plugin](#composer-requireupdate-magentocomposer-root-update-plugin) - [Magento module-based var installation](#magento-module-based-var-installation) - [Explicit var installation command](#explicit-var-installation-command) - + +*** + ## `composer require ` -**Scenario:** The user has an installed Magento project and wants to upgrade to a new version. They call `composer require ` from the command line or through the Magento Web Setup Wizard upgrade tool. +**Scenario:** The user has an installed Magento project and wants to upgrade to a new version. They call `composer require ` from the command line or through the Magento Web Setup Wizard upgrade tool. ![composer require flow](resources/require_command_flow.png) 1. Composer boilerplate and plugin setup 1. Composer sees the `"type": "composer-plugin"` value in the [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file for the plugin package - 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php)) - 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](../src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php)), which is supplied through `PluginDefinition::getCapabilities()` - 4. `CommandProvider::getCommands()` supplies Composer with an instance of [MageRootRequireCommand](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php) + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](class_descriptions.md#plugindefinition)) + 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](class_descriptions.md#commandprovider)), which is supplied through `PluginDefinition::getCapabilities()` + 4. `CommandProvider::getCommands()` supplies Composer with an instance of [MageRootRequireCommand](class_descriptions.md#commandsmagerootrequirecommand) 5. Composer calls `MageRootRequireCommand::configure()` to obtain the command's name, description, options, and help text - `MageRootRequireCommand` extends Composer's native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) and adds its own values to those in the existing implementation - Composer contains a command registry and rejects any new commands that have a conflicting name with a command that is already registered, so `MageRootRequireCommand::configure()` temporarily changes the command's name from `require` to a dummy value to bypass the registry check - 6. Composer calls `MageRootRequireCommand::setApplication()` after checking the for naming conflicts but before adding the command to the registry, at which time the command name changes back to `require` + 6. Composer calls `MageRootRequireCommand::setApplication()` after checking for naming conflicts but before adding the command to the registry, at which time the command name changes back to `require` 7. Composer adds `MageRootRequireCommand` to the registry, overwriting the native `RequireCommand` as the command associated with the name `require` 2. Composer recognizes `require` as the command passed to the executable and finds `MageRootRequireCommand` as the command object registered under that name 3. Composer calls `MageRootRequireCommand::execute()` -4. `MageRootRequireCommand::execute()` backs up the user's `composer.json` file through [ExtendableRequireCommand::parseComposerJsonFile()](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php) +4. `MageRootRequireCommand::execute()` backs up the user's `composer.json` file through [ExtendableRequireCommand::parseComposerJsonFile()](class_descriptions.md#extendablerequirecommand) 5. `MageRootRequireCommand::execute()` checks the `composer require` arguments for a `magento/product` package, and if it finds one it calls `MageRootRequireCommand::runUpdate()` -6. `MageRootRequireCommand::runUpdate()` calls [MagentoRootUpdater::runUpdate()](../src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php) -7. `MageRootRequireCommand::runUpdate()` calls [DeltaResolver::resolveRootDeltas()()](../src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php) -8. `DeltaResolver::resolveRootDeltas()()` uses [RootPackageRetriever](../src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php) to obtain the Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made -9. `DeltaResolver::resolveRootDeltas()()` iterates over the fields in `composer.json` to determine any values that need to be updated to match the root `composer.json` of the new Magento edition/version - 1. To find these values, it compares the values for each field in the default project for the installed edition/version and the project for the target edition/version (`DeltaResolver::findResolution()`) +6. `MageRootRequireCommand::runUpdate()` calls [MagentoRootUpdater::runUpdate()](class_descriptions.md#magentorootupdater) +7. `MageRootRequireCommand::runUpdate()` calls [DeltaResolver::resolveRootDeltas()](class_descriptions.md#deltaresolver) +8. `DeltaResolver::resolveRootDeltas()` uses [RootPackageRetriever](class_descriptions.md#rootpackageretriever) to obtain the Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made +9. `DeltaResolver::resolveRootDeltas()` iterates over the fields in `composer.json` to determine any values that need to be updated to match the root `composer.json` file of the new Magento edition/version + 1. To find these values, it compares the values for each field in the default project for the installed edition/version with the project for the target edition/version (`DeltaResolver::findResolution()`) 2. If a value has changed in the target, it checks that field in the user's customized root `composer.json` file to see if it has been overwritten with a custom value 3. If the user customized the value, the conflict will be resolved according to the specified resolution strategy: use the expected Magento value, use the user's custom value, or prompt the user to specify which value should be used -10. If `resolveRootDeltas()()` found values that need to change, `MageRootRequireCommand::execute()` calls `MagentoRootUpdater::writeUpdatedComposerJson()` to apply those changes +10. If `resolveRootDeltas()` found values that need to change, `MageRootRequireCommand::execute()` calls `MagentoRootUpdater::writeUpdatedComposerJson()` to apply those changes 11. `MageRootRequireCommand::execute()` calls the native `RequireCommand::execute()` function, which will now use the updated root `composer.json` file if the plugin made changes -12. If the `RequireCommand::execute()` call fails after the plugin had made changes, `MageRootRequireCommand::execute()` calls `ExtendableRequireCommand::revertMageComposerFile()` to restore the `composer.json` file to its original state - +12. If the `RequireCommand::execute()` call fails after the plugin makes changes, `MageRootRequireCommand::execute()` calls `ExtendableRequireCommand::revertMageComposerFile()` to restore the `composer.json` file to its original state + +*** + ## `composer require/update magento/composer-root-update-plugin` -**Scenario:** The user wants to install or update the version of the `magento/composer-root-update-plugin` package in their Magento installation. They call `composer require/update magento/composer-root-update-plugin`. The plugin needs to update a copy of itself in the `/var` directory, where it needs to exist in order to function during Web Setup Wizard operations. +**Scenario:** The user wants to install or update the version of the `magento/composer-root-update-plugin` package in their Magento installation. They call `composer require/update magento/composer-root-update-plugin`. The plugin needs to update a copy of itself in the `/var` directory, where it is required for Web Setup Wizard operations. ![self install flow](resources/self_install_flow.png) 1. Composer boilerplate and plugin setup 1. Composer sees the `"type": "composer-plugin"` value in the `composer.json` file for the plugin package - 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php)) + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](class_descriptions.md#plugindefinition)) 3. `PluginDefinition` implements [EventSubscriberInterface](https://github.com/composer/composer/blob/master/src/Composer/EventDispatcher/EventSubscriberInterface.php), telling Composer that it subscribes to events triggered by Composer operations 4. `PluginDefinition::getSubscribedEvents()` tells Composer to call the `PluginDefinition::packageUpdate()` function when the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` events are triggered 2. Composer runs the [RequireCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) or [UpdateCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/UpdateCommand.php) method as relevant, which results in Composer triggering either the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` event 3. Composer checks the listeners registered to the triggered event and calls `PluginDefinition::packageUpdate()` -4. `PluginDefinition::packageUpdate()` calls [WebSetupWizardPluginInstaller::packageEvent()](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) +4. `PluginDefinition::packageUpdate()` calls [WebSetupWizardPluginInstaller::packageEvent()](class_descriptions.md#websetupwizardplugininstaller) 5. `WebSetupWizardPluginInstaller::packageEvent()` checks the event to see if it was triggered by a change to the `magento/composer-root-update-plugin` package, and if so it calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` 6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there to see if it matches the version in the triggered event -7. If the version doesn't match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the new version of `magento/composer-root-update-plugin` in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation +7. If the version does not match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the new version of `magento/composer-root-update-plugin` in a temporary directory, then replaces `/var/vendor` with the `vendor` directory from the temporary installation + +*** ## Magento module-based `var` installation -**Scenario:** The user has called the `bin/magento setup:uninstall` command, which clears the `/var` directory, then runs `bin/magento setup:install`. The plugin needs to reinstall itself in the `/var` directory, where it needs to exist in order to function during Web Setup Wizard operations. +**Scenario:** The user calls the `bin/magento setup:uninstall` command, which clears the `/var` directory, then runs `bin/magento setup:install`. The plugin needs to reinstall itself in the `/var` directory, where it is required for Web Setup Wizard operations. ![module install flow](resources/module_install_flow.png) 1. The `"autoload"->"files": "registration.php"` value in the plugin's [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file causes [registration.php](../src/Magento/ComposerRootUpdatePlugin/registration.php) to be loaded by Magento 2. `registration.php` registers the plugin as the `Magento_ComposerRootUpdatePlugin` module so it can tie into the `bin/magento setup` module operations 3. Magento searches registered modules for any `Setup\InstallData`, `Setup\RecurringData`, or `Setup\UpgradeData` classes -4. Magento calls [InstallData::install()](../src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php), [RecurringData::install()](../src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php), or [UpgradeData::upgrade()](../src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php) as appropriate (which one depends on the specific `bin/magento setup` command and installed Magento version), which then calls [WebSetupWizardPluginInstaller::doVarInstall()](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) +4. Magento calls [InstallData::install()](class_descriptions.md#installdatarecurringdataupgradedata), [RecurringData::install()](class_descriptions.md#installdatarecurringdataupgradedata), or [UpgradeData::upgrade()](class_descriptions.md#installdatarecurringdataupgradedata) as appropriate (which one depends on the specific `bin/magento setup` command and installed Magento version), which then calls [WebSetupWizardPluginInstaller::doVarInstall()](class_descriptions.md#websetupwizardplugininstaller) 5. `WebSetupWizardPluginInstaller::doVarInstall()` finds the `magento/composer-root-update-plugin` version in the `composer.lock` file in the root Magento directory and calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` 6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there (if any) to see if it matches the version in the root `composer.lock` file -7. If the version doesn't match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation +7. If the version does not match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory, then replaces `/var/vendor` with the `vendor` directory from the temporary installation + +*** ## Explicit `var` installation command -**Scenario:** The user has cleared the `/var` directory and wants to use the Web Setup Wizard to upgrade their Magento installation. The plugin needs to exist in `/var` in order to function during Web Setup Wizard operations, so the user calls `composer magento-update-plugin install` to restore the plugin installation in the `/var` directory. +**Scenario:** The user clears the `/var` directory and wants to use the Web Setup Wizard to upgrade their Magento installation. The plugin must exist in `/var` for Web Setup Wizard operations, so the user calls `composer magento-update-plugin install` to restore the plugin installation in the `/var` directory. ![explicit install flow](resources/explicit_install_flow.png) 1. Composer boilerplate and plugin setup 1. Composer sees the `"type": "composer-plugin"` value in the [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file for the plugin package - 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](../src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php)) - 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](../src/Magento/ComposerRootUpdatePlugin/Plugin/CommandProvider.php)), which is supplied through `PluginDefinition::getCapabilities()` - 4. `CommandProvider::getCommands()` supplies Composer with an instance of [UpdatePluginNamespaceCommands](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php) + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](class_descriptions.md#plugindefinition)) + 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](class_descriptions.md#commandprovider)), which is supplied through `PluginDefinition::getCapabilities()` + 4. `CommandProvider::getCommands()` supplies Composer with an instance of [UpdatePluginNamespaceCommands](class_descriptions.md#commandsupdatepluginnamespacecommands) 5. Composer calls `UpdatePluginNamespaceCommands::configure()` to obtain the command's name, description, options, and help text 6. Composer adds `UpdatePluginNamespaceCommands` to the registry under the name `magento-update-plugin` 2. Composer recognizes `magento-update-plugin` as the command passed to the executable and finds `UpdatePluginNamespaceCommands` as the command object registered under that name 3. Composer calls `UpdatePluginNamespaceCommands::execute()` -4. `UpdatePluginNamespaceCommands::execute()` checks the first argument supplied to the `composer magento-update-plugin` command and sees `install`, so it calls [WebSetupWizardPluginInstaller::doVarInstall()](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) +4. `UpdatePluginNamespaceCommands::execute()` checks the first argument supplied to the `composer magento-update-plugin` command and sees `install`, so it calls [WebSetupWizardPluginInstaller::doVarInstall()](class_descriptions.md#websetupwizardplugininstaller) 5. `WebSetupWizardPluginInstaller::doVarInstall()` finds the `magento/composer-root-update-plugin` version in the `composer.lock` file in the root Magento directory and calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` 6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there (if any) to see if it matches the version in the root `composer.lock` file -7. If the version doesn't match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation +7. If the version does not match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory then replaces `/var/vendor` with the `vendor` directory from the temporary installation diff --git a/src/Magento/ComposerRootUpdatePlugin/README.md b/src/Magento/ComposerRootUpdatePlugin/README.md index 802813f..774a984 100644 --- a/src/Magento/ComposerRootUpdatePlugin/README.md +++ b/src/Magento/ComposerRootUpdatePlugin/README.md @@ -1,23 +1,29 @@ # Overview + ## Purpose of plugin -The **magento/composer-root-update-plugin** Composer plugin resolves changes that need to be made to the root project `composer.json` file before updating to a new Magento product requirement. +The `magento/composer-root-update-plugin` Composer plugin resolves changes that need to be made to the root project `composer.json` file before updating to a new Magento product requirement. This is accomplished by comparing the root `composer.json` file for the Magento project corresponding to the Magento version and edition in the current installation with the Magento project `composer.json` file for the target Magento product package when the `composer require` command runs and applying any deltas found between the two files if they do not conflict with the existing `composer.json` file in the Magento root directory. # Getting Started + ## System requirements -The **magento/composer-root-update-plugin** package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. + +The `magento/composer-root-update-plugin` package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. ## Installation + To install the plugin, run `composer require magento/composer-root-update-plugin ~1.0` in the Magento root directory. # Usage + The plugin adds functionality to the `composer require` command when a new Magento product package is required, and in most cases will not need additional options or commands run to function. If the `composer require` command for the target Magento package fails, one of the following may be necessary. ## Installations that started with another Magento product + If the local Magento installation has previously been updated from a previous Magento product version or edition, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. @@ -25,6 +31,7 @@ In this case, run the following command with the appropriate values to correct t composer require --base-magento-edition --base-magento-version ## Conflicting custom values + If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. @@ -32,21 +39,173 @@ To resolve these conflicts interactively, re-run the `composer require` command To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-default-magento-values` option. ## Bypassing the plugin + To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root-plugin` option. ## Refreshing the plugin for the Web Setup Wizard + If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. To reinstall the plugin in `var`, run the following command in the Magento root directory. composer magento-update-plugin install +## Example use case: Upgrading from Magento 2.2.8 to Magento 2.3.1 + +### Without `magento/composer-root-update-plugin`: + +In the project directory for a Magento Community Edition 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Community Edition 2.3.1 with these results: + +``` +$ composer require magento/product-community-edition 2.3.1 --no-update +./composer.json has been updated +$ composer update +Loading composer repositories with package information +Updating dependencies (including require-dev) +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Installation request for magento/product-community-edition 2.3.1 -> satisfiable by magento/product-community-edition[2.3.1]. + - magento/product-community-edition 2.3.1 requires magento/magento2-base 2.3.1 -> satisfiable by magento/magento2-base[2.3.1]. + ... + - sebastian/phpcpd 2.0.4 requires symfony/console ~2.7|^3.0 + ... + - magento/magento2-base 2.3.1 requires symfony/console ~4.1.0 -> satisfiable by symfony/console[v4.1.0, v4.1.1, v4.1.10, v4.1.11, v4.1.2, v4.1.3, v4.1.4, v4.1.5, v4.1.6, v4.1.7, v4.1.8, v4.1.9]. + - Conclusion: don't install symfony/console v4.1.11|install symfony/console v2.8.38 + - Installation request for sebastian/phpcpd 2.0.4 -> satisfiable by sebastian/phpcpd[2.0.4]. +``` + +This error occurs because the `"require-dev"` section in the `composer.json` file for `magento/project-community-edition` 2.2.8 conflicts with the dependencies for the new 2.3.1 version of `magento/product-community-edition`. The 2.2.8 `composer.json` file has a `"require-dev"` entry for `sebastian/phpcpd: 2.0.4`, which depends on `symfony/console: ~2.7|^3.0`, but the `magento/magento2-base` package required by `magento/product-community-edition` 2.3.1 depends on `symfony/console: ~4.1.0`, which does not overlap with the versions allowed by the `~2.7|^3.0` constraint. + +Because the `sebastian/phpcpd` requirement exists in the root `composer.json` file instead of one of the child dependencies of `magento/product-community-edition` 2.2.8, it does not get updated by Composer when the `magento/product-community-edition` version changes. + +In the `composer.json` file for `magento/project-community-edition` 2.3.1, that `sebastian/phpcpd` entry in `"require-dev"` has changed to `~3.0.0`, which is compatible with the `symfony/console` versions allowed by `magento/magento2-base` 2.3.1. However, without this plugin, Composer does not know that the value needs to change because the commands to upgrade Magento use the `magento/product-community-edition` metapackage and not the root `magento/project-community-edition` project package. + +This is only one of the changes to the root project `composer.json` file between Magento 2.2.8 and 2.3.1. There are several others, and future Magento versions can (and likely will) require further updates to the file. + +The changes to the root project `composer.json` files can be done manually by the user without the plugin, but the values that need to change can differ depending on the Magento versions involved and user-customized values may already override the Magento defaults. This means the exact upgrade steps necessary can be different for every user and determining the correct changes to make manually for a given user's configuration may be error-prone. + +For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Community Edition versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. + + - **2.2.8** + ``` + "require": { + "magento/product-community-edition": "2.2.8", + "composer/composer": "@alpha" + }, + "require-dev": { + "magento/magento2-functional-testing-framework": "2.3.13", + "phpunit/phpunit": "~6.2.0", + "squizlabs/php_codesniffer": "3.2.2", + "phpmd/phpmd": "@stable", + "pdepend/pdepend": "2.5.2", + "friendsofphp/php-cs-fixer": "~2.2.1", + "lusitanian/oauth": "~0.8.10", + "sebastian/phpcpd": "2.0.4" + } + ``` + + - **2.3.1** + ``` + "require": { + "magento/product-community-edition": "2.3.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.13.0", + "lusitanian/oauth": "~0.8.10", + "magento/magento2-functional-testing-framework": "~2.3.13", + "pdepend/pdepend": "2.5.2", + "phpmd/phpmd": "@stable", + "phpunit/phpunit": "~6.5.0", + "sebastian/phpcpd": "~3.0.0", + "squizlabs/php_codesniffer": "3.3.1", + "allure-framework/allure-phpunit": "~1.2.0" + } + ``` + +### With `magento/composer-root-update-plugin`: + +In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0` before the Magento Community Edition 2.3.1 upgrade commands. + +``` +$ composer require magento/composer-root-update-plugin ~1.0 +./composer.json has been updated +Loading composer repositories with package information +Updating dependencies (including require-dev) +Package operations: 1 install, 0 updates, 0 removals + - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) +Installing "magento/composer-root-update-plugin: 1.0.0" for the Web Setup Wizard +Loading composer repositories with package information +Updating dependencies +Package operations: 18 installs, 0 updates, 0 removals + - Installing ... + ... + - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) +Writing lock file +Generating autoload files +Writing lock file +Generating autoload files +``` + +As is normal for `composer require`, `magento/composer-root-update-plugin` is added to the `composer.json` file. The plugin also installs itself in the directory used by the Magento Web Setup Wizard during dependency validation. + +With the plugin installed, the user proceeds with the `composer require` command for Magento Community Edition 2.3.1 (`--verbose` mode used here for demonstration). + +``` +$ composer require magento/product-community-edition 2.3.1 --no-update --verbose + [Magento Community Edition 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 + [Magento Community Edition 2.3.1] Removing require entries: composer/composer + [Magento Community Edition 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 + [Magento Community Edition 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 + [Magento Community Edition 2.3.1] Adding conflict constraints: gene/bluefoot=* + [Magento Community Edition 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" +Updating composer.json for Magento Community Edition 2.3.1 ... + [Magento Community Edition 2.3.1] Writing changes to the root composer.json... + [Magento Community Edition 2.3.1] /composer.json has been updated +./composer.json has been updated +``` + +The plugin detects the user's request for the 2.3.1 version of `magento/product-community-edition` and looks up the `composer.json` file for the corresponding `magento/project-community-edition` 2.3.1 root project package. It finds the values that are different between 2.2.8 and 2.3.1 and updates the local `composer.json` file accordingly, then lets Composer proceed with the normal `composer require` functionality. + +With the root `composer.json` file updated for Magento Community Edition 2.3.1, the user proceeds with the `composer update` command: + +``` +$ composer update +Loading composer repositories with package information +Updating dependencies (including require-dev) +Package operations: 118 installs, 246 updates, 5 removals + - Removing symfony/polyfill-php55 (v1.11.0) + ... +Writing lock file +Generating autoload files +``` + +With the updated values from Magento Community Edition 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. + +For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Community Edition 2.2.8 installation. They contain exactly the same entries as the default Magento Community Edition 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). + + ``` + "require": { + "magento/product-community-edition": "2.3.1", + "magento/composer-root-update-plugin": "~1.0" + }, + "require-dev": { + "allure-framework/allure-phpunit": "~1.2.0", + "magento/magento2-functional-testing-framework": "~2.3.13", + "phpunit/phpunit": "~6.5.0", + "squizlabs/php_codesniffer": "3.3.1", + "phpmd/phpmd": "@stable", + "pdepend/pdepend": "2.5.2", + "friendsofphp/php-cs-fixer": "~2.13.0", + "lusitanian/oauth": "~0.8.10", + "sebastian/phpcpd": "~3.0.0" + } + ``` + # License -Each Magento source file included in this distribution is licensed under OSL 3.0 or the Magento Enterprise Edition (MEE) license. +Each Magento source file included in this distribution is licensed under OSL 3.0. [Open Software License (OSL 3.0)](https://opensource.org/licenses/osl-3.0.php). -Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. -Subject to Licensee's payment of fees and compliance with the terms and conditions of the MEE License, the MEE License supersedes the OSL 3.0 license for each source file. -Please see LICENSE_EE.txt for the full text of the MEE License or visit https://magento.com/legal/terms/enterprise. +Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. From d5dfd4f318b596100b98af09a26f524d05f9e7fc Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Tue, 16 Apr 2019 16:29:05 -0500 Subject: [PATCH 09/15] Switching composer links to api documentation instead of source where possible --- docs/class_descriptions.md | 24 ++++++++++++------------ docs/process_flows.md | 18 +++++++++--------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md index b10807a..1dd0aff 100644 --- a/docs/class_descriptions.md +++ b/docs/class_descriptions.md @@ -27,7 +27,7 @@ Because the plugin is hooking into the native `composer require` functionality d #### [**AccessibleRootPackageLoader**](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php) -**Composer class:** [RootPackageLoader](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) +**Composer class:** [RootPackageLoader](https://getcomposer.org/apidoc/master/Composer/Package/Loader/RootPackageLoader.htmlp) - **`extractStabilityFlags()`** -- see [RootPackageLoader::extractStabilityFlags()](https://github.com/composer/composer/blob/master/src/Composer/Package/Loader/RootPackageLoader.php) - Takes a package name, version, and minimum-stability setting and returns the stability level that should be used to find the package on a repository @@ -35,15 +35,15 @@ Because the plugin is hooking into the native `composer require` functionality d #### [**ExtendableRequireCommand**](../src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php) -**Composer class:** [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) +**Composer class:** [RequireCommand](https://getcomposer.org/apidoc/master/Composer/Command/RequireCommand.html) - - **`parseComposerJsonFile()`** -- see [RequireCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) + - **`parseComposerJsonFile()`** -- see [RequireCommand::execute()](https://getcomposer.org/apidoc/master/Composer/Command/RequireCommand.html#method_execute) - Checks the accessibility of the `composer.json` file and parses out relevant base information that is needed before starting the plugin's processing - **Reason for cloning:** The native code exists directly in `RequireCommand::execute()` instead of its own function, but the base information it parses is required by the plugin before it runs as part of the original `RequireCommand` code - - **`getRequirementsInteractive()`** -- see [InitCommand::determineRequirements()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php) + - **`getRequirementsInteractive()`** -- see [InitCommand::determineRequirements()](https://getcomposer.org/apidoc/master/Composer/Command/InitCommand.html#method_determineRequirements) - Interactively asks for the `composer require` arguments if they are not passed to the CLI command call - **Reason for cloning:** The native command calls [InitCommand::findBestVersionAndNameForPackage()](https://github.com/composer/composer/blob/master/src/Composer/Command/InitCommand.php), which would try to validate the target Magento package's requirements before the plugin can process the relevant changes to make it compatible. The original `determineRequirements()` call is still made by `RequireCommand::execute()` after the plugin runs, so Composer's validation still happens as normal. - - **`revertMageComposerFile()`** -- see [RequireCommand::revertComposerFile()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) + - **`revertMageComposerFile()`** -- see [RequireCommand::revertComposerFile()](https://getcomposer.org/apidoc/master/Composer/Command/RequireCommand.html#method_revertComposerFile) - Reverts the `composer.json` file to its original state from before the plugin's changes if the command fails - **Reason for cloning:** The plugin makes changes before `RequireCommand` creates a backup, which means when it runs `revertComposerFile()`, the reverted file from the backup does not match the original state, so this function is needed to also revert the plugin's changes @@ -55,9 +55,9 @@ Classes in this namespace tie into the Composer library's code that handles plug #### [**Commands\MageRootRequireCommand**](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php) -This class is the entry point into the plugin's functionality from the `composer require` CLI command. +This class is the entrypoint into the plugin's functionality from the `composer require` CLI command. -Extends the native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) functionality to add additional processing when run with a Magento product as one of the command's parameters. +Extends the native [RequireCommand](https://getcomposer.org/apidoc/master/Composer/Command/RequireCommand.html) functionality to add additional processing when run with a Magento product as one of the command's parameters. - **`configure()`** - Add the options and description for the plugin functionality to those already configured in `RequireCommand` and sets the new command's name to a dummy unique value so it passes Composer's command registry check @@ -93,7 +93,7 @@ This is a Composer boilerplate class to let the Composer plugin library know abo This class is Composer's entry point into the plugin's functionality and the definition supplied to the plugin registry. - **`activate()`** - - Method must exist in any implementation of [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) + - Method must exist in any implementation of [PluginInterface](https://getcomposer.org/apidoc/master/Composer/Plugin/PluginInterface.html) - **`getCapabilities()`** - Tells Composer that the plugin includes CLI commands and defines the [CommandProvider](#commandprovider) that supplies the command objects - **`getSubscribedEvents()`** @@ -169,7 +169,7 @@ This class runs [DeltaResolver::resolveRootDeltas()](#deltaresolver) if an updat #### [**RootPackageRetriever**](../src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php) -This class contains methods to retrieve Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version, and the currently-installed root project package (including all user customizations). +This class contains methods to retrieve Composer [Package](https://getcomposer.org/apidoc/master/Composer/Package/Package.html) objects for the target Magento root project package, the original (default) Magento root project package for the currently-installed Magento version, and the currently-installed root project package (including all user customizations). - **`getOriginalRootPackage()`** - Fetches the original (default) Magento root project package from the Composer repository @@ -182,7 +182,7 @@ This class contains methods to retrieve Composer [Package](https://github.com/co - **`parseOriginalVersionAndEditionFromLock()`** - Inspect the `composer.lock` file for the currently-installed Magento product package and parse out the edition and version for use by `getOriginalRootPackage()` - **`getRootLocker()`** - - Helper function to get the [Locker](https://github.com/composer/composer/blob/master/src/Composer/Package/Locker.php) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory + - Helper function to get the [Locker](https://getcomposer.org/apidoc/master/Composer/Package/Locker.html) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory *** @@ -195,7 +195,7 @@ This namespace contains utility classes shared across the rest of the plugin's c Command-line logger with interaction methods. - **`getIO()`** - - Returns the [IOInterface](https://github.com/composer/composer/blob/master/src/Composer/IO/IOInterface.php) instance + - Returns the [IOInterface](https://getcomposer.org/apidoc/master/Composer/IO/IOInterface.html) instance - **`ask()`** - Asks the user a yes or no question and return the result. If the console interface has been configured as non-interactive, it does not ask and returns the default value - **`log()`** @@ -212,6 +212,6 @@ Common package-related utility functions. - **`getMagentoProductEdition()`** - Extracts the edition (`community` or `enterprise`) from a Magento product package name - **`findRequire()`** - - Searches the `"require"` section of a [Composer](https://github.com/composer/composer/blob/master/src/Composer/Composer.php) object for a package link that fits the supplied name or matcher + - Searches the `"require"` section of a [Composer](https://getcomposer.org/apidoc/master/Composer/Composer.html) object for a package link that fits the supplied name or matcher - **`isConstraintStrict()`** - Checks if a version constraint is strict or if it allows multiple versions (such as `~1.0` or `>= 1.5.3`) diff --git a/docs/process_flows.md b/docs/process_flows.md index 2d5c641..3718b14 100644 --- a/docs/process_flows.md +++ b/docs/process_flows.md @@ -19,11 +19,11 @@ There are four paths through the plugin code that cover two main pieces of funct 1. Composer boilerplate and plugin setup 1. Composer sees the `"type": "composer-plugin"` value in the [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file for the plugin package - 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](class_descriptions.md#plugindefinition)) - 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](class_descriptions.md#commandprovider)), which is supplied through `PluginDefinition::getCapabilities()` + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://getcomposer.org/apidoc/master/Composer/Plugin/PluginInterface.html) ([PluginDefinition](class_descriptions.md#plugindefinition)) + 3. `PluginDefinition` implements [Capable](https://getcomposer.org/apidoc/master/Composer/Plugin/Capable.html), telling Composer that it provides some capability ([CommandProvider](class_descriptions.md#commandprovider)), which is supplied through `PluginDefinition::getCapabilities()` 4. `CommandProvider::getCommands()` supplies Composer with an instance of [MageRootRequireCommand](class_descriptions.md#commandsmagerootrequirecommand) 5. Composer calls `MageRootRequireCommand::configure()` to obtain the command's name, description, options, and help text - - `MageRootRequireCommand` extends Composer's native [RequireCommand](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) and adds its own values to those in the existing implementation + - `MageRootRequireCommand` extends Composer's native [RequireCommand](https://getcomposer.org/apidoc/master/Composer/Command/RequireCommand.html) and adds its own values to those in the existing implementation - Composer contains a command registry and rejects any new commands that have a conflicting name with a command that is already registered, so `MageRootRequireCommand::configure()` temporarily changes the command's name from `require` to a dummy value to bypass the registry check 6. Composer calls `MageRootRequireCommand::setApplication()` after checking for naming conflicts but before adding the command to the registry, at which time the command name changes back to `require` 7. Composer adds `MageRootRequireCommand` to the registry, overwriting the native `RequireCommand` as the command associated with the name `require` @@ -33,7 +33,7 @@ There are four paths through the plugin code that cover two main pieces of funct 5. `MageRootRequireCommand::execute()` checks the `composer require` arguments for a `magento/product` package, and if it finds one it calls `MageRootRequireCommand::runUpdate()` 6. `MageRootRequireCommand::runUpdate()` calls [MagentoRootUpdater::runUpdate()](class_descriptions.md#magentorootupdater) 7. `MageRootRequireCommand::runUpdate()` calls [DeltaResolver::resolveRootDeltas()](class_descriptions.md#deltaresolver) -8. `DeltaResolver::resolveRootDeltas()` uses [RootPackageRetriever](class_descriptions.md#rootpackageretriever) to obtain the Composer [Package](https://github.com/composer/composer/blob/master/src/Composer/Package/Package.php) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made +8. `DeltaResolver::resolveRootDeltas()` uses [RootPackageRetriever](class_descriptions.md#rootpackageretriever) to obtain the Composer [Package](https://getcomposer.org/apidoc/master/Composer/Package/Package.html) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made 9. `DeltaResolver::resolveRootDeltas()` iterates over the fields in `composer.json` to determine any values that need to be updated to match the root `composer.json` file of the new Magento edition/version 1. To find these values, it compares the values for each field in the default project for the installed edition/version with the project for the target edition/version (`DeltaResolver::findResolution()`) 2. If a value has changed in the target, it checks that field in the user's customized root `composer.json` file to see if it has been overwritten with a custom value @@ -52,10 +52,10 @@ There are four paths through the plugin code that cover two main pieces of funct 1. Composer boilerplate and plugin setup 1. Composer sees the `"type": "composer-plugin"` value in the `composer.json` file for the plugin package - 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](class_descriptions.md#plugindefinition)) - 3. `PluginDefinition` implements [EventSubscriberInterface](https://github.com/composer/composer/blob/master/src/Composer/EventDispatcher/EventSubscriberInterface.php), telling Composer that it subscribes to events triggered by Composer operations + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://getcomposer.org/apidoc/master/Composer/Plugin/PluginInterface.html) ([PluginDefinition](class_descriptions.md#plugindefinition)) + 3. `PluginDefinition` implements [EventSubscriberInterface](https://getcomposer.org/apidoc/master/Composer/EventDispatcher/EventSubscriberInterface.html), telling Composer that it subscribes to events triggered by Composer operations 4. `PluginDefinition::getSubscribedEvents()` tells Composer to call the `PluginDefinition::packageUpdate()` function when the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` events are triggered -2. Composer runs the [RequireCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/RequireCommand.php) or [UpdateCommand::execute()](https://github.com/composer/composer/blob/master/src/Composer/Command/UpdateCommand.php) method as relevant, which results in Composer triggering either the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` event +2. Composer runs the [RequireCommand::execute()](https://getcomposer.org/apidoc/master/Composer/Command/RequireCommand.html#method_execute) or [UpdateCommand::execute()](https://getcomposer.org/apidoc/master/Composer/Command/UpdateCommand.html#method_execute) method as relevant, which results in Composer triggering either the `POST_PACKAGE_INSTALL` or `POST_PACKAGE_UPDATE` event 3. Composer checks the listeners registered to the triggered event and calls `PluginDefinition::packageUpdate()` 4. `PluginDefinition::packageUpdate()` calls [WebSetupWizardPluginInstaller::packageEvent()](class_descriptions.md#websetupwizardplugininstaller) 5. `WebSetupWizardPluginInstaller::packageEvent()` checks the event to see if it was triggered by a change to the `magento/composer-root-update-plugin` package, and if so it calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` @@ -88,8 +88,8 @@ There are four paths through the plugin code that cover two main pieces of funct 1. Composer boilerplate and plugin setup 1. Composer sees the `"type": "composer-plugin"` value in the [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file for the plugin package - 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://github.com/composer/composer/blob/master/src/Composer/Plugin/PluginInterface.php) ([PluginDefinition](class_descriptions.md#plugindefinition)) - 3. `PluginDefinition` implements [Capable](https://github.com/composer/composer/blob/master/src/Composer/Plugin/Capable.php), telling Composer that it provides some capability ([CommandProvider](class_descriptions.md#commandprovider)), which is supplied through `PluginDefinition::getCapabilities()` + 2. Composer reads the `"extra"->"class"` field to find the class that implements [PluginInterface](https://getcomposer.org/apidoc/master/Composer/Plugin/PluginInterface.html) ([PluginDefinition](class_descriptions.md#plugindefinition)) + 3. `PluginDefinition` implements [Capable](https://getcomposer.org/apidoc/master/Composer/Plugin/Capable.html), telling Composer that it provides some capability ([CommandProvider](class_descriptions.md#commandprovider)), which is supplied through `PluginDefinition::getCapabilities()` 4. `CommandProvider::getCommands()` supplies Composer with an instance of [UpdatePluginNamespaceCommands](class_descriptions.md#commandsupdatepluginnamespacecommands) 5. Composer calls `UpdatePluginNamespaceCommands::configure()` to obtain the command's name, description, options, and help text 6. Composer adds `UpdatePluginNamespaceCommands` to the registry under the name `magento-update-plugin` From 89fdbd97c0cdcf872148f00e698fd778d17cd07b Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Thu, 18 Apr 2019 15:49:51 -0500 Subject: [PATCH 10/15] MC-5465: Correcting install instructions to use --no-update --- README.md | 12 ++++++++---- src/Magento/ComposerRootUpdatePlugin/README.md | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e636738..395d9ea 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,10 @@ The `magento/composer-root-update-plugin` package requires Composer version 1.8. ## Installation -To install the plugin, run `composer require magento/composer-root-update-plugin ~1.0` in the Magento root directory. +To install the plugin, run the following commands in the Magento root directory. + + composer require magento/composer-root-update-plugin ~1.0 --no-update + composer update # Usage @@ -24,7 +27,7 @@ If the `composer require` command for the target Magento package fails, one of t ## Installations that started with another Magento product -If the local Magento installation has previously been updated from a previous Magento product version or edition, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. +If the local Magento installation has previously been updated from a previous Magento product version or edition without the plugin installed, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. @@ -125,11 +128,12 @@ For reference, these are the `"require"` and `"require-dev"` sections for defaul ### With `magento/composer-root-update-plugin`: -In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0` before the Magento Community Edition 2.3.1 upgrade commands. +In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Community Edition 2.3.1 upgrade commands. ``` -$ composer require magento/composer-root-update-plugin ~1.0 +$ composer require magento/composer-root-update-plugin ~1.0 --no-update ./composer.json has been updated +$ composer update Loading composer repositories with package information Updating dependencies (including require-dev) Package operations: 1 install, 0 updates, 0 removals diff --git a/src/Magento/ComposerRootUpdatePlugin/README.md b/src/Magento/ComposerRootUpdatePlugin/README.md index 774a984..7ff6953 100644 --- a/src/Magento/ComposerRootUpdatePlugin/README.md +++ b/src/Magento/ComposerRootUpdatePlugin/README.md @@ -14,7 +14,10 @@ The `magento/composer-root-update-plugin` package requires Composer version 1.8. ## Installation -To install the plugin, run `composer require magento/composer-root-update-plugin ~1.0` in the Magento root directory. +To install the plugin, run the following commands in the Magento root directory. + + composer require magento/composer-root-update-plugin ~1.0 --no-update + composer update # Usage @@ -24,7 +27,7 @@ If the `composer require` command for the target Magento package fails, one of t ## Installations that started with another Magento product -If the local Magento installation has previously been updated from a previous Magento product version or edition, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. +If the local Magento installation has previously been updated from a previous Magento product version or edition without the plugin installed, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. @@ -125,11 +128,12 @@ For reference, these are the `"require"` and `"require-dev"` sections for defaul ### With `magento/composer-root-update-plugin`: -In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0` before the Magento Community Edition 2.3.1 upgrade commands. +In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Community Edition 2.3.1 upgrade commands. ``` -$ composer require magento/composer-root-update-plugin ~1.0 +$ composer require magento/composer-root-update-plugin ~1.0 --no-update ./composer.json has been updated +$ composer update Loading composer repositories with package information Updating dependencies (including require-dev) Package operations: 1 install, 0 updates, 0 removals From 095762c852db53c3a7a8eedd3c3c586806d4609f Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Wed, 24 Apr 2019 14:36:00 -0500 Subject: [PATCH 11/15] MC-5465: Enforcing 'require' and 'require-dev' ordering --- docs/class_descriptions.md | 10 +- .../Updater/DeltaResolver.php | 261 ++++++++++++------ .../UpdatePluginTestCase.php | 68 ++--- .../Updater/DeltaResolverTest.php | 95 ++++++- .../Updater/MagentoRootUpdaterTest.php | 71 ++++- 5 files changed, 364 insertions(+), 141 deletions(-) diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md index 1dd0aff..b177973 100644 --- a/docs/class_descriptions.md +++ b/docs/class_descriptions.md @@ -150,13 +150,17 @@ This is accomplished by comparing `composer.json` fields between the original Ma - **`findResolution()`** - For an individual field value, compare the original Magento value to the target Magento value, and if a delta is found, check if the user's installation has a customized value for the field. If the user has changed the value, resolve the conflict according to the CLI command options: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used - **`resolveLinkSection()`** - - For a given `composer.json` section that consists of links to package versions/constraints (such as the `require` and `conflict` sections), call `findResolution()` for each package constraint found in either the original Magento root or the target Magento root + - For a given `composer.json` section that consists of links to package versions/constraints (such as the `require` and `conflict` sections), call `findLinkResolution()` for each package constraint found in either the original Magento root or the target Magento root - **`resolveArraySection()`** - For a given `composer.json` section that consists of data that is not package links (such as the `"autoload"` or `"extra"` sections), call `resolveNestedArray()` and accept the new values if changes were made - **`resolveNestedArray()`** - Recursively processes changes to a `composer.json` value that could be a nested array, calling `findResolution()` for each "leaf" value found in either the original Magento root or the target Magento root - - **`linksToMap()`** - - Helper function to convert a set of package links to an associative array for use by `resolveLinkSection()` + - **`findLinkResolution()`** + - Helper function to call `findResolution()` for a particular package for use by `resolveLinkSection()` + - **`getLinkOrderOverride()`** + - Determine the order to use for a link section when the user's order disagrees with the target Magento section order + - **`buildLinkOrderComparator()`** + - Construct the comparator function to use for sorting a set of links according to `getLinkOverride()` results followed by the order in the target Magento version followed by the order of custom values in the user's installation #### [**MagentoRootUpdater**](../src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php) diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php index 8fcd429..922e550 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php @@ -89,21 +89,41 @@ public function resolveRootDeltas() $target = $this->targetMageRootPackage; $user = $this->userRootPackage; - $this->resolveLinkSection('require', $original->getRequires(), $target->getRequires(), $user->getRequires()); + $this->resolveLinkSection( + 'require', + $original->getRequires(), + $target->getRequires(), + $user->getRequires(), + true + ); $this->resolveLinkSection( 'require-dev', $original->getDevRequires(), $target->getDevRequires(), - $user->getDevRequires() + $user->getDevRequires(), + true ); $this->resolveLinkSection( 'conflict', $original->getConflicts(), $target->getConflicts(), - $user->getConflicts() + $user->getConflicts(), + false + ); + $this->resolveLinkSection( + 'provide', + $original->getProvides(), + $target->getProvides(), + $user->getProvides(), + false + ); + $this->resolveLinkSection( + 'replace', + $original->getReplaces(), + $target->getReplaces(), + $user->getReplaces(), + false ); - $this->resolveLinkSection('provide', $original->getProvides(), $target->getProvides(), $user->getProvides()); - $this->resolveLinkSection('replace', $original->getReplaces(), $target->getReplaces(), $user->getReplaces()); $this->resolveArraySection('autoload', $original->getAutoload(), $target->getAutoload(), $user->getAutoload()); $this->resolveArraySection( @@ -215,53 +235,26 @@ public function findResolution( * @param Link[] $originalMageLinks * @param Link[] $targetMageLinks * @param Link[] $userLinks + * @param bool $verifyOrder * @return array */ - public function resolveLinkSection($section, $originalMageLinks, $targetMageLinks, $userLinks) + public function resolveLinkSection($section, $originalMageLinks, $targetMageLinks, $userLinks, $verifyOrder) { - /** @var Link[] $originalLinkMap */ - $originalLinkMap = static::linksToMap($originalMageLinks); - - /** @var Link[] $targetLinkMap */ - $targetLinkMap = static::linksToMap($targetMageLinks); - - /** @var Link[] $userLinkMap */ - $userLinkMap = static::linksToMap($userLinks); - $adds = []; $removes = []; $changes = []; - $magePackages = array_unique(array_merge(array_keys($originalLinkMap), array_keys($targetLinkMap))); + $magePackages = array_unique(array_merge(array_keys($originalMageLinks), array_keys($targetMageLinks))); foreach ($magePackages as $pkg) { if ($section === 'require' && PackageUtils::getMagentoProductEdition($pkg)) { continue; } - $field = "$section:$pkg"; - $originalConstraint = key_exists($pkg, $originalLinkMap) ? $originalLinkMap[$pkg]->getConstraint() : null; - $originalMageVal = ($originalConstraint === null) ? null : $originalConstraint->__toString(); - $prettyOriginalMageVal = ($originalConstraint === null) ? null : $originalConstraint->getPrettyString(); - $targetConstraint = key_exists($pkg, $targetLinkMap) ? $targetLinkMap[$pkg]->getConstraint() : null; - $targetMageVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); - $prettyTargetMageVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); - $userConstraint = key_exists($pkg, $userLinkMap) ? $userLinkMap[$pkg]->getConstraint() : null; - $userVal = ($userConstraint === null) ? null : $userConstraint->__toString(); - $prettyUserVal = ($userConstraint === null) ? null : $userConstraint->getPrettyString(); - - $action = $this->findResolution( - $field, - $originalMageVal, - $targetMageVal, - $userVal, - $prettyOriginalMageVal, - $prettyTargetMageVal, - $prettyUserVal - ); + $action = $this->findLinkResolution($section, $pkg, $originalMageLinks, $targetMageLinks, $userLinks); if ($action == static::ADD_VAL) { - $adds[$pkg] = $targetLinkMap[$pkg]; + $adds[$pkg] = $targetMageLinks[$pkg]; } elseif ($action == static::REMOVE_VAL) { $removes[] = $pkg; } elseif ($action == static::CHANGE_VAL) { - $changes[$pkg] = $targetLinkMap[$pkg]; + $changes[$pkg] = $targetMageLinks[$pkg]; } } @@ -287,11 +280,26 @@ public function resolveLinkSection($section, $originalMageLinks, $targetMageLink $this->console->labeledVerbose("Updating $section constraints: " . implode(', ', $prettyChanges)); } + $enforcedOrder = []; + if ($verifyOrder) { + $enforcedOrder = $this->getLinkOrderOverride( + $section, + array_keys($originalMageLinks), + array_keys($targetMageLinks), + array_keys($userLinks) + ); + if ($enforcedOrder !== []) { + $changed = true; + $prettyOrder = " [\n " . implode(",\n ", $enforcedOrder) . "\n ]"; + $this->console->labeledVerbose("Updating $section order:\n$prettyOrder"); + } + } + if ($changed) { $replacements = array_values($adds); /** @var Link $userLink */ - foreach ($userLinkMap as $pkg => $userLink) { + foreach ($userLinks as $pkg => $userLink) { if (in_array($pkg, $removes)) { continue; } elseif (key_exists($pkg, $changes)) { @@ -301,6 +309,12 @@ public function resolveLinkSection($section, $originalMageLinks, $targetMageLink } } + usort($replacements, $this->buildLinkOrderComparator( + $enforcedOrder, + array_keys($targetMageLinks), + array_keys($userLinks) + )); + $newJson = []; /** @var Link $link */ foreach ($replacements as $link) { @@ -349,37 +363,15 @@ public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $us $result = $userVal === null ? [] : $userVal; if (is_array($originalMageVal) && is_array($targetMageVal) && is_array($userVal)) { - $originalMageAssociativePart = []; - $originalMageFlatPart = []; - foreach ($originalMageVal as $key => $value) { - if (is_string($key)) { - $originalMageAssociativePart[$key] = $value; - } else { - $originalMageFlatPart[] = $value; - } - } + $originalMageAssociativePart = array_filter($originalMageVal, 'is_string', ARRAY_FILTER_USE_KEY); + $originalMageFlatPart = array_filter($originalMageVal, 'is_int', ARRAY_FILTER_USE_KEY); - $targetMageAssociativePart = []; - $targetMageFlatPart = []; - foreach ($targetMageVal as $key => $value) { - if (is_string($key)) { - $targetMageAssociativePart[$key] = $value; - } else { - $targetMageFlatPart[] = $value; - } - } + $targetMageAssociativePart = array_filter($targetMageVal, 'is_string', ARRAY_FILTER_USE_KEY); + $targetMageFlatPart = array_filter($targetMageVal, 'is_int', ARRAY_FILTER_USE_KEY); - $userAssociativePart = []; - $userFlatPart = []; - foreach ($userVal as $key => $value) { - if (is_string($key)) { - $userAssociativePart[$key] = $value; - } else { - $userFlatPart[] = $value; - } - } + $userAssociativePart = array_filter($userVal, 'is_string', ARRAY_FILTER_USE_KEY); - $associativeResult = array_filter($result, 'is_string', ARRAY_FILTER_USE_KEY); + $associativeResult = $userAssociativePart; $mageKeys = array_unique( array_merge(array_keys($originalMageAssociativePart), array_keys($targetMageAssociativePart)) ); @@ -417,7 +409,7 @@ public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $us } } - $flatResult = array_filter($result, 'is_int', ARRAY_FILTER_USE_KEY); + $flatResult = array_filter($userVal, 'is_int', ARRAY_FILTER_USE_KEY); $flatAdds = array_diff(array_diff($targetMageFlatPart, $originalMageFlatPart), $flatResult); if ($flatAdds !== []) { $valChanged = true; @@ -456,17 +448,132 @@ public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $us } /** - * Helper function to convert a set of links to an associative array with target package names as keys + * Helper function to find the resolution action for a package constraint in the Link sections * - * @param Link[] $links - * @return array + * @param string $section + * @param string $pkg + * @param Link[] $originalLinkMap + * @param Link[] $targetLinkMap + * @param Link[] $userLinkMap + * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the link constraint, null for no change */ - protected function linksToMap($links) + protected function findLinkResolution($section, $pkg, $originalLinkMap, $targetLinkMap, $userLinkMap) { - $targets = array_map(function ($link) { - /** @var Link $link */ - return $link->getTarget(); - }, $links); - return array_combine($targets, $links); + $field = "$section:$pkg"; + $originalConstraint = key_exists($pkg, $originalLinkMap) ? $originalLinkMap[$pkg]->getConstraint() : null; + $originalMageVal = ($originalConstraint === null) ? null : $originalConstraint->__toString(); + $prettyOriginalMageVal = ($originalConstraint === null) ? null : $originalConstraint->getPrettyString(); + $targetConstraint = key_exists($pkg, $targetLinkMap) ? $targetLinkMap[$pkg]->getConstraint() : null; + $targetMageVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); + $prettyTargetMageVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); + $userConstraint = key_exists($pkg, $userLinkMap) ? $userLinkMap[$pkg]->getConstraint() : null; + $userVal = ($userConstraint === null) ? null : $userConstraint->__toString(); + $prettyUserVal = ($userConstraint === null) ? null : $userConstraint->getPrettyString(); + + return $this->findResolution( + $field, + $originalMageVal, + $targetMageVal, + $userVal, + $prettyOriginalMageVal, + $prettyTargetMageVal, + $prettyUserVal + ); + } + + /** + * Get the order to use for a link section if local and target versions disagree + * + * @param string $section + * @param string[] $originalMageOrder + * @param string[] $targetMageOrder + * @param string[] $userOrder + * @return string[] + */ + protected function getLinkOrderOverride($section, $originalMageOrder, $targetMageOrder, $userOrder) + { + $overrideOrder = []; + + $conflictTargetOrder = array_values(array_intersect($targetMageOrder, $userOrder)); + $conflictUserOrder = array_values(array_intersect($userOrder, $targetMageOrder)); + + // Check if the user's link order does not match the target section for links that appear in both + if ($conflictTargetOrder != $conflictUserOrder) { + $conflictOriginalOrder = array_values(array_intersect($originalMageOrder, $targetMageOrder)); + + // Check if the user's order is different than the target order because the order has changed between + // the original and target Magento versions + if ($conflictOriginalOrder !== $conflictUserOrder) { + $targetLabel = $this->retriever->getTargetLabel(); + $userOrderDesc = " [\n " . implode(",\n ", $conflictUserOrder) . "\n ]"; + $targetOrderDesc = " [\n " . implode(",\n ", $conflictTargetOrder) . "\n ]"; + $conflictDesc = "$targetLabel is trying to change the existing order of the $section section.\n" . + "Local order:\n$userOrderDesc\n$targetLabel order:\n$targetOrderDesc"; + $shouldOverride = $this->overrideUserValues; + if ($this->overrideUserValues) { + $this->console->log($conflictDesc); + $this->console->log( + 'Overriding local order due to --' . MageRootRequireCommand::OVERRIDE_OPT . '.' + ); + } else { + $shouldOverride = $this->console->ask( + "$conflictDesc\nWould you like to override the local order?" + ); + } + + if (!$shouldOverride) { + $this->console->comment("$conflictDesc but it will not be changed. Re-run using " . + '--' . MageRootRequireCommand::OVERRIDE_OPT . ' or ' . + '--' . MageRootRequireCommand::INTERACTIVE_OPT . ' to override with the Magento order.'); + $overrideOrder = $conflictUserOrder; + } else { + $overrideOrder = $conflictTargetOrder; + } + } else { + $overrideOrder = $conflictTargetOrder; + } + } + + return $overrideOrder; + } + + /** + * Construct a comparison function to use in sorting an array of links by prioritized order lists + * + * @param string[] $overrideOrder + * @param string[] $targetMageOrder + * @param string[] $userOrder + * @return \Closure + */ + protected function buildLinkOrderComparator($overrideOrder, $targetMageOrder, $userOrder) + { + $prioritizedOrderings = [$overrideOrder, $targetMageOrder, $userOrder]; + + return function ($link1, $link2) use ($prioritizedOrderings) { + /** + * @var Link $link1 + * @var Link $link2 + */ + $package1 = $link1->getTarget(); + $package2 = $link2->getTarget(); + + // Check each ordering array to see if it contains both links and if so use their positions to sort + // If the ordering array does not contain both links, try the next one + foreach ($prioritizedOrderings as $sortOrder) { + $index1 = array_search($package1, $sortOrder); + $index2 = array_search($package2, $sortOrder); + if ($index1 !== false && $index2 !== false) { + if ($index1 == $index2) { + return 0; + } else { + return $index1 < $index2 ? -1 : 1; + } + } + } + + // None of the ordering arrays contain both elements, so their relative positions in the sorted array + // do not matter + return 0; + }; } } diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php index 11847af..247cdc1 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/UpdatePluginTestCase.php @@ -26,7 +26,8 @@ public static function createLinks($count, $target = 'package/name') { $links = []; for ($i = 1; $i <= $count; $i++) { - $links[] = new Link('root/pkg', "$target$i", new Constraint('==', "$i.0.0"), null, "$i.0.0"); + $name = "$target$i"; + $links[$name] = new Link('root/pkg', $name, new Constraint('==', "$i.0.0"), null, "$i.0.0"); } return $links; } @@ -41,12 +42,13 @@ public static function createLinks($count, $target = 'package/name') public static function changeLink($links, $index) { $result = $links; - $changeLink = $links[$index]; + /** @var Link $changeLink */ + $changeLink = array_values($links)[$index]; $version = explode(' ', $changeLink->getConstraint()->getPrettyString())[1]; $versionParts = array_map('intval', explode('.', $version)); $versionParts[1] = $versionParts[1] + 1; $version = implode('.', $versionParts); - $result[$index] = new Link( + $result[$changeLink->getTarget()] = new Link( $changeLink->getSource(), $changeLink->getTarget(), new Constraint('==', $version), @@ -66,58 +68,30 @@ public static function changeLink($links, $index) public static function assertLinksEqual($expected, $jsonChanges) { static::assertEquals(count($expected), count($jsonChanges)); - while (count($expected) > 0) { - $expectedLink = array_shift($expected); - $expectedTarget = $expectedLink->getTarget(); - $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); - $found = null; - foreach ($jsonChanges as $target => $constraint) { - if ($target === $expectedTarget && - $constraint === $expectedConstraint) { - $found = $target; - break; - } - } - static::assertNotEmpty($found, "Could not find a link matching $expectedLink"); - unset($jsonChanges[$found]); + $remainingJson = $jsonChanges; + foreach ($expected as $expectedTarget => $expectedLink) { + $expectedTarget = strtolower($expectedTarget); + static::assertArrayHasKey($expectedTarget, $remainingJson); + static::assertEquals($expectedLink->getConstraint()->getPrettyString(), $remainingJson[$expectedTarget]); + unset($remainingJson[$expectedTarget]); } } /** - * Assert that two arrays of links are not equal without checking order - * + * Assert that the links in the $jsonChanges are ordered as expected + * @param Link[] $expected - * @param Link[] $actual + * @param array $jsonChanges * @return void */ - public static function assertLinksNotEqual($expected, $actual) + public static function assertLinksOrdered($expected, $jsonChanges) { - if (count($expected) !== count($actual)) { - static::assertNotEquals(count($expected), count($actual)); - return; - } - - while (count($expected) > 0) { - $expectedLink = array_shift($expected); - $expectedSource = $expectedLink->getSource(); - $expectedTarget = $expectedLink->getTarget(); - $expectedConstraint = $expectedLink->getConstraint()->getPrettyString(); - $found = -1; - foreach ($actual as $key => $actualLink) { - if ($actualLink->getSource() === $expectedSource && - $actualLink->getTarget() === $expectedTarget && - $actualLink->getConstraint()->getPrettyString() === $expectedConstraint) { - $found = $key; - break; - } - } - if ($found === -1) { - static::assertEquals(-1, $found); - return; - } - unset($actual[$found]); - } - static::fail('Expected Link sets to not be equal'); + $expectedOrder = array_map(function ($link) { + /** @var Link $link */ + return $link->getTarget(); + }, array_values($expected)); + $actualOrder = array_keys($jsonChanges); + static::assertEquals($expectedOrder, $actualOrder); } /** diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php index f1d64ac..2868e85 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php @@ -324,7 +324,8 @@ public function testResolveLinksAddLink() 'require', $originalMageLinks, $targetMageLinks, - $userLinks + $userLinks, + false ); $this->assertLinksEqual($expected, $result['require']); @@ -343,7 +344,8 @@ public function testResolveLinksRemoveLink() 'require', $originalMageLinks, $targetMageLinks, - $userLinks + $userLinks, + false ); $this->assertLinksEqual($expected, $result['require']); @@ -362,12 +364,99 @@ public function testResolveLinksChangeLink() 'require', $originalMageLinks, $targetMageLinks, - $userLinks + $userLinks, + false ); $this->assertLinksEqual($expected, $result['require']); } + public function testResolveLinksUpdateOrder() + { + $orderedLinks = $this->createLinks(4, 'target/link'); + $reorderedLinks = array_reverse($orderedLinks); + $targetMageLinks = array_merge($this->createLinks(1), $orderedLinks); + $originalMageLinks = array_merge($this->createLinks(2), $reorderedLinks); + + $resolver = new DeltaResolver($this->console, true, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $originalMageLinks, + true + ); + + $this->assertLinksEqual($targetMageLinks, $result['require']); + $this->assertLinksOrdered($targetMageLinks, $result['require']); + } + + public function testResolveLinksAddLinkWithOrder() + { + $targetMageLinks = array_merge($this->createLinks(3), $this->createLinks(4, 'target/link')); + $originalMageLinks = array_merge($this->createLinks(2), $this->createLinks(4, 'target/link')); + + $resolver = new DeltaResolver($this->console, true, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $originalMageLinks, + true + ); + + $this->assertLinksEqual($targetMageLinks, $result['require']); + $this->assertLinksOrdered($targetMageLinks, $result['require']); + } + + public function testResolveLinksOrderOverride() + { + $orderedLinks = $this->createLinks(4, 'target/link'); + $reorderedLinks = array_reverse($orderedLinks); + $originalMageLinks = $this->createLinks(2); + $targetMageLinks = array_merge($originalMageLinks, $orderedLinks); + $userLinks = array_merge($originalMageLinks, $reorderedLinks); + + $this->io->expects($this->at(1))->method('writeError') + ->with($this->stringContains('overriding local order')); + + $resolver = new DeltaResolver($this->console, true, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $userLinks, + true + ); + + $this->assertLinksEqual($targetMageLinks, $result['require']); + $this->assertLinksOrdered($targetMageLinks, $result['require']); + } + + public function testResolveLinksOrderNoOverride() + { + $orderedLinks = $this->createLinks(4, 'target/link'); + $reorderedLinks = array_reverse($orderedLinks); + $originalMageLinks = $this->createLinks(2); + $targetMageLinks = array_merge($originalMageLinks, $orderedLinks); + $userLinks = array_merge($originalMageLinks, $reorderedLinks); + + $this->io->expects($this->at(0))->method('writeError') + ->with($this->stringContains('will not be changed')); + + $resolver = new DeltaResolver($this->console, false, $this->retriever); + $result = $resolver->resolveLinkSection( + 'require', + $originalMageLinks, + $targetMageLinks, + $userLinks, + true + ); + + $this->assertLinksEqual($userLinks, $result['require']); + $this->assertLinksOrdered($userLinks, $result['require']); + } + public function setUp() { $this->io = $this->getMockForAbstractClass(IOInterface::class); diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php index 23f46b6..2120739 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdaterTest.php @@ -79,7 +79,9 @@ public function testMagentoUpdateSetsFieldsNoOverride() $result = $updater->getJsonChanges(); $this->assertLinksEqual($this->expectedNoOverride->getRequires(), $result['require']); + $this->assertLinksOrdered($this->expectedNoOverride->getRequires(), $result['require']); $this->assertLinksEqual($this->expectedNoOverride->getDevRequires(), $result['require-dev']); + $this->assertLinksOrdered($this->expectedNoOverride->getDevRequires(), $result['require-dev']); $this->assertEquals($this->expectedNoOverride->getAutoload(), $result['autoload']); $this->assertEquals($this->expectedNoOverride->getDevAutoload(), $result['autoload-dev']); $this->assertLinksEqual($this->expectedNoOverride->getConflicts(), $result['conflict']); @@ -96,7 +98,9 @@ public function testMagentoUpdateSetsFieldsWithOverride() $result = $updater->getJsonChanges(); $this->assertLinksEqual($this->expectedWithOverride->getRequires(), $result['require']); + $this->assertLinksOrdered($this->expectedWithOverride->getRequires(), $result['require']); $this->assertLinksEqual($this->expectedWithOverride->getDevRequires(), $result['require-dev']); + $this->assertLinksOrdered($this->expectedWithOverride->getDevRequires(), $result['require-dev']); $this->assertEquals($this->expectedWithOverride->getAutoload(), $result['autoload']); $this->assertEquals($this->expectedWithOverride->getDevAutoload(), $result['autoload-dev']); $this->assertLinksEqual($this->expectedWithOverride->getConflicts(), $result['conflict']); @@ -113,9 +117,22 @@ public function setUp() */ $baseRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); $baseRoot->setRequires([ - new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '1.0.0'), null, '1.0.0'), - new Link('root/pkg', PluginDefinition::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), - new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') + 'magento/product-community-edition' => new Link( + 'root/pkg', 'magento/product-community-edition', + new Constraint('==', '1.0.0'), null, '1.0.0' + ), + PluginDefinition::PACKAGE_NAME => new Link( + 'root/pkg', + PluginDefinition::PACKAGE_NAME, + new Constraint('==', '1.0.0.0') + ), + 'vendor/package1' => new Link( + 'root/pkg', + 'vendor/package1', + new Constraint('==', '1.0.0'), + null, + '1.0.0' + ) ]); $baseRoot->setDevRequires($this->createLinks(2, 'vendor/dev-package')); $baseRoot->setAutoload(['psr-4' => ['Magento\\' => 'src/Magento/']]); @@ -128,11 +145,26 @@ public function setUp() $targetRoot = new RootPackage('magento/project-community-edition', '2.0.0.0', '2.0.0'); $targetRoot->setRequires([ - new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), - new Link('root/pkg', PluginDefinition::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), - new Link('root/pkg', 'vendor/package1', new Constraint('==', '2.0.0'), null, '2.0.0') + 'magento/product-community-edition' => new Link( + 'root/pkg', + 'magento/product-community-edition', + new Constraint('==', '2.0.0'), + null, + '2.0.0' + ), + PluginDefinition::PACKAGE_NAME => new Link( + 'root/pkg', + PluginDefinition::PACKAGE_NAME, + new Constraint('==', '1.0.0.0') + ), + 'vendor/package1' => new Link( + 'root/pkg', + 'vendor/package1', + new Constraint('==', '2.0.0'), + null, '2.0.0' + ) ]); - $targetRoot->setDevRequires($this->createLinks(1, 'vendor/dev-package')); + $targetRoot->setDevRequires(array_merge($this->createLinks(1, 'vendor/dev-package-new'), $this->createLinks(2, 'vendor/dev-package'))); $targetRoot->setAutoload(['psr-4' => [ 'Magento\\' => 'src/Magento/', 'Zend\\Mvc\\Controller\\'=> 'setup/src/Zend/Mvc/Controller/' @@ -146,9 +178,24 @@ public function setUp() $installRoot = new RootPackage('magento/project-community-edition', '1.0.0.0', '1.0.0'); $installRoot->setRequires([ - new Link('root/pkg', 'magento/product-community-edition', new Constraint('==', '2.0.0'), null, '2.0.0'), - new Link('root/pkg', PluginDefinition::PACKAGE_NAME, new Constraint('==', '1.0.0.0')), - new Link('root/pkg', 'vendor/package1', new Constraint('==', '1.0.0'), null, '1.0.0') + 'magento/product-community-edition' => new Link( + 'root/pkg', + 'magento/product-community-edition', + new Constraint('==', '2.0.0'), + null, + '2.0.0' + ), + PluginDefinition::PACKAGE_NAME => new Link( + 'root/pkg', + PluginDefinition::PACKAGE_NAME, + new Constraint('==', '1.0.0.0') + ), + 'vendor/package1' => new Link( + 'root/pkg', + 'vendor/package1', + new Constraint('==', '1.0.0'), + null, '1.0.0' + ) ]); $installRoot->setDevRequires($baseRoot->getDevRequires()); $installRoot->setAutoload(array_merge($baseRoot->getAutoload(), ['files' => 'app/etc/Register.php'])); @@ -179,8 +226,10 @@ public function setUp() 'Magento\\Sniffs\\' => 'dev/tests/framework/Magento/Sniffs/', 'Magento\\Tools\\' => 'dev/tools/Magento/Tools2/' ]]); + /** @var Link $newConflict */ + $newConflict = array_values($targetRoot->getConflicts())[2]; $expectedNoOverride->setConflicts( - array_merge($this->installRoot->getConflicts(), [$targetRoot->getConflicts()[2]]) + array_merge($this->installRoot->getConflicts(), [$newConflict->getTarget() => $newConflict]) ); $noOverrideExtra = $targetRoot->getExtra(); $noOverrideExtra['extra-key1'] = $this->installRoot->getExtra()['extra-key1']; From 6732bbf3b3e3ec9daf0c3cf902f9bc5b239be2ab Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Mon, 29 Apr 2019 17:26:08 -0500 Subject: [PATCH 12/15] MC-5465: Refactoring to use marketing edition labels in docs and options --- CONTRIBUTING.md | 2 +- README.md | 34 +++++++++---------- docs/class_descriptions.md | 2 +- .../Commands/MageRootRequireCommand.php | 11 +++--- .../ComposerRootUpdatePlugin/README.md | 34 +++++++++---------- .../Setup/WebSetupWizardPluginInstaller.php | 6 +++- .../Updater/DeltaResolver.php | 2 +- .../Updater/RootPackageRetriever.php | 34 ++++++++++++++----- .../Utils/PackageUtils.php | 11 ++++-- 9 files changed, 83 insertions(+), 53 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97b9bac..768262c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Any contributions that do not meet these requirements will not be accepted. ### Composer compatibility -Maintaining compatibility with the Composer versions listed in the [composer.json](composer.json) file is important for this project. Due to the way Composer works with plugins, the version that is used when the plugin runs is the local `composer.phar` executable version (as reported by `composer --version`) and not the version installed in the project's `vendor` folder or `composer.lock` file This means that in order to properly verify Composer compatibility, tests must be run against the local `composer.phar` executable, not just the installed `composer/composer` dependency. +Maintaining compatibility with the Composer versions listed in the [composer.json](composer.json) file is important for this project. Due to the way Composer works with plugins, the version that is used when the plugin runs is the local `composer.phar` executable version (as reported by `composer --version`) and not the version installed in the project's `vendor` folder or `composer.lock` file. This means that in order to properly verify Composer compatibility, tests must be run against the local `composer.phar` executable, not just the installed `composer/composer` dependency. Additionally, because of the way the plugin interacts with the native `composer require` command, some parts of the Composer library sometimes need to be re-implemented in an accessible manner if the original code is in private methods or part of larger functions. Such implementations should be located in the [Magento\ComposerRootUpdatePlugin\ComposerReimplementation](src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation) namespace and documented with the reason for re-implementation and a link to the original method. diff --git a/README.md b/README.md index 395d9ea..e000f39 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ If the local Magento installation has previously been updated from a previous Ma In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. - composer require --base-magento-edition --base-magento-version + composer require --base-magento-edition '' --base-magento-version ## Conflicting custom values @@ -57,7 +57,7 @@ To reinstall the plugin in `var`, run the following command in the Magento root ### Without `magento/composer-root-update-plugin`: -In the project directory for a Magento Community Edition 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Community Edition 2.3.1 with these results: +In the project directory for a Magento Open Source 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Open Source 2.3.1 with these results: ``` $ composer require magento/product-community-edition 2.3.1 --no-update @@ -88,7 +88,7 @@ This is only one of the changes to the root project `composer.json` file between The changes to the root project `composer.json` files can be done manually by the user without the plugin, but the values that need to change can differ depending on the Magento versions involved and user-customized values may already override the Magento defaults. This means the exact upgrade steps necessary can be different for every user and determining the correct changes to make manually for a given user's configuration may be error-prone. -For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Community Edition versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. +For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Open Source versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. - **2.2.8** ``` @@ -128,7 +128,7 @@ For reference, these are the `"require"` and `"require-dev"` sections for defaul ### With `magento/composer-root-update-plugin`: -In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Community Edition 2.3.1 upgrade commands. +In the project directory for a Magento Open Source 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Open Source 2.3.1 upgrade commands. ``` $ composer require magento/composer-root-update-plugin ~1.0 --no-update @@ -153,25 +153,25 @@ Generating autoload files As is normal for `composer require`, `magento/composer-root-update-plugin` is added to the `composer.json` file. The plugin also installs itself in the directory used by the Magento Web Setup Wizard during dependency validation. -With the plugin installed, the user proceeds with the `composer require` command for Magento Community Edition 2.3.1 (`--verbose` mode used here for demonstration). +With the plugin installed, the user proceeds with the `composer require` command for Magento Open Source 2.3.1 (`--verbose` mode used here for demonstration). ``` $ composer require magento/product-community-edition 2.3.1 --no-update --verbose - [Magento Community Edition 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 - [Magento Community Edition 2.3.1] Removing require entries: composer/composer - [Magento Community Edition 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 - [Magento Community Edition 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 - [Magento Community Edition 2.3.1] Adding conflict constraints: gene/bluefoot=* - [Magento Community Edition 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" -Updating composer.json for Magento Community Edition 2.3.1 ... - [Magento Community Edition 2.3.1] Writing changes to the root composer.json... - [Magento Community Edition 2.3.1] /composer.json has been updated + [Magento Open Source 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 + [Magento Open Source 2.3.1] Removing require entries: composer/composer + [Magento Open Source 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 + [Magento Open Source 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 + [Magento Open Source 2.3.1] Adding conflict constraints: gene/bluefoot=* + [Magento Open Source 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" +Updating composer.json for Magento Open Source 2.3.1 ... + [Magento Open Source 2.3.1] Writing changes to the root composer.json... + [Magento Open Source 2.3.1] /composer.json has been updated ./composer.json has been updated ``` The plugin detects the user's request for the 2.3.1 version of `magento/product-community-edition` and looks up the `composer.json` file for the corresponding `magento/project-community-edition` 2.3.1 root project package. It finds the values that are different between 2.2.8 and 2.3.1 and updates the local `composer.json` file accordingly, then lets Composer proceed with the normal `composer require` functionality. -With the root `composer.json` file updated for Magento Community Edition 2.3.1, the user proceeds with the `composer update` command: +With the root `composer.json` file updated for Magento Open Source 2.3.1, the user proceeds with the `composer update` command: ``` $ composer update @@ -184,9 +184,9 @@ Writing lock file Generating autoload files ``` -With the updated values from Magento Community Edition 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. +With the updated values from Magento Open Source 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. -For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Community Edition 2.2.8 installation. They contain exactly the same entries as the default Magento Community Edition 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). +For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Open Source 2.2.8 installation. They contain exactly the same entries as the default Magento Open Source 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). ``` "require": { diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md index 1dd0aff..ee9ebbf 100644 --- a/docs/class_descriptions.md +++ b/docs/class_descriptions.md @@ -210,7 +210,7 @@ Common package-related utility functions. - **`getMagentoPackageType()`** - Extracts the package type (`product` or `project`) from a Magento package name - **`getMagentoProductEdition()`** - - Extracts the edition (`community` or `enterprise`) from a Magento product package name + - Extracts the package edition from a Magento product package name - **`findRequire()`** - Searches the `"require"` section of a [Composer](https://getcomposer.org/apidoc/master/Composer/Composer.html) object for a package link that fits the supplied name or matcher - **`isConstraintStrict()`** diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php index 98395f1..ae7983c 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php @@ -98,7 +98,7 @@ public function configure() null, InputOption::VALUE_REQUIRED, 'Edition of the initially-installed Magento product to use as the base for composer.json updates. ' . - 'Valid values: community, enterprise' + 'Valid values: \'Open Source\', \'Commerce\'' ) ->addOption( static::BASE_VERSION_OPT, @@ -174,7 +174,8 @@ public function execute(InputInterface $input, OutputInterface $output) $updater = new MagentoRootUpdater($this->console, $this->getComposer()); $didUpdate = $this->runUpdate($updater, $input, $edition, $constraint); } catch (\Exception $e) { - $label = 'Magento ' . ucfirst($edition) . " Edition $constraint"; + $editionLabel = $edition == PackageUtils::COMMERCE_PKG_EDITION ? 'Commerce' : 'Open Source'; + $label = "Magento $editionLabel $constraint"; $this->revertMageComposerFile("Update of composer.json with $label changes failed"); $this->console->log($e->getMessage()); $didUpdate = false; @@ -241,10 +242,12 @@ protected function runUpdate($updater, $input, $targetEdition, $targetConstraint $overrideOriginalVersion = $input->getOption(static::BASE_VERSION_OPT); if ($overrideOriginalEdition) { $overrideOriginalEdition = strtolower($overrideOriginalEdition); - if ($overrideOriginalEdition !== 'community' && $overrideOriginalEdition !== 'enterprise') { + if ($overrideOriginalEdition !== 'open source' && $overrideOriginalEdition !== 'commerce') { $opt = '--' . static::BASE_EDITION_OPT; - throw new InvalidOptionException("'$opt' accepts only 'community' or 'enterprise'"); + throw new InvalidOptionException("'$opt' accepts only 'Open Source' or 'Commerce'"); } + $overrideOriginalEdition = $overrideOriginalEdition = 'open source' ? + PackageUtils::OPEN_SOURCE_PKG_EDITION : PackageUtils::COMMERCE_PKG_EDITION; } $this->retriever = new RootPackageRetriever( diff --git a/src/Magento/ComposerRootUpdatePlugin/README.md b/src/Magento/ComposerRootUpdatePlugin/README.md index 7ff6953..9063a9c 100644 --- a/src/Magento/ComposerRootUpdatePlugin/README.md +++ b/src/Magento/ComposerRootUpdatePlugin/README.md @@ -31,7 +31,7 @@ If the local Magento installation has previously been updated from a previous Ma In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. - composer require --base-magento-edition --base-magento-version + composer require --base-magento-edition '' --base-magento-version ## Conflicting custom values @@ -57,7 +57,7 @@ To reinstall the plugin in `var`, run the following command in the Magento root ### Without `magento/composer-root-update-plugin`: -In the project directory for a Magento Community Edition 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Community Edition 2.3.1 with these results: +In the project directory for a Magento Open Source 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Open Source 2.3.1 with these results: ``` $ composer require magento/product-community-edition 2.3.1 --no-update @@ -88,7 +88,7 @@ This is only one of the changes to the root project `composer.json` file between The changes to the root project `composer.json` files can be done manually by the user without the plugin, but the values that need to change can differ depending on the Magento versions involved and user-customized values may already override the Magento defaults. This means the exact upgrade steps necessary can be different for every user and determining the correct changes to make manually for a given user's configuration may be error-prone. -For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Community Edition versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. +For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Open Source versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. - **2.2.8** ``` @@ -128,7 +128,7 @@ For reference, these are the `"require"` and `"require-dev"` sections for defaul ### With `magento/composer-root-update-plugin`: -In the project directory for a Magento Community Edition 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Community Edition 2.3.1 upgrade commands. +In the project directory for a Magento Open Source 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Open Source 2.3.1 upgrade commands. ``` $ composer require magento/composer-root-update-plugin ~1.0 --no-update @@ -153,25 +153,25 @@ Generating autoload files As is normal for `composer require`, `magento/composer-root-update-plugin` is added to the `composer.json` file. The plugin also installs itself in the directory used by the Magento Web Setup Wizard during dependency validation. -With the plugin installed, the user proceeds with the `composer require` command for Magento Community Edition 2.3.1 (`--verbose` mode used here for demonstration). +With the plugin installed, the user proceeds with the `composer require` command for Magento Open Source 2.3.1 (`--verbose` mode used here for demonstration). ``` $ composer require magento/product-community-edition 2.3.1 --no-update --verbose - [Magento Community Edition 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 - [Magento Community Edition 2.3.1] Removing require entries: composer/composer - [Magento Community Edition 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 - [Magento Community Edition 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 - [Magento Community Edition 2.3.1] Adding conflict constraints: gene/bluefoot=* - [Magento Community Edition 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" -Updating composer.json for Magento Community Edition 2.3.1 ... - [Magento Community Edition 2.3.1] Writing changes to the root composer.json... - [Magento Community Edition 2.3.1] /composer.json has been updated + [Magento Open Source 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 + [Magento Open Source 2.3.1] Removing require entries: composer/composer + [Magento Open Source 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 + [Magento Open Source 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 + [Magento Open Source 2.3.1] Adding conflict constraints: gene/bluefoot=* + [Magento Open Source 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" +Updating composer.json for Magento Open Source 2.3.1 ... + [Magento Open Source 2.3.1] Writing changes to the root composer.json... + [Magento Open Source 2.3.1] /composer.json has been updated ./composer.json has been updated ``` The plugin detects the user's request for the 2.3.1 version of `magento/product-community-edition` and looks up the `composer.json` file for the corresponding `magento/project-community-edition` 2.3.1 root project package. It finds the values that are different between 2.2.8 and 2.3.1 and updates the local `composer.json` file accordingly, then lets Composer proceed with the normal `composer require` functionality. -With the root `composer.json` file updated for Magento Community Edition 2.3.1, the user proceeds with the `composer update` command: +With the root `composer.json` file updated for Magento Open Source 2.3.1, the user proceeds with the `composer update` command: ``` $ composer update @@ -184,9 +184,9 @@ Writing lock file Generating autoload files ``` -With the updated values from Magento Community Edition 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. +With the updated values from Magento Open Source 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. -For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Community Edition 2.2.8 installation. They contain exactly the same entries as the default Magento Community Edition 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). +For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Open Source 2.2.8 installation. They contain exactly the same entries as the default Magento Open Source 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). ``` "require": { diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php index eb90a7b..12a181e 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php @@ -133,7 +133,11 @@ public function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) // If in ./var already or Magento or the plugin is missing from composer.json, do not install in var if (!preg_match('/\/composer\.json$/', $filePath) || preg_match('/\/var\/composer\.json$/', $filePath) || - !PackageUtils::findRequire($composer, '/magento\/product-(community|enterprise)-edition/') || + !PackageUtils::findRequire( + $composer, + '/magento\/product-(' . PackageUtils::OPEN_SOURCE_PKG_EDITION . '|' . + PackageUtils::COMMERCE_PKG_EDITION . ')-edition/' + ) || !PackageUtils::findRequire($composer, $packageName)) { return false; } diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php index 8fcd429..7e583e3 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php @@ -119,7 +119,7 @@ public function resolveRootDeltas() } /** - * Find value deltas from base->target version and resolve any conflicts with overlapping user changes + * Find value deltas from original->target version and resolve any conflicts with overlapping user changes * * @param string $field * @param array|mixed|null $originalMageVal diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php index 25735a7..ee179b0 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php @@ -313,8 +313,8 @@ protected function parseOriginalVersionAndEditionFromLock($overrideEdition = nul if ($pkgEdition) { $lockedMageProduct = $lockedPackage; - // Both editions exist for enterprise, so stop at enterprise to not overwrite with community - if ($pkgEdition == 'enterprise') { + // Both editions exist for commerce, so stop at commerce to not overwrite with open source + if ($pkgEdition == PackageUtils::COMMERCE_PKG_EDITION) { break; } } @@ -373,10 +373,11 @@ protected function getRootLocker() */ public function getTargetLabel() { - if ($this->targetEdition && $this->prettyTargetVersion) { - return 'Magento ' . ucfirst($this->targetEdition) . " Edition " . $this->prettyTargetVersion; - } elseif ($this->targetEdition && $this->targetConstraint) { - return 'Magento ' . ucfirst($this->targetEdition) . " Edition " . $this->targetConstraint; + $editionLabel = $this->getEditionLabel($this->targetEdition); + if ($editionLabel && $this->prettyTargetVersion) { + return "Magento $editionLabel " . $this->prettyTargetVersion; + } elseif ($editionLabel && $this->targetConstraint) { + return "Magento $editionLabel " . $this->targetConstraint; } return static::MISSING_ROOT_LABEL; } @@ -388,12 +389,29 @@ public function getTargetLabel() */ public function getOriginalLabel() { - if ($this->originalEdition && $this->prettyOriginalVersion) { - return 'Magento ' . ucfirst($this->originalEdition) . " Edition " . $this->prettyOriginalVersion; + $editionLabel = $this->getEditionLabel($this->originalEdition); + if ($editionLabel && $this->prettyOriginalVersion) { + return "Magento $editionLabel " . $this->prettyOriginalVersion; } return static::MISSING_ROOT_LABEL; } + /** + * Helper function to turn a package edition into the appropriate label + * + * @param string $packageEdition + * @return string|null + */ + private function getEditionLabel($packageEdition) + { + if ($packageEdition == PackageUtils::OPEN_SOURCE_PKG_EDITION) { + return 'Open Source'; + } elseif ($packageEdition == PackageUtils::COMMERCE_PKG_EDITION) { + return 'Commerce'; + } + return null; + } + /** * @return string */ diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php index 3234af6..170cced 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php @@ -15,6 +15,9 @@ */ class PackageUtils { + const OPEN_SOURCE_PKG_EDITION = 'community'; + const COMMERCE_PKG_EDITION = 'enterprise'; + /** * Helper function to extract the package type from a Magento product or project package name * @@ -23,7 +26,8 @@ class PackageUtils */ static public function getMagentoPackageType($packageName) { - $regex = '/^magento\/(?product|project)-(community|enterprise)-edition$/'; + $regex = '/^magento\/(?product|project)-(' . static::OPEN_SOURCE_PKG_EDITION . '|' . + static::COMMERCE_PKG_EDITION . ')-edition$/'; if (preg_match($regex, $packageName, $matches)) { return $matches['type']; } else { @@ -35,11 +39,12 @@ static public function getMagentoPackageType($packageName) * Helper function to extract the edition from a package name if it is a Magento product * * @param string $packageName - * @return string|null 'community' or 'enterprise' as applicable, null if not matching + * @return string|null OPEN_SOURCE_PKG_EDITION or COMMERCE_PKG_EDITION as applicable, null if not matching */ static public function getMagentoProductEdition($packageName) { - $regex = '/^magento\/product-(?community|enterprise)-edition$/'; + $regex = '/^magento\/product-(?' . static::OPEN_SOURCE_PKG_EDITION . '|' . + static::COMMERCE_PKG_EDITION . ')-edition$/'; if ($packageName && preg_match($regex, $packageName, $matches)) { return $matches['edition']; } else { From 4384348561414debb9b70280d7e54156e672d008 Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Tue, 21 May 2019 11:45:03 -0500 Subject: [PATCH 13/15] MC-5465: Refactoring module operations to use abstract class --- docs/class_descriptions.md | 9 ++++- docs/process_flows.md | 2 +- .../ExtendableRequireCommand.php | 4 +- .../Commands/MageRootRequireCommand.php | 2 +- .../Plugin/PluginDefinition.php | 2 +- .../Setup/AbstractModuleOperation.php | 38 +++++++++++++++++++ .../Setup/InstallData.php | 24 +----------- .../Setup/RecurringData.php | 24 +----------- .../Setup/UpgradeData.php | 24 +----------- .../Setup/WebSetupWizardPluginInstaller.php | 2 +- .../Updater/DeltaResolver.php | 4 +- .../Updater/MagentoRootUpdater.php | 6 +-- .../Updater/RootPackageRetriever.php | 12 +++--- .../Utils/Console.php | 10 ++--- .../Utils/PackageUtils.php | 4 +- .../TestHelpers/TestApplication.php | 4 +- 16 files changed, 78 insertions(+), 93 deletions(-) create mode 100644 src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md index 6b0d2ba..9b2e1a4 100644 --- a/docs/class_descriptions.md +++ b/docs/class_descriptions.md @@ -109,9 +109,16 @@ Classes in this namespace deal with installing the plugin inside the project's ` When the Web Setup Wizard runs an upgrade operation, it first tries to validate the upgrade by copying the `composer.json` file into the `var` directory and attempting a dry-run upgrade. However, because it only copies the `composer.json` file and not any of the other code in the installation (including the plugin's root installation in `vendor`), the plugin will not function for this dry run. In order to enable the plugin, it needs to already be present in `var/vendor`, where the Wizard's `composer require` for the validation will find it. +#### [**AbstractModuleOperation**](../src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php) + +This abstract class allows extending Magento module operation classes to trigger [WebSetupWizardPluginInstaller::doVarInstall()](#websetupwizardplugininstaller). + + - **`doVarInstall()`** + - Helper method for extending classes to setup and call `WebSetupWizardPluginInstaller::doVarInstall()` + #### **[InstallData](../src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php)/[RecurringData](../src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php)/[UpgradeData](../src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php)** -These are Magento module setup classes to trigger [WebSetupWizardPluginInstaller::doVarInstall()](#websetupwizardplugininstaller) on `bin/magento setup` commands. Specifically, this is necessary when the `bin/magento setup:uninstall` and `bin/magento setup:install` commands are run, which would otherwise remove the plugin from the `var` directory without triggering the Composer package events that would normally install the plugin there. +These are Magento module setup classes to trigger `AbstractModuleOperation::doVarInstall()` on `bin/magento setup` commands. Specifically, this is necessary when the `bin/magento setup:uninstall` and `bin/magento setup:install` commands are run, which would otherwise remove the plugin from the `var` directory without triggering the Composer package events that would normally install the plugin there. #### [**WebSetupWizardPluginInstaller**](../src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php) diff --git a/docs/process_flows.md b/docs/process_flows.md index 3718b14..8f90536 100644 --- a/docs/process_flows.md +++ b/docs/process_flows.md @@ -73,7 +73,7 @@ There are four paths through the plugin code that cover two main pieces of funct 1. The `"autoload"->"files": "registration.php"` value in the plugin's [composer.json](../src/Magento/ComposerRootUpdatePlugin/composer.json) file causes [registration.php](../src/Magento/ComposerRootUpdatePlugin/registration.php) to be loaded by Magento 2. `registration.php` registers the plugin as the `Magento_ComposerRootUpdatePlugin` module so it can tie into the `bin/magento setup` module operations 3. Magento searches registered modules for any `Setup\InstallData`, `Setup\RecurringData`, or `Setup\UpgradeData` classes -4. Magento calls [InstallData::install()](class_descriptions.md#installdatarecurringdataupgradedata), [RecurringData::install()](class_descriptions.md#installdatarecurringdataupgradedata), or [UpgradeData::upgrade()](class_descriptions.md#installdatarecurringdataupgradedata) as appropriate (which one depends on the specific `bin/magento setup` command and installed Magento version), which then calls [WebSetupWizardPluginInstaller::doVarInstall()](class_descriptions.md#websetupwizardplugininstaller) +4. Magento calls [InstallData::install()](class_descriptions.md#installdatarecurringdataupgradedata), [RecurringData::install()](class_descriptions.md#installdatarecurringdataupgradedata), or [UpgradeData::upgrade()](class_descriptions.md#installdatarecurringdataupgradedata) as appropriate (which one depends on the specific `bin/magento setup` command and installed Magento version), which then calls [WebSetupWizardPluginInstaller::doVarInstall()](class_descriptions.md#websetupwizardplugininstaller) through [AbstractModuleOperation::doVarInstall()](class_descriptions.md#abstractmoduleoperation) 5. `WebSetupWizardPluginInstaller::doVarInstall()` finds the `magento/composer-root-update-plugin` version in the `composer.lock` file in the root Magento directory and calls `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` 6. `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` checks the `/var/vendor` directory for the `magento/composer-root-update-plugin` version installed there (if any) to see if it matches the version in the root `composer.lock` file 7. If the version does not match or `magento/composer-root-update-plugin` is absent in `/var/vendor`, `WebSetupWizardPluginInstaller::updateSetupWizardPlugin()` installs the root project's `magento/composer-root-update-plugin` version in a temporary directory, then replaces `/var/vendor` with the `vendor` directory from the temporary installation diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php index 0a31134..99e9ab0 100644 --- a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php @@ -35,12 +35,12 @@ abstract class ExtendableRequireCommand extends RequireCommand protected $jsonFile; /** - * @var boolean $mageNewlyCreated + * @var bool $mageNewlyCreated */ protected $mageNewlyCreated; /** - * @var boolean|string $mageComposerBackup + * @var bool|string $mageComposerBackup */ protected $mageComposerBackup; diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php index ae7983c..9df3dd9 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php @@ -234,7 +234,7 @@ public function execute(InputInterface $input, OutputInterface $output) * @param InputInterface $input * @param string $targetEdition * @param string $targetConstraint - * @return boolean Returns true if updates were necessary and prepared successfully + * @return bool Returns true if updates were necessary and prepared successfully */ protected function runUpdate($updater, $input, $targetEdition, $targetConstraint) { diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php index f388bd9..95805b1 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/PluginDefinition.php @@ -13,7 +13,7 @@ use Composer\IO\IOInterface; use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; use Composer\Plugin\Capable; -use Composer\Plugin\PluginInterface;; +use Composer\Plugin\PluginInterface; use Magento\ComposerRootUpdatePlugin\Setup\WebSetupWizardPluginInstaller; use Magento\ComposerRootUpdatePlugin\Utils\Console; diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php b/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php new file mode 100644 index 0000000..c04574b --- /dev/null +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php @@ -0,0 +1,38 @@ +doVarInstall(); + } +} diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php index d5de0fb..71e7ece 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/InstallData.php @@ -6,24 +6,14 @@ namespace Magento\ComposerRootUpdatePlugin\Setup; -use Composer\IO\ConsoleIO; -use Magento\ComposerRootUpdatePlugin\Utils\Console; use Magento\Framework\Setup\InstallDataInterface; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Symfony\Component\Console\Helper\DebugFormatterHelper; -use Symfony\Component\Console\Helper\FormatterHelper; -use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\ProcessHelper; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Console\Output\OutputInterface; /** * Magento module hook to attach plugin installation functionality to `magento setup` operations */ -class InstallData implements InstallDataInterface +class InstallData extends AbstractModuleOperation implements InstallDataInterface { /** * Passthrough Magento setup command to check the plugin installation in the var directory @@ -34,16 +24,6 @@ class InstallData implements InstallDataInterface */ public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - $io = new ConsoleIO(new ArrayInput([]), - new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), - new HelperSet([ - new FormatterHelper(), - new DebugFormatterHelper(), - new ProcessHelper(), - new QuestionHelper() - ]) - ); - $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); - $setupWizardInstaller->doVarInstall(); + $this->doVarInstall($setup, $context); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php index 2ca8000..8a0b745 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/RecurringData.php @@ -6,24 +6,14 @@ namespace Magento\ComposerRootUpdatePlugin\Setup; -use Composer\IO\ConsoleIO; -use Magento\ComposerRootUpdatePlugin\Utils\Console; use Magento\Framework\Setup\InstallDataInterface; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\ModuleDataSetupInterface; -use Symfony\Component\Console\Helper\DebugFormatterHelper; -use Symfony\Component\Console\Helper\FormatterHelper; -use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\ProcessHelper; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Console\Output\OutputInterface; /** * Magento module hook to attach plugin installation functionality to `magento setup` operations */ -class RecurringData implements InstallDataInterface +class RecurringData extends AbstractModuleOperation implements InstallDataInterface { /** * Passthrough Magento setup command to check the plugin installation in the var directory @@ -34,16 +24,6 @@ class RecurringData implements InstallDataInterface */ public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - $io = new ConsoleIO(new ArrayInput([]), - new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), - new HelperSet([ - new FormatterHelper(), - new DebugFormatterHelper(), - new ProcessHelper(), - new QuestionHelper() - ]) - ); - $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); - $setupWizardInstaller->doVarInstall(); + $this->doVarInstall($setup, $context); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php b/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php index 5ef1a5d..42607ea 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/UpgradeData.php @@ -6,24 +6,14 @@ namespace Magento\ComposerRootUpdatePlugin\Setup; -use Composer\IO\ConsoleIO; -use Magento\ComposerRootUpdatePlugin\Utils\Console; use Magento\Framework\Setup\ModuleContextInterface; use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\Setup\UpgradeDataInterface; -use Symfony\Component\Console\Helper\DebugFormatterHelper; -use Symfony\Component\Console\Helper\FormatterHelper; -use Symfony\Component\Console\Helper\HelperSet; -use Symfony\Component\Console\Helper\ProcessHelper; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Console\Output\OutputInterface; /** * Magento module hook to attach plugin installation functionality to `magento setup` operations */ -class UpgradeData implements UpgradeDataInterface +class UpgradeData extends AbstractModuleOperation implements UpgradeDataInterface { /** * Passthrough Magento setup command to check the plugin installation in the var directory @@ -34,16 +24,6 @@ class UpgradeData implements UpgradeDataInterface */ public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - $io = new ConsoleIO(new ArrayInput([]), - new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), - new HelperSet([ - new FormatterHelper(), - new DebugFormatterHelper(), - new ProcessHelper(), - new QuestionHelper() - ]) - ); - $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); - $setupWizardInstaller->doVarInstall(); + $this->doVarInstall($setup, $context); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php index 12a181e..8342d5a 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php @@ -123,7 +123,7 @@ public function doVarInstall() * @param Composer $composer * @param string $filePath * @param string $pluginVersion - * @return boolean + * @return bool * @throws Exception */ public function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php index ecb15fa..b88473e 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php @@ -30,7 +30,7 @@ class DeltaResolver protected $console; /** - * @var boolean $overrideUserValues + * @var bool $overrideUserValues */ protected $overrideUserValues; @@ -63,7 +63,7 @@ class DeltaResolver * DeltaResolver constructor. * * @param Console $console - * @param boolean $overrideUserValues + * @param bool $overrideUserValues * @param RootPackageRetriever $retriever * @return void */ diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php index fb30549..9ab8f47 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php @@ -50,11 +50,11 @@ public function __construct($console, $composer) * Look ahead to the target Magento version and execute any changes to the root composer.json file in-memory * * @param RootPackageRetriever $retriever - * @param boolean $overrideOption - * @param boolean $ignorePlatformReqs + * @param bool $overrideOption + * @param bool $ignorePlatformReqs * @param string $phpVersion * @param string $stability - * @return boolean Returns true if updates were necessary and prepared successfully + * @return bool Returns true if updates were necessary and prepared successfully */ public function runUpdate( $retriever, diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php index ee179b0..d5ef2f2 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php @@ -47,7 +47,7 @@ class RootPackageRetriever protected $originalRootPackage; /** - * @var boolean $fetchedOriginal + * @var bool $fetchedOriginal */ protected $fetchedOriginal; @@ -57,7 +57,7 @@ class RootPackageRetriever protected $targetRootPackage; /** - * @var boolean $fetchedTarget + * @var bool $fetchedTarget */ protected $fetchedTarget; @@ -135,8 +135,8 @@ public function __construct( /** * Get the project package that should be used as the basis for Magento root comparisons * - * @param boolean $overrideOption - * @return PackageInterface|boolean + * @param bool $overrideOption + * @return PackageInterface|bool */ public function getOriginalRootPackage($overrideOption) { @@ -231,10 +231,10 @@ public function getUserRootPackage() * * @param string $edition * @param string $constraint - * @param boolean $ignorePlatformReqs + * @param bool $ignorePlatformReqs * @param string $phpVersion * @param string $preferredStability - * @return PackageInterface|boolean Best root package candidate or false if no valid packages found + * @return PackageInterface|bool Best root package candidate or false if no valid packages found */ protected function fetchMageRootFromRepo( $edition, diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php index c1c8656..d8f0fe8 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php @@ -42,7 +42,7 @@ class Console protected $verboseLabel; /** - * @var boolean $interactive + * @var bool $interactive */ protected $interactive; @@ -50,7 +50,7 @@ class Console * Console constructor. * * @param IOInterface $io - * @param boolean $interactive + * @param bool $interactive * @param string $verboseLabel * @return void */ @@ -78,7 +78,7 @@ public function getIO() /** * Whether or not ask() should interactively ask the question or just return the default value * - * @param boolean $interactive + * @param bool $interactive * @return void */ public function setInteractive($interactive) @@ -92,8 +92,8 @@ public function setInteractive($interactive) * If the console is not interactive, instead do not ask and just return the default * * @param string $question - * @param boolean $default - * @return boolean + * @param bool $default + * @return bool */ public function ask($question, $default = false) { diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php index 170cced..d5e2100 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php @@ -57,7 +57,7 @@ static public function getMagentoProductEdition($packageName) * * @param Composer $composer * @param string $packageMatcher - * @return Link|boolean + * @return Link|bool */ static public function findRequire($composer, $packageMatcher) { @@ -84,7 +84,7 @@ static public function findRequire($composer, $packageMatcher) * Is the given constraint strict or does it allow multiple versions * * @param string $constraint - * @return boolean + * @return bool */ static public function isConstraintStrict($constraint) { diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php index a3d7868..e0081d7 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/TestHelpers/TestApplication.php @@ -15,7 +15,7 @@ class TestApplication extends \Composer\Console\Application { /** - * @var boolean + * @var bool */ private $shouldRun = false; @@ -38,7 +38,7 @@ public function setComposer(Composer $composer) /** * Set whether or not doRunCommand should actually be run or not * - * @param boolean $shouldRun + * @param bool $shouldRun * @return void */ public function setShouldRun($shouldRun) From 12af12c9ec241ab308921dfda1ebb7178c4ffb2e Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Tue, 21 May 2019 11:47:45 -0500 Subject: [PATCH 14/15] MC-5465: Simplifying root readme by linking to plugin source readme --- README.md | 200 ++---------------------------------------------------- 1 file changed, 4 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index e000f39..b6bf4f0 100644 --- a/README.md +++ b/README.md @@ -8,203 +8,15 @@ This is accomplished by comparing the root `composer.json` file for the Magento # Getting Started -## System requirements - -The `magento/composer-root-update-plugin` package requires Composer version 1.8.0 or earlier. Compatibility with newer Composer versions will be tested and added in future plugin versions. - -## Installation - -To install the plugin, run the following commands in the Magento root directory. - - composer require magento/composer-root-update-plugin ~1.0 --no-update - composer update +For system requirements and installation instructions, see [README.md](src/magento/composerrootupdateplugin#gettingstarted) in the source directory. # Usage -The plugin adds functionality to the `composer require` command when a new Magento product package is required, and in most cases will not need additional options or commands run to function. - -If the `composer require` command for the target Magento package fails, one of the following may be necessary. - -## Installations that started with another Magento product - -If the local Magento installation has previously been updated from a previous Magento product version or edition without the plugin installed, the root `composer.json` file may still have values from the earlier package that need to be updated to the current Magento requirement before updating to the target Magento product. - -In this case, run the following command with the appropriate values to correct the existing `composer.json` file before proceeding with the expected `composer require` command for the target Magento product. - - composer require --base-magento-edition '' --base-magento-version - -## Conflicting custom values - -If the `composer.json` file has custom changes that do not match the values the plugin expects according to the installed Magento product, the entries may need to be corrected to values compatible with the target Magento package. - -To resolve these conflicts interactively, re-run the `composer require` command with the `--interactive-magento-conflicts` option. - -To override all conflicting custom values with the expected Magento values, re-run the `composer require` command with the `--use-default-magento-values` option. - -## Bypassing the plugin - -To run the native `composer require` command without the plugin's updates, use the `--skip-magento-root-plugin` option. - -## Refreshing the plugin for the Web Setup Wizard - -If the `var` directory in the Magento root folder has been cleared, the plugin may need to be re-installed there to function when updating Magento through the Web Setup Wizard. - -To reinstall the plugin in `var`, run the following command in the Magento root directory. - - composer magento-update-plugin install - -## Example use case: Upgrading from Magento 2.2.8 to Magento 2.3.1 - -### Without `magento/composer-root-update-plugin`: - -In the project directory for a Magento Open Source 2.2.8 installation, a user tries to run the `composer require` and `composer update` commands for Magento Open Source 2.3.1 with these results: - -``` -$ composer require magento/product-community-edition 2.3.1 --no-update -./composer.json has been updated -$ composer update -Loading composer repositories with package information -Updating dependencies (including require-dev) -Your requirements could not be resolved to an installable set of packages. - - Problem 1 - - Installation request for magento/product-community-edition 2.3.1 -> satisfiable by magento/product-community-edition[2.3.1]. - - magento/product-community-edition 2.3.1 requires magento/magento2-base 2.3.1 -> satisfiable by magento/magento2-base[2.3.1]. - ... - - sebastian/phpcpd 2.0.4 requires symfony/console ~2.7|^3.0 - ... - - magento/magento2-base 2.3.1 requires symfony/console ~4.1.0 -> satisfiable by symfony/console[v4.1.0, v4.1.1, v4.1.10, v4.1.11, v4.1.2, v4.1.3, v4.1.4, v4.1.5, v4.1.6, v4.1.7, v4.1.8, v4.1.9]. - - Conclusion: don't install symfony/console v4.1.11|install symfony/console v2.8.38 - - Installation request for sebastian/phpcpd 2.0.4 -> satisfiable by sebastian/phpcpd[2.0.4]. -``` - -This error occurs because the `"require-dev"` section in the `composer.json` file for `magento/project-community-edition` 2.2.8 conflicts with the dependencies for the new 2.3.1 version of `magento/product-community-edition`. The 2.2.8 `composer.json` file has a `"require-dev"` entry for `sebastian/phpcpd: 2.0.4`, which depends on `symfony/console: ~2.7|^3.0`, but the `magento/magento2-base` package required by `magento/product-community-edition` 2.3.1 depends on `symfony/console: ~4.1.0`, which does not overlap with the versions allowed by the `~2.7|^3.0` constraint. +For a usage overview and example use cases, see [README.md](src/magento/composerrootupdateplugin#usage) in the source directory. -Because the `sebastian/phpcpd` requirement exists in the root `composer.json` file instead of one of the child dependencies of `magento/product-community-edition` 2.2.8, it does not get updated by Composer when the `magento/product-community-edition` version changes. - -In the `composer.json` file for `magento/project-community-edition` 2.3.1, that `sebastian/phpcpd` entry in `"require-dev"` has changed to `~3.0.0`, which is compatible with the `symfony/console` versions allowed by `magento/magento2-base` 2.3.1. However, without this plugin, Composer does not know that the value needs to change because the commands to upgrade Magento use the `magento/product-community-edition` metapackage and not the root `magento/project-community-edition` project package. - -This is only one of the changes to the root project `composer.json` file between Magento 2.2.8 and 2.3.1. There are several others, and future Magento versions can (and likely will) require further updates to the file. - -The changes to the root project `composer.json` files can be done manually by the user without the plugin, but the values that need to change can differ depending on the Magento versions involved and user-customized values may already override the Magento defaults. This means the exact upgrade steps necessary can be different for every user and determining the correct changes to make manually for a given user's configuration may be error-prone. - -For reference, these are the `"require"` and `"require-dev"` sections for default installations (no user customizations) of Magento Open Source versions 2.2.8 and 2.3.1. It is important to note that these sections of `composer.json` are not the only ones that can change between versions. The `"autoload"` and `"conflict"` sections, for example, can also affect Magento functionality and need to be kept up-to-date with the installed Magento versions. - - - **2.2.8** - ``` - "require": { - "magento/product-community-edition": "2.2.8", - "composer/composer": "@alpha" - }, - "require-dev": { - "magento/magento2-functional-testing-framework": "2.3.13", - "phpunit/phpunit": "~6.2.0", - "squizlabs/php_codesniffer": "3.2.2", - "phpmd/phpmd": "@stable", - "pdepend/pdepend": "2.5.2", - "friendsofphp/php-cs-fixer": "~2.2.1", - "lusitanian/oauth": "~0.8.10", - "sebastian/phpcpd": "2.0.4" - } - ``` - - - **2.3.1** - ``` - "require": { - "magento/product-community-edition": "2.3.1" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.13.0", - "lusitanian/oauth": "~0.8.10", - "magento/magento2-functional-testing-framework": "~2.3.13", - "pdepend/pdepend": "2.5.2", - "phpmd/phpmd": "@stable", - "phpunit/phpunit": "~6.5.0", - "sebastian/phpcpd": "~3.0.0", - "squizlabs/php_codesniffer": "3.3.1", - "allure-framework/allure-phpunit": "~1.2.0" - } - ``` - -### With `magento/composer-root-update-plugin`: - -In the project directory for a Magento Open Source 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Open Source 2.3.1 upgrade commands. - -``` -$ composer require magento/composer-root-update-plugin ~1.0 --no-update -./composer.json has been updated -$ composer update -Loading composer repositories with package information -Updating dependencies (including require-dev) -Package operations: 1 install, 0 updates, 0 removals - - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) -Installing "magento/composer-root-update-plugin: 1.0.0" for the Web Setup Wizard -Loading composer repositories with package information -Updating dependencies -Package operations: 18 installs, 0 updates, 0 removals - - Installing ... - ... - - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) -Writing lock file -Generating autoload files -Writing lock file -Generating autoload files -``` - -As is normal for `composer require`, `magento/composer-root-update-plugin` is added to the `composer.json` file. The plugin also installs itself in the directory used by the Magento Web Setup Wizard during dependency validation. - -With the plugin installed, the user proceeds with the `composer require` command for Magento Open Source 2.3.1 (`--verbose` mode used here for demonstration). - -``` -$ composer require magento/product-community-edition 2.3.1 --no-update --verbose - [Magento Open Source 2.3.1] Base Magento project package version: magento/project-community-edition 2.2.8 - [Magento Open Source 2.3.1] Removing require entries: composer/composer - [Magento Open Source 2.3.1] Adding require-dev constraints: allure-framework/allure-phpunit=~1.2.0 - [Magento Open Source 2.3.1] Updating require-dev constraints: magento/magento2-functional-testing-framework=~2.3.13, phpunit/phpunit=~6.5.0, squizlabs/php_codesniffer=3.3.1, friendsofphp/php-cs-fixer=~2.13.0, sebastian/phpcpd=~3.0.0 - [Magento Open Source 2.3.1] Adding conflict constraints: gene/bluefoot=* - [Magento Open Source 2.3.1] Updating autoload.psr-4.Zend\Mvc\Controller\ entry: "setup/src/Zend/Mvc/Controller/" -Updating composer.json for Magento Open Source 2.3.1 ... - [Magento Open Source 2.3.1] Writing changes to the root composer.json... - [Magento Open Source 2.3.1] /composer.json has been updated -./composer.json has been updated -``` - -The plugin detects the user's request for the 2.3.1 version of `magento/product-community-edition` and looks up the `composer.json` file for the corresponding `magento/project-community-edition` 2.3.1 root project package. It finds the values that are different between 2.2.8 and 2.3.1 and updates the local `composer.json` file accordingly, then lets Composer proceed with the normal `composer require` functionality. - -With the root `composer.json` file updated for Magento Open Source 2.3.1, the user proceeds with the `composer update` command: - -``` -$ composer update -Loading composer repositories with package information -Updating dependencies (including require-dev) -Package operations: 118 installs, 246 updates, 5 removals - - Removing symfony/polyfill-php55 (v1.11.0) - ... -Writing lock file -Generating autoload files -``` - -With the updated values from Magento Open Source 2.3.1, the `symfony/console` conflict no longer exists and the update occurs as expected. - -For reference, these are the `"require"` and `"require-dev"` sections from the `composer.json` file after `composer require magento/product-community-edition 2.3.1 --no-update` runs with the plugin on a Magento Open Source 2.2.8 installation. They contain exactly the same entries as the default Magento Open Source 2.3.1 root `composer.json` file (with the addition of the `magento/composer-root-update-plugin` requirement). +# Developer documentation - ``` - "require": { - "magento/product-community-edition": "2.3.1", - "magento/composer-root-update-plugin": "~1.0" - }, - "require-dev": { - "allure-framework/allure-phpunit": "~1.2.0", - "magento/magento2-functional-testing-framework": "~2.3.13", - "phpunit/phpunit": "~6.5.0", - "squizlabs/php_codesniffer": "3.3.1", - "phpmd/phpmd": "@stable", - "pdepend/pdepend": "2.5.2", - "friendsofphp/php-cs-fixer": "~2.13.0", - "lusitanian/oauth": "~0.8.10", - "sebastian/phpcpd": "~3.0.0" - } - ``` +Class descriptions, process flows, and any other developer documentation can be found in the [docs](docs) directory. # License @@ -213,7 +25,3 @@ Each Magento source file included in this distribution is licensed under OSL 3.0 [Open Software License (OSL 3.0)](https://opensource.org/licenses/osl-3.0.php). Please see [LICENSE.txt](https://github.com/magento/composer-root-update-plugin/blob/develop/LICENSE.txt) for the full text of the OSL 3.0 license or contact license@magentocommerce.com for a copy. - -# Developer documentation - -Class descriptions, process flows, and any other developer documentation can be found in the [docs](docs) directory. From c083570722af13e343e13d43f238d5b17366c6aa Mon Sep 17 00:00:00 2001 From: pdohogne-magento Date: Tue, 21 May 2019 19:10:06 -0500 Subject: [PATCH 15/15] MC-5465: Addressing code sniff and mess detector reports --- docs/class_descriptions.md | 38 +- docs/process_flows.md | 17 +- .../AccessibleRootPackageLoader.php | 5 +- .../ExtendableRequireCommand.php | 5 + .../Commands/MageRootRequireCommand.php | 201 +++---- .../UpdatePluginNamespaceCommands.php | 9 +- .../ComposerRootUpdatePlugin/README.md | 14 +- .../Setup/AbstractModuleOperation.php | 20 +- .../Setup/WebSetupWizardPluginInstaller.php | 59 ++- .../Updater/DeltaResolver.php | 501 +++++++++++------- .../Updater/MagentoRootUpdater.php | 18 +- .../Updater/RootPackageRetriever.php | 276 ++++------ .../Utils/Console.php | 4 +- .../Utils/PackageUtils.php | 104 +++- .../ComposerRootUpdatePlugin/composer.json | 2 +- .../ComposerRootUpdatePlugin/etc/module.xml | 2 +- .../Updater/DeltaResolverTest.php | 18 +- 17 files changed, 776 insertions(+), 517 deletions(-) diff --git a/docs/class_descriptions.md b/docs/class_descriptions.md index 9b2e1a4..859909f 100644 --- a/docs/class_descriptions.md +++ b/docs/class_descriptions.md @@ -64,9 +64,11 @@ Extends the native [RequireCommand](https://getcomposer.org/apidoc/master/Compos - **`setApplication()`** - Overrides the command's name to `require` after the command registry is checked but before the command is actually added to the registry. This allows the command to replace the native `RequireCommand` instance that is normally associated with the `composer require` CLI command - **`execute()`** - - Wraps the native `RequireCommand::execute()` function with the Magento project update code if a Magento product package is found in the command's parameters + - Wraps the native `RequireCommand::execute()` function with the Magento project update code - **`runUpdate()`** - Calls [MagentoRootUpdater::runUpdate()](#magentorootupdater) after processing CLI options + - **`parseMagentoRequirement()`** + - Parses the CLI command arguments for a magento/product requirement #### [**Commands\UpdatePluginNamespaceCommands**](../src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php) @@ -132,6 +134,8 @@ This class manages the plugin's self-installation inside the `var` directory to - Called by `composer magento-update-plugin install` and the Magento module setup classes ([InstallData](#installdatarecurringdataupgradedata), [RecurringData](#installdatarecurringdataupgradedata), [UpgradeData](#installdatarecurringdataupgradedata)) - **`updateSetupWizardPlugin()`** - Installs the plugin inside `var/vendor` where it can be found by the `composer require` command run by the Web Setup Wizard's validation check. This is accomplished by creating a dummy project directory with a `composer.json` file that requires only the plugin, installing it, then copying the resulting `vendor` directory to `var/vendor` + - **`getTempDir()`** + - Creates a temporary directory inside the `var` directory to use for the dummy plugin project in `updateSetupWizardPlugin()` - **`deletePath()`** - Recursively deletes a file or directory and all its contents - **`copyAndReplace()`** @@ -155,15 +159,27 @@ This is accomplished by comparing `composer.json` fields between the original Ma - Entry point into the resolution functionality - Calls the relevant resolve function for each `composer.json` field that can be updated - **`findResolution()`** - - For an individual field value, compare the original Magento value to the target Magento value, and if a delta is found, check if the user's installation has a customized value for the field. If the user has changed the value, resolve the conflict according to the CLI command options: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used + - For an individual field value, compare the original Magento value to the target Magento value, and if a delta is found, check if the user's installation has a customized value for the field then apply the appropriate resolution + - **`prettify()`** + - Formats a field value to be human-readable if a preset pretty value is not present + - **`solveIfConflict()`** + - If the user has a field value that conflicts with an expected delta, resolve the conflict according to the CLI command options: use the user's custom value, override with the target Magento value, or interactively ask the user which of the two values should be used - **`resolveLinkSection()`** - For a given `composer.json` section that consists of links to package versions/constraints (such as the `require` and `conflict` sections), call `findLinkResolution()` for each package constraint found in either the original Magento root or the target Magento root + - **`resolveLink()`** + - Helper function to call `findResolution()` for a particular package for use by `resolveLinkSection()` + - **`getConstraintValues()`** + - Helper function to get the raw and pretty forms of a link for comparison + - **`applyLinkChanges()`** + - Adjust the json values for a link section according to the resolutions calculated by `resolveLinkSection()` - **`resolveArraySection()`** - For a given `composer.json` section that consists of data that is not package links (such as the `"autoload"` or `"extra"` sections), call `resolveNestedArray()` and accept the new values if changes were made - **`resolveNestedArray()`** - Recursively processes changes to a `composer.json` value that could be a nested array, calling `findResolution()` for each "leaf" value found in either the original Magento root or the target Magento root - - **`findLinkResolution()`** - - Helper function to call `findResolution()` for a particular package for use by `resolveLinkSection()` + - **`resolveFlatArray()`** + - Process changes to the non-associative portion of an array field value, treating it as an unordered set + - **`resolveAssociativeArray()`** + - Process changes to the associative portion of an array field value that could contain nested arrays - **`getLinkOrderOverride()`** - Determine the order to use for a link section when the user's order disagrees with the target Magento section order - **`buildLinkOrderComparator()`** @@ -190,10 +206,12 @@ This class contains methods to retrieve Composer [Package](https://getcomposer.o - Returns the existing root project package, including all user customizations - **`fetchMageRootFromRepo()`** - Given a Magento edition and version constraint, fetch the best-fit Magento root project package from the Composer repository - - **`parseOriginalVersionAndEditionFromLock()`** + - **`parseVersionAndEditionFromLock()`** - Inspect the `composer.lock` file for the currently-installed Magento product package and parse out the edition and version for use by `getOriginalRootPackage()` - - **`getRootLocker()`** - - Helper function to get the [Locker](https://getcomposer.org/apidoc/master/Composer/Package/Locker.html) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory + - **`getTargetLabel()`** + - Gets the formatted label for the target Magento version + - **`getOriginalLabel()`** + - Gets the formatted label for the originally-installed Magento version *** @@ -222,7 +240,13 @@ Common package-related utility functions. - Extracts the package type (`product` or `project`) from a Magento package name - **`getMagentoProductEdition()`** - Extracts the package edition from a Magento product package name + - **`getEditionLabel()`** + - Translates package edition into the marketing edition label - **`findRequire()`** - Searches the `"require"` section of a [Composer](https://getcomposer.org/apidoc/master/Composer/Composer.html) object for a package link that fits the supplied name or matcher - **`isConstraintStrict()`** - Checks if a version constraint is strict or if it allows multiple versions (such as `~1.0` or `>= 1.5.3`) + - **`getLockedProduct()`** + - Gets the installed magento/product package from the composer.lock file if it exists + - **`getRootLocker()`** + - Helper function to get the [Locker](https://getcomposer.org/apidoc/master/Composer/Package/Locker.html) object for the `composer.lock` file in the project root directory. If the current working directory is `var` (which is the case for the Web Setup Wizard), instead use the `composer.lock` file in the parent directory \ No newline at end of file diff --git a/docs/process_flows.md b/docs/process_flows.md index 8f90536..1e85264 100644 --- a/docs/process_flows.md +++ b/docs/process_flows.md @@ -30,17 +30,18 @@ There are four paths through the plugin code that cover two main pieces of funct 2. Composer recognizes `require` as the command passed to the executable and finds `MageRootRequireCommand` as the command object registered under that name 3. Composer calls `MageRootRequireCommand::execute()` 4. `MageRootRequireCommand::execute()` backs up the user's `composer.json` file through [ExtendableRequireCommand::parseComposerJsonFile()](class_descriptions.md#extendablerequirecommand) -5. `MageRootRequireCommand::execute()` checks the `composer require` arguments for a `magento/product` package, and if it finds one it calls `MageRootRequireCommand::runUpdate()` -6. `MageRootRequireCommand::runUpdate()` calls [MagentoRootUpdater::runUpdate()](class_descriptions.md#magentorootupdater) -7. `MageRootRequireCommand::runUpdate()` calls [DeltaResolver::resolveRootDeltas()](class_descriptions.md#deltaresolver) -8. `DeltaResolver::resolveRootDeltas()` uses [RootPackageRetriever](class_descriptions.md#rootpackageretriever) to obtain the Composer [Package](https://getcomposer.org/apidoc/master/Composer/Package/Package.html) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made -9. `DeltaResolver::resolveRootDeltas()` iterates over the fields in `composer.json` to determine any values that need to be updated to match the root `composer.json` file of the new Magento edition/version +5. `MageRootRequireCommand::execute()` calls `MageRootRequireCommand::runUpdate()` +6. `MageRootRequireCommand::runUpdate()` calls `MageRootRequireCommand::parseMageRequirement()` to check the `composer require` arguments for a `magento/product` package +7. If a `magento/product` package is found in the command arguments, it calls [MagentoRootUpdater::runUpdate()](class_descriptions.md#magentorootupdater) +8. `MagentoRootUpdater::runUpdate()` calls [DeltaResolver::resolveRootDeltas()](class_descriptions.md#deltaresolver) +9. `DeltaResolver::resolveRootDeltas()` uses [RootPackageRetriever](class_descriptions.md#rootpackageretriever) to obtain the Composer [Package](https://getcomposer.org/apidoc/master/Composer/Package/Package.html) objects for the root `composer.json` files from the default installation of the existing edition and version, the target edition and version supplied to the `composer require` call, and the user's current installation including any customizations they have made +10. `DeltaResolver::resolveRootDeltas()` iterates over the fields in `composer.json` to determine any values that need to be updated to match the root `composer.json` file of the new Magento edition/version 1. To find these values, it compares the values for each field in the default project for the installed edition/version with the project for the target edition/version (`DeltaResolver::findResolution()`) 2. If a value has changed in the target, it checks that field in the user's customized root `composer.json` file to see if it has been overwritten with a custom value 3. If the user customized the value, the conflict will be resolved according to the specified resolution strategy: use the expected Magento value, use the user's custom value, or prompt the user to specify which value should be used -10. If `resolveRootDeltas()` found values that need to change, `MageRootRequireCommand::execute()` calls `MagentoRootUpdater::writeUpdatedComposerJson()` to apply those changes -11. `MageRootRequireCommand::execute()` calls the native `RequireCommand::execute()` function, which will now use the updated root `composer.json` file if the plugin made changes -12. If the `RequireCommand::execute()` call fails after the plugin makes changes, `MageRootRequireCommand::execute()` calls `ExtendableRequireCommand::revertMageComposerFile()` to restore the `composer.json` file to its original state +11. If `resolveRootDeltas()` found values that need to change, `MageRootRequireCommand::runUpdate()` calls `MagentoRootUpdater::writeUpdatedComposerJson()` to apply those changes +12. `MageRootRequireCommand::execute()` calls the native `RequireCommand::execute()` function, which will now use the updated root `composer.json` file if the plugin made changes +13. If the `RequireCommand::execute()` call fails after the plugin makes changes, `MageRootRequireCommand::execute()` calls `ExtendableRequireCommand::revertMageComposerFile()` to restore the `composer.json` file to its original state *** diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php index 0373de8..f0e016e 100644 --- a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/AccessibleRootPackageLoader.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +// @codingStandardsIgnoreFile + namespace Magento\ComposerRootUpdatePlugin\ComposerReimplementation; use Composer\Package\BasePackage; @@ -28,8 +30,9 @@ class AccessibleRootPackageLoader * @param string $reqVersion * @param string $minimumStability * @return array + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - public static function extractStabilityFlags($reqName, $reqVersion, $minimumStability) + public function extractStabilityFlags($reqName, $reqVersion, $minimumStability) { $stabilityFlags = []; $stabilityMap = BasePackage::$stabilities; diff --git a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php index 99e9ab0..108c742 100644 --- a/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/ComposerReimplementation/ExtendableRequireCommand.php @@ -4,6 +4,8 @@ * See COPYING.txt for license details. */ +// @codingStandardsIgnoreFile + namespace Magento\ComposerRootUpdatePlugin\ComposerReimplementation; use Composer\Command\InitCommand; @@ -144,6 +146,9 @@ protected function parseComposerJsonFile($input) * * @return array * @throws \Exception + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function getRequirementsInteractive() { diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php index 9df3dd9..6173c9a 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/MageRootRequireCommand.php @@ -38,14 +38,24 @@ class MageRootRequireCommand extends ExtendableRequireCommand private $commandName; /** - * @var RootPackageRetriever $retriever + * @var Console $console */ - protected $retriever; + protected $console; /** - * @var Console $console + * @var PackageUtils $pkgUtils */ - protected $console; + protected $pkgUtils; + + /** + * @var string $package + */ + protected $package; + + /** + * @var string $constraint + */ + protected $constraint; /** * Call the parent setApplication method but also change the command's name to update @@ -67,13 +77,12 @@ public function setApplication(Application $application = null) * * @return void */ - public function configure() + protected function configure() { parent::configure(); $origName = $this->getName(); $this->commandName = $origName; - $this->retriever = null; $this->setName('require-magento-root') ->addOption( static::SKIP_OPT, @@ -137,67 +146,16 @@ public function configure() * * @throws \Exception */ - public function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output) { - $updater = null; $this->console = new Console($this->getIO(), $input->getOption(static::INTERACTIVE_OPT)); + $this->pkgUtils = new PackageUtils($this->console); $fileParsed = $this->parseComposerJsonFile($input); if ($fileParsed !== 0) { return $fileParsed; } - $updater = null; - $didUpdate = false; - $package = null; - $constraint = null; - $requires = $input->getArgument('packages'); - if (!$this->mageNewlyCreated && - !$input->getOption('no-plugins') && - !$input->getOption('dev') && - !$input->getOption(static::SKIP_OPT) - ) { - if (!$requires) { - $requires = $this->getRequirementsInteractive(); - $input->setArgument('packages', $requires); - } - - $requires = $this->normalizeRequirements($requires); - foreach ($requires as $requirement) { - $pkgEdition = PackageUtils::getMagentoProductEdition($requirement['name']); - if ($pkgEdition) { - $edition = $pkgEdition; - $package = "magento/product-$edition-edition"; - $constraint = isset($requirement['version']) ? $requirement['version'] : '*'; - - // Found a Magento product in the command arguments; try to run the updater - try { - $updater = new MagentoRootUpdater($this->console, $this->getComposer()); - $didUpdate = $this->runUpdate($updater, $input, $edition, $constraint); - } catch (\Exception $e) { - $editionLabel = $edition == PackageUtils::COMMERCE_PKG_EDITION ? 'Commerce' : 'Open Source'; - $label = "Magento $editionLabel $constraint"; - $this->revertMageComposerFile("Update of composer.json with $label changes failed"); - $this->console->log($e->getMessage()); - $didUpdate = false; - } - - break; - } - } - - if ($didUpdate) { - // Update composer.json before the native execute(), as it reads the file instead of an in-memory object - $label = $this->retriever->getTargetLabel(); - $this->console->info("Updating composer.json for $label ..."); - try { - $updater->writeUpdatedComposerJson(); - } catch (\Exception $e) { - $this->revertMageComposerFile("Update of composer.json with $label changes failed"); - $this->console->log($e->getMessage()); - $didUpdate = false; - } - } - } + $didUpdate = $this->runUpdate($input); // Run the native command functionality $errorCode = 0; @@ -211,9 +169,10 @@ public function execute(InputInterface $input, OutputInterface $output) if ($didUpdate && $errorCode !== 0) { // If the native execute() didn't succeed, revert the Magento changes to the composer.json file $this->revertMageComposerFile('The native \'composer ' . $this->commandName . '\' command failed'); - if ($constraint && !PackageUtils::isConstraintStrict($constraint)) { + if ($this->constraint && !$this->pkgUtils->isConstraintStrict($this->constraint)) { + $constraintLabel = $this->package . ': ' . $this->constraint; $this->console->comment( - "Recommended: Use a specific Magento version constraint instead of \"$package: $constraint\"" + "Recommended: Use a specific Magento version constraint instead of \"$constraintLabel\"" ); } } @@ -226,44 +185,100 @@ public function execute(InputInterface $input, OutputInterface $output) } /** - * Call MagentoRootUpdater::runUpdate() according to CLI options + * Checks the package arguments for a Magento product package and run the update if one is found * - * @see MagentoRootUpdater::runUpdate() + * Returns true if an update was attempted successfully * - * @param MagentoRootUpdater $updater * @param InputInterface $input - * @param string $targetEdition - * @param string $targetConstraint - * @return bool Returns true if updates were necessary and prepared successfully + * @return bool */ - protected function runUpdate($updater, $input, $targetEdition, $targetConstraint) + protected function runUpdate($input) { - $overrideOriginalEdition = $input->getOption(static::BASE_EDITION_OPT); - $overrideOriginalVersion = $input->getOption(static::BASE_VERSION_OPT); - if ($overrideOriginalEdition) { - $overrideOriginalEdition = strtolower($overrideOriginalEdition); - if ($overrideOriginalEdition !== 'open source' && $overrideOriginalEdition !== 'commerce') { - $opt = '--' . static::BASE_EDITION_OPT; - throw new InvalidOptionException("'$opt' accepts only 'Open Source' or 'Commerce'"); + $didUpdate = false; + $this->parseMageRequirement($input); + if ($this->package) { + $edition = $this->pkgUtils->getMagentoProductEdition($this->package); + $overrideEdition = $input->getOption(static::BASE_EDITION_OPT); + $overrideVersion = $input->getOption(static::BASE_VERSION_OPT); + if ($overrideEdition) { + $overrideEdition = strtolower($overrideEdition); + if ($overrideEdition !== 'open source' && $overrideEdition !== 'commerce') { + $opt = '--' . static::BASE_EDITION_OPT; + throw new InvalidOptionException("'$opt' accepts only 'Open Source' or 'Commerce'"); + } + $overrideEdition = $overrideEdition == 'open source' ? + PackageUtils::OPEN_SOURCE_PKG_EDITION : PackageUtils::COMMERCE_PKG_EDITION; + } + + $updater = new MagentoRootUpdater($this->console, $this->getComposer()); + $retriever = new RootPackageRetriever( + $this->console, + $this->getComposer(), + $edition, + $this->constraint, + $overrideEdition, + $overrideVersion + ); + + try { + $didUpdate = $updater->runUpdate( + $retriever, + $input->getOption(static::OVERRIDE_OPT), + $input->getOption('ignore-platform-reqs'), + $this->phpVersion, + $this->preferredStability + ); + } catch (\Exception $e) { + $label = $retriever->getTargetLabel(); + $this->revertMageComposerFile("Update of composer.json with $label changes failed"); + $this->console->log($e->getMessage()); + $didUpdate = false; + } + + if ($didUpdate) { + $label = $retriever->getTargetLabel(); + try { + $this->console->info("Updating composer.json for $label ..."); + $updater->writeUpdatedComposerJson(); + } catch (\Exception $e) { + $this->revertMageComposerFile("Update of composer.json with $label changes failed"); + $this->console->log($e->getMessage()); + $didUpdate = false; + } } - $overrideOriginalEdition = $overrideOriginalEdition = 'open source' ? - PackageUtils::OPEN_SOURCE_PKG_EDITION : PackageUtils::COMMERCE_PKG_EDITION; } - $this->retriever = new RootPackageRetriever( - $this->console, - $this->getComposer(), - $targetEdition, - $targetConstraint, - $overrideOriginalEdition, - $overrideOriginalVersion - ); - return $updater->runUpdate( - $this->retriever, - $input->getOption(static::OVERRIDE_OPT), - $input->getOption('ignore-platform-reqs'), - $this->phpVersion, - $this->preferredStability - ); + return $didUpdate; + } + + /** + * Check if the plugin should run and parses the package arguments for a magento/product requirement if so + * + * @param InputInterface $input + * @return void + */ + protected function parseMageRequirement(&$input) + { + $edition = null; + if (!$this->mageNewlyCreated && + !$input->getOption('dev') && + !$input->getOption('no-plugins') && + !$input->getOption(static::SKIP_OPT)) { + $requires = $input->getArgument('packages'); + if (!$requires) { + $requires = $this->getRequirementsInteractive(); + $input->setArgument('packages', $requires); + } + + $requires = $this->normalizeRequirements($requires); + foreach ($requires as $requirement) { + $edition = $this->pkgUtils->getMagentoProductEdition($requirement['name']); + if ($edition) { + $this->package = "magento/product-$edition-edition"; + $this->constraint = isset($requirement['version']) ? $requirement['version'] : '*'; + break; + } + } + } } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php index 908fdad..248d29d 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php +++ b/src/Magento/ComposerRootUpdatePlugin/Plugin/Commands/UpdatePluginNamespaceCommands.php @@ -49,7 +49,7 @@ protected function configure() { $help = "The %command.name% commands are operations specific to the\n" . "magento/composer-root-update-plugin functionality that do not belong to any native\n" . - "composer commands.\n\n" . static::describeOperations() . "\n"; + "composer commands.\n\n" . $this->describeOperations() . "\n"; $this->setName(static::NAME) ->setDescription('Operations specific to magento/composer-root-update-plugin') @@ -63,20 +63,21 @@ protected function configure() * @param InputInterface $input * @param OutputInterface $output * @return int + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ protected function execute(InputInterface $input, OutputInterface $output) { $this->console = new Console($this->getIO()); $operation = $input->getArgument('operation'); if (empty($operation) || $operation == 'list') { - $this->console->log(static::describeOperations() . "\n"); + $this->console->log($this->describeOperations() . "\n"); return 0; } if ($operation == 'install') { $setupWizardInstaller = new WebSetupWizardPluginInstaller($this->console); return $setupWizardInstaller->doVarInstall(); } else { - $this->console->error("'$operation' is not a supported operation for ".static::NAME); + $this->console->error("'$operation' is not a supported operation for " . static::NAME); return 1; } } @@ -86,7 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string */ - private static function describeOperations() + protected function describeOperations() { $output = 'Available operations:'; foreach (static::$operations as $operation => $description) { diff --git a/src/Magento/ComposerRootUpdatePlugin/README.md b/src/Magento/ComposerRootUpdatePlugin/README.md index 9063a9c..1358eef 100644 --- a/src/Magento/ComposerRootUpdatePlugin/README.md +++ b/src/Magento/ComposerRootUpdatePlugin/README.md @@ -16,7 +16,7 @@ The `magento/composer-root-update-plugin` package requires Composer version 1.8. To install the plugin, run the following commands in the Magento root directory. - composer require magento/composer-root-update-plugin ~1.0 --no-update + composer require magento/composer-root-update-plugin ~0.1 --no-update composer update # Usage @@ -128,23 +128,23 @@ For reference, these are the `"require"` and `"require-dev"` sections for defaul ### With `magento/composer-root-update-plugin`: -In the project directory for a Magento Open Source 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~1.0 --no-update` and `composer update` before the Magento Open Source 2.3.1 upgrade commands. +In the project directory for a Magento Open Source 2.2.8 installation, a user runs `composer require magento/composer-root-update-plugin ~0.1 --no-update` and `composer update` before the Magento Open Source 2.3.1 upgrade commands. ``` -$ composer require magento/composer-root-update-plugin ~1.0 --no-update +$ composer require magento/composer-root-update-plugin ~0.1 --no-update ./composer.json has been updated $ composer update Loading composer repositories with package information Updating dependencies (including require-dev) Package operations: 1 install, 0 updates, 0 removals - - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) -Installing "magento/composer-root-update-plugin: 1.0.0" for the Web Setup Wizard + - Installing magento/composer-root-update-plugin (0.1.0): Downloading (100%) +Installing "magento/composer-root-update-plugin: 0.1.0" for the Web Setup Wizard Loading composer repositories with package information Updating dependencies Package operations: 18 installs, 0 updates, 0 removals - Installing ... ... - - Installing magento/composer-root-update-plugin (1.0.0): Downloading (100%) + - Installing magento/composer-root-update-plugin (0.1.0): Downloading (100%) Writing lock file Generating autoload files Writing lock file @@ -191,7 +191,7 @@ For reference, these are the `"require"` and `"require-dev"` sections from the ` ``` "require": { "magento/product-community-edition": "2.3.1", - "magento/composer-root-update-plugin": "~1.0" + "magento/composer-root-update-plugin": "~0.1" }, "require-dev": { "allure-framework/allure-phpunit": "~1.2.0", diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php b/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php index c04574b..1099085 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/AbstractModuleOperation.php @@ -14,25 +14,35 @@ use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\ProcessHelper; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +/** + * Provides the ability for Magento module operations to trigger WebSetupWizardPluginInstaller::doVarInstall() + */ abstract class AbstractModuleOperation { + /** + * Helper function to call WebSetupWizardPluginInstaller::doVarInstall() with a default ConsoleIO + * + * @param ModuleDataSetupInterface $setup + * @param ModuleContextInterface $context + * @return int + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ public function doVarInstall(ModuleDataSetupInterface $setup, ModuleContextInterface $context) { - $io = new ConsoleIO(new ArrayInput([]), + $io = new ConsoleIO( + new ArrayInput([]), new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG), new HelperSet([ new FormatterHelper(), new DebugFormatterHelper(), - new ProcessHelper(), - new QuestionHelper() + new ProcessHelper() ]) ); $setupWizardInstaller = new WebSetupWizardPluginInstaller(new Console($io)); - $setupWizardInstaller->doVarInstall(); + return $setupWizardInstaller->doVarInstall(); } } diff --git a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php index 8342d5a..417c760 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php +++ b/src/Magento/ComposerRootUpdatePlugin/Setup/WebSetupWizardPluginInstaller.php @@ -26,6 +26,11 @@ class WebSetupWizardPluginInstaller * @var Console $console */ protected $console; + + /** + * @var PackageUtils $pkgUtils + */ + protected $pkgUtils; /** * WebSetupWizardPluginInstaller constructor. @@ -36,6 +41,7 @@ class WebSetupWizardPluginInstaller public function __construct($console) { $this->console = $console; + $this->pkgUtils = new PackageUtils($console); } /** @@ -87,8 +93,7 @@ public function doVarInstall() return 1; } - $factory = new Factory(); - $composer = $factory->createComposer($this->console->getIO(), $path, true, null, true); + $composer = (new Factory())->createComposer($this->console->getIO(), $path, true, null, true); $locker = $composer->getLocker(); if ($locker->isLocked()) { $pkg = $locker->getLockedRepository()->findPackage(PluginDefinition::PACKAGE_NAME, '*'); @@ -133,12 +138,12 @@ public function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) // If in ./var already or Magento or the plugin is missing from composer.json, do not install in var if (!preg_match('/\/composer\.json$/', $filePath) || preg_match('/\/var\/composer\.json$/', $filePath) || - !PackageUtils::findRequire( + !$this->pkgUtils->findRequire( $composer, '/magento\/product-(' . PackageUtils::OPEN_SOURCE_PKG_EDITION . '|' . PackageUtils::COMMERCE_PKG_EDITION . ')-edition/' ) || - !PackageUtils::findRequire($composer, $packageName)) { + !$this->pkgUtils->findRequire($composer, $packageName)) { return false; } @@ -165,23 +170,15 @@ public function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) $this->console->info("Installing \"$packageName: $pluginVersion\" for the Web Setup Wizard"); - if (!file_exists($var)) { - mkdir($var); - } - if (!is_writable($var)) { - throw new FilesystemException( - "Could not install \"$packageName: $pluginVersion\" for the Web Setup Wizard; $var is not writable." - ); - } - - $tmpDir = tempnam($var, "composer-plugin_tmp."); $exception = null; + $tmpDir = null; try { - unlink($tmpDir); - mkdir($tmpDir); + $tmpDir = $this->getTempDir($var, $packageName, $pluginVersion); - $tmpComposer = $this->createPluginComposer($tmpDir, $pluginVersion, $composer); - $install = Installer::create($this->console->getIO(), $tmpComposer); + $install = Installer::create( + $this->console->getIO(), + $this->createPluginComposer($tmpDir, $pluginVersion, $composer) + ); $install ->setDumpAutoloader(true) ->setRunScripts(false) @@ -203,6 +200,32 @@ public function updateSetupWizardPlugin($composer, $filePath, $pluginVersion) return true; } + /** + * Creates a temporary directory inside var/ and returns the directory path + * + * @param string $varDir + * @param string $packageName + * @param string $pluginVersion + * @return bool|string + * @throws FilesystemException + */ + private function getTempDir($varDir, $packageName, $pluginVersion) + { + if (!file_exists($varDir)) { + mkdir($varDir); + } + if (!is_writable($varDir)) { + throw new FilesystemException( + "Could not install \"$packageName: $pluginVersion\" for the Web Setup Wizard; $varDir is not writable." + ); + } + + $tmpDir = tempnam($varDir, "composer-plugin_tmp."); + $madeDir = $tmpDir ? unlink($tmpDir) && mkdir($tmpDir) : false; + + return $madeDir ? $tmpDir : false; + } + /** * Deletes a file or a directory and all its contents * diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php index b88473e..7713f87 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolver.php @@ -28,6 +28,11 @@ class DeltaResolver * @var Console $console */ protected $console; + + /** + * @var PackageUtils $pkgUtils + */ + protected $pkgUtils; /** * @var bool $overrideUserValues @@ -47,17 +52,17 @@ class DeltaResolver /** * @var RootPackageInterface $originalMageRootPackage */ - protected $originalMageRootPackage; + protected $origMageRootPkg; /** * @var RootPackageInterface $targetMageRootPackage */ - protected $targetMageRootPackage; + protected $targetMageRootPkg; /** * @var RootPackageInterface $userRootPackage */ - protected $userRootPackage; + protected $userRootPkg; /** * DeltaResolver constructor. @@ -70,11 +75,12 @@ class DeltaResolver public function __construct($console, $overrideUserValues, $retriever) { $this->console = $console; + $this->pkgUtils = new PackageUtils($console); $this->overrideUserValues = $overrideUserValues; $this->retriever = $retriever; - $this->originalMageRootPackage = $retriever->getOriginalRootPackage($overrideUserValues); - $this->targetMageRootPackage = $retriever->getTargetRootPackage(); - $this->userRootPackage = $retriever->getUserRootPackage(); + $this->origMageRootPkg = $retriever->getOriginalRootPackage($overrideUserValues); + $this->targetMageRootPkg = $retriever->getTargetRootPackage(); + $this->userRootPkg = $retriever->getUserRootPackage(); $this->jsonChanges = []; } @@ -85,55 +91,55 @@ public function __construct($console, $overrideUserValues, $retriever) */ public function resolveRootDeltas() { - $original = $this->originalMageRootPackage; - $target = $this->targetMageRootPackage; - $user = $this->userRootPackage; + $orig = $this->origMageRootPkg; + $target = $this->targetMageRootPkg; + $user = $this->userRootPkg; $this->resolveLinkSection( 'require', - $original->getRequires(), + $orig->getRequires(), $target->getRequires(), $user->getRequires(), true ); $this->resolveLinkSection( 'require-dev', - $original->getDevRequires(), + $orig->getDevRequires(), $target->getDevRequires(), $user->getDevRequires(), true ); $this->resolveLinkSection( 'conflict', - $original->getConflicts(), + $orig->getConflicts(), $target->getConflicts(), $user->getConflicts(), false ); $this->resolveLinkSection( 'provide', - $original->getProvides(), + $orig->getProvides(), $target->getProvides(), $user->getProvides(), false ); $this->resolveLinkSection( 'replace', - $original->getReplaces(), + $orig->getReplaces(), $target->getReplaces(), $user->getReplaces(), false ); - $this->resolveArraySection('autoload', $original->getAutoload(), $target->getAutoload(), $user->getAutoload()); + $this->resolveArraySection('autoload', $orig->getAutoload(), $target->getAutoload(), $user->getAutoload()); $this->resolveArraySection( 'autoload-dev', - $original->getDevAutoload(), + $orig->getDevAutoload(), $target->getDevAutoload(), $user->getDevAutoload() ); - $this->resolveArraySection('extra', $original->getExtra(), $target->getExtra(), $user->getExtra()); - $this->resolveArraySection('suggest', $original->getSuggests(), $target->getSuggests(), $user->getSuggests()); + $this->resolveArraySection('extra', $orig->getExtra(), $target->getExtra(), $user->getExtra()); + $this->resolveArraySection('suggest', $orig->getSuggests(), $target->getSuggests(), $user->getSuggests()); return $this->jsonChanges; } @@ -142,45 +148,36 @@ public function resolveRootDeltas() * Find value deltas from original->target version and resolve any conflicts with overlapping user changes * * @param string $field - * @param array|mixed|null $originalMageVal + * @param array|mixed|null $origMageVal * @param array|mixed|null $targetMageVal * @param array|mixed|null $userVal - * @param string|null $prettyOriginalMageVal + * @param string|null $prettyOrigMageVal * @param string|null $prettyTargetMageVal * @param string|null $prettyUserVal * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the existing composer.json file, null for no change */ public function findResolution( $field, - $originalMageVal, + $origMageVal, $targetMageVal, $userVal, - $prettyOriginalMageVal = null, + $prettyOrigMageVal = null, $prettyTargetMageVal = null, $prettyUserVal = null ) { - if ($prettyOriginalMageVal === null) { - $prettyOriginalMageVal = json_encode($originalMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyOriginalMageVal = trim($prettyOriginalMageVal, "'\""); - } - if ($prettyTargetMageVal === null) { - $prettyTargetMageVal = json_encode($targetMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyTargetMageVal = trim($prettyTargetMageVal, "'\""); - } - if ($prettyUserVal === null) { - $prettyUserVal = json_encode($userVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $prettyUserVal = trim($prettyUserVal, "'\""); - } + $prettyOrigMageVal = $this->prettify($origMageVal, $prettyOrigMageVal); + $prettyTargetMageVal = $this->prettify($targetMageVal, $prettyTargetMageVal); + $prettyUserVal = $this->prettify($userVal, $prettyUserVal); $targetLabel = $this->retriever->getTargetLabel(); - $originalLabel = $this->retriever->getOriginalLabel(); + $origLabel = $this->retriever->getOriginalLabel(); $action = null; $conflictDesc = null; - if ($originalMageVal == $targetMageVal || $userVal == $targetMageVal) { + if ($origMageVal == $targetMageVal || $userVal == $targetMageVal) { $action = null; - } elseif ($originalMageVal === null) { + } elseif ($origMageVal === null) { if ($userVal === null) { $action = static::ADD_VAL; } else { @@ -189,14 +186,14 @@ public function findResolution( } } elseif ($targetMageVal === null) { $action = static::REMOVE_VAL; - if ($userVal !== $originalMageVal) { - $conflictDesc = "remove the $field=$prettyOriginalMageVal entry in $originalLabel but it is instead " . + if ($userVal !== $origMageVal) { + $conflictDesc = "remove the $field=$prettyOrigMageVal entry in $origLabel but it is instead " . $prettyUserVal; } } else { $action = static::CHANGE_VAL; - if ($userVal !== $originalMageVal) { - $conflictDesc = "update $field to $prettyTargetMageVal from $prettyOriginalMageVal in $originalLabel"; + if ($userVal !== $origMageVal) { + $conflictDesc = "update $field to $prettyTargetMageVal from $prettyOrigMageVal in $origLabel"; if ($userVal === null) { $action = static::ADD_VAL; $conflictDesc = "$conflictDesc but the field has been removed"; @@ -206,6 +203,35 @@ public function findResolution( } } + return $this->solveIfConflict($action, $conflictDesc, $targetLabel); + } + + /** + * Helper function to make a value human-readable + * + * @param string $val + * @param string $prettyVal + * @return string + */ + protected function prettify($val, $prettyVal = null) + { + if ($prettyVal === null) { + $prettyVal = json_encode($val, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $prettyVal = trim($prettyVal, "'\""); + } + return $prettyVal; + } + + /** + * Check if a conflict was found and if so adjust the action according to override rules + * + * @param string $action + * @param string|null $conflictDesc + * @param string $targetLabel + * @return string + */ + protected function solveIfConflict($action, $conflictDesc, $targetLabel) + { if ($conflictDesc !== null) { $conflictDesc = "$targetLabel is trying to $conflictDesc in this installation"; @@ -232,30 +258,23 @@ public function findResolution( * Process changes to corresponding sets of package version links * * @param string $section - * @param Link[] $originalMageLinks + * @param Link[] $origMageLinks * @param Link[] $targetMageLinks * @param Link[] $userLinks * @param bool $verifyOrder * @return array */ - public function resolveLinkSection($section, $originalMageLinks, $targetMageLinks, $userLinks, $verifyOrder) + public function resolveLinkSection($section, $origMageLinks, $targetMageLinks, $userLinks, $verifyOrder) { $adds = []; $removes = []; $changes = []; - $magePackages = array_unique(array_merge(array_keys($originalMageLinks), array_keys($targetMageLinks))); - foreach ($magePackages as $pkg) { - if ($section === 'require' && PackageUtils::getMagentoProductEdition($pkg)) { + $magePkgs = array_unique(array_merge(array_keys($origMageLinks), array_keys($targetMageLinks))); + foreach ($magePkgs as $pkg) { + if ($section === 'require' && $this->pkgUtils->getMagentoProductEdition($pkg)) { continue; } - $action = $this->findLinkResolution($section, $pkg, $originalMageLinks, $targetMageLinks, $userLinks); - if ($action == static::ADD_VAL) { - $adds[$pkg] = $targetMageLinks[$pkg]; - } elseif ($action == static::REMOVE_VAL) { - $removes[] = $pkg; - } elseif ($action == static::CHANGE_VAL) { - $changes[$pkg] = $targetMageLinks[$pkg]; - } + $this->resolveLink($section, $pkg, $origMageLinks, $targetMageLinks, $userLinks, $adds, $removes, $changes); } $changed = false; @@ -284,61 +303,149 @@ public function resolveLinkSection($section, $originalMageLinks, $targetMageLink if ($verifyOrder) { $enforcedOrder = $this->getLinkOrderOverride( $section, - array_keys($originalMageLinks), + array_keys($origMageLinks), array_keys($targetMageLinks), - array_keys($userLinks) + array_keys($userLinks), + $changed ); - if ($enforcedOrder !== []) { - $changed = true; - $prettyOrder = " [\n " . implode(",\n ", $enforcedOrder) . "\n ]"; - $this->console->labeledVerbose("Updating $section order:\n$prettyOrder"); - } } if ($changed) { - $replacements = array_values($adds); - - /** @var Link $userLink */ - foreach ($userLinks as $pkg => $userLink) { - if (in_array($pkg, $removes)) { - continue; - } elseif (key_exists($pkg, $changes)) { - $replacements[] = $changes[$pkg]; - } else { - $replacements[] = $userLink; - } - } - - usort($replacements, $this->buildLinkOrderComparator( + $this->applyLinkChanges( + $section, + $targetMageLinks, + $userLinks, $enforcedOrder, - array_keys($targetMageLinks), - array_keys($userLinks) - )); + $adds, + $removes, + $changes + ); + } - $newJson = []; - /** @var Link $link */ - foreach ($replacements as $link) { - $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); + return $this->jsonChanges; + } + + /** + * Helper function to find the resolution for a package constraint in the Link sections + * + * @param string $section + * @param string $pkg + * @param Link[] $origLinkMap + * @param Link[] $targetLinkMap + * @param Link[] $userLinkMap + * @param Link[] $adds + * @param Link[] $removes + * @param Link[] $changes + * @return void + */ + protected function resolveLink( + $section, + $pkg, + $origLinkMap, + $targetLinkMap, + $userLinkMap, + &$adds, + &$removes, + &$changes + ) { + $field = "$section:$pkg"; + list($origMageVal, $prettyOrigMageVal) = $this->getConstraintValues($origLinkMap, $pkg); + list($targetMageVal, $prettyTargetMageVal) = $this->getConstraintValues($targetLinkMap, $pkg); + list($userVal, $prettyUserVal) = $this->getConstraintValues($userLinkMap, $pkg); + + $action = $this->findResolution( + $field, + $origMageVal, + $targetMageVal, + $userVal, + $prettyOrigMageVal, + $prettyTargetMageVal, + $prettyUserVal + ); + + if ($action == static::ADD_VAL) { + $adds[$pkg] = $targetLinkMap[$pkg]; + } elseif ($action == static::REMOVE_VAL) { + $removes[] = $pkg; + } elseif ($action == static::CHANGE_VAL) { + $changes[$pkg] = $targetLinkMap[$pkg]; + } + } + + /** + * Helper function to get the raw and pretty forms of a constraint value for a package name + * + * @param Link[] $linkMap + * @param string $pkg + * @return array + */ + protected function getConstraintValues($linkMap, $pkg) + { + $constraint = key_exists($pkg, $linkMap) ? $linkMap[$pkg]->getConstraint() : null; + $val = null; + $prettyVal = null; + if ($constraint) { + $val = $constraint->__toString(); + $prettyVal = $constraint->getPrettyString(); + } + return [$val, $prettyVal]; + } + + /** + * Apply added, removed, and changed links to the stored json changes + * + * @param string $section + * @param Link[] $targetMageLinks + * @param Link[] $userLinks + * @param string[] $order + * @param Link[] $adds + * @param Link[] $removes + * @param Link[] $changes + * @return void + */ + protected function applyLinkChanges($section, $targetMageLinks, $userLinks, $order, $adds, $removes, $changes) + { + $replacements = array_values($adds); + + /** @var Link $userLink */ + foreach ($userLinks as $pkg => $userLink) { + if (in_array($pkg, $removes)) { + continue; + } elseif (key_exists($pkg, $changes)) { + $replacements[] = $changes[$pkg]; + } else { + $replacements[] = $userLink; } + } + + usort($replacements, $this->buildLinkOrderComparator( + $order, + array_keys($targetMageLinks), + array_keys($userLinks) + )); - $this->jsonChanges[$section] = $newJson; + $newJson = []; + /** @var Link $link */ + foreach ($replacements as $link) { + $newJson[$link->getTarget()] = $link->getConstraint()->getPrettyString(); } - return $this->jsonChanges; + $this->jsonChanges[$section] = $newJson; } /** * Process changes to an array (non-package link) section * * @param string $section - * @param array|mixed|null $originalMageVal + * @param array|mixed|null $origMageVal * @param array|mixed|null $targetMageVal * @param array|mixed|null $userVal * @return array */ - public function resolveArraySection($section, $originalMageVal, $targetMageVal, $userVal) + public function resolveArraySection($section, $origMageVal, $targetMageVal, $userVal) { - list($changed, $value) = $this->resolveNestedArray($section, $originalMageVal, $targetMageVal, $userVal); + $changed = false; + $value = $this->resolveNestedArray($section, $origMageVal, $targetMageVal, $userVal, $changed); if ($changed) { $this->jsonChanges[$section] = $value; } @@ -352,145 +459,157 @@ public function resolveArraySection($section, $originalMageVal, $targetMageVal, * Associative arrays are resolved recursively and non-associative arrays are treated as unordered sets * * @param string $field - * @param array|mixed|null $originalMageVal + * @param array|mixed|null $origMageVal * @param array|mixed|null $targetMageVal * @param array|mixed|null $userVal - * @return array [, ], value of null/empty array indicates to remove the entry from parent + * @param bool $changed + * @return array|mixed|null null/empty array indicates to remove the entry from parent */ - public function resolveNestedArray($field, $originalMageVal, $targetMageVal, $userVal) + public function resolveNestedArray($field, $origMageVal, $targetMageVal, $userVal, &$changed) { - $valChanged = false; $result = $userVal === null ? [] : $userVal; - if (is_array($originalMageVal) && is_array($targetMageVal) && is_array($userVal)) { - $originalMageAssociativePart = array_filter($originalMageVal, 'is_string', ARRAY_FILTER_USE_KEY); - $originalMageFlatPart = array_filter($originalMageVal, 'is_int', ARRAY_FILTER_USE_KEY); - - $targetMageAssociativePart = array_filter($targetMageVal, 'is_string', ARRAY_FILTER_USE_KEY); - $targetMageFlatPart = array_filter($targetMageVal, 'is_int', ARRAY_FILTER_USE_KEY); - - $userAssociativePart = array_filter($userVal, 'is_string', ARRAY_FILTER_USE_KEY); - - $associativeResult = $userAssociativePart; - $mageKeys = array_unique( - array_merge(array_keys($originalMageAssociativePart), array_keys($targetMageAssociativePart)) + if (is_array($origMageVal) && is_array($targetMageVal) && is_array($userVal)) { + $assocResult = $this->resolveAssociativeArray( + $field, + $origMageVal, + $targetMageVal, + $userVal, + $changed ); - foreach ($mageKeys as $key) { - if (key_exists($key, $originalMageAssociativePart)) { - $originalMageNestedVal = $originalMageAssociativePart[$key]; - } else { - $originalMageNestedVal = []; - } - if (key_exists($key, $targetMageAssociativePart)) { - $targetMageNestedVal = $targetMageAssociativePart[$key]; - } else { - $targetMageNestedVal = []; - } - if (key_exists($key, $userAssociativePart)) { - $userNestedVal = $userAssociativePart[$key]; - } else { - $userNestedVal = []; - } - list($changed, $value) = $this->resolveNestedArray( - "$field.$key", - $originalMageNestedVal, - $targetMageNestedVal, - $userNestedVal - ); - if ($value === null || $value === []) { - if (key_exists($key, $associativeResult)) { - $valChanged = true; - unset($associativeResult[$key]); - } - } else { - $valChanged = $valChanged || $changed; - $associativeResult[$key] = $value; - } - } - - $flatResult = array_filter($userVal, 'is_int', ARRAY_FILTER_USE_KEY); - $flatAdds = array_diff(array_diff($targetMageFlatPart, $originalMageFlatPart), $flatResult); - if ($flatAdds !== []) { - $valChanged = true; - $this->console->labeledVerbose("Adding $field entries: " . implode(', ', $flatAdds)); - $flatResult = array_unique(array_merge($flatResult, $flatAdds)); - } - - $flatRemoves = array_intersect(array_diff($originalMageFlatPart, $targetMageFlatPart), $flatResult); - if ($flatRemoves !== []) { - $valChanged = true; - $this->console->labeledVerbose("Removing $field entries: " . implode(', ', $flatRemoves)); - $flatResult = array_diff($flatResult, $flatRemoves); - } + $flatResult = $this->resolveFlatArray( + $field, + $origMageVal, + $targetMageVal, + $userVal, + $changed + ); - $result = array_merge($flatResult, $associativeResult); + $result = array_merge($flatResult, $assocResult); } else { // Some or all of the values aren't arrays so they should all be compared as non-array values - $action = $this->findResolution($field, $originalMageVal, $targetMageVal, $userVal); + $action = $this->findResolution($field, $origMageVal, $targetMageVal, $userVal); $prettyTargetMageVal = json_encode($targetMageVal, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($action == static::ADD_VAL) { - $valChanged = true; + $changed = true; $this->console->labeledVerbose("Adding $field entry: $prettyTargetMageVal"); $result = $targetMageVal; } elseif ($action == static::CHANGE_VAL) { - $valChanged = true; + $changed = true; $this->console->labeledVerbose("Updating $field entry: $prettyTargetMageVal"); $result = $targetMageVal; } elseif ($action == static::REMOVE_VAL) { - $valChanged = true; + $changed = true; $this->console->labeledVerbose("Removing $field entry"); $result = null; } } - return [$valChanged, $result]; + return $result; } /** - * Helper function to find the resolution action for a package constraint in the Link sections + * Process changes to the non-associative portion of an array * - * @param string $section - * @param string $pkg - * @param Link[] $originalLinkMap - * @param Link[] $targetLinkMap - * @param Link[] $userLinkMap - * @return string|null ADD_VAL|REMOVE_VAL|CHANGE_VAL to adjust the link constraint, null for no change + * @param string $field + * @param array $origMageArray + * @param array $targetMageArray + * @param array $userArray + * @param bool $changed + * @return array */ - protected function findLinkResolution($section, $pkg, $originalLinkMap, $targetLinkMap, $userLinkMap) + protected function resolveFlatArray($field, $origMageArray, $targetMageArray, $userArray, &$changed) { - $field = "$section:$pkg"; - $originalConstraint = key_exists($pkg, $originalLinkMap) ? $originalLinkMap[$pkg]->getConstraint() : null; - $originalMageVal = ($originalConstraint === null) ? null : $originalConstraint->__toString(); - $prettyOriginalMageVal = ($originalConstraint === null) ? null : $originalConstraint->getPrettyString(); - $targetConstraint = key_exists($pkg, $targetLinkMap) ? $targetLinkMap[$pkg]->getConstraint() : null; - $targetMageVal = ($targetConstraint === null) ? null : $targetConstraint->__toString(); - $prettyTargetMageVal = ($targetConstraint === null) ? null : $targetConstraint->getPrettyString(); - $userConstraint = key_exists($pkg, $userLinkMap) ? $userLinkMap[$pkg]->getConstraint() : null; - $userVal = ($userConstraint === null) ? null : $userConstraint->__toString(); - $prettyUserVal = ($userConstraint === null) ? null : $userConstraint->getPrettyString(); - - return $this->findResolution( - $field, - $originalMageVal, - $targetMageVal, - $userVal, - $prettyOriginalMageVal, - $prettyTargetMageVal, - $prettyUserVal + $origMageFlatPart = array_filter($origMageArray, 'is_int', ARRAY_FILTER_USE_KEY); + $targetMageFlatPart = array_filter($targetMageArray, 'is_int', ARRAY_FILTER_USE_KEY); + + $result = array_filter($userArray, 'is_int', ARRAY_FILTER_USE_KEY); + $adds = array_diff(array_diff($targetMageFlatPart, $origMageFlatPart), $result); + if ($adds !== []) { + $changed = true; + $this->console->labeledVerbose("Adding $field entries: " . implode(', ', $adds)); + $result = array_unique(array_merge($result, $adds)); + } + + $removes = array_intersect(array_diff($origMageFlatPart, $targetMageFlatPart), $result); + if ($removes !== []) { + $changed = true; + $this->console->labeledVerbose("Removing $field entries: " . implode(', ', $removes)); + $result = array_diff($result, $removes); + } + + return $result; + } + + /** + * Process changes to the associative portion of an array that could be nested + * + * @param string $field + * @param array $origMageArray + * @param array $targetMageArray + * @param array $userArray + * @param bool $changed + * @return array + */ + protected function resolveAssociativeArray($field, $origMageArray, $targetMageArray, $userArray, &$changed) + { + $origMageAssocPart = array_filter($origMageArray, 'is_string', ARRAY_FILTER_USE_KEY); + $targetMageAssocPart = array_filter($targetMageArray, 'is_string', ARRAY_FILTER_USE_KEY); + $userAssocPart = array_filter($userArray, 'is_string', ARRAY_FILTER_USE_KEY); + + $result = $userAssocPart; + $mageKeys = array_unique( + array_merge(array_keys($origMageAssocPart), array_keys($targetMageAssocPart)) ); + foreach ($mageKeys as $key) { + if (key_exists($key, $origMageAssocPart)) { + $origMageNestedVal = $origMageAssocPart[$key]; + } else { + $origMageNestedVal = []; + } + if (key_exists($key, $targetMageAssocPart)) { + $targetMageNestedVal = $targetMageAssocPart[$key]; + } else { + $targetMageNestedVal = []; + } + if (key_exists($key, $userAssocPart)) { + $userNestedVal = $userAssocPart[$key]; + } else { + $userNestedVal = []; + } + + $value = $this->resolveNestedArray( + "$field.$key", + $origMageNestedVal, + $targetMageNestedVal, + $userNestedVal, + $changed + ); + if ($value === null || $value === []) { + if (key_exists($key, $result)) { + $changed = true; + unset($result[$key]); + } + } else { + $result[$key] = $value; + } + } + + return $result; } /** * Get the order to use for a link section if local and target versions disagree * * @param string $section - * @param string[] $originalMageOrder + * @param string[] $origMageOrder * @param string[] $targetMageOrder * @param string[] $userOrder + * @param bool $changed * @return string[] */ - protected function getLinkOrderOverride($section, $originalMageOrder, $targetMageOrder, $userOrder) + protected function getLinkOrderOverride($section, $origMageOrder, $targetMageOrder, $userOrder, &$changed) { $overrideOrder = []; @@ -499,14 +618,14 @@ protected function getLinkOrderOverride($section, $originalMageOrder, $targetMag // Check if the user's link order does not match the target section for links that appear in both if ($conflictTargetOrder != $conflictUserOrder) { - $conflictOriginalOrder = array_values(array_intersect($originalMageOrder, $targetMageOrder)); + $conflictOrigOrder = array_values(array_intersect($origMageOrder, $targetMageOrder)); // Check if the user's order is different than the target order because the order has changed between // the original and target Magento versions - if ($conflictOriginalOrder !== $conflictUserOrder) { + if ($conflictOrigOrder !== $conflictUserOrder) { $targetLabel = $this->retriever->getTargetLabel(); $userOrderDesc = " [\n " . implode(",\n ", $conflictUserOrder) . "\n ]"; - $targetOrderDesc = " [\n " . implode(",\n ", $conflictTargetOrder) . "\n ]"; + $targetOrderDesc = " [\n " . implode(",\n ", $conflictTargetOrder) . "\n ]"; $conflictDesc = "$targetLabel is trying to change the existing order of the $section section.\n" . "Local order:\n$userOrderDesc\n$targetLabel order:\n$targetOrderDesc"; $shouldOverride = $this->overrideUserValues; @@ -534,6 +653,12 @@ protected function getLinkOrderOverride($section, $originalMageOrder, $targetMag } } + if ($overrideOrder !== []) { + $changed = true; + $prettyOrder = " [\n " . implode(",\n ", $overrideOrder) . "\n ]"; + $this->console->labeledVerbose("Updating $section order:\n$prettyOrder"); + } + return $overrideOrder; } diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php index 9ab8f47..0a60d48 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/MagentoRootUpdater.php @@ -27,6 +27,11 @@ class MagentoRootUpdater */ protected $composer; + /** + * @var PackageUtils $pkgUtils; + */ + protected $pkgUtils; + /** * @var array $jsonChanges Json-writable sections of composer.json that have been updated */ @@ -43,6 +48,7 @@ public function __construct($console, $composer) { $this->console = $console; $this->composer = $composer; + $this->pkgUtils = new PackageUtils($console, $composer); $this->jsonChanges = []; } @@ -65,21 +71,21 @@ public function runUpdate( ) { $composer = $this->composer; - if (!PackageUtils::findRequire($composer, PluginDefinition::PACKAGE_NAME)) { + if (!$this->pkgUtils->findRequire($composer, PluginDefinition::PACKAGE_NAME)) { // If the plugin requirement has been removed but we're still trying to run (code still existing in the // vendor directory), return without executing. return false; } - $originalEdition = $retriever->getOriginalEdition(); - $originalVersion = $retriever->getOriginalVersion(); - $prettyOriginalVersion = $retriever->getPrettyOriginalVersion(); + $origEdition = $retriever->getOriginalEdition(); + $origVersion = $retriever->getOriginalVersion(); + $prettyOrigVersion = $retriever->getPrettyOriginalVersion(); if (!$retriever->getTargetRootPackage($ignorePlatformReqs, $phpVersion, $stability)) { throw new \RuntimeException('Magento root updates cannot run without a valid target package'); } - if ($originalEdition == $retriever->getTargetEdition() && $originalVersion == $retriever->getTargetVersion()) { + if ($origEdition == $retriever->getTargetEdition() && $origVersion == $retriever->getTargetVersion()) { $this->console->labeledVerbose( 'The Magento product requirement matched the current installation; no root updates are required' ); @@ -93,7 +99,7 @@ public function runUpdate( $this->console->setVerboseLabel($retriever->getTargetLabel()); $this->console->labeledVerbose( - "Base Magento project package version: magento/project-$originalEdition-edition $prettyOriginalVersion" + "Base Magento project package version: magento/project-$origEdition-edition $prettyOrigVersion" ); $resolver = new DeltaResolver($this->console, $overrideOption, $retriever); diff --git a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php index d5ef2f2..c69f6bf 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php +++ b/src/Magento/ComposerRootUpdatePlugin/Updater/RootPackageRetriever.php @@ -9,9 +9,7 @@ use Composer\Composer; use Composer\DependencyResolver\Pool; use Composer\IO\IOInterface; -use Composer\Json\JsonFile; use Composer\Package\BasePackage; -use Composer\Package\Locker; use Composer\Package\PackageInterface; use Composer\Package\RootPackageInterface; use Composer\Package\Version\VersionParser; @@ -42,14 +40,19 @@ class RootPackageRetriever protected $composer; /** - * @var PackageInterface $originalRootPackage + * @var PackageUtils $pkgUtils */ - protected $originalRootPackage; + protected $pkgUtils; /** - * @var bool $fetchedOriginal + * @var PackageInterface $origRootPackage */ - protected $fetchedOriginal; + protected $origRootPackage; + + /** + * @var bool $fetchedOrig + */ + protected $fetchedOrig; /** * @var PackageInterface $targetRootPackage @@ -62,39 +65,39 @@ class RootPackageRetriever protected $fetchedTarget; /** - * @var string $originalEdition + * @var string $origEdition */ - protected $originalEdition = null; + protected $origEdition; /** - * @var string $originalVersion + * @var string $origVersion */ - protected $originalVersion = null; + protected $origVersion; /** - * @var string $prettyOriginalVersion + * @var string $prettyOrigVersion */ - protected $prettyOriginalVersion = null; + protected $prettyOrigVersion; /** * @var string $targetEdition */ - protected $targetEdition = null; + protected $targetEdition; /** * @var string $targetConstraint */ - protected $targetConstraint = null; + protected $targetConstraint; /** * @var string $targetVersion */ - protected $targetVersion = null; + protected $targetVersion; /** * @var string $prettyTargetVersion */ - protected $prettyTargetVersion = null; + protected $prettyTargetVersion; /** * RootPackageRetriever constructor. @@ -103,32 +106,35 @@ class RootPackageRetriever * @param Composer $composer * @param string $targetEdition * @param string $targetConstraint - * @param string $overrideOriginalEdition - * @param string $overrideOriginalVersion + * @param string $overrideOrigEdition + * @param string $overrideOrigVersion */ public function __construct( $console, $composer, $targetEdition, $targetConstraint, - $overrideOriginalEdition = null, - $overrideOriginalVersion = null + $overrideOrigEdition = null, + $overrideOrigVersion = null ) { $this->console = $console; $this->composer = $composer; + $this->pkgUtils = new PackageUtils($console, $composer); - $this->originalRootPackage = null; - $this->fetchedOriginal = false; + $this->origRootPackage = null; + $this->fetchedOrig = false; $this->targetEdition = $targetEdition; $this->targetConstraint = $targetConstraint; $this->targetRootPackage = null; $this->fetchedTarget = null; - if (!$overrideOriginalEdition || !$overrideOriginalVersion) { - $this->parseOriginalVersionAndEditionFromLock($overrideOriginalEdition, $overrideOriginalVersion); + $this->targetVersion = null; + $this->prettyTargetVersion = null; + if (!$overrideOrigEdition || !$overrideOrigVersion) { + $this->parseVersionAndEditionFromLock($overrideOrigEdition, $overrideOrigVersion); } else { - $this->originalEdition = $overrideOriginalEdition; - $this->originalVersion = $overrideOriginalVersion; - $this->prettyOriginalVersion = $overrideOriginalVersion; + $this->origEdition = $overrideOrigEdition; + $this->origVersion = $overrideOrigVersion; + $this->prettyOrigVersion = $overrideOrigVersion; } } @@ -140,43 +146,42 @@ public function __construct( */ public function getOriginalRootPackage($overrideOption) { - if ($this->fetchedOriginal) { - return $this->originalRootPackage; - } + if (!$this->fetchedOrig) { + $originalRootPackage = null; + $originalEdition = $this->origEdition; + $originalVersion = $this->origVersion; + $prettyOrigVersion = $this->prettyOrigVersion; + if ($originalEdition && $originalVersion) { + $originalRootPackage = $this->fetchMageRootFromRepo($originalEdition, $prettyOrigVersion); + } - $originalRootPackage = null; - $originalEdition = $this->originalEdition; - $originalVersion = $this->originalVersion; - $prettyOriginalVersion = $this->prettyOriginalVersion; - if ($originalEdition && $originalVersion) { - $originalRootPackage = $this->fetchMageRootFromRepo($originalEdition, $prettyOriginalVersion); - } + if (!$originalRootPackage) { + if (!$originalEdition || !$originalVersion) { + $this->console->warning('No Magento product package was found in the current installation.'); + } else { + $this->console->warning('The Magento project package corresponding to the currently installed ' . + "\"magento/product-$originalEdition-edition: $prettyOrigVersion\" package is unavailable."); + } - if (!$originalRootPackage) { - if (!$originalEdition || !$originalVersion) { - $this->console->warning('No Magento product package was found in the current installation.'); - } else { - $this->console->warning('The Magento project package corresponding to the currently installed ' . - "\"magento/product-$originalEdition-edition: $prettyOriginalVersion\" package is unavailable."); - } + $overrideRoot = $overrideOption; + if (!$overrideRoot) { + $question = 'Would you like to update the root composer.json file anyway? ' . + 'This will override any changes you have made to the default composer.json file.'; + $overrideRoot = $this->console->ask($question); + } - $overrideRoot = $overrideOption; - if (!$overrideRoot) { - $question = 'Would you like to update the root composer.json file anyway? ' . - 'This will override any changes you have made to the default composer.json file.'; - $overrideRoot = $this->console->ask($question); + if ($overrideRoot) { + $originalRootPackage = $this->getUserRootPackage(); + } else { + $originalRootPackage = null; + } } - if ($overrideRoot) { - $originalRootPackage = $this->getUserRootPackage(); - } else { - $originalRootPackage = null; - } + $this->origRootPackage = $originalRootPackage; + $this->fetchedOrig = true; } - $this->originalRootPackage = $originalRootPackage; - $this->fetchedOriginal = true; - return $this->originalRootPackage; + return $this->origRootPackage; } /** @@ -192,27 +197,26 @@ public function getTargetRootPackage( $phpVersion = null, $preferredStability = 'stable' ) { - if ($this->fetchedTarget) { - return $this->targetRootPackage; - } - - $targetRoot = $this->fetchMageRootFromRepo( - $this->targetEdition, - $this->targetConstraint, - $ignorePlatformReqs, - $phpVersion, - $preferredStability - ); - if ($targetRoot) { - $this->targetVersion = $targetRoot->getVersion(); - $this->prettyTargetVersion = $targetRoot->getPrettyVersion(); - if (!$this->prettyTargetVersion) { - $this->prettyTargetVersion = $this->targetVersion; + if (!$this->fetchedTarget) { + $targetRoot = $this->fetchMageRootFromRepo( + $this->targetEdition, + $this->targetConstraint, + $ignorePlatformReqs, + $phpVersion, + $preferredStability + ); + if ($targetRoot) { + $this->targetVersion = $targetRoot->getVersion(); + $this->prettyTargetVersion = $targetRoot->getPrettyVersion(); + if (!$this->prettyTargetVersion) { + $this->prettyTargetVersion = $this->targetVersion; + } } + + $this->targetRootPackage = $targetRoot; + $this->fetchedTarget = true; } - $this->targetRootPackage = $targetRoot; - $this->fetchedTarget = true; return $this->targetRootPackage; } @@ -243,29 +247,28 @@ protected function fetchMageRootFromRepo( $phpVersion = null, $preferredStability = 'stable' ) { - $composer = $this->composer; $packageName = strtolower("magento/project-$edition-edition"); - $versionParser = new VersionParser(); - $parsedConstraint = $versionParser->parseConstraints($constraint); + $parsedConstraint = (new VersionParser())->parseConstraints($constraint); - $minStability = $composer->getPackage()->getMinimumStability(); + $minStability = $this->composer->getPackage()->getMinimumStability(); if (!$minStability) { $minStability = 'stable'; } - $stabilityFlags = AccessibleRootPackageLoader::extractStabilityFlags($packageName, $constraint, $minStability); + $rootPackageLoader = new AccessibleRootPackageLoader(); + $stabilityFlags = $rootPackageLoader->extractStabilityFlags($packageName, $constraint, $minStability); $stability = key_exists($packageName, $stabilityFlags) ? array_search($stabilityFlags[$packageName], BasePackage::$stabilities) : $minStability; $this->console->comment("Minimum stability for \"$packageName: $constraint\": $stability", IOInterface::DEBUG); + $pool = new Pool( $stability, $stabilityFlags, [$packageName => $parsedConstraint] ); - $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); - $pool->addRepository($repos); + $pool->addRepository(new CompositeRepository($this->composer->getRepositoryManager()->getRepositories())); - if (!PackageUtils::isConstraintStrict($constraint)) { + if (!$this->pkgUtils->isConstraintStrict($constraint)) { $this->console->warning( "The version constraint \"magento/product-$edition-edition: $constraint\" is not exact; " . 'the Magento root updater might not accurately determine the version to use according to other ' . @@ -275,8 +278,12 @@ protected function fetchMageRootFromRepo( $phpVersion = $ignorePlatformReqs ? null : $phpVersion; - $versionSelector = new VersionSelector($pool); - $result = $versionSelector->findBestCandidate($packageName, $constraint, $phpVersion, $preferredStability); + $result = (new VersionSelector($pool))->findBestCandidate( + $packageName, + $constraint, + $phpVersion, + $preferredStability + ); if (!$result) { $err = "Could not find a Magento project package matching \"magento/product-$edition-edition $constraint\""; @@ -290,82 +297,35 @@ protected function fetchMageRootFromRepo( } /** - * Gets the Magento product package in composer.lock and populates the version and edition in CommonUtils + * Gets the original Magento product edition and version from the package in composer.lock * * @param string $overrideEdition * @param string $overrideVersion * @return void */ - protected function parseOriginalVersionAndEditionFromLock($overrideEdition = null, $overrideVersion = null) + protected function parseVersionAndEditionFromLock($overrideEdition = null, $overrideVersion = null) { - $locker = $this->getRootLocker(); - if (!$locker || !$locker->isLocked()) { - $this->console->labeledVerbose( - 'No composer.lock file was found in the root project to check for the installed Magento version' - ); - return; - } - - $lockPackages = $locker->getLockedRepository()->getPackages(); - $lockedMageProduct = null; - foreach ($lockPackages as $lockedPackage) { - $pkgEdition = PackageUtils::getMagentoProductEdition($lockedPackage->getName()); - if ($pkgEdition) { - $lockedMageProduct = $lockedPackage; - - // Both editions exist for commerce, so stop at commerce to not overwrite with open source - if ($pkgEdition == PackageUtils::COMMERCE_PKG_EDITION) { - break; - } - } - } - + $lockedMageProduct = $this->pkgUtils->getLockedProduct(); if ($lockedMageProduct) { if ($overrideEdition) { - $this->originalEdition = $overrideEdition; + $this->origEdition = $overrideEdition; } else { - $this->originalEdition = PackageUtils::getMagentoProductEdition($lockedMageProduct->getName()); + $this->origEdition = $this->pkgUtils->getMagentoProductEdition($lockedMageProduct->getName()); } if ($overrideVersion) { - $this->originalVersion = $overrideVersion; - $this->prettyOriginalVersion = $this->originalVersion; + $this->origVersion = $overrideVersion; + $this->prettyOrigVersion = $overrideVersion; } else { - $this->originalVersion = $lockedMageProduct->getVersion(); - $this->prettyOriginalVersion = $lockedMageProduct->getPrettyVersion(); - if (!$this->prettyOriginalVersion) { - $this->prettyOriginalVersion = $this->originalVersion; + $this->origVersion = $lockedMageProduct->getVersion(); + $this->prettyOrigVersion = $lockedMageProduct->getPrettyVersion(); + if (!$this->prettyOrigVersion) { + $this->prettyOrigVersion = $this->origVersion; } } } } - /** - * Get the Locker for the root, using the parent if currently in var - * - * @return Locker - */ - protected function getRootLocker() - { - $composer = $this->composer; - - $composerPath = $composer->getConfig()->getConfigSource()->getName(); - $locker = null; - if (preg_match('/\/var\/composer\.json$/', $composerPath)) { - $parentDir = preg_replace('/\/var\/composer\.json$/', '', $composerPath); - if (file_exists("$parentDir/composer.json") && file_exists("$parentDir/composer.lock")) { - $locker = new Locker( - $this->console->getIO(), - new JsonFile("$parentDir/composer.lock"), - $composer->getRepositoryManager(), - $composer->getInstallationManager(), - file_get_contents("$parentDir/composer.json") - ); - } - } - return $locker !== null ? $locker : $composer->getLocker(); - } - /** * Get the pretty label for the target Magento installation version * @@ -373,7 +333,7 @@ protected function getRootLocker() */ public function getTargetLabel() { - $editionLabel = $this->getEditionLabel($this->targetEdition); + $editionLabel = $this->pkgUtils->getEditionLabel($this->targetEdition); if ($editionLabel && $this->prettyTargetVersion) { return "Magento $editionLabel " . $this->prettyTargetVersion; } elseif ($editionLabel && $this->targetConstraint) { @@ -389,35 +349,19 @@ public function getTargetLabel() */ public function getOriginalLabel() { - $editionLabel = $this->getEditionLabel($this->originalEdition); - if ($editionLabel && $this->prettyOriginalVersion) { - return "Magento $editionLabel " . $this->prettyOriginalVersion; + $editionLabel = $this->pkgUtils->getEditionLabel($this->origEdition); + if ($editionLabel && $this->prettyOrigVersion) { + return "Magento $editionLabel " . $this->prettyOrigVersion; } return static::MISSING_ROOT_LABEL; } - /** - * Helper function to turn a package edition into the appropriate label - * - * @param string $packageEdition - * @return string|null - */ - private function getEditionLabel($packageEdition) - { - if ($packageEdition == PackageUtils::OPEN_SOURCE_PKG_EDITION) { - return 'Open Source'; - } elseif ($packageEdition == PackageUtils::COMMERCE_PKG_EDITION) { - return 'Commerce'; - } - return null; - } - /** * @return string */ public function getOriginalEdition() { - return $this->originalEdition; + return $this->origEdition; } /** @@ -425,7 +369,7 @@ public function getOriginalEdition() */ public function getOriginalVersion() { - return $this->originalVersion; + return $this->origVersion; } /** @@ -433,7 +377,7 @@ public function getOriginalVersion() */ public function getPrettyOriginalVersion() { - return $this->prettyOriginalVersion; + return $this->prettyOrigVersion; } /** diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php index d8f0fe8..97190b0 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/Console.php @@ -122,7 +122,7 @@ public function log($message, $verbosity = Console::NORMAL, $format = null) { if ($format) { $formatClose = str_replace('<', 'getIO()->writeError($message, true, $verbosity); } @@ -182,7 +182,7 @@ public function labeledVerbose( ) { if ($format) { $formatClose = str_replace('<', 'verboseLabel; diff --git a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php index d5e2100..0b98e47 100644 --- a/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php +++ b/src/Magento/ComposerRootUpdatePlugin/Utils/PackageUtils.php @@ -7,7 +7,10 @@ namespace Magento\ComposerRootUpdatePlugin\Utils; use Composer\Composer; +use Composer\Json\JsonFile; use Composer\Package\Link; +use Composer\Package\Locker; +use Composer\Package\PackageInterface; use Composer\Package\Version\VersionParser; /** @@ -18,13 +21,29 @@ class PackageUtils const OPEN_SOURCE_PKG_EDITION = 'community'; const COMMERCE_PKG_EDITION = 'enterprise'; + /** + * @var Console $console + */ + protected $console; + + /** + * @var Composer $composer + */ + protected $composer; + + public function __construct($console, $composer = null) + { + $this->console = $console; + $this->composer = $composer; + } + /** * Helper function to extract the package type from a Magento product or project package name * * @param string $packageName * @return string|null 'product' or 'project' as applicable, null if not matching */ - static public function getMagentoPackageType($packageName) + public function getMagentoPackageType($packageName) { $regex = '/^magento\/(?product|project)-(' . static::OPEN_SOURCE_PKG_EDITION . '|' . static::COMMERCE_PKG_EDITION . ')-edition$/'; @@ -41,7 +60,7 @@ static public function getMagentoPackageType($packageName) * @param string $packageName * @return string|null OPEN_SOURCE_PKG_EDITION or COMMERCE_PKG_EDITION as applicable, null if not matching */ - static public function getMagentoProductEdition($packageName) + public function getMagentoProductEdition($packageName) { $regex = '/^magento\/product-(?' . static::OPEN_SOURCE_PKG_EDITION . '|' . static::COMMERCE_PKG_EDITION . ')-edition$/'; @@ -52,6 +71,22 @@ static public function getMagentoProductEdition($packageName) } } + /** + * Helper function to turn a package edition into the appropriate marketing edition label + * + * @param string $packageEdition + * @return string|null + */ + public function getEditionLabel($packageEdition) + { + if ($packageEdition == static::OPEN_SOURCE_PKG_EDITION) { + return 'Open Source'; + } elseif ($packageEdition == static::COMMERCE_PKG_EDITION) { + return 'Commerce'; + } + return null; + } + /** * Returns the Link from the Composer require section matching the given package name or regex * @@ -59,7 +94,7 @@ static public function getMagentoProductEdition($packageName) * @param string $packageMatcher * @return Link|bool */ - static public function findRequire($composer, $packageMatcher) + public function findRequire($composer, $packageMatcher) { /** @var Link[] $requires */ $requires = array_values($composer->getPackage()->getRequires()); @@ -86,10 +121,71 @@ static public function findRequire($composer, $packageMatcher) * @param string $constraint * @return bool */ - static public function isConstraintStrict($constraint) + public function isConstraintStrict($constraint) { $versionParser = new VersionParser(); $parsedConstraint = $versionParser->parseConstraints($constraint); return strpbrk($parsedConstraint->__toString(), '[]|<>!') === false; } + + /** + * Checks the composer.lock for the installed Magento product package + * + * @return PackageInterface|null + */ + public function getLockedProduct() + { + $locker = $this->getRootLocker(); + $lockedMageProduct = null; + + if ($locker) { + $lockPackages = $locker->getLockedRepository()->getPackages(); + $lockedMageProduct = null; + foreach ($lockPackages as $lockedPackage) { + $pkgEdition = $this->getMagentoProductEdition($lockedPackage->getName()); + if ($pkgEdition) { + $lockedMageProduct = $lockedPackage; + + // Both editions exist for commerce, so stop at commerce to not overwrite with open source + if ($pkgEdition == static::COMMERCE_PKG_EDITION) { + break; + } + } + } + } + + return $lockedMageProduct; + } + + /** + * Get the Locker for the root, using the parent if currently in var + * + * @return Locker + */ + protected function getRootLocker() + { + $composer = $this->composer; + $composerPath = $composer->getConfig()->getConfigSource()->getName(); + $locker = null; + if (preg_match('/\/var\/composer\.json$/', $composerPath)) { + $parentDir = preg_replace('/\/var\/composer\.json$/', '', $composerPath); + if (file_exists("$parentDir/composer.json") && file_exists("$parentDir/composer.lock")) { + $locker = new Locker( + $this->console->getIO(), + new JsonFile("$parentDir/composer.lock"), + $composer->getRepositoryManager(), + $composer->getInstallationManager(), + file_get_contents("$parentDir/composer.json") + ); + } + } + $locker = $locker !== null ? $locker : $composer->getLocker(); + if (!$locker || !$locker->isLocked()) { + $this->console->labeledVerbose( + 'No composer.lock file was found in the root project to check for the installed Magento version' + ); + $locker = null; + } + return $locker; + } } diff --git a/src/Magento/ComposerRootUpdatePlugin/composer.json b/src/Magento/ComposerRootUpdatePlugin/composer.json index 7f3e08a..36e4837 100644 --- a/src/Magento/ComposerRootUpdatePlugin/composer.json +++ b/src/Magento/ComposerRootUpdatePlugin/composer.json @@ -1,7 +1,7 @@ { "name": "magento/composer-root-update-plugin", "description": "Plugin to look ahead for Magento project root changes when running composer update for new Magento versions", - "version": "1.0.0-beta1", + "version": "0.1.0", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/src/Magento/ComposerRootUpdatePlugin/etc/module.xml b/src/Magento/ComposerRootUpdatePlugin/etc/module.xml index d93a4b7..705752c 100644 --- a/src/Magento/ComposerRootUpdatePlugin/etc/module.xml +++ b/src/Magento/ComposerRootUpdatePlugin/etc/module.xml @@ -6,6 +6,6 @@ */ --> - + diff --git a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php index 2868e85..299044f 100644 --- a/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php +++ b/tests/Unit/Magento/ComposerRootUpdatePlugin/Updater/DeltaResolverTest.php @@ -136,25 +136,31 @@ public function testFindResolutionNonInteractiveEnvironmentError() public function testResolveNestedArrayNonArrayAdd() { $resolver = new DeltaResolver($this->console, false, $this->retriever); - $result = $resolver->resolveNestedArray('field', null, 'newVal', null); + $changed = false; + $result = $resolver->resolveNestedArray('field', null, 'newVal', null, $changed); - $this->assertEquals([true, 'newVal'], $result); + $this->assertTrue($changed); + $this->assertEquals('newVal', $result); } public function testResolveNestedArrayNonArrayRemove() { $resolver = new DeltaResolver($this->console, false, $this->retriever); - $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal'); + $changed = false; + $result = $resolver->resolveNestedArray('field', 'oldVal', null, 'oldVal', $changed); - $this->assertEquals([true, null], $result); + $this->assertTrue($changed); + $this->assertEquals(null, $result); } public function testResolveNestedArrayNonArrayChange() { $resolver = new DeltaResolver($this->console, false, $this->retriever); - $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal'); + $changed = false; + $result = $resolver->resolveNestedArray('field', 'oldVal', 'newVal', 'oldVal', $changed); - $this->assertEquals([true, 'newVal'], $result); + $this->assertTrue($changed); + $this->assertEquals('newVal', $result); } public function testResolveArrayMismatchedArray()