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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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
- Update checks for modules installed by Composer: a module whose path is a Composer package's install path (placed in `Modules/` by the skeleton's `installer-paths` rule, or scanned in `vendor/`) carries that package's version, whatever its type; a local module has none and is never checked. The latest release comes from the repository the project's `composer.json` declares for it — a `composer` repository (Private Packagist, Satis: `{url}/p2/{package}.json`), a GitHub `vcs` repository (latest release, else highest tag; `MODULES_GITHUB_TOKEN` for a private one), or Packagist — is cached per package for 12 hours (1 hour when the source does not answer), and is fetched by a daily WP-Cron task or `pollora:module:outdated`, never during a front-end request. Plugins › Modules shows "1.3.0 · 1.4.0 available" with the `composer update` command, Site Health tests "Pollora modules are up to date", and `pollora:status` (`--json` too) gives each module's version and latest; a `dev-*` build is never reported outdated
- `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`

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions config/modules-defaults.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@
'toggle' => env('MODULES_ADMIN_TOGGLE', true),
'capability' => 'activate_plugins',
],

// Update checks of the modules installed by Composer from a private GitHub repository
'versions' => [
'github_token' => env('MODULES_GITHUB_TOKEN'),
],
];
19 changes: 18 additions & 1 deletion src/Dashboard/Domain/Services/SystemInfoCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Pollora\Attributes\Taxonomy;
use Pollora\Discovery\Application\Services\DiscoveryManager;
use Pollora\Modules\Application\Services\ModuleStates;
use Pollora\Modules\Application\Services\ModuleVersions;
use Pollora\Support\Domain\StringHelper;
use Pollora\VersionCheck\Domain\Services\VersionComparator;
use Psr\Container\ContainerInterface;
Expand Down Expand Up @@ -437,7 +438,7 @@ public function collectCacheInfo(): array
}

/**
* @return array{count: int, enabled: int, disabled: int, connector: string|null, items: list<array{name: string, status: string, description: string, priority: string}>}
* @return array{count: int, enabled: int, disabled: int, connector: string|null, items: list<array{name: string, status: string, description: string, priority: string, version: string|null, latest: string|null}>}
*/
public function collectModulesInfo(): array
{
Expand All @@ -448,6 +449,7 @@ public function collectModulesInfo(): array
$enabled = $modules->allEnabled();
$disabled = $modules->allDisabled();

$versions = $this->moduleVersions();
$items = [];
foreach ($all as $module) {
$items[] = [
Expand All @@ -457,6 +459,9 @@ public function collectModulesInfo(): array
'description' => $module->getDescription(),
// getPriority() is typed string and throws on a module.json without priority
'priority' => (string) $module->get('priority', ''),
// Only modules installed by Composer have one
'version' => $versions[$module->getName()]['version'] ?? null,
'latest' => $versions[$module->getName()]['latest'] ?? null,
];
}

Expand All @@ -472,6 +477,18 @@ public function collectModulesInfo(): array
}
}

/**
* @return array<string, array{version: string, latest: string|null}>
*/
private function moduleVersions(): array
{
try {
return $this->container->get(ModuleVersions::class)->all();
} catch (\Throwable) {
return [];
}
}

/**
* Where module states live: "JSON file", "Database"…
*/
Expand Down
5 changes: 3 additions & 2 deletions src/Dashboard/UI/Console/StatusCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ private function renderDiscovery(array $discovery): void
}

/**
* @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list<array{name: string, status: string, description: string, priority: string}>} $modules
* @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list<array{name: string, status: string, description: string, priority: string, version: string|null, latest: string|null}>} $modules
*/
private function renderModules(array $modules): void
{
Expand All @@ -193,7 +193,8 @@ private function renderModules(array $modules): void

foreach ($modules['items'] as $module) {
$status = $module['status'] === 'enabled' ? '✓' : '✗';
$this->line(sprintf(' %s %s', $status, $module['name']));
$version = $module['version'] === null ? '' : ' '.$module['version'].($module['latest'] !== null && version_compare($module['latest'], $module['version'], '>') ? ' ('.$module['latest'].' available)' : '');
$this->line(sprintf(' %s %s%s', $status, $module['name'], $version));
}

$this->newLine();
Expand Down
2 changes: 1 addition & 1 deletion src/Dashboard/UI/Http/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ private function renderServiceProvidersCard(array $providers): void
}

