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;
}