ENHANCEMENT Register installed modules and files in composer.extra so that removal is respected

Fixes #2
This commit is contained in:
Damian Mooyman 2017-07-17 17:36:08 +12:00
parent eea37537ae
commit 8006dee7d9
No known key found for this signature in database
GPG Key ID: 78B823A10DE27D1A
3 changed files with 170 additions and 96 deletions

View File

@ -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 <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;
}
// 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();
}
}

View File

@ -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 <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 {
$any++;
$this->io->write(" - Copying <info>$relativePath</info>");
$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]
);
}
}

View File

@ -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);
}
/**