/**
* @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list<array{name: string, status: string, description: string, priority: string}>} $modules
* @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list<array{name: string, status: string, description: string, priority: string, version: string|null, latest: string|null}>} $modules
*/
private function renderModulesCard(array $modules): void
{
Expand Down
138 changes: 138 additions & 0 deletions src/Modules/Application/Services/ModuleVersions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

declare(strict_types=1);

namespace Pollora\Modules\Application\Services;

use Composer\InstalledVersions;
use Illuminate\Contracts\Container\Container;
use Pollora\VersionCheck\Application\Services\PackageVersionChecker;
use Pollora\VersionCheck\Domain\Services\StableVersions;

/**
* Versions of the modules installed by Composer.
*
* A module installed as a package (placed in Modules/ by an installer-paths
* rule, or scanned in vendor/) is matched to it by install path, and
* carries that package's version. A local module — its composer.json merged
* into the project's — has no version: nothing is shown, checked or requested.
*/
class ModuleVersions
{
/**
* @var array<string, string>|null Package name by real install path
*/
private ?array $packagesByPath = null;

public function __construct(
private readonly Container $app,
private readonly PackageVersionChecker $checker,
) {}

/**
* The Composer package a module was installed from, or null for a local module.
*/
public function packageOf(string $modulePath): ?string
{
$path = realpath($modulePath);

return $path === false ? null : ($this->packagesByPath()[$path] ?? null);
}

/**
* Version of each module installed by Composer, by module name, read from the cache.
*
* @return array<string, array{package: string, version: string, latest: string|null, development: bool, update: bool, release_url: string|null}>
*/
public function all(): array
{
$versions = [];

foreach ($this->composerModules() as $name => $package) {
$versions[$name] = $this->describe($package, $this->checker->cachedLatest($package));
}

return $versions;
}

/**
* Fetch the latest version of every module installed by Composer.
*
* @return array<string, array{package: string, version: string, latest: string|null, development: bool, update: bool, release_url: string|null}>
*/
public function refresh(): array
{
$versions = [];

foreach ($this->composerModules() as $name => $package) {
$versions[$name] = $this->describe($package, $this->checker->refresh($package));
}

return $versions;
}

/**
* @return array{package: string, version: string, latest: string|null, development: bool, update: bool, release_url: string|null}
*/
private function describe(string $package, ?string $latest): array
{
$version = (string) $this->checker->current($package);
$update = $this->checker->isUpdateAvailable($version, $latest);

return [
'package' => $package,
'version' => $version,
'latest' => $latest,
'development' => StableVersions::isDevelopmentBuild($version),
'update' => $update,
'release_url' => $update && $latest !== null ? $this->checker->releaseUrl($package, $latest) : null,
];
}

/**
* Package of each module installed by Composer, by module name.
*
* @return array<string, string>
*/
private function composerModules(): array
{
if (! $this->app->bound('modules')) {
return [];
}

$modules = [];

foreach ($this->app->make('modules')->all() as $module) {
$package = $this->packageOf((string) $module->getPath());

if ($package !== null) {
$modules[(string) $module->getName()] = $package;
}
}

return $modules;
}

/**
* @return array<string, string>
*/
private function packagesByPath(): array
{
if ($this->packagesByPath !== null) {
return $this->packagesByPath;
}

$packages = [];

foreach (InstalledVersions::getInstalledPackages() as $package) {
$installPath = InstalledVersions::getInstallPath($package);
$realPath = $installPath === null ? false : realpath($installPath);

if ($realPath !== false && ! isset($packages[$realPath])) {
$packages[$realPath] = $package;
}
}

return $this->packagesByPath = $packages;
}
}
28 changes: 27 additions & 1 deletion src/Modules/Infrastructure/Providers/ModuleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider;
use Pollora\Hook\Domain\Contract\Action;
use Pollora\Hook\Domain\Contract\Filter;
use Pollora\Modules\Application\Services\ModuleVersions;
use Pollora\Modules\Application\UseCases\ApplyModulesUseCase;
use Pollora\Modules\Application\UseCases\DiscoverModulesUseCase;
use Pollora\Modules\Domain\Contracts\ModuleDiscoveryOrchestratorInterface;
Expand All @@ -28,7 +29,11 @@
use Pollora\Modules\UI\Console\MakeModuleCommand;
use Pollora\Modules\UI\Console\ModuleConnectorCommand;
use Pollora\Modules\UI\Console\ModuleFrontendCommand;
use Pollora\Modules\UI\Console\ModuleOutdatedCommand;
use Pollora\Modules\UI\Http\ModulesAdminPage;
use Pollora\Modules\UI\Http\ModuleVersionsHealthCheck;
use Pollora\VersionCheck\Infrastructure\Sources\HttpGet;
use Pollora\VersionCheck\Infrastructure\Sources\VersionSources;

/**
* Main service provider for the generic module system.
Expand All @@ -37,6 +42,11 @@
*/
class ModuleServiceProvider extends ServiceProvider
{
/**
* WP-Cron event refreshing the latest versions of the modules installed by Composer.
*/
public const string REFRESH_VERSIONS_EVENT = 'pollora_refresh_module_versions';

public function register(): void
{
$this->registerDomainContracts();
Expand All @@ -52,6 +62,12 @@ public function register(): void
$this->app->isBooted() ? $setUpModuleAssets() : $this->app->booting($setUpModuleAssets);

$this->mergeConfigFrom(__DIR__.'/../../../../config/modules-defaults.php', 'modules');

$this->app->singleton(VersionSources::class, fn (Container $app): VersionSources => new VersionSources(
$app->make(HttpGet::class),
base_path('composer.json'),
$app->make('config')->get('modules.versions.github_token'),
));
}

public function boot(): void
Expand All @@ -77,7 +93,7 @@ public function boot(): void
], 'pollora-modules');

