diff --git a/CHANGELOG.md b/CHANGELOG.md index f2543d68..580f803c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-`, 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=` 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/config/modules-defaults.php b/config/modules-defaults.php index 559b2258..1ee5040e 100644 --- a/config/modules-defaults.php +++ b/config/modules-defaults.php @@ -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'), + ], ]; diff --git a/src/Dashboard/Domain/Services/SystemInfoCollector.php b/src/Dashboard/Domain/Services/SystemInfoCollector.php index 05796867..0ddcba13 100644 --- a/src/Dashboard/Domain/Services/SystemInfoCollector.php +++ b/src/Dashboard/Domain/Services/SystemInfoCollector.php @@ -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; @@ -437,7 +438,7 @@ public function collectCacheInfo(): array } /** - * @return array{count: int, enabled: int, disabled: int, connector: string|null, items: list} + * @return array{count: int, enabled: int, disabled: int, connector: string|null, items: list} */ public function collectModulesInfo(): array { @@ -448,6 +449,7 @@ public function collectModulesInfo(): array $enabled = $modules->allEnabled(); $disabled = $modules->allDisabled(); + $versions = $this->moduleVersions(); $items = []; foreach ($all as $module) { $items[] = [ @@ -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, ]; } @@ -472,6 +477,18 @@ public function collectModulesInfo(): array } } + /** + * @return array + */ + private function moduleVersions(): array + { + try { + return $this->container->get(ModuleVersions::class)->all(); + } catch (\Throwable) { + return []; + } + } + /** * Where module states live: "JSON file", "Database"… */ diff --git a/src/Dashboard/UI/Console/StatusCommand.php b/src/Dashboard/UI/Console/StatusCommand.php index 6a0893e9..e03c0f17 100644 --- a/src/Dashboard/UI/Console/StatusCommand.php +++ b/src/Dashboard/UI/Console/StatusCommand.php @@ -176,7 +176,7 @@ private function renderDiscovery(array $discovery): void } /** - * @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list} $modules + * @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list} $modules */ private function renderModules(array $modules): void { @@ -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(); diff --git a/src/Dashboard/UI/Http/DashboardController.php b/src/Dashboard/UI/Http/DashboardController.php index 43414d73..fa8f637c 100644 --- a/src/Dashboard/UI/Http/DashboardController.php +++ b/src/Dashboard/UI/Http/DashboardController.php @@ -324,7 +324,7 @@ private function renderServiceProvidersCard(array $providers): void } /** - * @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list} $modules + * @param array{count: int, enabled: int, disabled: int, connector: string|null, items: list} $modules */ private function renderModulesCard(array $modules): void { diff --git a/src/Modules/Application/Services/ModuleVersions.php b/src/Modules/Application/Services/ModuleVersions.php new file mode 100644 index 00000000..71e7029e --- /dev/null +++ b/src/Modules/Application/Services/ModuleVersions.php @@ -0,0 +1,138 @@ +|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 + */ + 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 + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php b/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php index c34dff09..bd4f4464 100644 --- a/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php +++ b/src/Modules/Infrastructure/Providers/ModuleServiceProvider.php @@ -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; @@ -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. @@ -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(); @@ -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 @@ -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(); @@ -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); + } + }); } /** diff --git a/src/Modules/UI/Console/ModuleOutdatedCommand.php b/src/Modules/UI/Console/ModuleOutdatedCommand.php new file mode 100644 index 00000000..a38a3706 --- /dev/null +++ b/src/Modules/UI/Console/ModuleOutdatedCommand.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/src/Modules/UI/Http/ModuleVersionsHealthCheck.php b/src/Modules/UI/Http/ModuleVersionsHealthCheck.php new file mode 100644 index 00000000..65241feb --- /dev/null +++ b/src/Modules/UI/Http/ModuleVersionsHealthCheck.php @@ -0,0 +1,63 @@ + $tests + * @return array + */ + public function addTests(array $tests): array + { + $tests['direct']['pollora_modules_update'] = [ + 'label' => __('Pollora modules are up to date', 'pollora'), + 'test' => $this->test(...), + ]; + + return $tests; + } + + /** + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, test: string} + */ + public function test(): array + { + $outdated = array_filter($this->versions->all(), fn (array $version): bool => $version['update']); + + if ($outdated === []) { + return [ + 'label' => __('Pollora modules are up to date', 'pollora'), + 'status' => 'good', + 'badge' => ['label' => 'Pollora', 'color' => 'blue'], + 'description' => '

