Add an unpack command to allow ejection of code from recipes.

This commit is contained in:
Stevie Mayhew 2019-07-30 09:42:10 +12:00
parent 88cd7ed3a0
commit 54857a20a3
10 changed files with 316 additions and 8 deletions

View File

@ -15,6 +15,7 @@ These recipes allow for the following features:
- A `require-recipe` command to inline a recipe into the root composer.json, allowing the developer to customise the - A `require-recipe` command to inline a recipe into the root composer.json, allowing the developer to customise the
recipe dependencies without mandating the inclusion of all requirements directly. recipe dependencies without mandating the inclusion of all requirements directly.
- An `update-recipe` command to upgrade to a newer version of a recipe. - An `update-recipe` command to upgrade to a newer version of a recipe.
- An `unpack` command to eject the recipe from the current project.
## Example output ## Example output
@ -182,3 +183,19 @@ public/
blog.css blog.css
composer.json composer.json
``` ```
## Unpacking a recipe
Sometimes you need to remove the recipe configuration for more control over your dependency set. In these situations
you can unpack a SilverStripe recipe.
To unpack a recipe you can run the following command
```shell
composer unpack silverstripe/recipe-core
```
This commands moves the composer requirements out of the recipe and puts them into the root `composer.json`. This allows
the ability to easily modify recipe requirements when you need to.
###Credit
Parts of the unpack system were influenced by, or taken completely, from the work of Fabien Potencier on
[Symfony Flex](https://github.com/symfony/flex/blob/master/LICENSE).

View File

@ -22,7 +22,8 @@
"lint-clean": "phpcbf src/" "lint-clean": "phpcbf src/"
}, },
"require": { "require": {
"composer-plugin-api": "^1.1" "composer-plugin-api": "^1.1",
"ext-json": "*"
}, },
"require-dev": { "require-dev": {
"composer/composer": "^1.2" "composer/composer": "^1.2"

View File

@ -1,6 +1,6 @@
<?php <?php
namespace SilverStripe\RecipePlugin; namespace SilverStripe\RecipePlugin\Command;
use Composer\Command\RequireCommand; use Composer\Command\RequireCommand;
use Composer\Command\UpdateCommand; use Composer\Command\UpdateCommand;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace SilverStripe\RecipePlugin; namespace SilverStripe\RecipePlugin\Command;
use Composer\Command\BaseCommand; use Composer\Command\BaseCommand;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
* Provides the 'require-recipe' command which allows a new recipe to be installed, but also * Provides the 'require-recipe' command which allows a new recipe to be installed, but also
* soft-updates any existing recipe. * soft-updates any existing recipe.
*/ */
class RequireRecipeCommand extends BaseCommand class RequireRecipe extends BaseCommand
{ {
use RecipeCommandBehaviour; use RecipeCommandBehaviour;

View File

@ -0,0 +1,126 @@
<?php
namespace SilverStripe\RecipePlugin\Command;
use Composer\Command\BaseCommand;
use Composer\Config\JsonConfigSource;
use Composer\Factory;
use Composer\Installer;
use Composer\Json\JsonFile;
use Composer\Package\Locker;
use Composer\Package\Version\VersionParser;
use SilverStripe\RecipePlugin\PackageResolver;
use SilverStripe\RecipePlugin\Unpack\Unpacker;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @author Fabien Potencier <fabien@symfony.com> - See README.md for details
*/
class UnpackCommand extends BaseCommand
{
protected function configure()
{
$this->setName('silverstripe:unpack')
->setAliases(['unpack'])
->setDescription('Unpacks a SilverStripe recipe.')
->addArgument(
'packages',
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
'Installed packages to unpack.'
)
->addUsage('silverstripe/recipe-core')
->setHelp(
<<<HELP
This command unpacks a SilverStripe recipe and removes it from your requirements. Useful when you want to eject
from a recipe for more control over version constraints.
HELP
);
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$composer = $this->getComposer();
$io = $this->getIO();
$resolver = new PackageResolver();
$packages = $resolver->resolve($input->getArgument('packages'), true);
$json = new JsonFile(Factory::getComposerFile());
$manipulator = new JsonConfigSource($json);
$locker = $composer->getLocker();
$lockData = $locker->getLockData();
$installedRepo = $composer->getRepositoryManager()->getLocalRepository();
$versionParser = new VersionParser();
$data = [];
foreach ($versionParser->parseNameVersionPairs($packages) as $package) {
if (null === $pkg = $installedRepo->findPackage($package['name'], '*')) {
$io->writeError(sprintf('<error>Package %s is not installed</error>', $package['name']));
return 1;
}
$dev = false;
foreach ($lockData['packages-dev'] as $p) {
if ($package['name'] === $p['name']) {
$dev = true;
break;
}
}
$data[] = [
'pkg' => $pkg,
'dev' => $dev,
];
}
$unpacker = new Unpacker($composer, $resolver);
$result = $unpacker->unpack($data);
// remove the packages themselves
if (!$result->getUnpacked()) {
$io->writeError('<info>Nothing to unpack</info>');
return;
}
foreach ($result->getUnpacked() as $pkg) {
$io->writeError(sprintf('<info>Unpacked %s dependencies</info>', $pkg->getName()));
}
foreach ($result->getUnpacked() as $package) {
$manipulator->removeLink('require-dev', $package->getName());
foreach ($lockData['packages-dev'] as $i => $pkg) {
if ($package->getName() === $pkg['name']) {
unset($lockData['packages-dev'][$i]);
}
}
$manipulator->removeLink('require', $package->getName());
foreach ($lockData['packages'] as $i => $pkg) {
if ($package->getName() === $pkg['name']) {
unset($lockData['packages'][$i]);
}
}
}
$lockData['packages'] = array_values($lockData['packages']);
$lockData['packages-dev'] = array_values($lockData['packages-dev']);
$lockData['content-hash'] = $locker->getContentHash(file_get_contents($json->getPath()));
$lockFile = new JsonFile(substr($json->getPath(), 0, -4) . 'lock', null, $io);
$lockFile->write($lockData);
// force removal of files under vendor/
$locker = new Locker($io, $lockFile, $composer->getRepositoryManager(), $composer->getInstallationManager(), file_get_contents($json->getPath()));
$composer->setLocker($locker);
$install = Installer::create($io, $composer);
$install
->setDevMode(true)
->setDumpAutoloader(false)
->setRunScripts(false)
->setSkipSuggest(true)
->setIgnorePlatformRequirements(true);
return $install->run();
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace SilverStripe\RecipePlugin; namespace SilverStripe\RecipePlugin\Command;
use BadMethodCallException; use BadMethodCallException;
use Composer\Command\BaseCommand; use Composer\Command\BaseCommand;
@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class UpdateRecipeCommand extends BaseCommand class UpdateRecipe extends BaseCommand
{ {
use RecipeCommandBehaviour; use RecipeCommandBehaviour;

56
src/PackageResolver.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace SilverStripe\RecipePlugin;
use Composer\Package\Version\VersionParser;
use Composer\Repository\PlatformRepository;
/**
* @author Fabien Potencier <fabien@symfony.com> - See README.md for details
*/
class PackageResolver
{
private static $aliases = [
'core' => 'silverstripe/recipe-core'
];
public function resolve(array $arguments = []): array
{
$versionParser = new VersionParser();
// first pass split on : and = to separate package names and versions
$explodedArguments = [];
foreach ($arguments as $argument) {
if ((false !== $pos = strpos($argument, ':')) || (false !== $pos = strpos($argument, '='))) {
$explodedArguments[] = substr($argument, 0, $pos);
$explodedArguments[] = substr($argument, $pos + 1);
} else {
$explodedArguments[] = $argument;
}
}
// second pass to resolve package names
$packages = [];
foreach ($explodedArguments as $i => $argument) {
if (false === strpos($argument, '/') && !preg_match(PlatformRepository::PLATFORM_PACKAGE_REGEX, $argument) && !\in_array($argument, ['mirrors', 'nothing'])) {
if (isset(self::$aliases[$argument])) {
$argument = self::$aliases[$argument];
} else {
$versionParser->parseConstraints($argument);
}
}
$packages[] = $argument;
}
// third pass to resolve versions
$requires = [];
foreach ($versionParser->parseNameVersionPairs($packages) as $package) {
$requires[] = $package['name'];
}
return array_unique($requires);
}
}

View File

@ -4,6 +4,9 @@ namespace SilverStripe\RecipePlugin;
use Composer\Command\BaseCommand; use Composer\Command\BaseCommand;
use Composer\Plugin\Capability\CommandProvider; use Composer\Plugin\Capability\CommandProvider;
use SilverStripe\RecipePlugin\Command\RequireRecipe;
use SilverStripe\RecipePlugin\Command\UnpackCommand;
use SilverStripe\RecipePlugin\Command\UpdateRecipe;
class RecipeCommandProvider implements CommandProvider class RecipeCommandProvider implements CommandProvider
{ {
@ -15,8 +18,9 @@ class RecipeCommandProvider implements CommandProvider
public function getCommands() public function getCommands()
{ {
return [ return [
new RequireRecipeCommand(), new RequireRecipe(),
new UpdateRecipeCommand(), new UpdateRecipe(),
new UnpackCommand(),
]; ];
} }
} }

41
src/Unpack/Result.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace SilverStripe\RecipePlugin\Unpack;
use Composer\Package\PackageInterface;
/**
* @author Fabien Potencier <fabien@symfony.com> - See README.md for details
*/
class Result
{
private $unpacked = [];
private $required = [];
public function addUnpacked(PackageInterface $package)
{
$this->unpacked[] = $package;
}
/**
* @return PackageInterface[]
*/
public function getUnpacked(): array
{
return $this->unpacked;
}
public function addRequired(string $package)
{
$this->required[] = $package;
}
/**
* @return string[]
*/
public function getRequired(): array
{
// we need at least one package for the command to work properly
return $this->required ?: ['silverstripe/recipe-plugin'];
}
}

63
src/Unpack/Unpacker.php Normal file
View File

@ -0,0 +1,63 @@
<?php
namespace SilverStripe\RecipePlugin\Unpack;
use Composer\Composer;
use Composer\Factory;
use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use SilverStripe\RecipePlugin\PackageResolver;
/**
* @author Fabien Potencier <fabien@symfony.com> - See README.md for details
*/
class Unpacker
{
private $composer;
private $resolver;
public function __construct(Composer $composer, PackageResolver $resolver)
{
$this->composer = $composer;
$this->resolver = $resolver;
}
public function unpack(array $packages): Result
{
$result = new Result();
$json = new JsonFile(Factory::getComposerFile());
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
foreach ($packages as $package) {
$dev = $package['dev'];
$package = $package['pkg'];
// not unpackable or no --unpack flag or empty packs (markers)
if (
null === $package ||
'silverstripe-recipe' !== $package->getType() ||
0 === \count($package->getRequires()) + \count($package->getDevRequires())
) {
$result->addRequired($package->getName().($package->getVersion() ? ':'.$package->getVersion() : ''));
continue;
}
$result->addUnpacked($package);
foreach ($package->getRequires() as $link) {
if ('php' === $link->getTarget()) {
continue;
}
$constraint = $link->getPrettyConstraint();
if (!$manipulator->addLink($dev ? 'require-dev' : 'require', $link->getTarget(), $constraint)) {
throw new \RuntimeException(sprintf('Unable to unpack package "%s".', $link->getTarget()));
}
}
}
file_put_contents($json->getPath(), $manipulator->getContents());
return $result;
}
}