From 8006dee7d90b9a86602f616c7e014c8a7b9fd146 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Mon, 17 Jul 2017 17:36:08 +1200 Subject: [PATCH] ENHANCEMENT Register installed modules and files in composer.extra so that removal is respected Fixes #2 --- src/RecipeCommandBehaviour.php | 175 ++++++++++++++++++++------------- src/RecipeInstaller.php | 45 +++++++-- src/RecipePlugin.php | 46 +++++---- 3 files changed, 170 insertions(+), 96 deletions(-) diff --git a/src/RecipeCommandBehaviour.php b/src/RecipeCommandBehaviour.php index aeabd09..6dd51bd 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 @@ -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); } /**