Merge pull request #3 from open-sausages/pulls/1.0/removable-resources

ENHANCEMENT Register installed modules and files in composer.extra
This commit is contained in:
Chris Joe 2017-07-24 15:02:38 +12:00 committed by GitHub
commit 0592e33189
4 changed files with 235 additions and 119 deletions

View File

@ -14,7 +14,7 @@ These recipes allow for the following features:
- Recipes also can be used as a base composer project. - Recipes also can be used as a base composer project.
- 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 `upgrade-recipe` command to upgrade to a newer version of a recipe. - An `update-recipe` command to upgrade to a newer version of a recipe.
## Example output ## Example output
@ -26,38 +26,80 @@ Recipes can be introduced to any existing project (even if not created on a silv
```shell ```shell
$ composer init $ composer init
$ composer require silverstripe/recipe-plugin ^0.1 $ composer require silverstripe/recipe-cms ^1.0@dev
$ composer require-recipe silverstripe/recipe-cms ^4.0@dev
```` ````
Alternatively, instead of having to install the recipe-plugin manually, you can require the recipe
directly and inline this as a subsequent command. This is necessary to make the new commands available
to the command line.
```shell
$ composer init
$ composer require silverstripe/recipe-cms ^4.0@dev
$ composer upgrade-recipe silverstripe/recipe-cms
```
Alternatively you can create a new project based on an existing recipe Alternatively you can create a new project based on an existing recipe
```shell ```shell
$ composer create-project silverstripe/recipe-cms ./myssproject ^4.0@dev $ composer create-project silverstripe/recipe-cms ./myssproject ^1.0@dev
``` ```
## Upgrading recipes ## Inlining recipes
Any existing recipe, whether installed via `composer require` or `composer require-recipe` can be safely upgraded You can "inline" either a previously installed recipe, or a new one that you would like to include
via `composer upgrade-recipe`. dependencies for in your main project. By inlining a recipe, you promote its requirements, as well as
its project files, up into your main project, and remove the recipe itself from your dependencies.
When upgrading a version constraint is recommended, but not necessary. If omitted, then the existing installed This can be done with either `update-recipe`, which will update a recipe, or `require-recipe` which will
version will be detected, and a safe default chosen. install a new recipe.
Note that if you with to run this command you must first install either a recipe via normal composer
commands, or install the recipe plugin:
```shell ```shell
$ composer upgrade-recipe silverstripe/recipe-cms ^1.0@dev $ composer init
$ composer require silverstripe/recipe-plugin ^0.1
$ composer require-recipe silverstripe/recipe-cms ^1.0@dev
``` ```
or
```shell
$ composer init
$ composer require silverstripe/recipe-cms ^1.0@dev
$ composer update-recipe silverstripe/recipe-cms
```
## Removing recipe dependencies or files
Any project file installed via a recipe, or any module installed by inlining a recipe, can be easily removed.
Subsequent updates to this recipe will not re-install any of those files or dependencies.
In order to ensure this, a record of all inlined modules, and all installed files are stored in composer.json
as below.
```json
{
"extra": {
"project-files-installed": [
"mysite/code/Page.php",
"mysite/code/PageController.php"
],
"project-dependencies-installed": {
"silverstripe/admin": "1.0.x-dev",
"silverstripe/asset-admin": "1.0.x-dev",
"silverstripe/campaign-admin": "1.0.x-dev"
}
}
}
```
To remove a file, simply delete it from the folder your project is installed in, but don't modify
`project-files-installed` (as this is how composer knows what not to re-install).
Likewise to remove a module, use `composer remove <module>` and it will be removed. As above, don't
modify `project-dependencies-instaleld`, otherwise that module will be re-installed on subsequent
`composer update-recipe`.
## Un-doing a deleted project file / dependency
If you have deleted a module or file and want to re-install it you should remove the appropriate
entry from either 'project-files-installed' or 'project-dependencies-installed' and then run
`composer update-recipe <recipe>` again.
The file or module will be re-installed.
## Removing recipes ## Removing recipes
As installation of a recipe inlines all dependencies and passes ownership to the root project, As installation of a recipe inlines all dependencies and passes ownership to the root project,
@ -65,7 +107,7 @@ there is no automatic removal process. To remove a recipe, you should manually r
required module that is no longer desired via `composer remove <module>`. required module that is no longer desired via `composer remove <module>`.
The `provide` reference to the recipe can also be safely removed, although it has no practical result The `provide` reference to the recipe can also be safely removed, although it has no practical result
other than to disable future calls to `upgrade-recipe` on this recipe. other than to disable future calls to `update-recipe` on this recipe.
## Installing or upgrading recipes without inlining them ## Installing or upgrading recipes without inlining them
@ -101,7 +143,7 @@ An example recipe:
"type": "silverstripe-recipe", "type": "silverstripe-recipe",
"require": { "require": {
"silverstripe/recipe-plugin": "^0.1", "silverstripe/recipe-plugin": "^0.1",
"silverstripe/recipe-cms": "^4.0", "silverstripe/recipe-cms": "^1.0",
"silverstripe/blog": "^3.0@dev", "silverstripe/blog": "^3.0@dev",
"silverstripe/lumberjack": "^2.1@dev", "silverstripe/lumberjack": "^2.1@dev",
}, },

View File

@ -2,11 +2,12 @@
namespace SilverStripe\RecipePlugin; namespace SilverStripe\RecipePlugin;
use BadMethodCallException;
use Composer\Command\RequireCommand; use Composer\Command\RequireCommand;
use Composer\Command\UpdateCommand; use Composer\Command\UpdateCommand;
use Composer\Composer; use Composer\Composer;
use MongoDB\Driver\Exception\InvalidArgumentException; use Composer\Factory;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -34,50 +35,9 @@ trait RecipeCommandBehaviour
public abstract function resetComposer(); public abstract function resetComposer();
/** /**
* Load composer data from the given directory * @return IOInterface
*
* @param string $path
* @param array|null $default If file doesn't exist use this default. If null, file is mandatory and there is
* no default
* @return array
*/ */
protected function loadComposer($path, $default = null) abstract public function getIO();
{
if (!file_exists($path)) {
if (isset($default)) {
return $default;
}
throw new BadMethodCallException("Could not find " . basename($path));
}
$data = json_decode(file_get_contents($path), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \LogicException("Invalid composer.json with error: " . json_last_error_msg());
}
return $data;
}
/**
* Save the given data to the composer file in the given directory
*
* @param string $directory
* @param array $data
*/
protected function saveComposer($directory, $data)
{
$path = $directory.'/composer.json';
if (!file_exists($path)) {
throw new BadMethodCallException("Could not find composer.json");
}
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Make sure errors are reported
if (json_last_error()) {
throw new InvalidArgumentException("Invalid composer.json data with error: " . json_last_error_msg());
}
file_put_contents($path, $content);
// Reset composer object
$this->resetComposer();
}
/** /**
* @param OutputInterface $output * @param OutputInterface $output
@ -147,7 +107,7 @@ trait RecipeCommandBehaviour
// Check requires // Check requires
$requires = $this->getComposer()->getPackage()->getRequires(); $requires = $this->getComposer()->getPackage()->getRequires();
if (isset($requires[$recipe])) { if (isset($requires[$recipe])) {
return $provides[$recipe]->getPrettyConstraint(); return $requires[$recipe]->getPrettyConstraint();
} }
// No existing version // No existing version
@ -224,36 +184,109 @@ trait RecipeCommandBehaviour
return $returnCode; return $returnCode;
} }
// Begin modification of composer.json // inline all dependencies inline into composer.json
$composerData = $this->loadComposer(getcwd() . '/composer.json'); $this->modifyComposer(function ($composerData) use ($output, $recipe, $installedVersion) {
// Check previously installed, and currently installed modules
$require = isset($composerData['require']) ? $composerData['require'] : [];
$previouslyInstalled = isset($composerData['extra'][RecipePlugin::PROJECT_DEPENDENCIES_INSTALLED])
? $composerData['extra'][RecipePlugin::PROJECT_DEPENDENCIES_INSTALLED]
: [];
// Get composer data for both root and newly installed recipe // Get composer data for both root and newly installed recipe
$installedRecipe = $this $installedRecipe = $this
->getComposer() ->getComposer()
->getRepositoryManager() ->getRepositoryManager()
->getLocalRepository() ->getLocalRepository()
->findPackage($recipe, '*'); ->findPackage($recipe, '*');
if ($installedRecipe) { if ($installedRecipe) {
$output->writeln("Inlining all dependencies for recipe <info>{$recipe}</info>:"); $output->writeln("Inlining all dependencies for recipe <info>{$recipe}</info>:");
foreach ($installedRecipe->getRequires() as $requireName => $require) { foreach ($installedRecipe->getRequires() as $requireName => $requireConstraint) {
$requireVersion = $require->getPrettyConstraint(); $requireVersion = $requireConstraint->getPrettyConstraint();
$output->writeln(
" - Inlining <info>{$requireName}</info> (<comment>{$requireVersion}</comment>)" // If already installed, upgrade
); if (isset($require[$requireName])) {
$composerData['require'][$requireName] = $requireVersion; // Check if upgrade or not
$requireInstalledVersion = $require[$requireName];
if ($requireInstalledVersion === $requireVersion) {
// No need to upgrade
$output->writeln(
" - Skipping <info>{$requireName}</info> "
. "(Already installed as <comment>{$requireVersion}</comment>)"
);
} else {
// Upgrade obsolete version
$output->writeln(
" - Inlining <info>{$requireName}</info> "
. "(Updated to <comment>{$requireVersion}</comment> from "
. "<comment>{$requireInstalledVersion}</comment>)"
);
$require[$requireName] = $requireVersion;
}
} elseif (isset($previouslyInstalled[$requireName])) {
// Old module, manually removed
$output->writeln(
" - Skipping <info>{$requireName}</info> (Manually removed from recipe)"
);
} else {
// New module
$output->writeln(
" - Inlining <info>{$requireName}</info> (<comment>{$requireVersion}</comment>)"
);
$require[$requireName] = $requireVersion;
}
// note dependency as previously installed
$previouslyInstalled[$requireName] = $requireVersion;
}
} }
}
// Move recipe from 'require' to 'provide' // Add new require / extra-installed
$installedVersion = $this->findInstalledVersion($recipe) ?: $installedVersion; $composerData['require'] = $require;
unset($composerData['require'][$recipe]); if ($previouslyInstalled){
if (!isset($composerData['provide'])) { if (!isset($composerData['extra'])) {
$composerData['provide'] = []; $composerData['extra'] = [];
} }
$composerData['provide'][$recipe] = $installedVersion; ksort($previouslyInstalled);
$composerData['extra'][RecipePlugin::PROJECT_DEPENDENCIES_INSTALLED] = $previouslyInstalled;
}
// Update composer.json and synchronise composer.lock // Move recipe from 'require' to 'provide'
$this->saveComposer(getcwd(), $composerData); $installedVersion = $this->findInstalledVersion($recipe) ?: $installedVersion;
unset($composerData['require'][$recipe]);
if (!isset($composerData['provide'])) {
$composerData['provide'] = [];
}
$composerData['provide'][$recipe] = $installedVersion;
return $composerData;
});
// Update synchronise composer.lock
return $this->updateProject($output); return $this->updateProject($output);
} }
/**
* callback to safely modify composer.json data
*
* @param callable $callable Callable which will safely take and return the composer data.
* This should return false if no content changed, or the updated data
*/
protected function modifyComposer($callable)
{
// Begin modification of composer.json
$composerFile = new JsonFile(Factory::getComposerFile(), null, $this->getIO());
$composerData = $composerFile->read();
// Note: Respect call by ref $composerData
$result = $callable($composerData);
if ($result === false) {
return;
}
if ($result) {
$composerData = $result;
}
// Update composer.json and refresh local composer instance
$composerFile->write($composerData);
$this->resetComposer();
}
} }

