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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<lower>`, where Pollora never looks) and points to it
- Plugins › Modules (`plugins.php?page=pollora-modules`, linked as "Modules (n)" among the plugin views): every module with its description, path, state (enabled, disabled, locked) and where the state lives, with Enable / Disable per row and as bulk actions. Switches need `activate_plugins` (`modules.admin.capability`), are nonce-protected, go through nwidart's `Module::enable()` / `disable()` and say the change applies from the next request. A locked module has no switch; `MODULES_ADMIN_TOGGLE=false` turns every switch off. With the JSON file, the view warns that the next deployment resets it and each switch asks for confirmation; when the file cannot be written, switches are off and the view explains how to change states
- Tools › Pollora's Modules card and `pollora:status` say where module states are stored, and the card links to the Modules view
- `pollora:make:block --module=<name>` 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`
Expand Down
17 changes: 17 additions & 0 deletions src/Doctor/Infrastructure/Checks/AssetBuildCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -133,6 +140,16 @@ private function problem(ProjectModule $module): ?array
];
}

/**
* The Vite config nwidart's stub writes: `buildDirectory: 'build-<lower>'`.
*/
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
{
Expand Down
152 changes: 152 additions & 0 deletions src/Modules/Infrastructure/Checks/ModuleActivationCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);

namespace Pollora\Modules\Infrastructure\Checks;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Env;
use Nwidart\Modules\Contracts\ActivatorInterface;
use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Pollora\Modules\Infrastructure\Activation\ConnectorActivator;
use Pollora\Modules\Infrastructure\Activation\DatabaseStateConnector;
use Pollora\Modules\Infrastructure\Activation\JsonStateConnector;
use Throwable;

/**
* Where module states come from, and whether a switch takes effect.
*
* - The database connector reads its fallback: the options table could not be read.
* - The connector names a module that is no longer on disk.
* - The state file cannot be written, while Plugins › Modules is the way to switch.
* - The configuration or route cache was written before the last switch: the
* module's config and routes stay as they were.
* - MODULES_* settings without a published config/modules.php: nwidart reads its
* activator while it registers, so they are never applied.
*/
final readonly class ModuleActivationCheck implements CheckInterface
{
/**
* Settings only config/modules.php applies.
*
* @var list<string>
*/
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<string>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface;
use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider;
use Pollora\Hook\Domain\Contract\Action;
use Pollora\Hook\Domain\Contract\Filter;
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;
Expand All @@ -25,6 +27,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;
use Pollora\Modules\UI\Http\ModulesAdminPage;

/**
Expand Down Expand Up @@ -65,13 +68,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]);
}

$this->registerModulesAdminPage();
Expand Down
72 changes: 72 additions & 0 deletions src/Modules/UI/Console/ModuleFrontendCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Pollora\Modules\UI\Console;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Pollora\Modules\Infrastructure\Services\ModuleTemplate;

/**
* Give an existing module the frontend stack of the module template:
* package.json and vite.config.js on @pollora/vite-config, and
* resources/assets/app.{js,css}. Each file it replaces is kept as <file>.bak.
*
* A module made by nwidart's stock module:make builds into public/build-<lower>,
* 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<string>
*/
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: <comment>cd %s && npm install && npm run build</comment>.', $this->relative($modulePath)));
$this->line(' Entries other than resources/assets/app.js go in the <comment>input</comment> option of pollora() in vite.config.js.');

return self::SUCCESS;
}

private function relative(string $path): string
{
return ltrim(str_replace(base_path(), '', $path), '/');
}
}
9 changes: 9 additions & 0 deletions tests/Feature/Doctor/ModuleChecksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading