diff --git a/CHANGELOG.md b/CHANGELOG.md index a8451c40..9a600748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.40.0...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.40.1...develop) + +## [v13.40.1](https://github.com/Pollora/framework/compare/v13.40.0...v13.40.1) - 2026-11-05 + +### Added +- **`pollora:plugin:activate`**: activates WordPress plugins, given as paths relative to the plugins directory (`woocommerce/woocommerce.php`), and refuses to run when WordPress is not fully loaded + +### Fixed +- **`pollora new` no longer crashes when a theme template requires a WordPress plugin and you activate it** (`arsort(): Argument #1 ($array) must be of type array, null given`). The E-commerce template (`pollora/theme-apiary`) always hit it: it requires WooCommerce. On a first install `pollora:make:theme` runs in the process that has just installed WordPress, which is only partly loaded (`SHORTINIT`), and `activate_plugin()` failed there before the theme build and the theme activation. The plugins are now activated by a fresh `pollora:plugin:activate` process, where WordPress loads completely and the activation hooks run. If that process fails, the install carries on and prints the `wp plugin activate ` to run afterwards (Pollora/pollora#274) ## [v13.40.0](https://github.com/Pollora/framework/compare/v13.39.3...v13.40.0) - 2026-11-04 diff --git a/src/Plugin/Infrastructure/Providers/PluginServiceProvider.php b/src/Plugin/Infrastructure/Providers/PluginServiceProvider.php index 8a4dc810..b1edd7a3 100644 --- a/src/Plugin/Infrastructure/Providers/PluginServiceProvider.php +++ b/src/Plugin/Infrastructure/Providers/PluginServiceProvider.php @@ -13,6 +13,7 @@ use Pollora\Plugin\Infrastructure\Repositories\PluginRepository; use Pollora\Plugin\Infrastructure\Services\PluginAutoloader; use Pollora\Plugin\Infrastructure\Services\WordPressPluginParser; +use Pollora\Plugin\UI\Console\Commands\PluginActivateCommand; use Pollora\Plugin\UI\Console\Commands\PluginListCommand; use Pollora\Plugin\UI\Console\Commands\PluginStatusCommand; use Pollora\Plugin\UI\Console\MakePluginCommand; @@ -115,6 +116,7 @@ protected function registerPluginCommands(): void if ($this->app->runningInConsole()) { $this->commands([ MakePluginCommand::class, + PluginActivateCommand::class, PluginStatusCommand::class, PluginListCommand::class, ]); @@ -163,6 +165,7 @@ public function provides(): array 'plugin.manager', 'plugin.registrar', MakePluginCommand::class, + PluginActivateCommand::class, PluginStatusCommand::class, PluginListCommand::class, ]; diff --git a/src/Plugin/UI/Console/Commands/PluginActivateCommand.php b/src/Plugin/UI/Console/Commands/PluginActivateCommand.php new file mode 100644 index 00000000..a70ac830 --- /dev/null +++ b/src/Plugin/UI/Console/Commands/PluginActivateCommand.php @@ -0,0 +1,60 @@ +isWordPressFullyLoaded()) { + $this->error('WordPress is not fully loaded: the plugins cannot be activated. Run "wp plugin activate" instead.'); + + return self::FAILURE; + } + + $status = self::SUCCESS; + + foreach ((array) $this->argument('plugins') as $plugin) { + $result = $this->activatePlugin((string) $plugin); + + if (is_wp_error($result)) { + $this->error(sprintf('Failed to activate "%s": %s', $plugin, $result->get_error_message())); + $status = self::FAILURE; + + continue; + } + + $this->info(sprintf('Plugin "%s" activated.', $plugin)); + } + + return $status; + } + + protected function isWordPressFullyLoaded(): bool + { + return function_exists('activate_plugin') && did_action('plugins_loaded') > 0; + } + + /** + * @return \WP_Error|null + */ + protected function activatePlugin(string $pluginFile): mixed + { + return activate_plugin($pluginFile); + } +} diff --git a/src/Theme/UI/Console/MakeThemeCommand.php b/src/Theme/UI/Console/MakeThemeCommand.php index 1e590c69..5fe6f625 100755 --- a/src/Theme/UI/Console/MakeThemeCommand.php +++ b/src/Theme/UI/Console/MakeThemeCommand.php @@ -263,7 +263,7 @@ protected function installRequirementsIfNeeded(): void */ protected function activateWordPressPlugins(array $packages): void { - if (! function_exists('activate_plugin')) { + if (! $this->wordPressCanActivatePlugins()) { return; } @@ -284,6 +284,12 @@ protected function activateWordPressPlugins(array $packages): void return; } + if (! did_action('plugins_loaded')) { + $this->activatePluginsInNewProcess($plugins); + + return; + } + foreach ($plugins as $slug) { $pluginFile = $this->findPluginEntryFile($slug); @@ -293,7 +299,7 @@ protected function activateWordPressPlugins(array $packages): void continue; } - $result = activate_plugin($pluginFile); + $result = $this->activatePlugin($pluginFile); if (is_wp_error($result)) { $this->error(sprintf('Failed to activate "%s": %s', $slug, $result->get_error_message())); @@ -303,6 +309,65 @@ protected function activateWordPressPlugins(array $packages): void } } + protected function wordPressCanActivatePlugins(): bool + { + return function_exists('activate_plugin'); + } + + /** + * Activate one plugin in this process (WordPress fully loaded). + * + * @return \WP_Error|null + */ + protected function activatePlugin(string $pluginFile): mixed + { + return activate_plugin($pluginFile); + } + + /** + * Activate plugins from a fresh process, when WordPress is only partly loaded here. + * + * A first install runs this command with WordPress in SHORTINIT: activate_plugin() + * exists but crashes, and an activation hook would run without plugins_loaded or + * init. A new process loads WordPress fully, now that it is installed. + * + * @param array $plugins Map of package name => plugin directory slug. + */ + protected function activatePluginsInNewProcess(array $plugins): void + { + $files = []; + + foreach ($plugins as $slug) { + $file = $this->findPluginEntryFile($slug); + + if ($file === null) { + $this->warn(sprintf('Could not find entry file for plugin "%s".', $slug)); + + continue; + } + + $files[$slug] = $file; + } + + if ($files === []) { + return; + } + + $process = new Process([PHP_BINARY, 'artisan', 'pollora:plugin:activate', ...array_values($files)], base_path()); + $process->setTimeout(120); + $process->run(function ($type, string|iterable $buffer): void { + $this->getOutput()->write($buffer); + }); + + if (! $process->isSuccessful()) { + $this->warn('The plugins could not be activated now. Activate them once the installation is done:'); + + foreach (array_keys($files) as $slug) { + $this->line(sprintf(' wp plugin activate %s', $slug)); + } + } + } + /** * Filter a list of Composer packages to only those of type `wordpress-plugin`. * @@ -313,7 +378,7 @@ protected function activateWordPressPlugins(array $packages): void * @param array $packages Composer package names. * @return array Map of package name => plugin directory slug. */ - private function resolveWordPressPlugins(array $packages): array + protected function resolveWordPressPlugins(array $packages): array { $installedPlugins = InstalledVersions::getInstalledPackagesByType('wordpress-plugin'); $plugins = []; @@ -337,7 +402,7 @@ private function resolveWordPressPlugins(array $packages): array * @param string $slug The plugin directory name (e.g. "woocommerce"). * @return string|null The plugin-relative path (e.g. "woocommerce/woocommerce.php"), or null if not found. */ - private function findPluginEntryFile(string $slug): ?string + protected function findPluginEntryFile(string $slug): ?string { $pluginDir = defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR.'/'.$slug diff --git a/tests/Feature/Plugin/PluginActivateCommandTest.php b/tests/Feature/Plugin/PluginActivateCommandTest.php new file mode 100644 index 00000000..a8005906 --- /dev/null +++ b/tests/Feature/Plugin/PluginActivateCommandTest.php @@ -0,0 +1,54 @@ + */ + public array $activated = []; + + protected function isWordPressFullyLoaded(): bool + { + return $this->fullyLoaded; + } + + protected function activatePlugin(string $pluginFile): mixed + { + $this->activated[] = $pluginFile; + + return null; + } + }; + $command->fullyLoaded = $fullyLoaded; + $command->setLaravel(app()); + + $input = new ArrayInput(['plugins' => $plugins], new InputDefinition([new InputArgument('plugins', InputArgument::IS_ARRAY)])); + $output = new BufferedOutput; + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + return [$command->handle(), $output->fetch(), $command->activated]; +} + +it('activates the given plugins', function (): void { + [$status, , $activated] = runPluginActivate(['woocommerce/woocommerce.php'], fullyLoaded: true); + + expect($status)->toBe(0)->and($activated)->toBe(['woocommerce/woocommerce.php']); +}); + +it('fails instead of activating when WordPress is not fully loaded', function (): void { + [$status, $output, $activated] = runPluginActivate(['woocommerce/woocommerce.php'], fullyLoaded: false); + + expect($status)->toBe(1)->and($activated)->toBe([])->and($output)->toContain('not fully loaded'); +}); diff --git a/tests/Feature/Theme/MakeThemeActivationTest.php b/tests/Feature/Theme/MakeThemeActivationTest.php index bfa3db21..cbf86828 100644 --- a/tests/Feature/Theme/MakeThemeActivationTest.php +++ b/tests/Feature/Theme/MakeThemeActivationTest.php @@ -7,12 +7,15 @@ use Illuminate\Console\OutputStyle; use Illuminate\Container\Container; use Illuminate\Filesystem\Filesystem; +use Laravel\Prompts\ConfirmPrompt; +use Laravel\Prompts\Prompt; use Pollora\Theme\Application\Services\ThemeAvailability; use Pollora\Theme\Domain\Contracts\ThemeModuleInterface; use Pollora\Theme\Domain\Contracts\ThemeRegistrarInterface; use Pollora\Theme\Domain\Models\ThemeMetadata; use Pollora\Theme\UI\Console\MakeThemeCommand; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Output\BufferedOutput; /** @@ -88,3 +91,77 @@ function activates(MakeThemeCommand $command): bool expect(activates(activationCommand(['--activate' => true], themeMissing: false)))->toBeFalse(); }); + +/** + * A first install runs the command with WordPress only partly loaded: activate_plugin() crashed + * there in plugin_basename(), so the activation moves to a fresh process. + * + * @return array{0: array, 1: array} Plugins activated here, plugins handed to a new process. + */ +function pluginActivation(int $pluginsLoaded): array +{ + Functions\when('did_action')->justReturn($pluginsLoaded); + Prompt::fallbackWhen(true); + ConfirmPrompt::fallbackUsing(fn (): bool => true); + + $command = new class(new Repository, new Filesystem) extends MakeThemeCommand + { + /** @var array */ + public array $handedOver = []; + + /** @var array */ + public array $activated = []; + + protected function wordPressCanActivatePlugins(): bool + { + return true; + } + + protected function activatePlugin(string $pluginFile): mixed + { + $this->activated[] = $pluginFile; + + return null; + } + + protected function resolveWordPressPlugins(array $packages): array + { + return ['wp-plugin/woocommerce' => 'woocommerce']; + } + + protected function findPluginEntryFile(string $slug): string + { + return $slug.'/'.$slug.'.php'; + } + + protected function activatePluginsInNewProcess(array $plugins): void + { + $this->handedOver = array_values($plugins); + } + }; + $command->setLaravel(app()); + + $input = new ArrayInput([], new InputDefinition); + $input->setInteractive(false); + + $command->setInput($input); + $command->setOutput(new OutputStyle($input, new BufferedOutput)); + + (fn () => $this->activateWordPressPlugins(['wp-plugin/woocommerce']))->call($command); + + return [$command->activated, $command->handedOver]; +} + +it('hands plugin activation to a new process while WordPress is not fully loaded', function (): void { + [$activated, $handedOver] = pluginActivation(pluginsLoaded: 0); + + expect($activated)->toBe([]) + ->and($handedOver)->toBe(['woocommerce']); +}); + +it('activates plugins in place once WordPress is fully loaded', function (): void { + [$activated, $handedOver] = pluginActivation(pluginsLoaded: 1); + + expect($activated)->toBe(['woocommerce/woocommerce.php']) + ->and($handedOver)->toBe([]); +});