if ($this->app->runningInConsole()) {
$this->commands([MakeModuleCommand::class, ModuleConnectorCommand::class, ModuleFrontendCommand::class]);
$this->commands([MakeModuleCommand::class, ModuleConnectorCommand::class, ModuleFrontendCommand::class, ModuleOutdatedCommand::class]);
}

$this->registerModulesAdminPage();
Expand All @@ -99,6 +115,16 @@ private function registerModulesAdminPage(): void
$action->add('load-plugins_page_'.ModulesAdminPage::SLUG, fn () => $this->app->make(ModulesAdminPage::class)->handleRequest());

$filter->add('views_plugins', fn (array $views): array => $this->app->make(ModulesAdminPage::class)->addView($views));

// Versions of the modules installed by Composer: fetched once a day, never on a front-end request
$filter->add('site_status_tests', fn (array $tests): array => $this->app->make(ModuleVersionsHealthCheck::class)->addTests($tests));

$action->add(self::REFRESH_VERSIONS_EVENT, fn () => $this->app->make(ModuleVersions::class)->refresh());
$action->add('admin_init', function (): void {
if (function_exists('wp_next_scheduled') && ! wp_next_scheduled(self::REFRESH_VERSIONS_EVENT)) {
wp_schedule_event(time() + 60, 'daily', self::REFRESH_VERSIONS_EVENT);
}
});
}

/**
Expand Down
55 changes: 55 additions & 0 deletions src/Modules/UI/Console/ModuleOutdatedCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?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 Pollora\Modules\Application\Services\ModuleVersions;

/**
* Check now whether the modules installed by Composer have a newer release,
* and refresh what the admin shows.
*/
#[Description('List the modules installed by Composer that have a newer release')]
#[Signature('pollora:module:outdated {--json : Output as JSON}')]
class ModuleOutdatedCommand extends Command
{
public function handle(ModuleVersions $versions): int
{
$modules = $versions->refresh();

if ($this->option('json')) {
$this->line((string) json_encode($modules, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

return self::SUCCESS;
}

if ($modules === []) {
$this->components->info('No module is installed by Composer: local modules have no version to check.');

return self::SUCCESS;
}

$this->table(['Module', 'Package', 'Installed', 'Latest', ''], array_map(
fn (string $name, array $version): array => [
$name,
$version['package'],
$version['version'],
$version['latest'] ?? 'unknown',
match (true) {
$version['development'] => 'development build',
$version['update'] => 'composer update '.$version['package'],
$version['latest'] === null => 'source did not answer',
default => 'up to date',
},
],
array_keys($modules),
$modules,
));

return self::SUCCESS;
}
}
Loading
Loading