View File

@ -2,9 +2,11 @@
namespace SilverStripe\RecipePlugin; namespace SilverStripe\RecipePlugin;
use Composer\Factory;
use Composer\Installer\LibraryInstaller; use Composer\Installer\LibraryInstaller;
use Composer\Composer; use Composer\Composer;
use Composer\IO\IOInterface; use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Package\PackageInterface; use Composer\Package\PackageInterface;
use FilesystemIterator; use FilesystemIterator;
use Iterator; use Iterator;
@ -28,11 +30,19 @@ class RecipeInstaller extends LibraryInstaller {
*/ */
protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $filePatterns) protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $filePatterns)
{ {
// load composer json data
$composerFile = new JsonFile(Factory::getComposerFile(), null, $this->io);
$composerData = $composerFile->read();
$installedFiles = isset($composerData['extra'][RecipePlugin::PROJECT_FILES_INSTALLED])
? $composerData['extra'][RecipePlugin::PROJECT_FILES_INSTALLED]
: [];
// Load all project files
$fileIterator = $this->getFileIterator($sourceRoot, $filePatterns); $fileIterator = $this->getFileIterator($sourceRoot, $filePatterns);
$any = false; $any = false;
foreach($fileIterator as $path => $info) { foreach($fileIterator as $path => $info) {
$relativePath = substr($path, strlen($sourceRoot)); $destination = $destinationRoot . substr($path, strlen($sourceRoot));
$destination = $destinationRoot . $relativePath; $relativePath = substr($path, strlen($sourceRoot) + 1); // Name path without leading '/'
// Write header // Write header
if (!$any) { if (!$any) {
@ -51,11 +61,32 @@ class RecipeInstaller extends LibraryInstaller {
" - Skipping <info>$relativePath</info> (<comment>existing and modified in project</comment>)" " - Skipping <info>$relativePath</info> (<comment>existing and modified in project</comment>)"
); );
} }
} elseif (in_array($relativePath, $installedFiles)) {
// Don't re-install previously installed files that have been deleted
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>previously installed</comment>)"
);
} else { } else {
$any++;
$this->io->write(" - Copying <info>$relativePath</info>"); $this->io->write(" - Copying <info>$relativePath</info>");
$this->filesystem->ensureDirectoryExists(dirname($destination)); $this->filesystem->ensureDirectoryExists(dirname($destination));
copy($path, $destination); copy($path, $destination);
} }
// Add file to installed (even if already exists)
if (!in_array($relativePath, $installedFiles)) {
$installedFiles[] = $relativePath;
}
}
// If any files are written, modify composer.json with newly installed files
if ($installedFiles) {
sort($installedFiles);
if (!isset($composerData['extra'])) {
$composerData['extra'] = [];
}
$composerData['extra'][RecipePlugin::PROJECT_FILES_INSTALLED] = $installedFiles;
$composerFile->write($composerData);
} }
} }
@ -108,20 +139,22 @@ class RecipeInstaller extends LibraryInstaller {
public function installLibrary(PackageInterface $package) public function installLibrary(PackageInterface $package)
{ {
// Check if silverstripe-recipe type // Check if silverstripe-recipe type
if ($package->getType() !== 'silverstripe-recipe') { if ($package->getType() !== RecipePlugin::RECIPE_TYPE) {
return; return;
} }
// Find project path
$destinationPath = dirname(realpath(Factory::getComposerFile()));
// Copy project files to root // Copy project files to root
$destinationPath = getcwd();
$name = $package->getName(); $name = $package->getName();
$extra = $package->getExtra(); $extra = $package->getExtra();
if (isset($extra['project-files'])) { if (isset($extra[RecipePlugin::PROJECT_FILES])) {
$this->installProjectFiles( $this->installProjectFiles(
$name, $name,
$this->getInstallPath($package), $this->getInstallPath($package),
$destinationPath, $destinationPath,
$extra['project-files'] $extra[RecipePlugin::PROJECT_FILES]
); );
} }
} }

View File

@ -7,14 +7,15 @@ use Composer\Composer;
use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\InstallOperation;
use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Operation\UpdateOperation;
use Composer\EventDispatcher\EventSubscriberInterface; use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Factory;
use Composer\Installer\PackageEvent; use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface; use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Package\PackageInterface; use Composer\Package\PackageInterface;
use Composer\Plugin\Capability\CommandProvider; use Composer\Plugin\Capability\CommandProvider;
use Composer\Plugin\Capable; use Composer\Plugin\Capable;
use Composer\Plugin\PluginInterface; use Composer\Plugin\PluginInterface;
use Composer\Script\Event; use Composer\Script\Event;
use LogicException;
/** /**
* Register the RecipeInstaller * Register the RecipeInstaller
@ -23,6 +24,26 @@ use LogicException;
*/ */
class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable
{ {
/**
* Type of recipe to check for
*/
const RECIPE_TYPE = 'silverstripe-recipe';
/**
* 'extra' key for project files
*/
const PROJECT_FILES = 'project-files';
/**
* 'extra' key for list of project files installed
*/
const PROJECT_FILES_INSTALLED = 'project-files-installed';
/**
* 'extra' key for project dependencies installed
*/
const PROJECT_DEPENDENCIES_INSTALLED = 'project-dependencies-installed';
public function activate(Composer $composer, IOInterface $io) public function activate(Composer $composer, IOInterface $io)
{ {
} }
@ -57,30 +78,17 @@ class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable
*/ */
public function cleanupProject(Event $event) public function cleanupProject(Event $event)
{ {
$path = getcwd() . '/composer.json'; $file = new JsonFile(Factory::getComposerFile());
$data = $file->read();
// Load composer data // Remove project-files from project, and any empty extra
$data = json_decode(file_get_contents($path), true); unset($data['extra']['project-files']);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new LogicException("Invalid composer.json with error: " . json_last_error_msg());
}
// Remove project-files from project
if (isset($data['extra']['project-files'])) {
unset($data['extra']['project-files']);
}
// Clean empty extra key
if (empty($data['extra'])) { if (empty($data['extra'])) {
unset($data['extra']); unset($data['extra']);
} }
// Save back to composer.json // Save back to composer.json
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $file->write($data);
if (json_last_error()) {
throw new LogicException("Invalid composer.json data with error: " . json_last_error_msg());
}
file_put_contents($path, $content);
} }
/** /**