Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,38 @@ independent of the application's logging:
- `plugin:info {id}` - Show information about a plugin
- `plugin:enable {id}` - Enable a plugin
- `plugin:disable {id}` - Disable a plugin
- `plugin:install {package}` - Install a plugin
- `plugin:install {package}` - Install a plugin from a Composer package, a repository URL, or a local path
- `plugin:discover` - Discover and register all available plugins
- `plugin:cache` - Compile discovered plugins into a cache file
- `plugin:clear` - Remove the plugin cache file

### Plugin dependencies

When a plugin is installed from a URL or a local path, the engine reads the
plugin's own `composer.json` and installs its `require` dependencies into the
host application, so the plugin's classes are available at runtime.

For hosts that embed several plugins, enable
[`wikimedia/composer-merge-plugin`](https://github.com/wikimedia/composer-merge-plugin)
so each plugin keeps owning its dependencies while Composer resolves them
together into the single host `vendor/`:

```jsonc
// host composer.json
"extra": {
"merge-plugin": {
"include": ["plugins/*/composer.json"]
}
},
"config": {
"allow-plugins": { "wikimedia/composer-merge-plugin": true }
}
```

With this configured, installing a plugin runs a targeted `composer update`;
otherwise the engine falls back to `composer require` for the plugin's
dependencies.

### Caching

By default, plugins are discovered by scanning the plugins directory and
Expand Down
188 changes: 180 additions & 8 deletions src/Console/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace WhileSmart\LaravelPluginEngine\Console\Commands;

use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
use WhileSmart\LaravelPluginEngine\Services\ComposerRunner;
use WhileSmart\LaravelPluginEngine\Services\PluginManager;

class InstallCommand extends PluginCommand
{
protected $signature = 'plugin:install
{package : The package name (vendor/name) or URL of the plugin to install}
{package : The package name (vendor/name), URL, or local path of the plugin to install}
{--dev : Install development dependencies}'
.'{--no-dev : Do not install development dependencies}'
.'{--no-scripts : Skip running installation scripts}'
Expand All @@ -22,19 +25,29 @@ class InstallCommand extends PluginCommand

protected $description = 'Install a plugin';

public function __construct(PluginManager $pluginManager, protected ComposerRunner $composer)
Comment thread
nfebe marked this conversation as resolved.
{
parent::__construct($pluginManager);
}

public function handle()
{
$package = $this->argument('package');

try {
$this->info("Installing plugin: {$package}");

// Check if it's a URL or a package name
// A remote repository URL: clone it, then install from the clone.
if (filter_var($package, FILTER_VALIDATE_URL)) {
return $this->installFromUrl($package);
}

// Otherwise, treat as a Composer package
// A local directory holding the plugin source: copy it into place.
if (is_dir($package)) {
return $this->installFromLocalPath($package);
}

// Otherwise, treat as a Composer package.
return $this->installWithComposer($package);

} catch (\Exception $e) {
Expand Down Expand Up @@ -72,7 +85,7 @@ protected function installFromUrl(string $url): int
throw new \RuntimeException($process->getErrorOutput());
}

// Install the plugin
// Move the clone into the plugins directory and finish the install.
return $this->installFromPath($tempDir, $pluginName);

} finally {
Expand All @@ -81,6 +94,21 @@ protected function installFromUrl(string $url): int
}
}

protected function installFromLocalPath(string $path): int
{
$path = rtrim($path, '/');
$pluginName = basename($path);

$this->info("Installing plugin from path: {$path}");

$targetPath = $this->resolveTargetPath($pluginName);

// Copy rather than move, so the user's source directory is left intact.
File::copyDirectory($path, $targetPath);

return $this->finalizeInstall($targetPath);
}

protected function installWithComposer(string $package): int
{
$this->info("Installing plugin using Composer: {$package}");
Expand All @@ -106,31 +134,175 @@ protected function installWithComposer(string $package): int
}

protected function installFromPath(string $path, string $pluginName): int
{
$targetPath = $this->resolveTargetPath($pluginName);

// Move the plugin to the plugins directory
rename($path, $targetPath);

return $this->finalizeInstall($targetPath);
}

/**
* Resolve and validate the destination directory for a plugin, creating
* the plugins directory when it does not yet exist.
*/
protected function resolveTargetPath(string $pluginName): string
{
$pluginsPath = config('plugins.path', base_path('plugins'));
$targetPath = rtrim($pluginsPath, '/').'/'.$pluginName;

// Create plugins directory if it doesn't exist
if (! is_dir($pluginsPath)) {
mkdir($pluginsPath, 0755, true);
}

// Check if plugin already exists
if (is_dir($targetPath)) {
throw new \RuntimeException("Plugin directory already exists: {$targetPath}");
}

// Move the plugin to the plugins directory
rename($path, $targetPath);
return $targetPath;
}

/**
* Shared tail of every non-Composer install: install the plugin's own
* dependencies, then discover it.
*/
protected function finalizeInstall(string $targetPath): int
{
$this->info("Plugin installed successfully to: {$targetPath}");

$this->installPluginDependencies($targetPath);

// Run plugin discovery
$this->call('plugin:discover');

return 0;
}

/**
* Install the dependencies a plugin declares in its own composer.json.
*
* The webservice loads a plugin by reading plugin.json and PSR-4
* registering its namespace; it never reads the plugin's composer.json.
* So without this step the plugin's `require` is never resolved and its
* classes are missing at runtime.
*/
protected function installPluginDependencies(string $targetPath): void
{
$composerFile = $targetPath.'/composer.json';

if (! is_file($composerFile)) {
return;
}

$manifest = json_decode((string) file_get_contents($composerFile), true);
Comment thread
nfebe marked this conversation as resolved.

if (! is_array($manifest)) {
$this->warn('Plugin composer.json is not valid JSON; skipping dependency install.');

return;
}

$packages = $this->resolvablePackages($manifest['require'] ?? []);

if (empty($packages)) {
return;
}

// Register any repositories the plugin declares (VCS, path, ...) so its
// requires can be resolved against them.
foreach (($manifest['repositories'] ?? []) as $name => $repository) {
$repoName = is_string($name) ? $name : 'plugin-'.substr(md5(json_encode($repository)), 0, 8);

$this->composer->run(
['composer', 'config', '--no-interaction', 'repositories.'.$repoName, json_encode($repository)],
base_path()
);
}

// When the host merges plugin composer.json files (the wikimedia
// composer-merge-plugin pattern), the plugin's `require` is already
// part of the resolved set, so a targeted `composer update` installs
// it while leaving ownership with the plugin. Otherwise fall back to
// `composer require`, which resolves the packages against the host's
// existing constraints and records them in the root composer.json.
$verb = $this->hostMergesPluginManifests() ? 'update' : 'require';

$command = array_merge(
['composer', $verb, '--no-interaction'],
$packages,
$this->getComposerOptions()
);

$this->info('Installing plugin dependencies: '.implode(', ', $packages));

$ok = $this->composer->run($command, base_path(), function ($type, $buffer) {
$this->output->write($buffer);
});

if (! $ok) {
throw new \RuntimeException('Failed to install plugin dependencies.');
}
}

/**
* Reduce a composer `require` map to the real packages to install,
* dropping platform constraints (php, ext-*, lib-*) that Composer
* checks but never downloads.
*
* @param array<string, string> $require
* @return array<int, string>
*/
protected function resolvablePackages(array $require): array
{
$packages = [];

foreach ($require as $name => $constraint) {
if ($name === 'php' || Str::startsWith($name, ['ext-', 'lib-'])) {
continue;
}

$packages[] = $name.':'.$constraint;
Comment thread
nfebe marked this conversation as resolved.
}

return $packages;
}

/**
* Whether the host application merges plugin composer.json files into its
* own dependency resolution (wikimedia/composer-merge-plugin configured to
* include the plugins path).
*/
protected function hostMergesPluginManifests(): bool
{
$rootComposer = base_path('composer.json');

if (! is_file($rootComposer)) {
return false;
}

$config = json_decode((string) file_get_contents($rootComposer), true);
Comment thread
nfebe marked this conversation as resolved.
$includes = $config['extra']['merge-plugin']['include'] ?? [];
$includes = is_array($includes) ? $includes : [$includes];

if (empty($includes)) {
return false;
}

$pluginsPath = config('plugins.path', base_path('plugins'));
$relative = trim(str_replace('\\', '/', Str::after($pluginsPath, base_path())), '/');

foreach ($includes as $pattern) {
$normalizedPattern = ltrim(str_replace('\\', '/', $pattern), './');

if ($relative !== '' && Str::startsWith($normalizedPattern, $relative)) {
return true;
}
}

return false;
}

protected function getComposerOptions(): array
{
$options = [];
Expand Down
4 changes: 4 additions & 0 deletions src/Providers/PluginServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use WhileSmart\LaravelPluginEngine\Console\Commands\InstallCommand;
use WhileSmart\LaravelPluginEngine\Console\Commands\ListCommand;
use WhileSmart\LaravelPluginEngine\Logging\LevelFilteringLogger;
use WhileSmart\LaravelPluginEngine\Services\ComposerRunner;
use WhileSmart\LaravelPluginEngine\Services\PluginManager;
use WhileSmart\LaravelPluginEngine\Services\SymfonyComposerRunner;

class PluginServiceProvider extends ServiceProvider
{
Expand Down Expand Up @@ -47,6 +49,8 @@ public function register(): void

$this->registerPluginManager();
$this->registerCommands();

$this->app->bind(ComposerRunner::class, SymfonyComposerRunner::class);
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/Services/ComposerRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace WhileSmart\LaravelPluginEngine\Services;

/**
* Runs Composer commands for the plugin engine. Abstracted behind an
* interface so installs can be exercised in tests without shelling out.
*/
interface ComposerRunner
{
/**
* Run a Composer command in the given working directory.
*
* @param array<int, string> $command Full command, e.g. ['composer', 'require', 'vendor/pkg:^1.0'].
* @param callable|null $onOutput Optional output handler receiving (string $type, string $buffer).
* @return bool Whether the command succeeded.
*/
public function run(array $command, string $workingDirectory, ?callable $onOutput = null): bool;
}
17 changes: 17 additions & 0 deletions src/Services/SymfonyComposerRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace WhileSmart\LaravelPluginEngine\Services;

use Symfony\Component\Process\Process;

class SymfonyComposerRunner implements ComposerRunner
{
public function run(array $command, string $workingDirectory, ?callable $onOutput = null): bool
{
$process = new Process($command, $workingDirectory, null, null, 300);

$process->run($onOutput);

return $process->isSuccessful();
}
}
Loading
Loading