diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php index 0caf2ad34..fc04e2714 100644 --- a/system/src/Grav/Common/GPM/Local/Package.php +++ b/system/src/Grav/Common/GPM/Local/Package.php @@ -39,6 +39,7 @@ class Package extends BasePackage $this->data->set('description_html', $html_description); $this->data->set('description_plain', strip_tags($html_description)); $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug'))); + $this->data->set('compatibility', $this->resolveCompatibility($data)); } /** @@ -48,4 +49,52 @@ class Package extends BasePackage { return (bool)$this->settings['enabled']; } + + /** + * Resolve the compatibility metadata for this package. + * + * @param Data $data Blueprint data + * @return array{grav: string[], api: string[]} + */ + protected function resolveCompatibility(Data $data): array + { + $raw = $data->get('compatibility'); + + if (is_array($raw) && isset($raw['grav']) && is_array($raw['grav'])) { + return [ + 'grav' => array_map('strval', $raw['grav']), + 'api' => isset($raw['api']) && is_array($raw['api']) ? array_map('strval', $raw['api']) : [], + ]; + } + + return $this->inferCompatibility($data->get('dependencies') ?? []); + } + + /** + * Infer Grav compatibility from the dependencies array. + * + * @param array $dependencies + * @return array{grav: string[], api: string[]} + */ + protected function inferCompatibility(array $dependencies): array + { + foreach ($dependencies as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $version = $dep['version'] ?? ''; + + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { + continue; + } + + if (version_compare($m[1], '1.8', '>=')) { + return ['grav' => ['1.8'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } } diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php index 082fe0095..ca28a042b 100644 --- a/system/src/Grav/Console/Gpm/IndexCommand.php +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -141,7 +141,7 @@ class IndexCommand extends GpmCommand if (!empty($packages)) { $io->section('Packages table'); $table = new Table($io); - $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']); + $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Compat', 'Installed', 'Enabled']); $index = 0; foreach ($packages as $slug => $package) { @@ -150,6 +150,7 @@ class IndexCommand extends GpmCommand 'Name' => '' . Utils::truncate($package->name, 20, false, ' ', '...') . ' ', 'Slug' => $slug, 'Version'=> $this->version($package), + 'Compat' => $this->compatBadges($package), 'Installed' => $this->installed($package), 'Enabled' => $this->enabled($package), ]; @@ -231,6 +232,38 @@ class IndexCommand extends GpmCommand return $result; } + /** + * @param Package $package + * @return string + */ + private function compatBadges(Package $package): string + { + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); + + if ($installed) { + $local = $this->gpm->{'getInstalled' . $type}($package->slug); + $compat = $local->compatibility ?? null; + } else { + $compat = $package->compatibility ?? null; + } + + if (!is_array($compat) || empty($compat['grav'])) { + return '1.7'; + } + + $badges = []; + if (in_array('1.7', $compat['grav'], true)) { + $badges[] = '1.7'; + } + if (in_array('1.8', $compat['grav'], true)) { + $badges[] = '1.8'; + } + + return implode(' ', $badges); + } + /** * @param Packages $data * @return Packages diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php index 0eb6c6653..d2e89002a 100644 --- a/system/src/Grav/Console/Gpm/InfoCommand.php +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -131,9 +131,36 @@ class InfoCommand extends GpmCommand } } + // Display compatibility badges $type = rtrim($foundPackage->package_type, 's'); - $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug); $installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug); + if ($installed) { + $local = $this->gpm->{'getInstalled' . $type}($foundPackage->slug); + $compat = $local->compatibility ?? null; + } else { + $compat = $foundPackage->compatibility ?? null; + } + + $compatStr = ''; + if (is_array($compat) && !empty($compat['grav'])) { + $badges = []; + if (in_array('1.7', $compat['grav'], true)) { + $badges[] = '1.7'; + } + if (in_array('1.8', $compat['grav'], true)) { + $badges[] = '1.8'; + } + $compatStr = implode(' ', $badges); + } else { + $compatStr = '1.7'; + } + $io->writeln('' . str_pad('Compatibility', 12) . ': ' . $compatStr); + + if (is_array($compat) && !empty($compat['api'])) { + $io->writeln('' . str_pad('API', 12) . ': ' . implode(', ', $compat['api'])); + } + + $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug); // display current version if installed and different if ($installed && $updatable) { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index a3c7c6f14..25a455087 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -19,8 +19,11 @@ use Grav\Console\GpmCommand; use Grav\Installer\Install; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; +use Grav\Common\Yaml; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; +use Throwable; use ZipArchive; use function is_callable; use function strlen; @@ -333,6 +336,14 @@ class SelfupgradeCommand extends GpmCommand $script = $folder . '/system/install.php'; if ((file_exists($script) && $install = include $script) && is_callable($install)) { + // Run preflight from the NEW package's installer if available + if (is_object($install) && method_exists($install, 'generatePreflightReport')) { + $report = $install->generatePreflightReport(); + if (!$this->handlePreflightReport($report)) { + Installer::setError('Upgrade aborted due to preflight requirements.'); + return; + } + } $install($zip); } else { throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); @@ -341,4 +352,254 @@ class SelfupgradeCommand extends GpmCommand Installer::setError($e->getMessage()); } } + + /** + * Process a preflight report from the target package's installer. + * + * @param array $preflight + * @return bool True to proceed, false to abort + */ + protected function handlePreflightReport(array $preflight): bool + { + $io = $this->getIO(); + $pending = $preflight['plugins_pending'] ?? []; + $blocking = $preflight['blocking'] ?? []; + $conflicts = $preflight['psr_log_conflicts'] ?? []; + $monologConflicts = $preflight['monolog_conflicts'] ?? []; + $warnings = $preflight['warnings'] ?? []; + $incompatible = $preflight['incompatible_packages'] ?? []; + $incompatibleBlocking = $incompatible['blocking'] ?? []; + $incompatibleTarget = $incompatible['target'] ?? ''; + $isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? false; + + if ($warnings) { + $io->newLine(); + $io->writeln('Preflight warnings detected:'); + foreach ($warnings as $warning) { + $io->writeln(' • ' . $warning); + } + } + + // Filter out the incompatible-packages blocker (handled separately below) + $filteredBlocking = array_filter($blocking, static function ($reason) { + return !stripos($reason, 'not been marked as compatible'); + }); + + if ($filteredBlocking && empty($pending)) { + $io->newLine(); + $io->writeln('Upgrade blocked:'); + foreach ($filteredBlocking as $reason) { + $io->writeln(' - ' . $reason); + } + + return false; + } + + if (empty($pending) && empty($conflicts) && empty($monologConflicts) && empty($incompatibleBlocking)) { + return true; + } + + if ($pending && $isMajorMinorUpgrade) { + $local = $this->upgrader ? $this->upgrader->getLocalVersion() : 'unknown'; + $remote = $this->upgrader ? $this->upgrader->getRemoteVersion() : 'unknown'; + + $io->newLine(); + $io->writeln('The following packages need updating before Grav upgrade:'); + foreach ($pending as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $current = $info['current'] ?? 'unknown'; + $available = $info['available'] ?? 'unknown'; + $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available)); + } + + $io->writeln(' › For major version upgrades (v' . $local . ' → v' . $remote . '), plugins must be updated first.'); + $io->writeln(' Please run `bin/gpm update` to update these packages, then retry self-upgrade.'); + + $proceed = false; + if (!$this->all_yes) { + $question = new ConfirmationQuestion('Proceed anyway? [y|N] ', false); + $proceed = $io->askQuestion($question); + } + + if (!$proceed) { + $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + + return false; + } + + if (method_exists(Install::class, 'allowPendingPackageOverride')) { + Install::allowPendingPackageOverride(true); + } + $io->writeln(' › Proceeding despite pending plugin/theme updates.'); + } + + // Handle incompatible packages + if ($incompatibleBlocking) { + $io->newLine(); + $io->writeln('The following enabled plugins/themes are not marked as compatible with Grav ' . $incompatibleTarget . ':'); + foreach ($incompatibleBlocking as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $ver = $info['version'] ?? 'unknown'; + $gravCompat = implode(', ', $info['compatibility']['grav'] ?? ['?']); + $io->writeln(sprintf(' - %s (%s v%s) — compatible with: %s', $slug, $type, $ver, $gravCompat)); + } + $io->writeln(' › Plugins/themes must be marked as compatible with Grav ' . $incompatibleTarget . ' before upgrading.'); + $io->writeln(' Either update the plugins, or disable them to proceed.'); + + $choice = $this->all_yes ? 'abort' : $io->choice( + 'How would you like to proceed?', + ['disable', 'continue', 'abort'], + 'abort' + ); + + if ($choice === 'abort') { + $io->writeln('Aborting self-upgrade. Update or disable incompatible plugins first.'); + + return false; + } + + if ($choice === 'disable') { + foreach (array_keys($incompatibleBlocking) as $slug) { + $this->disablePluginConfig($slug); + $io->writeln(sprintf(' - Disabled %s.', $slug)); + } + $io->writeln('Continuing with incompatible plugins disabled.'); + } else { + if (method_exists(Install::class, 'allowIncompatibleOverride')) { + Install::allowIncompatibleOverride(true); + } + $io->writeln(' › Proceeding despite incompatible plugins/themes.'); + } + } + + // Show incompatible warnings (disabled packages) — informational only + $incompatibleWarnings = $incompatible['warnings'] ?? []; + if ($incompatibleWarnings) { + $io->newLine(); + $io->writeln('Disabled plugins/themes not yet compatible with Grav ' . $incompatibleTarget . ' (will not block upgrade):'); + foreach ($incompatibleWarnings as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $ver = $info['version'] ?? 'unknown'; + $io->writeln(sprintf(' - %s (%s v%s)', $slug, $type, $ver)); + } + } + + $handled = $this->handleConflicts( + $conflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential psr/log incompatibilities:'); + foreach ($conflicts as $slug => $info) { + $requires = $info['requires'] ?? '*'; + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires)); + } + }, + 'Update the plugin or add "replace": {"psr/log": "*"} to its composer.json.', + 'Aborting self-upgrade. Adjust composer requirements or update affected plugins.', + 'Proceeding with potential psr/log incompatibilities still active.' + ); + + if (!$handled) { + return false; + } + + $handledMonolog = $this->handleConflicts( + $monologConflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential Monolog logger API incompatibilities:'); + foreach ($conflicts as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + }, + 'Update the plugin to use PSR-3 style logger methods before upgrading.', + 'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.', + 'Proceeding with potential Monolog API incompatibilities still active.' + ); + + if (!$handledMonolog) { + return false; + } + + return true; + } + + /** + * Handle a set of conflicts with user choice (disable/continue/abort). + * + * @param array $conflicts + * @param callable $printer + * @param string $advice + * @param string $abortMessage + * @param string $continueMessage + * @return bool + */ + private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage): bool + { + if (empty($conflicts)) { + return true; + } + + $io = $this->getIO(); + $printer($io, $conflicts); + $io->writeln(' › ' . $advice); + + $choice = $this->all_yes ? 'abort' : $io->choice( + 'How would you like to proceed?', + ['disable', 'continue', 'abort'], + 'abort' + ); + + if ($choice === 'abort') { + $io->writeln($abortMessage); + + return false; + } + + if ($choice === 'disable') { + foreach (array_keys($conflicts) as $slug) { + $this->disablePluginConfig($slug); + $io->writeln(sprintf(' - Disabled plugin %s.', $slug)); + } + $io->writeln('Continuing with conflicted plugins disabled.'); + + return true; + } + + $io->writeln($continueMessage); + + return true; + } + + /** + * Disable a plugin by writing enabled: false to its config file. + * Used on 1.7 where RecoveryManager may not be available. + * + * @param string $slug + */ + private function disablePluginConfig(string $slug): void + { + $configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml'; + + try { + if (is_file($configPath)) { + $contents = @file_get_contents($configPath); + $data = $contents !== false ? Yaml::parse($contents) : []; + if (!is_array($data)) { + $data = []; + } + } else { + $data = []; + } + + $data['enabled'] = false; + file_put_contents($configPath, Yaml::dump($data)); + } catch (Throwable $e) { + // best effort — if config write fails, upgrade will be blocked by Install.php + } + } } diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 6e20c9391..b81b80768 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -187,13 +187,29 @@ class UpdateCommand extends GpmCommand $package->available = $package->version; } + // Build compatibility badges + $compat = $package->compatibility ?? null; + $compatStr = ''; + if (is_array($compat) && !empty($compat['grav'])) { + $badges = []; + if (in_array('1.7', $compat['grav'], true)) { + $badges[] = '1.7'; + } + if (in_array('1.8', $compat['grav'], true)) { + $badges[] = '1.8'; + } + $compatStr = ' ' . implode(' ', $badges); + } + $io->writeln( // index str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' . // name '' . str_pad($package->name, 15) . ' ' . // version - "[v{$package->version} -> v{$package->available}]" + "[v{$package->version} -> v{$package->available}]" . + // compat badges + $compatStr ); $slugs[] = $slug; }