-
Notifications
You must be signed in to change notification settings - Fork 0
chore: Release 1.2.0 #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,19 +25,29 @@ 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'); | ||||||||||||||
|
|
||||||||||||||
| 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); | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| $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; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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 = []; | ||||||||||||||
|
|
||||||||||||||
| 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; | ||
| } |
| 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(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When decoding JSON, you should check if
json_decodereturnednulland if a JSON error occurred to handle malformed files gracefully.