diff --git a/CHANGELOG.md b/CHANGELOG.md index 9791027..5311866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.2.0] - 2026-07-04 + +### Added +- Installing a plugin now installs the plugin's own declared dependencies into the host application, so packages the plugin relies on are available at runtime instead of failing with class-not-found +- Local directory paths are a supported install source: `plugin:install ./path/to/plugin` copies the plugin into place, alongside the existing package-name and repository-URL sources +- Dependency installation integrates with `wikimedia/composer-merge-plugin` when the host enables it, and otherwise installs the plugin's requirements directly + ## [1.1.0] - 2026-06-07 ### Added diff --git a/README.md b/README.md index 766bfbb..e4acd7f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/composer.json b/composer.json index c104f63..2add7de 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "A flexible plugin engine for Laravel applications", "type": "library", "license": "proprietary", - "package-version": "1.1.0", + "version": "1.2.0", "autoload": { "psr-4": { "WhileSmart\\LaravelPluginEngine\\": "src/" diff --git a/composer.lock b/composer.lock index b359b7b..0b08e77 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4fddc38db5ede904f47f388b3a38bcac", + "content-hash": "b8ec48352185449cfd3fa7b9a3406d59", "packages": [ { "name": "brick/math", diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index d256d2a..3a73e3e 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -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}' @@ -22,6 +25,11 @@ class InstallCommand extends PluginCommand protected $description = 'Install a plugin'; + public function __construct(PluginManager $pluginManager, protected ComposerRunner $composer) + { + parent::__construct($pluginManager); + } + public function handle() { $package = $this->argument('package'); @@ -29,12 +37,17 @@ public function handle() 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) { @@ -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 { @@ -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}"); @@ -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); + + 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 $require + * @return array + */ + protected function resolvablePackages(array $require): array + { + $packages = []; + + foreach ($require as $name => $constraint) { + if ($name === 'php' || Str::startsWith($name, ['ext-', 'lib-'])) { + continue; + } + + $packages[] = $name.':'.$constraint; + } + + 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); + $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 = []; diff --git a/src/Providers/PluginServiceProvider.php b/src/Providers/PluginServiceProvider.php index 9d0505b..2fcb35e 100644 --- a/src/Providers/PluginServiceProvider.php +++ b/src/Providers/PluginServiceProvider.php @@ -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 { @@ -47,6 +49,8 @@ public function register(): void $this->registerPluginManager(); $this->registerCommands(); + + $this->app->bind(ComposerRunner::class, SymfonyComposerRunner::class); } /** diff --git a/src/Services/ComposerRunner.php b/src/Services/ComposerRunner.php new file mode 100644 index 0000000..a77623f --- /dev/null +++ b/src/Services/ComposerRunner.php @@ -0,0 +1,19 @@ + $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; +} diff --git a/src/Services/SymfonyComposerRunner.php b/src/Services/SymfonyComposerRunner.php new file mode 100644 index 0000000..5aa5487 --- /dev/null +++ b/src/Services/SymfonyComposerRunner.php @@ -0,0 +1,17 @@ +run($onOutput); + + return $process->isSuccessful(); + } +} diff --git a/tests/Feature/Plugin/InstallCommandTest.php b/tests/Feature/Plugin/InstallCommandTest.php new file mode 100644 index 0000000..f818504 --- /dev/null +++ b/tests/Feature/Plugin/InstallCommandTest.php @@ -0,0 +1,116 @@ +composer = new FakeComposerRunner; + $this->app->instance(ComposerRunner::class, $this->composer); + + $this->sourceParent = sys_get_temp_dir().'/engine-install-src-'.Str::random(8); + } + + protected function tearDown(): void + { + if (File::isDirectory($this->sourceParent)) { + File::deleteDirectory($this->sourceParent); + } + + parent::tearDown(); + } + + /** @test */ + public function it_installs_the_plugins_declared_dependencies_from_a_local_path() + { + $source = $this->makeSourcePlugin('acme-bank', [ + 'require' => [ + 'php' => '^8.1', + 'acme/widget' => '^1.0', + ], + ]); + + $this->artisan('plugin:install', ['package' => $source]) + ->assertExitCode(0); + + $this->assertDirectoryExists("{$this->pluginsPath}/acme-bank"); + $this->assertTrue( + $this->composer->ran('composer', 'acme/widget:^1.0'), + 'Expected the plugin dependency to be installed via Composer.' + ); + $this->assertFalse( + $this->composer->ran('php:^8.1'), + 'Platform constraints must not be passed to Composer as packages.' + ); + } + + /** @test */ + public function it_installs_the_plugin_without_composer_when_there_is_no_composer_json() + { + $source = $this->makeSourcePlugin('acme-plain', null); + + $this->artisan('plugin:install', ['package' => $source]) + ->assertExitCode(0); + + $this->assertDirectoryExists("{$this->pluginsPath}/acme-plain"); + $this->assertSame([], $this->composer->commands); + } + + /** @test */ + public function it_does_not_call_composer_when_require_holds_only_platform_packages() + { + $source = $this->makeSourcePlugin('acme-platform', [ + 'require' => ['php' => '^8.1', 'ext-json' => '*'], + ]); + + $this->artisan('plugin:install', ['package' => $source]) + ->assertExitCode(0); + + $this->assertSame([], $this->composer->commands); + } + + /** + * Build a plugin source directory (plugin.json, a provider stub, and an + * optional composer.json) the install command can copy into place. + * Returns the path to copy from. + */ + private function makeSourcePlugin(string $id, ?array $composer): string + { + $source = "{$this->sourceParent}/{$id}"; + + File::ensureDirectoryExists("{$source}/src"); + + File::put("{$source}/plugin.json", json_encode([ + 'id' => $id, + 'name' => 'Test Plugin '.ucfirst($id), + 'version' => '1.0.0', + 'namespace' => 'WhileSmart\\AcmePlugin', + 'provider' => 'WhileSmart\\AcmePlugin\\AcmeServiceProvider', + 'enabled' => false, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + File::put( + "{$source}/src/AcmeServiceProvider.php", + "> */ + public array $commands = []; + + public function __construct(public bool $succeeds = true) {} + + public function run(array $command, string $workingDirectory, ?callable $onOutput = null): bool + { + $this->commands[] = $command; + + return $this->succeeds; + } + + /** + * Whether any recorded command contains all the given fragments. + */ + public function ran(string ...$fragments): bool + { + foreach ($this->commands as $command) { + $haystack = implode(' ', $command); + + if (collect($fragments)->every(fn ($fragment) => str_contains($haystack, $fragment))) { + return true; + } + } + + return false; + } +}