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
*/