├── 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 |
--------------------------------------------------------------------------------