'.esc_html__('Every module installed by Composer runs its latest known version. Local modules have no version and are not checked.', 'pollora').'

', + 'test' => 'pollora_modules_update', + ]; + } + + $items = ''; + + foreach ($outdated as $name => $version) { + /* translators: 1: module, 2: installed version, 3: latest version, 4: Composer package */ + $items .= '
  • '.esc_html(sprintf(__('%1$s %2$s → %3$s (composer update %4$s)', 'pollora'), $name, $version['version'], (string) $version['latest'], $version['package'])).'
  • '; + } + + return [ + /* translators: %d: number of outdated modules */ + 'label' => sprintf(_n('%d Pollora module has an update', '%d Pollora modules have an update', count($outdated), 'pollora'), count($outdated)), + 'status' => 'recommended', + 'badge' => ['label' => 'Pollora', 'color' => 'orange'], + 'description' => '
      '.$items.'
    ', + 'test' => 'pollora_modules_update', + ]; + } +} diff --git a/src/Modules/UI/Http/ModulesAdminPage.php b/src/Modules/UI/Http/ModulesAdminPage.php index fe718038..9658d45a 100644 --- a/src/Modules/UI/Http/ModulesAdminPage.php +++ b/src/Modules/UI/Http/ModulesAdminPage.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Request; use Pollora\Modules\Application\Services\ModuleStates; +use Pollora\Modules\Application\Services\ModuleVersions; use Pollora\Modules\Infrastructure\Activation\JsonStateConnector; use RuntimeException; @@ -23,7 +24,10 @@ class ModulesAdminPage private const string NONCE = 'pollora-modules'; - public function __construct(private readonly ModuleStates $states) {} + public function __construct( + private readonly ModuleStates $states, + private readonly ModuleVersions $versions, + ) {} public function url(): string { @@ -170,15 +174,18 @@ public function render(): void printf('%s', esc_html__('Module', 'pollora')); printf('%s', esc_html__('Description', 'pollora')); printf('%s', esc_html__('State', 'pollora')); + printf('%s', esc_html__('Version', 'pollora')); printf('%s', esc_html__('Connector', 'pollora')); echo ''; if ($modules === []) { - printf('%s', esc_html__('No modules found.', 'pollora')); + printf('%s', esc_html__('No modules found.', 'pollora')); } + $versions = $this->versions->all(); + foreach ($modules as $module) { - $this->renderRow($module, $connector->label(), $confirm); + $this->renderRow($module, $connector->label(), $confirm, $versions[$module['name']] ?? null); } echo ''; @@ -186,8 +193,9 @@ public function render(): void /** * @param array{name: string, description: string, path: string, enabled: bool, locked: bool|null, toggleable: bool, reason: string|null} $module + * @param array{package: string, version: string, latest: string|null, development: bool, update: bool, release_url: string|null}|null $version Null for a local module */ - private function renderRow(array $module, string $connectorLabel, string $confirm): void + private function renderRow(array $module, string $connectorLabel, string $confirm, ?array $version): void { $name = $module['name']; @@ -230,7 +238,44 @@ private function renderRow(array $module, string $connectorLabel, string $confir default => __('Disabled', 'pollora'), }; - printf('%s%s', esc_html($state), esc_html($connectorLabel)); + printf('%s%s%s', esc_html($state), $this->versionCell($version), esc_html($connectorLabel)); + } + + /** + * "1.3.0 · 1.4.0 available", with the command that updates it. A local + * module has no version, and the cell stays empty. + * + * @param array{package: string, version: string, latest: string|null, development: bool, update: bool, release_url: string|null}|null $version + */ + private function versionCell(?array $version): string + { + if ($version === null) { + return ''; + } + + $cell = esc_html($version['version']); + + if ($version['development']) { + return $cell.' '.esc_html__('(development build)', 'pollora').''; + } + + if (! $version['update'] || $version['latest'] === null) { + return $cell; + } + + /* translators: %s: latest version */ + $available = esc_html(sprintf(__('%s available', 'pollora'), $version['latest'])); + + if ($version['release_url'] !== null) { + $available = sprintf('%s', esc_url($version['release_url']), $available); + } + + return sprintf( + '%s · %s
    composer update %s', + $cell, + $available, + esc_html($version['package']), + ); } private function renderBulkActions(string $confirm): void diff --git a/src/VersionCheck/Application/Services/PackageVersionChecker.php b/src/VersionCheck/Application/Services/PackageVersionChecker.php new file mode 100644 index 00000000..43d8b9cc --- /dev/null +++ b/src/VersionCheck/Application/Services/PackageVersionChecker.php @@ -0,0 +1,78 @@ +key($package)) : false; + + return is_array($cached) && is_string($cached['latest'] ?? null) ? $cached['latest'] : null; + } + + /** + * Fetch the latest version and cache the answer, an empty one included. + */ + public function refresh(string $package): ?string + { + $latest = $this->sources->for($package)->latest($package); + + if (function_exists('set_transient')) { + set_transient($this->key($package), ['latest' => $latest], $latest === null ? self::NEGATIVE_TTL : self::TTL); + } + + return $latest; + } + + public function releaseUrl(string $package, string $version): ?string + { + return $this->sources->for($package)->releaseUrl($package, $version); + } + + public function isUpdateAvailable(?string $current, ?string $latest): bool + { + return $current !== null && $latest !== null + && ! StableVersions::isDevelopmentBuild($current) + && version_compare($latest, $current, '>'); + } + + private function key(string $package): string + { + return 'pollora_latest_'.sha1($package); + } +} diff --git a/src/VersionCheck/Domain/Contracts/VersionSource.php b/src/VersionCheck/Domain/Contracts/VersionSource.php new file mode 100644 index 00000000..5f323bea --- /dev/null +++ b/src/VersionCheck/Domain/Contracts/VersionSource.php @@ -0,0 +1,22 @@ + $versions + */ + public static function highest(iterable $versions): ?string + { + $highest = null; + + foreach ($versions as $version) { + if (! is_string($version)) { + continue; + } + + $version = ltrim(trim($version), 'vV'); + + if (! self::isStable($version)) { + continue; + } + + if ($highest === null || version_compare($version, $highest, '>')) { + $highest = $version; + } + } + + return $highest; + } + + public static function isStable(string $version): bool + { + return preg_match('/^\d+(\.\d+)*$/', $version) === 1; + } + + /** + * Whether an installed version is a development build, never compared with releases. + */ + public static function isDevelopmentBuild(string $version): bool + { + return str_starts_with($version, 'dev-') || str_ends_with($version, '-dev'); + } +} diff --git a/src/VersionCheck/Infrastructure/Sources/ComposerRepositorySource.php b/src/VersionCheck/Infrastructure/Sources/ComposerRepositorySource.php new file mode 100644 index 00000000..b11dc226 --- /dev/null +++ b/src/VersionCheck/Infrastructure/Sources/ComposerRepositorySource.php @@ -0,0 +1,41 @@ +http->json(rtrim($this->url, '/').'/p2/'.$package.'.json'); + + $releases = $data['packages'][$package] ?? null; + + if (! is_array($releases)) { + return null; + } + + return StableVersions::highest(array_map( + fn (mixed $release): mixed => is_array($release) ? ($release['version'] ?? null) : null, + $releases, + )); + } + + public function releaseUrl(string $package, string $version): ?string + { + return str_contains($this->url, 'packagist.org') ? sprintf('https://packagist.org/packages/%s#%s', $package, $version) : null; + } +} diff --git a/src/VersionCheck/Infrastructure/Sources/GitHubSource.php b/src/VersionCheck/Infrastructure/Sources/GitHubSource.php new file mode 100644 index 00000000..23adff4d --- /dev/null +++ b/src/VersionCheck/Infrastructure/Sources/GitHubSource.php @@ -0,0 +1,51 @@ + 'application/vnd.github+json']; + + if ($this->token !== null && $this->token !== '') { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $release = $this->http->json(sprintf('https://api.github.com/repos/%s/releases/latest', $this->repository), $headers); + + if (is_array($release) && is_string($release['tag_name'] ?? null)) { + $version = ltrim($release['tag_name'], 'vV'); + + if (StableVersions::isStable($version)) { + return $version; + } + } + + $tags = $this->http->json(sprintf('https://api.github.com/repos/%s/tags?per_page=100', $this->repository), $headers); + + return is_array($tags) + ? StableVersions::highest(array_map(fn (mixed $tag): mixed => is_array($tag) ? ($tag['name'] ?? null) : null, $tags)) + : null; + } + + public function releaseUrl(string $package, string $version): ?string + { + return sprintf('https://github.com/%s/releases', $this->repository); + } +} diff --git a/src/VersionCheck/Infrastructure/Sources/HttpGet.php b/src/VersionCheck/Infrastructure/Sources/HttpGet.php new file mode 100644 index 00000000..28f4957d --- /dev/null +++ b/src/VersionCheck/Infrastructure/Sources/HttpGet.php @@ -0,0 +1,32 @@ + $headers + */ + public function json(string $url, array $headers = []): mixed + { + if (! function_exists('wp_remote_get')) { + return null; + } + + $response = wp_remote_get($url, [ + 'timeout' => 5, + 'headers' => ['Accept' => 'application/json', ...$headers], + ]); + + if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) { + return null; + } + + return json_decode((string) wp_remote_retrieve_body($response), true); + } +} diff --git a/src/VersionCheck/Infrastructure/Sources/VersionSources.php b/src/VersionCheck/Infrastructure/Sources/VersionSources.php new file mode 100644 index 00000000..e60f60a4 --- /dev/null +++ b/src/VersionCheck/Infrastructure/Sources/VersionSources.php @@ -0,0 +1,82 @@ +>|null + */ + private ?array $repositories = null; + + public function __construct( + private readonly HttpGet $http, + private readonly string $composerJsonPath, + private readonly ?string $gitHubToken = null, + ) {} + + public function for(string $package): VersionSource + { + foreach ($this->repositories() as $repository) { + $type = $repository['type'] ?? null; + $url = is_string($repository['url'] ?? null) ? $repository['url'] : ''; + + if ($type === 'composer' && $url !== '' && ! str_contains($url, 'packagist.org') && $this->serves($repository, $package)) { + return new ComposerRepositorySource($this->http, $url); + } + + if ($type === 'vcs' && preg_match('#github\.com[/:]([^/]+/[^/]+?)(\.git)?/?$#', $url, $matches) === 1 + && strtolower(basename($matches[1])) === strtolower(basename($package))) { + return new GitHubSource($this->http, $matches[1], $this->gitHubToken); + } + } + + return new ComposerRepositorySource($this->http); + } + + /** + * @param array $repository + */ + private function serves(array $repository, string $package): bool + { + if (! isset($repository['only'])) { + return true; + } + + foreach ((array) $repository['only'] as $pattern) { + if (is_string($pattern) && fnmatch($pattern, $package)) { + return true; + } + } + + return false; + } + + /** + * @return array> + */ + private function repositories(): array + { + if ($this->repositories !== null) { + return $this->repositories; + } + + $composer = is_file($this->composerJsonPath) ? json_decode((string) file_get_contents($this->composerJsonPath), true) : null; + $repositories = is_array($composer) ? ($composer['repositories'] ?? []) : []; + + return $this->repositories = array_values(array_filter((array) $repositories, is_array(...))); + } +} diff --git a/tests/Feature/ModuleActivation/ModuleVersionsTest.php b/tests/Feature/ModuleActivation/ModuleVersionsTest.php new file mode 100644 index 00000000..80541c10 --- /dev/null +++ b/tests/Feature/ModuleActivation/ModuleVersionsTest.php @@ -0,0 +1,200 @@ + JSON body, recording each request. + * + * @param array $responses + */ +function fakeHttp(array $responses, array &$requests = []): void +{ + Functions\when('wp_remote_get')->alias(function (string $url, array $args = []) use ($responses, &$requests): array { + $requests[] = ['url' => $url, 'headers' => $args['headers'] ?? []]; + + return array_key_exists($url, $responses) + ? ['code' => 200, 'body' => (string) json_encode($responses[$url])] + : ['code' => 404, 'body' => '{}']; + }); + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_remote_retrieve_response_code')->alias(fn (array $response): int => $response['code']); + Functions\when('wp_remote_retrieve_body')->alias(fn (array $response): string => $response['body']); +} + +/** + * Keep transients in memory. + */ +function fakeTransients(array &$transients): void +{ + // An arrow function would capture the array by value, and never see a later write + Functions\when('get_transient')->alias(function (string $key) use (&$transients): mixed { + return $transients[$key]['value'] ?? false; + }); + Functions\when('set_transient')->alias(function (string $key, mixed $value, int $ttl) use (&$transients): bool { + $transients[$key] = ['value' => $value, 'ttl' => $ttl]; + + return true; + }); +} + +beforeEach(function (): void { + $this->directory = sys_get_temp_dir().'/pollora-module-versions-'.uniqid(); + File::ensureDirectoryExists($this->directory); + $this->transients = []; + fakeTransients($this->transients); +}); + +afterEach(function (): void { + File::deleteDirectory($this->directory); +}); + +describe('StableVersions', function (): void { + it('picks the highest stable version, whatever the order and the "v"', function (): void { + expect(StableVersions::highest(['v1.2.0', '1.10.0', '2.0.0-beta1', '1.9.9', 'dev-main', '1.11.0-RC1']))->toBe('1.10.0') + ->and(StableVersions::highest(['dev-main']))->toBeNull() + ->and(StableVersions::isDevelopmentBuild('dev-main'))->toBeTrue() + ->and(StableVersions::isDevelopmentBuild('1.x-dev'))->toBeTrue() + ->and(StableVersions::isDevelopmentBuild('1.3.0'))->toBeFalse(); + }); +}); + +describe('version sources', function (): void { + it("reads a Composer repository's p2 metadata, Satis and Private Packagist included", function (): void { + fakeHttp(['https://satis.example.com/p2/acme/crm.json' => ['packages' => ['acme/crm' => [ + ['version' => '1.5.0-beta1'], ['version' => 'v1.4.0'], ['version' => '1.3.0'], + ]]]]); + + expect((new ComposerRepositorySource(new HttpGet, 'https://satis.example.com/'))->latest('acme/crm'))->toBe('1.4.0') + ->and((new ComposerRepositorySource(new HttpGet, 'https://satis.example.com'))->latest('acme/other'))->toBeNull(); + }); + + it("reads a GitHub repository's latest release, else its highest tag, with a token for a private one", function (): void { + $requests = []; + fakeHttp([ + 'https://api.github.com/repos/acme/crm-module/tags?per_page=100' => [['name' => 'v2.1.0'], ['name' => 'v2.0.0'], ['name' => 'v3.0.0-alpha']], + ], $requests); + + expect((new GitHubSource(new HttpGet, 'acme/crm-module', 'secret'))->latest('acme/crm-module'))->toBe('2.1.0') + ->and($requests[0]['headers']['Authorization'])->toBe('Bearer secret'); + }); + + it("picks the source from the project's repositories", function (): void { + file_put_contents($this->directory.'/composer.json', json_encode(['repositories' => [ + ['type' => 'composer', 'url' => 'https://satis.example.com', 'only' => ['acme/*']], + ['type' => 'vcs', 'url' => 'https://github.com/other/billing.git'], + ]])); + $sources = new VersionSources(new HttpGet, $this->directory.'/composer.json'); + + expect($sources->for('acme/crm'))->toBeInstanceOf(ComposerRepositorySource::class) + ->and($sources->for('other/billing'))->toBeInstanceOf(GitHubSource::class) + ->and($sources->for('vendor/package')->releaseUrl('vendor/package', '1.0.0'))->toBe('https://packagist.org/packages/vendor/package#1.0.0'); + }); +}); + +describe('PackageVersionChecker', function (): void { + it('fetches only on refresh, and caches a missing answer for an hour', function (): void { + $requests = []; + fakeHttp(['https://repo.packagist.org/p2/acme/crm.json' => ['packages' => ['acme/crm' => [['version' => '1.4.0']]]]], $requests); + $checker = new PackageVersionChecker(new VersionSources(new HttpGet, $this->directory.'/composer.json')); + + expect($checker->cachedLatest('acme/crm'))->toBeNull() + ->and($requests)->toBe([]) + ->and($checker->refresh('acme/crm'))->toBe('1.4.0') + ->and($checker->cachedLatest('acme/crm'))->toBe('1.4.0') + ->and($this->transients['pollora_latest_'.sha1('acme/crm')]['ttl'])->toBe(PackageVersionChecker::TTL); + + $checker->refresh('acme/gone'); + + expect($this->transients['pollora_latest_'.sha1('acme/gone')]['ttl'])->toBe(PackageVersionChecker::NEGATIVE_TTL); + }); + + it('never reports a development build as outdated', function (): void { + $checker = new PackageVersionChecker(new VersionSources(new HttpGet, $this->directory.'/composer.json')); + + expect($checker->isUpdateAvailable('dev-main', '2.0.0'))->toBeFalse() + ->and($checker->isUpdateAvailable('1.3.0', '1.4.0'))->toBeTrue() + ->and($checker->isUpdateAvailable('1.4.0', '1.4.0'))->toBeFalse(); + }); +}); + +describe('ModuleVersions', function (): void { + beforeEach(function (): void { + // A module installed by Composer: its path is a package's install path + $this->package = 'nwidart/laravel-modules'; + $installed = (string) InstalledVersions::getInstallPath($this->package); + $local = $this->directory.'/Modules/Local'; + File::ensureDirectoryExists($local); + + $this->app->instance('modules', new readonly class(['Shop' => $installed, 'Local' => $local]) + { + public function __construct(private array $paths) {} + + public function all(): array + { + return array_map(fn (string $name): object => new readonly class($name, $this->paths[$name]) + { + public function __construct(private string $name, private string $path) {} + + public function getName(): string + { + return $this->name; + } + + public function getPath(): string + { + return $this->path; + } + }, array_keys($this->paths)); + } + }); + $this->app->instance(VersionSources::class, new VersionSources(new HttpGet, $this->directory.'/composer.json')); + + fakeHttp(['https://repo.packagist.org/p2/nwidart/laravel-modules.json' => ['packages' => ['nwidart/laravel-modules' => [['version' => '99.0.0']]]]]); + }); + + it('gives a version to modules installed by Composer only', function (): void { + $versions = $this->app->make(ModuleVersions::class); + + expect(array_keys($versions->all()))->toBe(['Shop']) + ->and($versions->all()['Shop'])->toMatchArray([ + 'package' => $this->package, + 'version' => ltrim((string) InstalledVersions::getPrettyVersion($this->package), 'v'), + 'latest' => null, + 'update' => false, + ]); + }); + + it('shows the update once refreshed, with the command that applies it', function (): void { + $this->app->make(ModuleVersions::class)->refresh(); + $versions = $this->app->make(ModuleVersions::class)->all(); + + expect($versions['Shop'])->toMatchArray(['latest' => '99.0.0', 'update' => true, 'release_url' => 'https://packagist.org/packages/nwidart/laravel-modules#99.0.0']); + + $result = $this->app->make(ModuleVersionsHealthCheck::class)->test(); + + expect($result['status'])->toBe('recommended') + ->and($result['description'])->toContain('composer update nwidart/laravel-modules'); + }); + + it('lists outdated modules with pollora:module:outdated', function (): void { + $this->app->make(Kernel::class)->registerCommand($this->app->make(ModuleOutdatedCommand::class)); + + $this->artisan('pollora:module:outdated') + ->expectsOutputToContain('composer update nwidart/laravel-modules') + ->assertSuccessful(); + }); +}); diff --git a/tests/Feature/ModuleActivation/ModulesAdminPageTest.php b/tests/Feature/ModuleActivation/ModulesAdminPageTest.php index 2cb11386..e4376de1 100644 --- a/tests/Feature/ModuleActivation/ModulesAdminPageTest.php +++ b/tests/Feature/ModuleActivation/ModulesAdminPageTest.php @@ -6,9 +6,12 @@ use Illuminate\Support\Facades\File; use Nwidart\Modules\Contracts\ActivatorInterface; use Pollora\Modules\Application\Services\ModuleStates; +use Pollora\Modules\Application\Services\ModuleVersions; use Pollora\Modules\Infrastructure\Activation\ConnectorActivator; use Pollora\Modules\Infrastructure\Activation\JsonStateConnector; use Pollora\Modules\UI\Http\ModulesAdminPage; +use Pollora\VersionCheck\Infrastructure\Sources\HttpGet; +use Pollora\VersionCheck\Infrastructure\Sources\VersionSources; /** * A module as nwidart/laravel-modules hands it out, switched through the activator. @@ -97,6 +100,8 @@ public function find(string $name): ?object } }); + $this->app->instance(VersionSources::class, new VersionSources(new HttpGet, $this->directory.'/composer.json')); + $this->page = $this->app->make(ModulesAdminPage::class); }); @@ -190,6 +195,24 @@ public function find(string $name): ?object ->not->toContain('value="disable:Shop"'); }); + it('shows the version of a module installed by Composer and its update, nothing for a local one', function (): void { + $versions = Mockery::mock(ModuleVersions::class); + $versions->shouldReceive('all')->andReturn(['Crm' => [ + 'package' => 'acme/crm', 'version' => '1.3.0', 'latest' => '1.4.0', 'development' => false, 'update' => true, + 'release_url' => 'https://packagist.org/packages/acme/crm#1.4.0', + ]]); + $this->app->instance(ModuleVersions::class, $versions); + + ob_start(); + $this->app->make(ModulesAdminPage::class)->render(); + $html = (string) ob_get_clean(); + + expect($html)->toContain('1.3.0 · toContain('1.4.0 available') + ->toContain('composer update acme/crm') + ->and(substr_count($html, 'composer update'))->toBe(1); + }); + it('explains how to switch modules when the state cannot be written', function (): void { chmod($this->statuses, 0444);