├── README.md ├── composer.json ├── LICENSE.md └── src ├── InvalidPluginException.php ├── Plugin.php └── Installer.php /README.md: -------------------------------------------------------------------------------- 1 | # Craft CMS Plugin Installer for Composer 2 | 3 | This is the Composer installer for [Craft CMS](https://craftcms.com/) plugins. It implements a new Composer package type named `craft-plugin`, which should be used by all Craft plugins. 4 | 5 | Full details about how to create plugins for Craft CMS can be found in the [Craft documentation](https://craftcms.com/docs/5.x/extend/plugin-guide.html). 6 | 7 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "craftcms/plugin-installer", 3 | "description": "Craft CMS Plugin Installer", 4 | "keywords": [ 5 | "cms", 6 | "composer", 7 | "craftcms", 8 | "installer", 9 | "plugin" 10 | ], 11 | "homepage": "https://craftcms.com/", 12 | "type": "composer-plugin", 13 | "support": { 14 | "email": "support@craftcms.com", 15 | "issues": "https://github.com/craftcms/cms/issues?state=open", 16 | "forum": "https://craftcms.stackexchange.com/", 17 | "source": "https://github.com/craftcms/cms", 18 | "docs": "https://craftcms.com/docs", 19 | "rss": "https://craftcms.com/changelog.rss" 20 | }, 21 | "license": "MIT", 22 | "minimum-stability": "stable", 23 | "require": { 24 | "php": ">=5.4", 25 | "composer-plugin-api": "^1.0 || ^2.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "craft\\composer\\": "src/" 30 | } 31 | }, 32 | "require-dev": { 33 | "composer/composer": "^1.0 || ^2.0" 34 | }, 35 | "extra": { 36 | "class": "craft\\composer\\Plugin" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Pixel & Tonic, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/InvalidPluginException.php: -------------------------------------------------------------------------------- 1 | package = $package; 34 | $this->error = $error; 35 | 36 | parent::__construct("Couldn't install " . $package->getPrettyName() . ': ' . $error, 0, $previous); 37 | } 38 | 39 | /** 40 | * @return PackageInterface 41 | */ 42 | public function getPackage() 43 | { 44 | return $this->package; 45 | } 46 | 47 | /** 48 | * @return string 49 | */ 50 | public function getError() 51 | { 52 | return $this->error; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class Plugin implements PluginInterface 19 | { 20 | /** 21 | * @var Installer 22 | */ 23 | private $installer; 24 | 25 | /** 26 | * @inheritdoc 27 | */ 28 | public function activate(Composer $composer, IOInterface $io) 29 | { 30 | // Register the plugin installer 31 | $this->installer = new Installer($io, $composer, 'craft-plugin'); 32 | $composer->getInstallationManager()->addInstaller($this->installer); 33 | 34 | // Is this a plugin at root? Elementary, my dear Watson 🕵️! 35 | if ($this->installer->supports($composer->getPackage()->getType())) { 36 | $this->installer->addPlugin($composer->getPackage(), true); 37 | } 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | public function deactivate(Composer $composer, IOInterface $io) 44 | { 45 | $composer->getInstallationManager()->removeInstaller($this->installer); 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function uninstall(Composer $composer, IOInterface $io) 52 | { 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Installer.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class Installer extends LibraryInstaller 22 | { 23 | const PLUGINS_FILE = 'craftcms/plugins.php'; 24 | 25 | /** 26 | * @inheritdoc 27 | */ 28 | public function install(InstalledRepositoryInterface $repo, PackageInterface $package) 29 | { 30 | $addPlugin = function() use ($repo, $package) { 31 | // Add the plugin info to plugins.php 32 | try { 33 | $this->addPlugin($package); 34 | } catch (InvalidPluginException $e) { 35 | // Rollback 36 | parent::uninstall($repo, $package); 37 | throw $e; 38 | } 39 | }; 40 | 41 | // Install the plugin in vendor/ like a normal Composer library 42 | $promise = parent::install($repo, $package); 43 | 44 | // Composer v2 might return a promise here 45 | if ($promise instanceof PromiseInterface) { 46 | return $promise->then($addPlugin); 47 | } 48 | 49 | $addPlugin(); 50 | return null; 51 | } 52 | 53 | /** 54 | * @inheritdoc 55 | */ 56 | public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target) 57 | { 58 | $updatePlugin = function() use ($repo, $initial, $target) { 59 | // Remove the old plugin info from plugins.php 60 | $initialPlugin = $this->removePlugin($initial); 61 | 62 | // Add the new plugin info to plugins.php 63 | try { 64 | $this->addPlugin($target); 65 | } catch (InvalidPluginException $e) { 66 | // Rollback 67 | parent::update($repo, $target, $initial); 68 | if ($initialPlugin !== null) { 69 | $this->registerPlugin($initial->getName(), $initialPlugin); 70 | } 71 | throw $e; 72 | } 73 | }; 74 | 75 | // Update the plugin in vendor/ like a normal Composer library 76 | $promise = parent::update($repo, $initial, $target); 77 | 78 | // Composer v2 might return a promise here 79 | if ($promise instanceof PromiseInterface) { 80 | return $promise->then($updatePlugin); 81 | } 82 | 83 | $updatePlugin(); 84 | return null; 85 | } 86 | 87 | /** 88 | * @inheritdoc 89 | */ 90 | public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $package) 91 | { 92 | $removePlugin = function() use ($package) { 93 | // Remove the plugin info from plugins.php 94 | $this->removePlugin($package); 95 | }; 96 | 97 | // Uninstall the plugin from vendor/ like a normal Composer library 98 | $promise = parent::uninstall($repo, $package); 99 | 100 | // Composer v2 might return a promise here 101 | if ($promise instanceof PromiseInterface) { 102 | return $promise->then($removePlugin); 103 | } 104 | 105 | $removePlugin(); 106 | return null; 107 | } 108 | 109 | /** 110 | * @param PackageInterface $package 111 | * @param bool $isRoot 112 | * @throws InvalidPluginException() if there's an issue with the plugin 113 | */ 114 | public function addPlugin(PackageInterface $package, $isRoot = false) 115 | { 116 | $extra = $package->getExtra(); 117 | $prettyName = $package->getPrettyName(); 118 | 119 | // Find the PSR-4 autoload aliases, the primary Plugin class, and base path 120 | $class = isset($extra['class']) ? $extra['class'] : null; 121 | $basePath = isset($extra['basePath']) ? $extra['basePath'] : null; 122 | $aliases = $this->generateDefaultAliases($package, $class, $basePath, $isRoot); 123 | 124 | // class + basePath (required) 125 | if ($class === null) { 126 | throw new InvalidPluginException($package, 'Unable to determine the Plugin class'); 127 | } 128 | 129 | if ($basePath === null) { 130 | throw new InvalidPluginException($package, 'Unable to determine the base path'); 131 | } 132 | 133 | // handle (required) 134 | if (!isset($extra['handle']) || !preg_match('/^_?[a-zA-Z][\w\-]*$/', $extra['handle'])) { 135 | throw new InvalidPluginException($package, 'Invalid or missing plugin handle'); 136 | } 137 | 138 | // normalize the handle (copied from craft\services\Plugins::_normalizeHandle()) 139 | $handle = $extra['handle']; 140 | if (strtolower($handle) !== $handle) { 141 | // copied from yii\helpers\BaseInflector::camel2id() w/ default options 142 | $handle = strtolower(trim(str_replace('_', '-', preg_replace('/(?io->write('' . $prettyName . ' uses the old plugin handle format ("' . $extra['handle'] . '"). It should be "' . $handle . '".'); 147 | } 148 | 149 | $plugin = [ 150 | 'class' => $class, 151 | 'basePath' => $basePath, 152 | 'handle' => $handle, 153 | ]; 154 | 155 | if ($aliases) { 156 | $plugin['aliases'] = $aliases; 157 | } 158 | 159 | if (strpos($prettyName, '/') !== false) { 160 | list($vendor, $name) = explode('/', $prettyName); 161 | } else { 162 | $vendor = null; 163 | $name = $prettyName; 164 | } 165 | 166 | // name 167 | if (isset($extra['name'])) { 168 | $plugin['name'] = $extra['name']; 169 | } else { 170 | $plugin['name'] = $name; 171 | } 172 | 173 | // version 174 | if (isset($extra['version'])) { 175 | $plugin['version'] = $extra['version']; 176 | } else { 177 | $plugin['version'] = $package->getPrettyVersion(); 178 | } 179 | 180 | // schemaVersion 181 | if (isset($extra['schemaVersion'])) { 182 | $plugin['schemaVersion'] = $extra['schemaVersion']; 183 | } 184 | 185 | // description 186 | if (isset($extra['description'])) { 187 | $plugin['description'] = $extra['description']; 188 | } else if ($package instanceof CompletePackageInterface && ($description = $package->getDescription())) { 189 | $plugin['description'] = $description; 190 | } 191 | 192 | // developer 193 | if (isset($extra['developer'])) { 194 | $plugin['developer'] = $extra['developer']; 195 | } else if ($authorName = $this->getAuthorProperty($package, 'name')) { 196 | $plugin['developer'] = $authorName; 197 | } else if ($vendor !== null) { 198 | $plugin['developer'] = $vendor; 199 | } 200 | 201 | // developerUrl 202 | if (isset($extra['developerUrl'])) { 203 | $plugin['developerUrl'] = $extra['developerUrl']; 204 | } else if ($package instanceof CompletePackageInterface && ($homepage = $package->getHomepage())) { 205 | $plugin['developerUrl'] = $homepage; 206 | } else if ($authorHomepage = $this->getAuthorProperty($package, 'homepage')) { 207 | $plugin['developerUrl'] = $authorHomepage; 208 | } 209 | 210 | // developerEmail 211 | if (isset($extra['developerEmail'])) { 212 | $plugin['developerEmail'] = $extra['developerEmail']; 213 | } else if ($package instanceof CompletePackageInterface && ($support = $package->getSupport()) && isset($support['email'])) { 214 | $plugin['developerEmail'] = $support['email']; 215 | } 216 | 217 | // documentationUrl 218 | if (isset($extra['documentationUrl'])) { 219 | $plugin['documentationUrl'] = $extra['documentationUrl']; 220 | } else if ($package instanceof CompletePackageInterface && ($support = $package->getSupport()) && isset($support['docs'])) { 221 | $plugin['documentationUrl'] = $support['docs']; 222 | } 223 | 224 | // changelogUrl 225 | // todo: check $extra['support']['changelog'] if that becomes a thing - https://github.com/composer/composer/issues/6079 226 | if (isset($extra['changelogUrl'])) { 227 | $plugin['changelogUrl'] = $extra['changelogUrl']; 228 | } 229 | 230 | // downloadUrl 231 | if (isset($extra['downloadUrl'])) { 232 | $plugin['downloadUrl'] = $extra['downloadUrl']; 233 | } 234 | 235 | // t9nCategory 236 | if (isset($extra['t9nCategory'])) { 237 | $plugin['t9nCategory'] = $extra['t9nCategory']; 238 | } 239 | 240 | // sourceLanguage 241 | if (isset($extra['sourceLanguage'])) { 242 | $plugin['sourceLanguage'] = $extra['sourceLanguage']; 243 | } 244 | 245 | // hasCpSettings 246 | if (isset($extra['hasCpSettings'])) { 247 | $plugin['hasCpSettings'] = (bool)$extra['hasCpSettings']; 248 | } 249 | 250 | // hasCpSection 251 | if (isset($extra['hasCpSection'])) { 252 | $plugin['hasCpSection'] = (bool)$extra['hasCpSection']; 253 | } 254 | 255 | // components 256 | if (isset($extra['components'])) { 257 | $plugin['components'] = $extra['components']; 258 | } 259 | 260 | // modules 261 | if (isset($extra['modules'])) { 262 | $plugin['modules'] = $extra['modules']; 263 | } 264 | 265 | // minimum required version 266 | if (isset($extra['minVersionRequired'])) { 267 | $plugin['minVersionRequired'] = $extra['minVersionRequired']; 268 | } 269 | 270 | $this->registerPlugin($package->getName(), $plugin); 271 | } 272 | 273 | /** 274 | * @param string $name The plugin's package name 275 | * @param array $plugin The plugin config 276 | */ 277 | protected function registerPlugin($name, array $plugin) 278 | { 279 | $plugins = $this->loadPlugins(); 280 | $plugins[$name] = $plugin; 281 | $this->savePlugins($plugins); 282 | } 283 | 284 | /** 285 | * @param PackageInterface $package 286 | * @param $class 287 | * @param $basePath 288 | * @param bool $isRoot 289 | * @return array|null 290 | */ 291 | protected function generateDefaultAliases(PackageInterface $package, &$class, &$basePath, $isRoot) 292 | { 293 | $autoload = $package->getAutoload(); 294 | 295 | if (empty($autoload['psr-4'])) { 296 | return null; 297 | } 298 | 299 | $fs = new Filesystem(); 300 | $vendorDir = $fs->normalizePath($this->vendorDir); 301 | $aliases = []; 302 | 303 | foreach ($autoload['psr-4'] as $namespace => $path) { 304 | if (is_array($path)) { 305 | // Yii doesn't support aliases that point to multiple base paths 306 | continue; 307 | } 308 | 309 | // Normalize $path to an absolute path 310 | $cwd = $fs->normalizePath(getcwd()); 311 | if (!$fs->isAbsolutePath($path)) { 312 | if ($isRoot) { 313 | $path = $cwd . '/' . $path; 314 | } else { 315 | $path = $this->vendorDir . '/' . $package->getPrettyName() . '/' . $path; 316 | } 317 | } 318 | 319 | $path = $fs->normalizePath($path); 320 | $alias = '@' . str_replace('\\', '/', trim($namespace, '\\')); 321 | 322 | $aliases[$alias] = $this->_path($vendorDir, $cwd, $path); 323 | 324 | // If we're still looking for the primary Plugin class, see if it's in here 325 | if ($class === null && file_exists($path . '/Plugin.php')) { 326 | $class = $namespace . 'Plugin'; 327 | } 328 | 329 | // If we're still looking for the base path but we know the primary Plugin class, 330 | // see if the class namespace matches up, and the file is in here. 331 | // If so, set the base path to whatever directory contains the plugin class. 332 | if ($basePath === null && $class !== null) { 333 | $n = strlen($namespace); 334 | if (strncmp($namespace, $class, $n) === 0) { 335 | $testClassPath = $path . '/' . str_replace('\\', '/', substr($class, $n)) . '.php'; 336 | if (file_exists($testClassPath)) { 337 | $basePath = $this->_path($vendorDir, $cwd, dirname($testClassPath)); 338 | } 339 | } 340 | } 341 | } 342 | 343 | return $aliases; 344 | } 345 | 346 | /** 347 | * @param PackageInterface $package 348 | * @param string $property 349 | * 350 | * @return null 351 | */ 352 | protected function getAuthorProperty(PackageInterface $package, $property) 353 | { 354 | if (!$package instanceof CompletePackageInterface) { 355 | return null; 356 | } 357 | 358 | $authors = $package->getAuthors(); 359 | if (empty($authors)) { 360 | return null; 361 | } 362 | 363 | $firstAuthor = reset($authors); 364 | 365 | if (!isset($firstAuthor[$property])) { 366 | return null; 367 | } 368 | 369 | return $firstAuthor[$property]; 370 | } 371 | 372 | /** 373 | * @param PackageInterface $package 374 | * 375 | * @return array|null The removed plugin info, or null if it wasn't there in the first place 376 | */ 377 | protected function removePlugin(PackageInterface $package) 378 | { 379 | return $this->unregisterPlugin($package->getName()); 380 | } 381 | 382 | /** 383 | * @param string $name The plugin's package name 384 | * 385 | * @return array|null The removed plugin info, or null if it wasn't there in the first place 386 | */ 387 | protected function unregisterPlugin($name) 388 | { 389 | $plugins = $this->loadPlugins(); 390 | 391 | if (!isset($plugins[$name])) { 392 | return null; 393 | } 394 | 395 | $plugin = $plugins[$name]; 396 | unset($plugins[$name]); 397 | $this->savePlugins($plugins); 398 | return $plugin; 399 | } 400 | 401 | /** 402 | * @return array|mixed 403 | */ 404 | protected function loadPlugins() 405 | { 406 | $file = $this->vendorDir . '/' . static::PLUGINS_FILE; 407 | 408 | if (!is_file($file)) { 409 | return []; 410 | } 411 | 412 | // Invalidate opcache of plugins.php if it exists 413 | if (function_exists('opcache_invalidate')) { 414 | @opcache_invalidate($file, true); 415 | } 416 | 417 | /** @var array $plugins */ 418 | $plugins = require($file); 419 | 420 | // Swap absolute paths with tags 421 | $vendorDir = str_replace('\\', '/', $this->vendorDir); 422 | $cwd = getcwd(); 423 | 424 | foreach ($plugins as &$plugin) { 425 | // basePath 426 | if (isset($plugin['basePath'])) { 427 | $plugin['basePath'] = $this->_path($vendorDir, $cwd, $plugin['basePath']); 428 | } 429 | // aliases 430 | if (isset($plugin['aliases'])) { 431 | foreach ($plugin['aliases'] as $alias => $path) { 432 | $plugin['aliases'][$alias] = $this->_path($vendorDir, $cwd, $plugin['aliases'][$alias]); 433 | } 434 | } 435 | } 436 | 437 | return $plugins; 438 | } 439 | 440 | /** 441 | * @param array $plugins 442 | */ 443 | protected function savePlugins(array $plugins) 444 | { 445 | $file = $this->vendorDir . '/' . static::PLUGINS_FILE; 446 | 447 | if (!file_exists(dirname($file))) { 448 | mkdir(dirname($file), 0777, true); 449 | } 450 | 451 | $array = str_replace(["'", "'"], ['$vendorDir . \'', '$rootDir . \''], var_export($plugins, true)); 452 | $fs = new Filesystem(); 453 | file_put_contents($file, "findShortestPathCode($this->vendorDir . '/craftcms', getcwd(), true) . ";\n\n" . 455 | "return $array;\n"); 456 | 457 | // Invalidate opcache of plugins.php if it exists 458 | if (function_exists('opcache_invalidate')) { 459 | @opcache_invalidate($file, true); 460 | } 461 | } 462 | 463 | /** 464 | * Prepares a path for inclusion in plugins.php. 465 | * 466 | * @param string $vendorDir The path to the vendor/ folder 467 | * @param string $cwd The current working directory 468 | * @param string $path The path to be normalized 469 | * @return string 470 | */ 471 | private function _path(string $vendorDir, string $cwd, string $path): string 472 | { 473 | $path = str_replace('\\', '/', $path); 474 | if (strpos("$path/", "$vendorDir/") === 0) { 475 | return '' . substr($path, strlen($vendorDir)); 476 | } 477 | if (strpos("$path/", "$cwd/") === 0) { 478 | return '' . substr($path, strlen($cwd)); 479 | } 480 | return $path; 481 | } 482 | } 483 | --------------------------------------------------------------------------------