Add compatibility: blueprint support and preflight handling for 1.7→1.8 upgrades

Enables the 1.7 SelfupgradeCommand to handle preflight reports from the 1.8
package's Install.php. When upgrading from 1.7 to 1.8, the command now:
- Calls generatePreflightReport() on the new package's installer
- Shows incompatible plugins with disable/continue/abort choices
- Handles pending updates, PSR/log and Monolog conflicts interactively
- Disables plugins via direct YAML config write (no RecoveryManager on 1.7)

Also adds:
- GPM Local/Package: computed compatibility property from blueprints
- CLI badges: IndexCommand, InfoCommand, UpdateCommand show 1.7/1.8 badges
This commit is contained in:
Andy Miller
2026-04-02 10:25:38 -06:00
parent 2025a5f39e
commit e460ed6613
5 changed files with 389 additions and 3 deletions

View File

@@ -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' => []];
}
}

View File

@@ -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' => '<cyan>' . Utils::truncate($package->name, 20, false, ' ', '...') . '</cyan> ',
'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 '<blue>1.7</blue>';
}
$badges = [];
if (in_array('1.7', $compat['grav'], true)) {
$badges[] = '<blue>1.7</blue>';
}
if (in_array('1.8', $compat['grav'], true)) {
$badges[] = '<green>1.8</green>';
}
return implode(' ', $badges);
}
/**
* @param Packages $data
* @return Packages

View File

@@ -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[] = '<blue>1.7</blue>';
}
if (in_array('1.8', $compat['grav'], true)) {
$badges[] = '<green>1.8</green>';
}
$compatStr = implode(' ', $badges);
} else {
$compatStr = '<blue>1.7</blue>';
}
$io->writeln('<green>' . str_pad('Compatibility', 12) . ':</green> ' . $compatStr);
if (is_array($compat) && !empty($compat['api'])) {
$io->writeln('<green>' . str_pad('API', 12) . ':</green> ' . implode(', ', $compat['api']));
}
$updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
// display current version if installed and different
if ($installed && $updatable) {

View File

@@ -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('<magenta>Preflight warnings detected:</magenta>');
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('<red>Upgrade blocked:</red>');
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('<yellow>The following packages need updating before Grav upgrade:</yellow>');
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('<yellow>The following enabled plugins/themes are not marked as compatible with Grav ' . $incompatibleTarget . ':</yellow>');
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('<cyan>Disabled plugins/themes not yet compatible with Grav ' . $incompatibleTarget . ' (will not block upgrade):</cyan>');
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('<yellow>Potential psr/log incompatibilities:</yellow>');
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('<yellow>Potential Monolog logger API incompatibilities:</yellow>');
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
}
}
}

View File

@@ -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[] = '<blue>1.7</blue>';
}
if (in_array('1.8', $compat['grav'], true)) {
$badges[] = '<green>1.8</green>';
}
$compatStr = ' ' . implode(' ', $badges);
}
$io->writeln(
// index
str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' .
// name
'<cyan>' . str_pad($package->name, 15) . '</cyan> ' .
// version
"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]"
"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]" .
// compat badges
$compatStr
);
$slugs[] = $slug;
}