From a6d7725bb8b681aa5b9e4ac53529138c471d21aa Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 11:42:42 +0200 Subject: [PATCH] feat: check module activation in pollora:doctor and add pollora:module:frontend Step 7 (framework part) of the modules specification: the module-activation check, the stock nwidart Vite config in the builds check, and pollora:module:frontend to move a module onto the template's frontend build. --- CHANGELOG.md | 2 + .../Infrastructure/Checks/AssetBuildCheck.php | 17 ++ .../Checks/ModuleActivationCheck.php | 152 ++++++++++++++++++ .../Providers/ModuleServiceProvider.php | 8 +- .../UI/Console/ModuleFrontendCommand.php | 72 +++++++++ tests/Feature/Doctor/ModuleChecksTest.php | 9 ++ .../ModuleActivation/ModuleDoctorTest.php | 143 ++++++++++++++++ tests/e2e/specs/doctor.spec.ts | 2 +- 8 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 src/Modules/Infrastructure/Checks/ModuleActivationCheck.php create mode 100644 src/Modules/UI/Console/ModuleFrontendCommand.php create mode 100644 tests/Feature/ModuleActivation/ModuleDoctorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c62284d0..6387fe97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A switch clears nwidart's provider manifest and the discovery cache, and fires `ModuleEnabled` / `ModuleDisabled` (module, source `admin` or `console`, WordPress user) - `config/modules.php`, published with `php artisan vendor:publish --tag=pollora-modules`: nwidart reads its activator while it registers, before any provider of the application, so the connector is chosen in this file, not from a provider - `pollora:module:connector [connector] [--import]` shows where module states live and copies them into another connector before the configuration points to it +- `pollora:doctor` and Site Health check module activation (`module-activation`): the database connector reading its fallback, a module the states list but the disk no longer has, a state file that cannot be written while Plugins › Modules is the way to switch, a configuration or route cache written before the last switch, and `MODULES_*` settings ignored because `config/modules.php` is not published +- `pollora:module:frontend Crm` gives an existing module the template's frontend build (`package.json` and `vite.config.js` on `@pollora/vite-config`, `resources/assets/app.{js,css}`), keeping a `.bak` of each file it replaces; the builds check recognises the Vite config of nwidart's stock `module:make` (`public/build-`, where Pollora never looks) and points to it - `pollora:make:block --module=` creates a block in a module's `resources/views/blocks`, named after the module in kebab case (`blocks-demo/hero`), like the seven generators that already took `--module` ### Fixed diff --git a/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php b/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php index caf5f097..ef5a19d6 100644 --- a/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php +++ b/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php @@ -118,6 +118,13 @@ private function problem(ProjectModule $module): ?array return null; } + if ($module->type === 'module' && $this->isStockModuleConfig($module)) { + return [ + sprintf("%s: its vite.config.js is the one nwidart/laravel-modules' module:make writes, which builds into public/build-%s, where Pollora never looks", $module->label(), strtolower(basename($module->root))), + sprintf("php artisan pollora:module:frontend %s (replaces package.json and vite.config.js with the module template's, keeping a backup), then cd %s && npm install && npm run build", basename($module->root), $module->relativeRoot()), + ]; + } + $elsewhere = $this->buildElsewhere($module); if ($elsewhere !== null) { @@ -133,6 +140,16 @@ private function problem(ProjectModule $module): ?array ]; } + /** + * The Vite config nwidart's stub writes: `buildDirectory: 'build-'`. + */ + private function isStockModuleConfig(ProjectModule $module): bool + { + $config = (string) @file_get_contents($module->root.'/vite.config.js'); + + return preg_match('/buildDirectory\s*:\s*[\'"]build-/', $config) === 1; + } + /** A manifest for this module under another public/build folder, relative to the project. */ private function buildElsewhere(ProjectModule $module): ?string { diff --git a/src/Modules/Infrastructure/Checks/ModuleActivationCheck.php b/src/Modules/Infrastructure/Checks/ModuleActivationCheck.php new file mode 100644 index 00000000..a948f20a --- /dev/null +++ b/src/Modules/Infrastructure/Checks/ModuleActivationCheck.php @@ -0,0 +1,152 @@ + + */ + private const array PUBLISHED_ONLY_SETTINGS = ['MODULES_CONNECTOR', 'MODULES_LOCKED_ENABLED', 'MODULES_LOCKED_DISABLED', 'MODULES_ENABLED', 'MODULES_DISABLED']; + + public function __construct(private Application $app) {} + + public function id(): string + { + return 'module-activation'; + } + + public function label(): string + { + return 'Module activation'; + } + + public function runsIn(): array + { + return [RunContext::Console, RunContext::Http]; + } + + public function run(RunContext $context): CheckResult + { + if (! $this->app->bound('modules') || ! $this->app->bound(ActivatorInterface::class)) { + return CheckResult::skipped('nwidart/laravel-modules is not installed.'); + } + + $activator = $this->app->make(ActivatorInterface::class); + $usesConnectorActivator = $activator instanceof ConnectorActivator; + $connector = $activator instanceof ConnectorActivator + ? $activator->connector() + : new JsonStateConnector((string) $this->app->make('config')->get('modules.activators.file.statuses-file', $this->app->basePath('modules_statuses.json'))); + + $warnings = []; + $fixes = []; + + if ($connector instanceof DatabaseStateConnector && $connector->usesFallback()) { + $warnings[] = sprintf('The %s option could not be read: module states come from the fallback (%s)', $connector->option(), $connector->fallback()->label()); + $fixes[] = 'check the database connection, or the options table of a fresh install'; + } + + $missing = array_diff(array_keys($connector->all()), $this->moduleNames()); + + if ($missing !== []) { + sort($missing); + $warnings[] = sprintf('%s (%s) lists module(s) no longer on disk: %s', $connector->label(), $this->where($connector), implode(', ', $missing)); + $fixes[] = sprintf('remove them from %s', $this->where($connector)); + } + + if (! $connector->writable() && filter_var($this->app->make('config')->get('modules.admin.toggle', true), FILTER_VALIDATE_BOOLEAN)) { + $warnings[] = sprintf('%s (%s) cannot be written: Plugins › Modules cannot switch modules', $connector->label(), $this->where($connector)); + $fixes[] = 'commit the module states, switch to the database connector (php artisan pollora:module:connector database --import), or set MODULES_ADMIN_TOGGLE=false'; + } + + $staleCache = $this->staleCache($connector); + + if ($staleCache !== null) { + $warnings[] = sprintf("The %s cache was written before the last module switch: it still holds the previous modules' configuration and routes", $staleCache); + $fixes[] = 'php artisan optimize:clear, then cache again'; + } + + if (! $usesConnectorActivator) { + $ignored = array_values(array_filter(self::PUBLISHED_ONLY_SETTINGS, fn (string $name): bool => ! in_array(Env::get($name), [null, ''], true))); + + if ($ignored !== []) { + $warnings[] = sprintf('%s set, but config/modules.php is not published: nwidart reads its activator before any provider, so they are ignored', implode(', ', $ignored)); + $fixes[] = 'php artisan vendor:publish --tag=pollora-modules'; + } + } + + if ($warnings !== []) { + return CheckResult::warning(sprintf('%d problem(s) with module states.', count($warnings)), $warnings, implode(' ; ', $fixes)); + } + + return CheckResult::ok(sprintf('Module states come from %s and apply on the next request.', $this->where($connector))); + } + + /** + * @return list + */ + private function moduleNames(): array + { + try { + return array_values(array_map(fn (object $module): string => (string) $module->getName(), $this->app->make('modules')->all())); + } catch (Throwable) { + return []; + } + } + + private function where(object $connector): string + { + return match (true) { + $connector instanceof JsonStateConnector => basename($connector->path()), + $connector instanceof DatabaseStateConnector => 'the '.$connector->option().' option', + default => $connector->label(), + }; + } + + /** + * The cache (configuration, routes) older than the state file, when the + * connector is a file whose modification time says when it last changed. + */ + private function staleCache(object $connector): ?string + { + if (! $connector instanceof JsonStateConnector || ! is_file($connector->path())) { + return null; + } + + $switchedAt = (int) filemtime($connector->path()); + + foreach (['configuration' => $this->app->getCachedConfigPath(), 'route' => $this->app->getCachedRoutesPath()] as $cache => $path) { + if (is_file($path) && (int) filemtime($path) < $switchedAt) { + return $cache; + } + } + + return null; + } +} diff --git a/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php b/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php index 704599fa..a5311ab2 100644 --- a/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php +++ b/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php @@ -9,9 +9,11 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface; +use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; use Pollora\Modules\Application\UseCases\ApplyModulesUseCase; use Pollora\Modules\Application\UseCases\DiscoverModulesUseCase; use Pollora\Modules\Domain\Contracts\ModuleDiscoveryOrchestratorInterface; +use Pollora\Modules\Infrastructure\Checks\ModuleActivationCheck; use Pollora\Modules\Infrastructure\Services\LaravelModuleAssets; use Pollora\Modules\Infrastructure\Services\LeanModuleMake; use Pollora\Modules\Infrastructure\Services\ModuleAssetManager; @@ -23,6 +25,7 @@ use Pollora\Modules\Infrastructure\Services\ModuleTemplate; use Pollora\Modules\UI\Console\MakeModuleCommand; use Pollora\Modules\UI\Console\ModuleConnectorCommand; +use Pollora\Modules\UI\Console\ModuleFrontendCommand; /** * Main service provider for the generic module system. @@ -60,13 +63,16 @@ public function boot(): void Event::dispatch('modules.routes.registered'); }); + // A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own + $this->app->tag([ModuleActivationCheck::class], DoctorServiceProvider::CHECKS_TAG); + // Activation connector settings, read by nwidart while it registers: published, never merged $this->publishes([ __DIR__.'/../../../../config/modules.php' => $this->app->configPath('modules.php'), ], 'pollora-modules'); if ($this->app->runningInConsole()) { - $this->commands([MakeModuleCommand::class, ModuleConnectorCommand::class]); + $this->commands([MakeModuleCommand::class, ModuleConnectorCommand::class, ModuleFrontendCommand::class]); } } diff --git a/src/Modules/UI/Console/ModuleFrontendCommand.php b/src/Modules/UI/Console/ModuleFrontendCommand.php new file mode 100644 index 00000000..b0683bad --- /dev/null +++ b/src/Modules/UI/Console/ModuleFrontendCommand.php @@ -0,0 +1,72 @@ +.bak. + * + * A module made by nwidart's stock module:make builds into public/build-, + * where Pollora never looks; this is the way out. Nothing runs at upgrade: a + * project's modules are its own code. + */ +#[Description('Give a module the frontend build of the Pollora module template')] +#[Signature('pollora:module:frontend {module : Module name} + {--no-backup : Replace the files without keeping a .bak copy}')] +class ModuleFrontendCommand extends Command +{ + /** + * Template files written into the module. + * + * @var list + */ + private const array FILES = ['package.json', 'vite.config.js', 'resources/assets/app.js', 'resources/assets/app.css']; + + public function handle(ModuleTemplate $template, Filesystem $files): int + { + $module = $this->laravel->bound('modules') ? $this->laravel->make('modules')->find((string) $this->argument('module')) : null; + + if ($module === null) { + $this->components->error(sprintf('Module "%s" does not exist.', $this->argument('module'))); + + return self::FAILURE; + } + + $modulePath = rtrim((string) $module->getPath(), '/'); + $replacements = $template->replacements((string) $module->getName(), (string) config('modules.namespace', 'Modules')); + + foreach (self::FILES as $file) { + $target = $modulePath.'/'.$file; + $existed = $files->exists($target); + + if ($existed && ! $this->option('no-backup')) { + $files->copy($target, $target.'.bak'); + } + + $files->ensureDirectoryExists(dirname($target)); + $files->put($target, strtr($files->get($template->defaultTemplatePath().'/'.$file), $replacements)); + + $this->components->twoColumnDetail($file, $existed ? ($this->option('no-backup') ? 'REPLACED' : 'REPLACED (backup: '.basename($file).'.bak)') : 'CREATED'); + } + + $this->newLine(); + $this->line(sprintf(' Next: cd %s && npm install && npm run build.', $this->relative($modulePath))); + $this->line(' Entries other than resources/assets/app.js go in the input option of pollora() in vite.config.js.'); + + return self::SUCCESS; + } + + private function relative(string $path): string + { + return ltrim(str_replace(base_path(), '', $path), '/'); + } +} diff --git a/tests/Feature/Doctor/ModuleChecksTest.php b/tests/Feature/Doctor/ModuleChecksTest.php index 137a8e01..d1b5af65 100644 --- a/tests/Feature/Doctor/ModuleChecksTest.php +++ b/tests/Feature/Doctor/ModuleChecksTest.php @@ -102,6 +102,15 @@ function availability(bool $themeMissing): ThemeAvailability ->and($result->fix)->toContain('cd public/content/plugins/acme-forms && npm install && npm run build'); }); + it("recognises the Vite config of nwidart's stock module:make and points to pollora:module:frontend", function (): void { + putModuleFile($this->module->root.'/vite.config.js', "laravel({\n publicDirectory: '../../public',\n buildDirectory: 'build-blocksdemo',\n})"); + + $result = (new AssetBuildCheck(availability(false), $this->modules))->run(RunContext::Console); + + expect($result->details)->toContain("module blocks-demo: its vite.config.js is the one nwidart/laravel-modules' module:make writes, which builds into public/build-blocksdemo, where Pollora never looks") + ->and($result->fix)->toContain('php artisan pollora:module:frontend BlocksDemo'); + }); + it('tells a stopped dev server from one the browser cannot reach', function (): void { putModuleFile($this->theme->root.'/vite.config.js', ''); putModuleFile($this->theme->hotFile, 'https://site.test:5173'); diff --git a/tests/Feature/ModuleActivation/ModuleDoctorTest.php b/tests/Feature/ModuleActivation/ModuleDoctorTest.php new file mode 100644 index 00000000..0acdf3e6 --- /dev/null +++ b/tests/Feature/ModuleActivation/ModuleDoctorTest.php @@ -0,0 +1,143 @@ +directory = sys_get_temp_dir().'/pollora-module-doctor-'.uniqid(); + File::ensureDirectoryExists($this->directory.'/Modules/Crm'); + $this->statuses = $this->directory.'/modules_statuses.json'; + file_put_contents($this->statuses, json_encode(['Crm' => true])); + + config([ + 'modules.connector' => 'json', + 'modules.connectors' => [ + 'json' => ['path' => $this->statuses], + 'database' => ['option' => 'pollora_modules', 'fallback' => 'json', 'connection' => 'missing_wordpress'], + ], + 'modules.admin.toggle' => true, + 'database.connections.missing_wordpress' => ['driver' => 'sqlite', 'database' => ':memory:'], + ]); + + $modulePath = $this->directory.'/Modules/Crm'; + $this->app->instance('modules', new readonly class($modulePath) + { + public function __construct(private string $path) {} + + public function all(): array + { + return ['Crm' => $this->find('Crm')]; + } + + public function find(string $name): ?object + { + $path = $this->path; + + return $name === 'Crm' ? new readonly class($path) + { + public function __construct(private string $path) {} + + public function getName(): string + { + return 'Crm'; + } + + public function getPath(): string + { + return $this->path; + } + } : null; + } + }); + $this->app->instance(ActivatorInterface::class, new ConnectorActivator($this->app)); +}); + +afterEach(function (): void { + File::deleteDirectory($this->directory); + putenv('MODULES_CONNECTOR'); +}); + +describe('Module activation check', function (): void { + it('passes when the states match the modules and can be written', function (): void { + $result = $this->app->make(ModuleActivationCheck::class)->run(RunContext::Console); + + expect($result->status->value)->toBe('ok') + ->and($result->summary)->toContain('modules_statuses.json'); + }); + + it('names a module the states list but the disk no longer has', function (): void { + file_put_contents($this->statuses, json_encode(['Crm' => true, 'Billing' => false])); + + $result = $this->app->make(ModuleActivationCheck::class)->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details[0])->toContain('no longer on disk: Billing'); + }); + + it('says when the database connector reads its fallback', function (): void { + config(['modules.connector' => 'database']); + $this->app->instance(ActivatorInterface::class, new ConnectorActivator($this->app)); + + $result = $this->app->make(ModuleActivationCheck::class)->run(RunContext::Console); + + expect($result->details[0])->toContain('The pollora_modules option could not be read: module states come from the fallback (JSON file)'); + }); + + it('warns about a configuration cache written before the last switch', function (): void { + $cachedConfig = $this->app->getCachedConfigPath(); + File::ensureDirectoryExists(dirname($cachedConfig)); + $existed = is_file($cachedConfig); + $previous = $existed ? file_get_contents($cachedConfig) : null; + file_put_contents($cachedConfig, 'app->make(ModuleActivationCheck::class)->run(RunContext::Console); + } finally { + $existed ? file_put_contents($cachedConfig, $previous) : unlink($cachedConfig); + } + + expect(implode("\n", $result->details))->toContain('The configuration cache was written before the last module switch'); + }); + + it('says MODULES_* settings are ignored without the connector activator', function (): void { + $this->app->instance(ActivatorInterface::class, Mockery::mock(ActivatorInterface::class)); + config(['modules.activators.file.statuses-file' => $this->statuses]); + putenv('MODULES_CONNECTOR=database'); + + $result = $this->app->make(ModuleActivationCheck::class)->run(RunContext::Console); + + expect(implode("\n", $result->details))->toContain('MODULES_CONNECTOR set, but config/modules.php is not published') + ->and($result->fix)->toContain('vendor:publish --tag=pollora-modules'); + }); +}); + +describe('pollora:module:frontend', function (): void { + beforeEach(function (): void { + $this->app->make(Kernel::class)->registerCommand($this->app->make(ModuleFrontendCommand::class)); + }); + + it("replaces the stock Vite build with the template's, keeping a backup", function (): void { + $module = $this->directory.'/Modules/Crm'; + file_put_contents($module.'/vite.config.js', "buildDirectory: 'build-crm'"); + + $this->artisan('pollora:module:frontend', ['module' => 'Crm'])->assertSuccessful(); + + expect((string) file_get_contents($module.'/vite.config.js'))->toContain("pollora({ type: 'module', name: 'crm' })") + ->and((string) file_get_contents($module.'/vite.config.js.bak'))->toBe("buildDirectory: 'build-crm'") + ->and(json_decode((string) file_get_contents($module.'/package.json'), true)['devDependencies'])->toHaveKey('@pollora/vite-config') + ->and($module.'/resources/assets/app.css')->toBeFile() + ->and($module.'/package.json.bak')->not->toBeFile(); + }); + + it('refuses an unknown module', function (): void { + $this->artisan('pollora:module:frontend', ['module' => 'Billing'])->assertFailed(); + }); +}); diff --git a/tests/e2e/specs/doctor.spec.ts b/tests/e2e/specs/doctor.spec.ts index 3d86b2cd..91b2a771 100644 --- a/tests/e2e/specs/doctor.spec.ts +++ b/tests/e2e/specs/doctor.spec.ts @@ -16,7 +16,7 @@ test('Site Health lists the Pollora checks, the web-only block check and the che // Passed tests sit in a collapsed list: read the triggers, visible or not. const results = page.locator('.health-check-accordion-trigger'); - for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme, plugin and module builds', 'Blocks registered', 'Typed meta', 'Roles', 'Block bindings', 'Asynchronous actions']) { + for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme, plugin and module builds', 'Blocks registered', 'Typed meta', 'Roles', 'Block bindings', 'Asynchronous actions', 'Module activation']) { await expect(results.filter({ hasText: label }).filter({ hasText: 'Pollora' }), label).toHaveCount(1); }