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
recipe dependencies without mandating the inclusion of all requirements directly.
- 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
@ -182,3 +183,19 @@ public/
blog.css
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/"
},
"require": {
"composer-plugin-api": "^1.1"
"composer-plugin-api": "^1.1",
"ext-json": "*"
},
"require-dev": {
"composer/composer": "^1.2"

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace SilverStripe\RecipePlugin;
namespace SilverStripe\RecipePlugin\Command;
use Composer\Command\BaseCommand;
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
* soft-updates any existing recipe.
*/
class RequireRecipeCommand extends BaseCommand
class RequireRecipe extends BaseCommand
{
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
namespace SilverStripe\RecipePlugin;
namespace SilverStripe\RecipePlugin\Command;
use BadMethodCallException;
use Composer\Command\BaseCommand;
@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class UpdateRecipeCommand extends BaseCommand
class UpdateRecipe extends BaseCommand
{
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\Plugin\Capability\CommandProvider;
use SilverStripe\RecipePlugin\Command\RequireRecipe;
use SilverStripe\RecipePlugin\Command\UnpackCommand;
use SilverStripe\RecipePlugin\Command\UpdateRecipe;
class RecipeCommandProvider implements CommandProvider
{
@ -15,8 +18,9 @@ class RecipeCommandProvider implements CommandProvider
public function getCommands()
{
return [
new RequireRecipeCommand(),
new UpdateRecipeCommand(),
new RequireRecipe(),
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;
}
}