Skip to content

Commit

Permalink
Merge pull request #3 from open-sausages/pulls/1.0/removable-resources
Browse files Browse the repository at this point in the history
ENHANCEMENT Register installed modules and files in composer.extra
  • Loading branch information
flamerohr authored Jul 24, 2017
2 parents eea3753 + 5fcff3b commit 0592e33
Show file tree
Hide file tree
Showing 4 changed files with 232 additions and 116 deletions.
80 changes: 61 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ These recipes allow for the following features:
- 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
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

Expand All @@ -26,46 +26,88 @@ Recipes can be introduced to any existing project (even if not created on a silv

```shell
$ composer init
$ composer require silverstripe/recipe-plugin ^0.1
$ composer require-recipe silverstripe/recipe-cms ^4.0@dev
$ composer require silverstripe/recipe-cms ^1.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.
Alternatively you can create a new project based on an existing recipe

```shell
$ composer create-project silverstripe/recipe-cms ./myssproject ^1.0@dev
```

## Inlining recipes

You can "inline" either a previously installed recipe, or a new one that you would like to include
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.

This can be done with either `update-recipe`, which will update a recipe, or `require-recipe` which will
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
$ composer init
$ composer require silverstripe/recipe-cms ^4.0@dev
$ composer upgrade-recipe silverstripe/recipe-cms
$ composer require silverstripe/recipe-plugin ^0.1
$ composer require-recipe silverstripe/recipe-cms ^1.0@dev
```

Alternatively you can create a new project based on an existing recipe
or

```shell
$ composer create-project silverstripe/recipe-cms ./myssproject ^4.0@dev
$ composer init
$ composer require silverstripe/recipe-cms ^1.0@dev
$ composer update-recipe silverstripe/recipe-cms
```

## Upgrading recipes
## Removing recipe dependencies or files

Any existing recipe, whether installed via `composer require` or `composer require-recipe` can be safely upgraded
via `composer upgrade-recipe`.
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.

When upgrading a version constraint is recommended, but not necessary. If omitted, then the existing installed
version will be detected, and a safe default chosen.
In order to ensure this, a record of all inlined modules, and all installed files are stored in composer.json
as below.

```shell
$ composer upgrade-recipe silverstripe/recipe-cms ^1.0@dev
```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

As installation of a recipe inlines all dependencies and passes ownership to the root project,
there is no automatic removal process. To remove a recipe, you should manually remove any
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
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

Expand Down Expand Up @@ -101,7 +143,7 @@ An example recipe:
"type": "silverstripe-recipe",
"require": {
"silverstripe/recipe-plugin": "^0.1",
"silverstripe/recipe-cms": "^4.0",
"silverstripe/recipe-cms": "^1.0",
"silverstripe/blog": "^3.0@dev",
"silverstripe/lumberjack": "^2.1@dev",
},
Expand Down
177 changes: 105 additions & 72 deletions src/RecipeCommandBehaviour.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

namespace SilverStripe\RecipePlugin;

use BadMethodCallException;
use Composer\Command\RequireCommand;
use Composer\Command\UpdateCommand;
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\Input\ArrayInput;
use Symfony\Component\Console\Output\OutputInterface;
Expand Down Expand Up @@ -34,50 +35,9 @@ public abstract function getComposer($required = true, $disablePlugins = null);
public abstract function resetComposer();

/**
* Load composer data from the given directory
*
* @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
* @return IOInterface
*/
protected function loadComposer($path, $default = null)
{
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();
}
abstract public function getIO();

/**
* @param OutputInterface $output
Expand Down Expand Up @@ -147,7 +107,7 @@ protected function findInstalledVersion($recipe)
// Check requires
$requires = $this->getComposer()->getPackage()->getRequires();
if (isset($requires[$recipe])) {
return $provides[$recipe]->getPrettyConstraint();
return $requires[$recipe]->getPrettyConstraint();
}

// No existing version
Expand Down Expand Up @@ -224,36 +184,109 @@ protected function installRecipe(OutputInterface $output, $recipe, $constraint,
return $returnCode;
}

// Begin modification of composer.json
$composerData = $this->loadComposer(getcwd() . '/composer.json');
// inline all dependencies inline into 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
$installedRecipe = $this
->getComposer()
->getRepositoryManager()
->getLocalRepository()
->findPackage($recipe, '*');
if ($installedRecipe) {
$output->writeln("Inlining all dependencies for recipe <info>{$recipe}</info>:");
foreach ($installedRecipe->getRequires() as $requireName => $require) {
$requireVersion = $require->getPrettyConstraint();
$output->writeln(
" - Inlining <info>{$requireName}</info> (<comment>{$requireVersion}</comment>)"
);
$composerData['require'][$requireName] = $requireVersion;
// Get composer data for both root and newly installed recipe
$installedRecipe = $this
->getComposer()
->getRepositoryManager()
->getLocalRepository()
->findPackage($recipe, '*');
if ($installedRecipe) {
$output->writeln("Inlining all dependencies for recipe <info>{$recipe}</info>:");
foreach ($installedRecipe->getRequires() as $requireName => $requireConstraint) {
$requireVersion = $requireConstraint->getPrettyConstraint();

// If already installed, upgrade
if (isset($require[$requireName])) {
// 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'
$installedVersion = $this->findInstalledVersion($recipe) ?: $installedVersion;
unset($composerData['require'][$recipe]);
if (!isset($composerData['provide'])) {
$composerData['provide'] = [];
}
$composerData['provide'][$recipe] = $installedVersion;
// Add new require / extra-installed
$composerData['require'] = $require;
if ($previouslyInstalled){
if (!isset($composerData['extra'])) {
$composerData['extra'] = [];
}
ksort($previouslyInstalled);
$composerData['extra'][RecipePlugin::PROJECT_DEPENDENCIES_INSTALLED] = $previouslyInstalled;
}

// Move recipe from 'require' to 'provide'
$installedVersion = $this->findInstalledVersion($recipe) ?: $installedVersion;
unset($composerData['require'][$recipe]);
if (!isset($composerData['provide'])) {
$composerData['provide'] = [];
}
$composerData['provide'][$recipe] = $installedVersion;
return $composerData;
});

// Update composer.json and synchronise composer.lock
$this->saveComposer(getcwd(), $composerData);
// Update synchronise composer.lock
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();
}
}
Loading

0 comments on commit 0592e33

Please sign in to comment.