diff --git a/src/Command/PluginListCommand.php b/src/Command/PluginListCommand.php index 70577f843f8..4fd0dd0cbcc 100644 --- a/src/Command/PluginListCommand.php +++ b/src/Command/PluginListCommand.php @@ -46,10 +46,11 @@ public static function defaultName(): string public function execute(Arguments $args, ConsoleIo $io): ?int { $loadedPluginsCollection = Plugin::getCollection(); - $config = PluginConfig::getAppConfig(); + $path = (string)$args->getOption('composer-path'); + $config = PluginConfig::getAppConfig($path ?: null); $table = [ - ['Plugin', 'Is Loaded', 'Only Debug', 'Only CLI', 'Optional'], + ['Plugin', 'Is Loaded', 'Only Debug', 'Only CLI', 'Optional', 'Version'], ]; if (empty($config)) { @@ -63,12 +64,14 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $onlyDebug = $options['onlyDebug'] ?? false; $onlyCli = $options['onlyCli'] ?? false; $optional = $options['optional'] ?? false; + $version = $options['version'] ?? ''; $table[] = [ $pluginName, $isLoaded ? 'X' : '', $onlyDebug ? 'X' : '', $onlyCli ? 'X' : '', $optional ? 'X' : '', + $version, ]; } $io->helper('Table')->output($table); @@ -85,6 +88,9 @@ public function execute(Arguments $args, ConsoleIo $io): ?int public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { $parser->setDescription('Displays all currently available plugins.'); + $parser->addOption('composer-path', [ + 'help' => 'The absolute path to the composer.lock file to retrieve the versions from', + ]); return $parser; } diff --git a/src/Core/PluginConfig.php b/src/Core/PluginConfig.php index cd7b99ea623..0d5676bba39 100644 --- a/src/Core/PluginConfig.php +++ b/src/Core/PluginConfig.php @@ -16,6 +16,7 @@ */ namespace Cake\Core; +use Cake\Core\Exception\CakeException; use Cake\Utility\Hash; /** @@ -57,9 +58,10 @@ public static function loadInstallerConfig(): void /** * Get the config how plugins should be loaded * + * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from * @return array */ - public static function getAppConfig(): array + public static function getAppConfig(?string $path = null): array { self::loadInstallerConfig(); @@ -71,11 +73,16 @@ public static function getAppConfig(): array $pluginLoadConfig = []; } + try { + $composerVersions = self::getVersions($path); + } catch (CakeException) { + $composerVersions = []; + } + $result = []; $availablePlugins = Configure::read('plugins', []); if ($availablePlugins && is_array($availablePlugins)) { - $availablePlugins = array_keys($availablePlugins); - foreach ($availablePlugins as $pluginName) { + foreach ($availablePlugins as $pluginName => $pluginPath) { if ($pluginLoadConfig && array_key_exists($pluginName, $pluginLoadConfig)) { $options = $pluginLoadConfig[$pluginName]; $hooks = PluginInterface::VALID_HOOKS; @@ -92,10 +99,27 @@ public static function getAppConfig(): array } else { $result[$pluginName]['isLoaded'] = false; } + + try { + $packageName = self::getPackageNameFromPath($pluginPath); + $result[$pluginName]['packagePath'] = $pluginPath; + $result[$pluginName]['package'] = $packageName; + } catch (CakeException) { + $packageName = null; + } + if ($composerVersions && $packageName) { + if (array_key_exists($packageName, $composerVersions['packages'])) { + $result[$pluginName]['version'] = $composerVersions['packages'][$packageName]; + $result[$pluginName]['isDevPackage'] = false; + } elseif (array_key_exists($packageName, $composerVersions['devPackages'])) { + $result[$pluginName]['version'] = $composerVersions['devPackages'][$packageName]; + $result[$pluginName]['isDevPackage'] = true; + } + } } } - $diff = array_diff(array_keys($pluginLoadConfig), $availablePlugins); + $diff = array_diff(array_keys($pluginLoadConfig), array_keys($availablePlugins)); foreach ($diff as $unknownPlugin) { $result[$unknownPlugin]['isLoaded'] = false; $result[$unknownPlugin]['isUnknown'] = true; @@ -103,4 +127,61 @@ public static function getAppConfig(): array return $result; } + + /** + * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from + * @return array + */ + public static function getVersions(?string $path = null): array + { + $lockFilePath = $path ?? ROOT . DIRECTORY_SEPARATOR . 'composer.lock'; + if (!file_exists($lockFilePath)) { + throw new CakeException(sprintf('composer.lock does not exist in %s', $lockFilePath)); + } + $lockFile = file_get_contents($lockFilePath); + if ($lockFile === false) { + throw new CakeException(sprintf('Could not read composer.lock: %s', $lockFilePath)); + } + $lockFileJson = json_decode($lockFile, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new CakeException(sprintf( + 'Error parsing composer.lock: %s', + json_last_error_msg() + )); + } + + $packages = Hash::combine($lockFileJson['packages'], '{n}.name', '{n}.version'); + $devPackages = Hash::combine($lockFileJson['packages-dev'], '{n}.name', '{n}.version'); + + return [ + 'packages' => $packages, + 'devPackages' => $devPackages, + ]; + } + + /** + * @param string $path + * @return string + */ + protected static function getPackageNameFromPath(string $path): string + { + $jsonPath = $path . DS . 'composer.json'; + if (!file_exists($jsonPath)) { + throw new CakeException(sprintf('composer.json does not exist in %s', $jsonPath)); + } + $jsonString = file_get_contents($jsonPath); + if ($jsonString === false) { + throw new CakeException(sprintf('Could not read composer.json: %s', $jsonPath)); + } + $json = json_decode($jsonString, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new CakeException(sprintf( + 'Error parsing %ss: %s', + $jsonPath, + json_last_error_msg() + )); + } + + return $json['name']; + } } diff --git a/tests/TestCase/Command/PluginListCommandTest.php b/tests/TestCase/Command/PluginListCommandTest.php index 34665458b18..ad5ba4b1438 100644 --- a/tests/TestCase/Command/PluginListCommandTest.php +++ b/tests/TestCase/Command/PluginListCommandTest.php @@ -176,4 +176,37 @@ public function testListUnknown(): void $this->exec('plugin list'); } + + /** + * Test listing vendor plugins with versions + */ + public function testListWithVersions(): void + { + $file = << [ + 'Chronos' => ROOT . '/vendor/cakephp/chronos', + 'CodeSniffer' => ROOT . '/vendor/cakephp/cakephp-codesniffer' + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + $path = ROOT . DS . 'tests' . DS . 'composer.lock'; + $this->exec(sprintf('plugin list --composer-path="%s"', $path)); + $this->assertOutputContains('| Chronos | X | | | | 3.0.4 |'); + $this->assertOutputContains('| CodeSniffer | X | | | | 5.1.1 |'); + } } diff --git a/tests/TestCase/Core/PluginConfigTest.php b/tests/TestCase/Core/PluginConfigTest.php index b6c1e09d86f..ec478869a82 100644 --- a/tests/TestCase/Core/PluginConfigTest.php +++ b/tests/TestCase/Core/PluginConfigTest.php @@ -255,4 +255,129 @@ public function testNoPluginConfig(): void ], ], PluginConfig::getAppConfig()); } + + public function testGetVersions(): void + { + $test = PluginConfig::getVersions(ROOT . DS . 'tests' . DS . 'composer.lock'); + $expected = [ + 'packages' => [ + 'cakephp/chronos' => '3.0.4', + 'psr/simple-cache' => '3.0.0', + ], + 'devPackages' => [ + 'cakephp/cakephp-codesniffer' => '5.1.1', + 'squizlabs/php_codesniffer' => '3.8.1', + 'theseer/tokenizer' => '1.2.2', + ], + ]; + $this->assertEquals($expected, $test); + } + + public function testSimpleConfiWithVersions(): void + { + $file = << [ + 'Chronos' => ROOT . DS . 'vendor' . DS . 'cakephp' . DS . 'chronos', + 'CodeSniffer' => ROOT . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp-codesniffer' + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + Configure::delete('plugins'); + $pathToRootVendor = ROOT . DS . 'vendor' . DS; + $result = [ + 'Chronos' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'packagePath' => $pathToRootVendor . 'cakephp' . DS . 'chronos', + 'package' => 'cakephp/chronos', + 'version' => '3.0.4', + 'isDevPackage' => false, + ], + 'CodeSniffer' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'packagePath' => $pathToRootVendor . 'cakephp' . DS . 'cakephp-codesniffer', + 'package' => 'cakephp/cakephp-codesniffer', + 'version' => '5.1.1', + 'isDevPackage' => true, + ], + ]; + $this->assertSame($result, PluginConfig::getAppConfig(ROOT . DS . 'tests' . DS . 'composer.lock')); + } + + public function testInvalidComposerLock(): void + { + $path = ROOT . DS . 'tests' . DS . 'unknown_composer.lock'; + $this->assertSame([], PluginConfig::getAppConfig($path)); + + file_put_contents($path, 'invalid-json'); + $this->assertSame([], PluginConfig::getAppConfig($path)); + unlink($path); + } + + public function testInvalidComposerJson(): void + { + $pathToTestPlugin = ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS; + $file = << [ + 'TestPlugin' => ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS, + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + file_put_contents($pathToTestPlugin . 'composer.json', 'invalid-json'); + + $this->assertSame([ + 'TestPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + ], + ], PluginConfig::getAppConfig()); + unlink($pathToTestPlugin . 'composer.json'); + } } diff --git a/tests/composer.lock b/tests/composer.lock new file mode 100644 index 00000000000..37a81cb2b47 --- /dev/null +++ b/tests/composer.lock @@ -0,0 +1,314 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8ba86848f9797e948641be63f29d81bf", + "packages": [ + { + "name": "cakephp/chronos", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/cakephp/chronos.git", + "reference": "9cb035acd10152a6b74df936986f15c4e6015bd3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/chronos/zipball/9cb035acd10152a6b74df936986f15c4e6015bd3", + "reference": "9cb035acd10152a6b74df936986f15c4e6015bd3", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^5.0", + "phpunit/phpunit": "^10.1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cake\\Chronos\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + }, + { + "name": "The CakePHP Team", + "homepage": "https://cakephp.org" + } + ], + "description": "A simple API extension for DateTime.", + "homepage": "https://cakephp.org", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "issues": "https://github.com/cakephp/chronos/issues", + "source": "https://github.com/cakephp/chronos" + }, + "time": "2023-10-17T07:41:48+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + } + ], + "packages-dev": [ + { + "name": "cakephp/cakephp-codesniffer", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/cakephp/cakephp-codesniffer.git", + "reference": "3227936e698774025a16fb808c28f92688672306" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/cakephp-codesniffer/zipball/3227936e698774025a16fb808c28f92688672306", + "reference": "3227936e698774025a16fb808c28f92688672306", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "phpstan/phpdoc-parser": "^1.4.5", + "slevomat/coding-standard": "^8.4", + "squizlabs/php_codesniffer": "^3.7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" + }, + "type": "phpcodesniffer-standard", + "autoload": { + "psr-4": { + "CakePHP\\": "CakePHP/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp-codesniffer/graphs/contributors" + } + ], + "description": "CakePHP CodeSniffer Standards", + "homepage": "https://cakephp.org", + "keywords": [ + "codesniffer", + "framework" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp-codesniffer/issues", + "source": "https://github.com/cakephp/cakephp-codesniffer" + }, + "time": "2023-04-09T13:00:25+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.8.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "14f5fff1e64118595db5408e946f3a22c75807f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/14f5fff1e64118595db5408e946f3a22c75807f7", + "reference": "14f5fff1e64118595db5408e946f3a22c75807f7", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2024-01-11T20:47:48+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2023-11-20T00:12:19+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +}