diff --git a/README.md b/README.md index 3a6b6c8..4c41a4b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -26,38 +26,80 @@ 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. - -```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 ```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 -via `composer upgrade-recipe`. +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. -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. +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 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 ` 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 ` 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, @@ -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 `. 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 @@ -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", }, diff --git a/src/RecipeCommandBehaviour.php b/src/RecipeCommandBehaviour.php index aeabd09..c6bcc32 100644 --- a/src/RecipeCommandBehaviour.php +++ b/src/RecipeCommandBehaviour.php @@ -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; @@ -34,50 +35,9 @@ trait RecipeCommandBehaviour 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 @@ -147,7 +107,7 @@ trait RecipeCommandBehaviour // Check requires $requires = $this->getComposer()->getPackage()->getRequires(); if (isset($requires[$recipe])) { - return $provides[$recipe]->getPrettyConstraint(); + return $requires[$recipe]->getPrettyConstraint(); } // No existing version @@ -224,36 +184,109 @@ trait RecipeCommandBehaviour 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 {$recipe}:"); - foreach ($installedRecipe->getRequires() as $requireName => $require) { - $requireVersion = $require->getPrettyConstraint(); - $output->writeln( - " - Inlining {$requireName} ({$requireVersion})" - ); - $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 {$recipe}:"); + 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 {$requireName} " + . "(Already installed as {$requireVersion})" + ); + } else { + // Upgrade obsolete version + $output->writeln( + " - Inlining {$requireName} " + . "(Updated to {$requireVersion} from " + . "{$requireInstalledVersion})" + ); + $require[$requireName] = $requireVersion; + } + } elseif (isset($previouslyInstalled[$requireName])) { + // Old module, manually removed + $output->writeln( + " - Skipping {$requireName} (Manually removed from recipe)" + ); + } else { + // New module + $output->writeln( + " - Inlining {$requireName} ({$requireVersion})" + ); + $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; + } - // Update composer.json and synchronise composer.lock - $this->saveComposer(getcwd(), $composerData); + // 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 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(); + } } diff --git a/src/RecipeInstaller.php b/src/RecipeInstaller.php index fefa069..e991767 100644 --- a/src/RecipeInstaller.php +++ b/src/RecipeInstaller.php @@ -2,9 +2,11 @@ namespace SilverStripe\RecipePlugin; +use Composer\Factory; use Composer\Installer\LibraryInstaller; use Composer\Composer; use Composer\IO\IOInterface; +use Composer\Json\JsonFile; use Composer\Package\PackageInterface; use FilesystemIterator; use Iterator; @@ -28,11 +30,19 @@ class RecipeInstaller extends LibraryInstaller { */ 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); $any = false; foreach($fileIterator as $path => $info) { - $relativePath = substr($path, strlen($sourceRoot)); - $destination = $destinationRoot . $relativePath; + $destination = $destinationRoot . substr($path, strlen($sourceRoot)); + $relativePath = substr($path, strlen($sourceRoot) + 1); // Name path without leading '/' // Write header if (!$any) { @@ -51,11 +61,32 @@ class RecipeInstaller extends LibraryInstaller { " - Skipping $relativePath (existing and modified in project)" ); } + } elseif (in_array($relativePath, $installedFiles)) { + // Don't re-install previously installed files that have been deleted + $this->io->write( + " - Skipping $relativePath (previously installed)" + ); } else { + $any++; $this->io->write(" - Copying $relativePath"); $this->filesystem->ensureDirectoryExists(dirname($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) { // Check if silverstripe-recipe type - if ($package->getType() !== 'silverstripe-recipe') { + if ($package->getType() !== RecipePlugin::RECIPE_TYPE) { return; } + // Find project path + $destinationPath = dirname(realpath(Factory::getComposerFile())); + // Copy project files to root - $destinationPath = getcwd(); $name = $package->getName(); $extra = $package->getExtra(); - if (isset($extra['project-files'])) { + if (isset($extra[RecipePlugin::PROJECT_FILES])) { $this->installProjectFiles( $name, $this->getInstallPath($package), $destinationPath, - $extra['project-files'] + $extra[RecipePlugin::PROJECT_FILES] ); } } diff --git a/src/RecipePlugin.php b/src/RecipePlugin.php index acee08a..262b1c6 100644 --- a/src/RecipePlugin.php +++ b/src/RecipePlugin.php @@ -7,14 +7,15 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Factory; use Composer\Installer\PackageEvent; use Composer\IO\IOInterface; +use Composer\Json\JsonFile; use Composer\Package\PackageInterface; use Composer\Plugin\Capability\CommandProvider; use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface; use Composer\Script\Event; -use LogicException; /** * Register the RecipeInstaller @@ -23,6 +24,26 @@ use LogicException; */ 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) { } @@ -57,30 +78,17 @@ class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable */ public function cleanupProject(Event $event) { - $path = getcwd() . '/composer.json'; + $file = new JsonFile(Factory::getComposerFile()); + $data = $file->read(); - // Load composer data - $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()); - } - - // Remove project-files from project - if (isset($data['extra']['project-files'])) { - unset($data['extra']['project-files']); - } - - // Clean empty extra key + // Remove project-files from project, and any empty extra + unset($data['extra']['project-files']); if (empty($data['extra'])) { unset($data['extra']); } // Save back to composer.json - $content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (json_last_error()) { - throw new LogicException("Invalid composer.json data with error: " . json_last_error_msg()); - } - file_put_contents($path, $content); + $file->write($data); } /**