diff --git a/.gitignore b/.gitignore index 3582897e81..667d3c33cb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ composer.lock .env.php selenium.php /bootstrap/compiled.php +/storage/framework/packages.json .phpunit.result.cache # Hosting ignores diff --git a/composer.json b/composer.json index 553c7ddb97..368d06e4ca 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,9 @@ "winter/wn-backend-module": "dev-develop", "winter/wn-cms-module": "dev-develop", "laravel/framework": "^9.1", - "wikimedia/composer-merge-plugin": "~2.1.0" + "winter/packager": "*", + "wikimedia/composer-merge-plugin": "~2.1.0", + "jaxwilko/composer-winter-plugin": "dev-main" }, "require-dev": { "phpunit/phpunit": "^9.5.8", @@ -82,7 +84,8 @@ "config": { "allow-plugins": { "composer/installers": true, - "wikimedia/composer-merge-plugin": true + "wikimedia/composer-merge-plugin": true, + "jaxwilko/composer-winter-plugin": true } } } diff --git a/modules/system/classes/PluginBase.php b/modules/system/classes/PluginBase.php index 466279660b..6e296733f8 100644 --- a/modules/system/classes/PluginBase.php +++ b/modules/system/classes/PluginBase.php @@ -53,6 +53,11 @@ class PluginBase extends ServiceProviderBase */ public $disabled = false; + /** + * @var ?array The composer package details for this plugin. + */ + protected ?array $composerPackage = null; + /** * Returns information about this plugin, including plugin name and developer name. * @@ -477,6 +482,30 @@ public function getPluginVersions(bool $includeScripts = true): array return $versions; } + /** + * Set the composer package property for the plugin + */ + public function setComposerPackage(?array $package): void + { + $this->composerPackage = $package; + } + + /** + * Get the composer package details + */ + public function getComposerPackage(): ?array + { + return $this->composerPackage; + } + + /** + * Get the composer package name + */ + public function getComposerPackageName(): ?string + { + return $this->composerPackage['name'] ?? null; + } + /** * Verifies the plugin's dependencies are present and enabled */ diff --git a/modules/system/classes/PluginManager.php b/modules/system/classes/PluginManager.php index 5bc1d61fc1..3117f7e37e 100644 --- a/modules/system/classes/PluginManager.php +++ b/modules/system/classes/PluginManager.php @@ -10,10 +10,12 @@ use Cache; use Config; use Schema; +use Storage; use SystemException; use FilesystemIterator; use RecursiveIteratorIterator; use RecursiveDirectoryIterator; +use System\Classes\Packager\Composer; use System\Models\PluginVersion; use Winter\Storm\Foundation\Application; use Winter\Storm\Support\ClassLoader; @@ -61,6 +63,11 @@ class PluginManager */ protected $pluginFlags = []; + /** + * @var array Array of packages installed via composer + */ + protected $composerPackages = []; + /** * @var PluginVersion[] Local cache of loaded PluginVersion records keyed by plugin code */ @@ -108,6 +115,9 @@ protected function init(): void { $this->app = App::make('app'); + // Load the packages registered via composer + $this->loadComposer(); + // Load the plugins from the filesystem and sort them by dependencies $this->loadPlugins(); @@ -119,6 +129,35 @@ protected function init(): void $this->registerPluginReplacements(); } + public function loadComposer(): array + { + return $this->composerPackages = Cache::rememberForever(Composer::COMPOSER_CACHE_KEY, function () { + $packageFile = Storage::path('../framework/packages.json'); + if (!is_file($packageFile)) { + return []; + } + $packages = []; + $installed = json_decode(file_get_contents($packageFile), JSON_OBJECT_AS_ARRAY); + foreach ($installed as $package) { + switch ($package['type']) { + case 'winter-plugin': + $packages['plugins'][$package['path']] = $package; + break; + case 'winter-module': + $packages['modules'][$package['path']] = $package; + break; + case 'winter-theme': + $packages['themes'][$package['path']] = $package; + break; + default: + break; + } + } + + return $packages; + }); + } + /** * Finds all available plugins and loads them in to the $this->plugins array. */ @@ -180,6 +219,8 @@ public function loadPlugin(string $namespace, string $path): ?PluginBase $this->plugins[$lowerClassId] = $pluginObj; $this->normalizedMap[$lowerClassId] = $classId; + $pluginObj->setComposerPackage($this->composerPackages['plugins'][$path] ?? null); + $replaces = $pluginObj->getReplaces(); if ($replaces) { foreach ($replaces as $replace) { @@ -471,6 +512,17 @@ public function findByIdentifier(PluginBase|string $identifier, bool $ignoreRepl return $this->plugins[$identifier] ?? null; } + public function findByComposerPackage(string $identifier): ?PluginBase + { + foreach ($this->getAllPlugins() as $plugin) { + if ($plugin->getComposerPackageName() === $identifier) { + return $plugin; + } + } + + return null; + } + /** * Checks to see if a plugin has been registered. */ diff --git a/modules/system/classes/packager/Composer.php b/modules/system/classes/packager/Composer.php new file mode 100644 index 0000000000..ad61c0a9d9 --- /dev/null +++ b/modules/system/classes/packager/Composer.php @@ -0,0 +1,55 @@ +|string + */ +class Composer +{ + public const COMPOSER_CACHE_KEY = 'winter.system.composer'; + + protected static PackagerComposer $composer; + + public static function make(bool $fresh = false): PackagerComposer + { + if (!$fresh && isset(static::$composer)) { + return static::$composer; + } + + static::$composer = new PackagerComposer(); + static::$composer->setWorkDir(base_path()); + + static::$composer->setCommand('remove', new RemoveCommand(static::$composer)); + static::$composer->setCommand('require', new RequireCommand(static::$composer)); + static::$composer->setCommand('search', new SearchCommand(static::$composer)); + static::$composer->setCommand('show', new ShowCommand(static::$composer)); + static::$composer->setCommand('update', new UpdateCommand(static::$composer)); + + return static::$composer; + } + + public static function __callStatic(string $name, array $args = []): mixed + { + if (!isset(static::$composer)) { + static::make(); + } + + return static::$composer->{$name}(...$args); + } +} diff --git a/modules/system/classes/packager/commands/RemoveCommand.php b/modules/system/classes/packager/commands/RemoveCommand.php new file mode 100644 index 0000000000..d533256438 --- /dev/null +++ b/modules/system/classes/packager/commands/RemoveCommand.php @@ -0,0 +1,70 @@ +package = $package; + $this->dryRun = $dryRun; + } + + /** + * @inheritDoc + */ + public function arguments(): array + { + $arguments = []; + + if ($this->dryRun) { + $arguments['--dry-run'] = true; + } + + $arguments['packages'] = [$this->package]; + + return $arguments; + } + + public function execute() + { + $output = $this->runComposerCommand(); + $message = implode(PHP_EOL, $output['output']); + + if ($output['code'] !== 0) { + throw new CommandException($message); + } + + Cache::forget(Composer::COMPOSER_CACHE_KEY); + + return $message; + } + + /** + * @inheritDoc + */ + public function getCommandName(): string + { + return 'remove'; + } +} diff --git a/modules/system/classes/packager/commands/RequireCommand.php b/modules/system/classes/packager/commands/RequireCommand.php new file mode 100644 index 0000000000..643c6c4334 --- /dev/null +++ b/modules/system/classes/packager/commands/RequireCommand.php @@ -0,0 +1,77 @@ +package = $package; + $this->dryRun = $dryRun; + $this->dev = $dev; + } + + /** + * @inheritDoc + */ + public function arguments(): array + { + $arguments = []; + + if ($this->dryRun) { + $arguments['--dry-run'] = true; + } + + if ($this->dev) { + $arguments['--dev'] = true; + } + + $arguments['packages'] = [$this->package]; + + return $arguments; + } + + public function execute() + { + $output = $this->runComposerCommand(); + $message = implode(PHP_EOL, $output['output']); + + if ($output['code'] !== 0) { + throw new CommandException($message); + } + + Cache::forget(Composer::COMPOSER_CACHE_KEY); + + return $message; + } + + /** + * @inheritDoc + */ + public function getCommandName(): string + { + return 'require'; + } +} diff --git a/modules/system/classes/packager/commands/SearchCommand.php b/modules/system/classes/packager/commands/SearchCommand.php new file mode 100644 index 0000000000..0b45343fcd --- /dev/null +++ b/modules/system/classes/packager/commands/SearchCommand.php @@ -0,0 +1,23 @@ +runComposerCommand(); + + if ($output['code'] !== 0) { + throw new CommandException(implode(PHP_EOL, $output['output'])); + } + + $this->results = json_decode(implode(PHP_EOL, $output['output']), true) ?? []; + + return $this; + } +} diff --git a/modules/system/classes/packager/commands/ShowCommand.php b/modules/system/classes/packager/commands/ShowCommand.php new file mode 100644 index 0000000000..cb07fe2188 --- /dev/null +++ b/modules/system/classes/packager/commands/ShowCommand.php @@ -0,0 +1,66 @@ +path = $path; + } + + /** + * @inheritDoc + */ + public function arguments(): array + { + $arguments = []; + + if (!empty($this->package)) { + $arguments['package'] = $this->package; + } + + if ($this->mode !== 'installed') { + $arguments['--' . $this->mode] = true; + } + + if ($this->noDev) { + $arguments['--no-dev'] = true; + } + + if ($this->path) { + $arguments['--path'] = true; + } + + $arguments['--format'] = 'json'; + + return $arguments; + } +} diff --git a/modules/system/classes/packager/commands/UpdateCommand.php b/modules/system/classes/packager/commands/UpdateCommand.php new file mode 100644 index 0000000000..8a15ec9680 --- /dev/null +++ b/modules/system/classes/packager/commands/UpdateCommand.php @@ -0,0 +1,88 @@ +executed) { + return; + } + + $this->includeDev = $includeDev; + $this->lockFileOnly = $lockFileOnly; + $this->ignorePlatformReqs = $ignorePlatformReqs; + $this->ignoreScripts = $ignoreScripts; + $this->dryRun = $dryRun; + $this->package = $package; + + if (in_array($installPreference, [self::PREFER_NONE, self::PREFER_DIST, self::PREFER_SOURCE])) { + $this->installPreference = $installPreference; + } + } + + /** + * @inheritDoc + */ + public function arguments(): array + { + $arguments = []; + + if ($this->dryRun) { + $arguments['--dry-run'] = true; + } + + if ($this->lockFileOnly) { + $arguments['--no-install'] = true; + } + + if ($this->ignorePlatformReqs) { + $arguments['--ignore-platform-reqs'] = true; + } + + if ($this->ignoreScripts) { + $arguments['--no-scripts'] = true; + } + + if (in_array($this->installPreference, [self::PREFER_DIST, self::PREFER_SOURCE])) { + $arguments['--prefer-' . $this->installPreference] = true; + } + + if ($this->package) { + $arguments['--'] = true; + $arguments[$this->package] = true; + } + + return $arguments; + } + + public function execute() + { + Cache::forget(Composer::COMPOSER_CACHE_KEY); + return parent::execute(); + } +} diff --git a/modules/system/console/PluginInstall.php b/modules/system/console/PluginInstall.php index 113fe40268..791a029283 100644 --- a/modules/system/console/PluginInstall.php +++ b/modules/system/console/PluginInstall.php @@ -1,8 +1,11 @@ argument('plugin'); - $manager = UpdateManager::instance()->setNotesOutput($this->output); + $manager = null; - $pluginDetails = $manager->requestPluginDetails($pluginName); - - $code = array_get($pluginDetails, 'code'); - $hash = array_get($pluginDetails, 'hash'); + if (strpos($pluginName, '/')) { + $code = $this->composerInstall($pluginName); + if (!$code) { + return; + } + } elseif (strpos($pluginName, '.')) { + $manager = UpdateManager::instance()->setNotesOutput($this->output); + $code = $this->winterInstall($pluginName, $manager); + } - $this->output->writeln(sprintf('Downloading plugin: %s', $code)); - $manager->downloadPlugin($code, $hash, true); - - $this->output->writeln(sprintf('Unpacking plugin: %s', $code)); - $manager->extractPlugin($code, $hash); + if (!$manager) { + $manager = UpdateManager::instance()->setNotesOutput($this->output); + } /* * Make sure plugin is registered @@ -64,4 +70,46 @@ public function handle() $this->output->writeln(sprintf('Migrating plugin...', $code)); $manager->updatePlugin($code); } + + public function winterInstall(string $pluginName, UpdateManager $manager): string + { + $pluginDetails = $manager->requestPluginDetails($pluginName); + + $code = array_get($pluginDetails, 'code'); + $hash = array_get($pluginDetails, 'hash'); + + $this->output->writeln(sprintf('Downloading plugin: %s', $code)); + $manager->downloadPlugin($code, $hash, true); + + $this->output->writeln(sprintf('Unpacking plugin: %s', $code)); + $manager->extractPlugin($code, $hash); + + return $code; + } + + public function composerInstall(string $pluginName): ?string + { + try { + $result = Composer::search($pluginName, 'winter-plugin')->getResults(); + + if (count($result) > 1) { + throw new SystemException('More than 1 plugin returned via composer search'); + } + + if (count($result) === 0) { + throw new SystemException('Plugin could not be found'); + } + + Composer::require($pluginName); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + return null; + } + + PluginManager::forgetInstance(); + UpdateManager::forgetInstance(); + VersionManager::forgetInstance(); + + return PluginManager::instance()->findByComposerPackage($pluginName)->getPluginIdentifier(); + } } diff --git a/modules/system/console/PluginList.php b/modules/system/console/PluginList.php index f7883c34d4..b6d7f3d640 100644 --- a/modules/system/console/PluginList.php +++ b/modules/system/console/PluginList.php @@ -2,6 +2,7 @@ use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\TableSeparator; +use System\Classes\PluginManager; use System\Models\PluginVersion; use Winter\Storm\Console\Command; @@ -34,24 +35,25 @@ class PluginList extends Command */ public function handle() { - $allPlugins = PluginVersion::all(); - $pluginsCount = count($allPlugins); + $plugins = PluginManager::instance()->getAllPlugins(); + $pluginVersions = PluginVersion::all()->keyBy('code'); - if ($pluginsCount <= 0) { + if (count($plugins) <= 0) { $this->info('No plugin found'); return; } $rows = []; - foreach ($allPlugins as $plugin) { + foreach ($plugins as $plugin) { $rows[] = [ - $plugin->code, - $plugin->version, - (!$plugin->is_frozen) ? 'Yes': 'No', - (!$plugin->is_disabled) ? 'Yes': 'No', + $plugin->getPluginIdentifier(), + $plugin->package, + $plugin->getPluginVersion(), + (!$pluginVersions[$plugin->getPluginIdentifier()]->is_frozen) ? 'Yes': 'No', + (!$pluginVersions[$plugin->getPluginIdentifier()]->is_disabled) ? 'Yes': 'No', ]; } - $this->table(['Plugin name', 'Version', 'Updates enabled', 'Plugin enabled'], $rows); + $this->table(['Plugin name', 'Composer Package', 'Version', 'Updates enabled', 'Plugin enabled'], $rows); } } diff --git a/modules/system/console/PluginRemove.php b/modules/system/console/PluginRemove.php index d442fb1514..55cb5d03b7 100644 --- a/modules/system/console/PluginRemove.php +++ b/modules/system/console/PluginRemove.php @@ -1,6 +1,7 @@ rollbackPlugin($pluginName); } + $plugin = $pluginManager->findByIdentifier($pluginName); + + /** + * Uninstall composer package + */ + if ($package = $plugin->getComposerPackageName()) { + $this->output->writeln(sprintf('Removing composer package: %s', $package)); + $this->output->write(Composer::remove($package) . PHP_EOL); + return 0; + } + /* * Delete from file system */