From 34c13724979ac48c6181cc5362febaa7dfc5d11f Mon Sep 17 00:00:00 2001 From: codebymikey Date: Mon, 22 Feb 2021 09:16:18 +0000 Subject: [PATCH 1/2] [dev] Add support for symlinking to the root project during local development. Disabled by default as it's not recommended. See https://github.com/composer/composer/pull/5974 and https://github.com/composer/composer/pull/6174 --- example/project/README.md | 9 ++ example/project/composer.json | 25 ++++ .../scripts/composer/ScriptHandler.php | 128 ++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 example/project/scripts/composer/ScriptHandler.php diff --git a/example/project/README.md b/example/project/README.md index 2623aab..477cddd 100644 --- a/example/project/README.md +++ b/example/project/README.md @@ -3,3 +3,12 @@ Demo root project integrating with Drupal libraries installer. Update locally after committing by running `composer update zodiacmedia/drupal-libraries-installer`. + +Or symlink to a copy of the current root project with: + +```bash +# Enable "symlink-root-project" mode (--json requires composer 2). +composer config --json extra.symlink-root-project true +# Apply the symlink. +composer update --lock +``` diff --git a/example/project/composer.json b/example/project/composer.json index 82f4536..96fe0c7 100644 --- a/example/project/composer.json +++ b/example/project/composer.json @@ -26,6 +26,30 @@ "zodiacmedia/drupal-libraries-installer": "*@dev", "zodiacmedia/drupal-libraries-installer-demo-dependency": "*@dev" }, + "require-dev": { + "composer/composer": "^1.10" + }, + "autoload": { + "classmap": [ + "scripts/composer/ScriptHandler.php" + ] + }, + "scripts": { + "preSymlinkMainProject": "ExampleDrupalLibrariesProject\\composer\\ScriptHandler::preInstall", + "symlinkMainProject": "ExampleDrupalLibrariesProject\\composer\\ScriptHandler::postInstall", + "pre-install-cmd": [ + "@preSymlinkMainProject" + ], + "pre-update-cmd": [ + "@preSymlinkMainProject" + ], + "post-install-cmd": [ + "@symlinkMainProject" + ], + "post-update-cmd": [ + "@symlinkMainProject" + ] + }, "config": { "preferred-install": "source", "classmap-authoritative": true, @@ -33,6 +57,7 @@ "optimize-autoloader": true }, "extra": { + "symlink-root-project": false, "installer-paths": { "web/libraries/ckeditor/{$name}": [ "vendor:drupal-library_ckeditor" diff --git a/example/project/scripts/composer/ScriptHandler.php b/example/project/scripts/composer/ScriptHandler.php new file mode 100644 index 0000000..e1dcdfd --- /dev/null +++ b/example/project/scripts/composer/ScriptHandler.php @@ -0,0 +1,128 @@ +getComposer(); + $root_package = $composer->getPackage(); + $locker = $composer->getLocker(); + // Always attempt to clean up if: + // - the lock file is not present. + // - the lock file is outdated. + // - in the pre-update hook. + $originating_event = $event->getOriginatingEvent(); + $originating_event_name = $originating_event ? $originating_event->getName() : NULL; + $needs_updating = $originating_event_name === 'pre-update-cmd' || !$locker->isLocked() || !$locker->isFresh(); + $should_symlink = empty($root_package->getExtra()['symlink-root-project']); + if ($needs_updating || $should_symlink) { + // Don't symlink the root project. Attempt to remove any existing symlink. + $filesystem = new Filesystem(); + $destination = static::getPackageDestination(); + $destination = $filesystem->normalizePath($destination); + + $is_junction = $filesystem->isJunction($destination); + $is_symlink = is_link($destination); + if ($is_junction || $is_symlink) { + $io = $event->getIO(); + if ($is_junction) { + $io->writeError(sprintf('Removing existing junction for %s', static::PACKAGE_NAME)); + } + elseif ($is_symlink) { + $io->writeError(sprintf('Removing existing symlink for %s', static::PACKAGE_NAME)); + } + $filesystem->removeDirectory($destination); + } + } + } + + /** + * Attempt to symlink to the parent repository during development. + * + * @param \Composer\Script\Event $event + * The composer event. + */ + public static function postInstall(Event $event) { + $root_package = $event->getComposer()->getPackage(); + if (empty($root_package->getExtra()['symlink-root-project'])) { + // Do nothing. + return; + } + + $io = $event->getIO(); + $filesystem = new Filesystem(); + $link = implode(DIRECTORY_SEPARATOR, ['..', '..', '..', '..']); + $destination = static::getPackageDestination(); + $destination = $filesystem->normalizePath($destination); + if (Platform::isWindows()) { + if (!$filesystem->isJunction($destination)) { + $io->writeError(sprintf('Creating junction for %s', static::PACKAGE_NAME)); + $filesystem->removeDirectory($destination); + $filesystem->junction($link, $destination); + } + } + else { + if (!$filesystem->isSymlinkedDirectory($destination)) { + $io->writeError(sprintf('Creating symlink for %s', static::PACKAGE_NAME)); + // Attempt to remove the existing directory. + $filesystem->removeDirectory($destination); + if (!static::createSymlink($link, $destination)) { + throw new \RuntimeException(sprintf('Failed to create a symlink for "%s"', static::PACKAGE_NAME)); + } + } + } + } + + /** + * Returns the destination package directory. + */ + protected static function getPackageDestination() { + return implode(DIRECTORY_SEPARATOR, [ + getcwd(), + 'vendor', + 'zodiacmedia', + 'drupal-libraries-installer', + ]); + } + + /** + * Creates a symlink to the root project. + */ + protected static function createSymlink($target, $link) { + if (!function_exists('symlink')) { + return FALSE; + } + + return @symlink($target, $link); + } + +} From 286b9d69c4c90efae83d7278769f88ac8fc27e63 Mon Sep 17 00:00:00 2001 From: codebymikey Date: Mon, 22 Feb 2021 09:19:26 +0000 Subject: [PATCH 2/2] Add better Composer 2 support. --- .lando.yml | 36 +++++++++++++ .php.ini | 13 +++++ CHANGELOG.md | 13 +++++ src/Plugin.php | 138 +++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 .lando.yml create mode 100644 .php.ini diff --git a/.lando.yml b/.lando.yml new file mode 100644 index 0000000..480c5c5 --- /dev/null +++ b/.lando.yml @@ -0,0 +1,36 @@ +name: drupal-libaries-installer + +services: + app: &appserver + type: php:7.2 + via: cli + composer_version: 1.10.20 + xdebug: false + overrides: + environment: + PHP_IDE_CONFIG: "serverName=appserver" + XDEBUG_CONFIG: "" + XDEBUG_MODE: debug + config: + php: .php.ini + app2: + <<: *appserver + composer_version: 2 + +tooling: + composer: { service: app, cmd: /app/vendor/bin/composer, description: Run local Composer } + composer1: { service: app, cmd: /usr/local/bin/composer, description: Run Composer 1 } + composer2: { service: app2, cmd: /usr/local/bin/composer, description: Run Composer 2 } + + xdebug-on: + description: Enable xdebug. + cmd: + - app: &xdebug_on docker-php-ext-enable xdebug 2>/dev/null && pkill -o -USR2 php-fpm && echo "Enabled xdebug" + - app2: *xdebug_on + user: root + xdebug-off: + description: Disable xdebug. + cmd: + - app: &xdebug_off rm -rf /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && pkill -o -USR2 php-fpm && echo "Disabled xdebug" + - app2: *xdebug_off + user: root diff --git a/.php.ini b/.php.ini new file mode 100644 index 0000000..6decb53 --- /dev/null +++ b/.php.ini @@ -0,0 +1,13 @@ +[xdebug] +; https://xdebug.org/docs/all_settings +xdebug.show_exception_trace = 0 +xdebug.max_nesting_level = 9999 +xdebug.idekey = PHPSTORM +# For xdebug 3 - https://xdebug.org/docs/upgrade_guide +xdebug.mode = debug +; Start only when triggered. +xdebug.start_with_request = trigger +xdebug.client_host = host.docker.internal +xdebug.connect_timeout_ms = 200 +xdebug.client_port = 9000 +; xdebug.discover_client_host = false diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a9525..d89be24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +1.4.1 / 2021-02-22 +======================== +* Add better Composer 2 support. + + * Fix issue where libraries could not be downloaded on an empty cache, creating an empty folder instead. + + Composer 2 introduces additional steps to [`DownloaderInterface`][composer-2-upgrade], which + needed integration as well as support for resolving the promises properly. [Additional reference][composer-2-download-support]. + * Support parallel library downloads on Composer 2, while keeping existing synchronous download support on Composer 1. +* Fix issue with the plugin failing early if the plugin package is an `AliasPackage`. + 1.4.0 / 2020-11-23 ======================== * Add Composer 2 support. @@ -38,4 +49,6 @@ definition for supporting: ======================== * Initial MVP plugin. +[composer-2-upgrade]: https://getcomposer.org/upgrade/UPGRADE-2.0.md +[composer-2-download-support]: https://github.com/composer/composer/issues/9209 [ckeditor-downloads]: https://github.com/balbuf/drupal-libraries-installer/issues/6 diff --git a/src/Plugin.php b/src/Plugin.php index c97e56a..5028ccc 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,9 +4,10 @@ use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\IO\ConsoleIO; use Composer\IO\IOInterface; use Composer\Json\JsonFile; -use Composer\Package\CompletePackage; +use Composer\Package\CompletePackageInterface; use Composer\Package\Package; use Composer\Package\PackageInterface; use Composer\Plugin\Capable; @@ -309,12 +310,14 @@ protected function removeUnusedLibraries(array $old_libraries) { * The currently installed libraries. */ protected function downloadLibraries(array $processed_libraries, array $applied_drupal_libraries) { + $download_promises = []; + $install_promises = []; + + $libraries_to_install = []; foreach ($processed_libraries as $library_name => $processed_library) { $library_package = $this->getLibraryPackage($library_name, $processed_library); - - $ignore_patterns = $processed_library['ignore']; - $rename = $processed_library['rename'] ?? NULL; $install_path = $this->installationManager->getInstallPath($library_package); + if ( ( !isset($applied_drupal_libraries[$library_name]) || @@ -326,25 +329,103 @@ protected function downloadLibraries(array $processed_libraries, array $applied_ // - wasn't in the lock file. // - doesn't match what is in the lock file. // - doesn't exist on disk. - $this->downloadPackage($library_package, $install_path, $ignore_patterns, $rename); + $download_result = $this->downloadManager->download($library_package, $install_path); + if ($download_result instanceof PromiseInterface) { + // https://github.com/composer/composer/issues/9209 + /* @see \Composer\Util\SyncHelper::downloadAndInstallPackageSync */ + $download_promises[] = $download_result + // Prepare for install. + ->then(function () use ($library_package, $install_path) { + return $this->downloadManager->prepare('install', $library_package, $install_path); + }) + // Clean up after any download errors. + ->then(NULL, function ($e) use ($library_package, $install_path) { + $this->composer->getLoop() + ->wait([ + $this->downloadManager->cleanup('install', $library_package, $install_path), + ]); + throw $e; + }); + + // Install after the download resolves. + $libraries_to_install[] = [ + $library_name, + $library_package, + $install_path, + ]; + } + else { + // Attempt to install synchronously. + $install_result = $this->installPackage($library_package, $install_path, $processed_library); + if ($install_result instanceof PromiseInterface) { + $install_promises[] = $install_result; + } + } } } + + if (count($download_promises)) { + // Wait on the download asynchronous promises to resolve. + $this->waitOnPromises($download_promises); + } + + foreach ($libraries_to_install as $library_to_install) { + [$library_name, $library_package, $install_path] = $library_to_install; + $install_result = $this->installPackage($library_package, $install_path, $processed_libraries[$library_name]); + if ($install_result instanceof PromiseInterface) { + $install_promises[] = $install_result; + } + } + + if (count($install_promises)) { + // Wait on the install promises to resolve. + $this->composer->getLoop()->wait($install_promises); + } } /** - * Downloads a library package to disk. + * Wait synchronously for an array of promises to resolve. + * + * @param array $promises + * Promises to await. + */ + protected function waitOnPromises(array $promises) { + $progress = NULL; + if ($this->io instanceof ConsoleIO && !$this->io->isDebug() && count($promises) > 1 && !getenv('COMPOSER_DISABLE_PROGRESS_BAR')) { + // Disable progress bar by setting COMPOSER_DISABLE_PROGRESS_BAR=1 as we + // are unable to read composer's "--no-input" option easily from here + // without introducing extra complexity with the PluginEvents::COMMAND + // event. + $progress = $this->io->getProgressBar(); + } + $this->composer->getLoop()->wait($promises, $progress); + if ($progress) { + $progress->clear(); + } + } + + /** + * Installs a library package to disk. * * @param \Composer\Package\Package $library_package * The library package. * @param string $install_path * The package install path. - * @param array $ignore_patterns - * File patterns to ignore. - * @param array|null $rename - * Array mapping of files/folders to rename. + * @param array $processed_library + * The library definition. + * + * @return \React\Promise\PromiseInterface|void + * Returns a promise or void. */ - protected function downloadPackage(Package $library_package, $install_path, array $ignore_patterns, array $rename = NULL) { - $process_download = function () use ($install_path, $ignore_patterns, $rename) { + protected function installPackage(Package $library_package, $install_path, array $processed_library) { + $ignore_patterns = $processed_library['ignore']; + $rename = $processed_library['rename'] ?? NULL; + + $process_install = function () use ($library_package, $install_path, $ignore_patterns, $rename) { + if ($ignore_patterns || $rename) { + $package_name = $library_package->getName(); + $this->io->writeError(" - Processing $package_name files..."); + } // Delete files/folders according to the ignore pattern(s). if ($ignore_patterns) { @@ -411,17 +492,32 @@ function ($file) use ($patterns) { } }; - // Let composer download and unpack the library for us! - $promise = $this->downloadManager->download($library_package, $install_path); + if (version_compare(PluginInterface::PLUGIN_API_VERSION, '2.0', '>=')) { + // Install the package after downloading (Composer 2 only). + $promise = $this->downloadManager->install($library_package, $install_path); - // Composer v2 might return a promise here. - if ($promise && $promise instanceof PromiseInterface) { - $promise->then($process_download); - return; + if (!($promise instanceof PromiseInterface)) { + // Not a promise, create one that can be cleaned up after. + $promise = \React\Promise\resolve(); + } + + return $promise + ->then($process_install) + // Clean up after the install. + ->then(function () use ($library_package, $install_path) { + return $this->downloadManager->cleanup('install', $library_package, $install_path); + }, function ($e) use ($library_package, $install_path) { + // Clean up after any errors. + $this->composer->getLoop() + ->wait([ + $this->downloadManager->cleanup('install', $library_package, $install_path), + ]); + throw $e; + }); } - // Execute as normal (composer v1, or v2 without async) - $process_download(); + // Execute as normal (Composer v1) + return $process_install(); } /** @@ -500,7 +596,7 @@ public function getInstalledJsonPath() { '*' ); - if (!$installer_library_package || !$installer_library_package instanceof CompletePackage) { + if (!$installer_library_package || !$installer_library_package instanceof CompletePackageInterface) { // Could not resolve the package. The package is most likely being // uninstalled. return FALSE;