From 92b3d5b1f8b20b234ac6191c67b6920b817eb1d0 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Tue, 11 Nov 2025 15:03:48 +0000 Subject: [PATCH] preflight integration for cli Signed-off-by: Andy Miller --- system/install.php | 10 +- .../Grav/Console/Gpm/SelfupgradeCommand.php | 42 +- system/src/Grav/Installer/Install.php | 443 ++++++++++++++++++ 3 files changed, 482 insertions(+), 13 deletions(-) diff --git a/system/install.php b/system/install.php index d73b9891a..27d38f238 100644 --- a/system/install.php +++ b/system/install.php @@ -14,14 +14,10 @@ if (!defined('GRAV_ROOT')) { // This happens when upgrading from older versions where the OLD Install class // was loaded via autoloader before extracting the update package (e.g., via Install::forceSafeUpgrade()) $logInstallerSource = static function ($install, string $source) { - try { - $reflection = new \ReflectionClass($install); - $path = $reflection->getFileName() ?: 'unknown'; - } catch (\Throwable $e) { - $path = 'unknown'; + $sourceLabel = $source === 'extracted update package' ? 'update package' : 'existing installation'; + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + echo sprintf(" |- Using installer from %s\n", $sourceLabel); } - - error_log(sprintf('[Grav Upgrade] Installer loaded from %s: %s', $source, $path)); }; if (class_exists('Grav\\Installer\\Install', false)) { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 6f51e22c1..8fcedad5c 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -392,14 +392,11 @@ class SelfupgradeCommand extends GpmCommand { $io = $this->getIO(); $pending = $preflight['plugins_pending'] ?? []; + $blocking = $preflight['blocking'] ?? []; $conflicts = $preflight['psr_log_conflicts'] ?? []; $monologConflicts = $preflight['monolog_conflicts'] ?? []; $warnings = $preflight['warnings'] ?? []; - if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { - return true; - } - if ($warnings) { $io->newLine(); $io->writeln('Preflight warnings detected:'); @@ -408,6 +405,20 @@ class SelfupgradeCommand extends GpmCommand } } + if ($blocking && empty($pending)) { + $io->newLine(); + $io->writeln('Upgrade blocked:'); + foreach ($blocking as $reason) { + $io->writeln(' - ' . $reason); + } + + return false; + } + + if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { + return true; + } + if ($pending) { // Use the is_major_minor_upgrade flag from preflight result if available $isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? false; @@ -449,9 +460,20 @@ class SelfupgradeCommand extends GpmCommand $io->writeln(' › Please run `bin/gpm update` to bring these packages current before upgrading Grav.'); } - $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + $proceed = false; + if (!$this->all_yes) { + $question = new ConfirmationQuestion('Proceed anyway? [y|N] ', false); + $proceed = $io->askQuestion($question); + } - return false; + if (!$proceed) { + $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + + return false; + } + + Install::allowPendingPackageOverride(true); + $io->writeln(' › Proceeding despite pending plugin/theme updates.'); } $handled = $this->handleConflicts( @@ -643,6 +665,14 @@ class SelfupgradeCommand extends GpmCommand $this->handleServiceProgress($stage, $message, $percent); }); } + 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'); diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 4d92dd07d..ba21d9797 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -13,24 +13,40 @@ use Composer\Autoload\ClassLoader; use Exception; use Grav\Common\Cache; use Grav\Common\Filesystem\Folder; +use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\Grav; use Grav\Common\Plugins; +use Grav\Common\Yaml; use RuntimeException; +use Throwable; +use function basename; use function class_exists; use function date; use function dirname; +use function explode; use function floor; use function function_exists; +use function glob; +use function iterator_to_array; use function is_dir; use function is_file; use function is_link; +use function method_exists; use function is_string; use function is_writable; use function json_encode; +use function json_decode; use function readlink; +use function array_fill_keys; +use function array_map; +use function array_pad; +use function array_key_exists; use function sort; use function sprintf; +use function strtolower; +use function strpos; +use function preg_match; use function symlink; use function time; use function uniqid; @@ -143,8 +159,12 @@ final class Install private static $instance; /** @var bool|null */ private static $forceSafeUpgrade = null; + /** @var bool */ + private static $allowPendingOverride = false; /** @var callable|null */ private $progressCallback = null; + /** @var array|null */ + private $pendingPreflight = null; /** * @return static @@ -173,6 +193,25 @@ final class Install { } + public static function allowPendingPackageOverride(?bool $state = true): void + { + if ($state === null) { + self::$allowPendingOverride = false; + } else { + self::$allowPendingOverride = (bool)$state; + } + } + + private function ensureLocation(): void + { + if (null === $this->location) { + $path = realpath(__DIR__); + if ($path) { + $this->location = dirname($path, 4); + } + } + } + /** * @param string|null $zip * @return $this @@ -192,6 +231,7 @@ final class Install public function __invoke(?string $zip) { $this->zip = $zip; + $this->pendingPreflight = null; $failedRequirements = $this->checkRequirements(); if ($failedRequirements) { @@ -258,6 +298,7 @@ ERR; public function prepare(): void { // Locate the new Grav update and the target site from the filesystem. + $this->ensureLocation(); $location = realpath(__DIR__); $target = realpath(GRAV_ROOT . '/index.php'); @@ -313,6 +354,15 @@ ERR; $safeUpgradeRequested = $this->shouldUseSafeUpgrade(); $targetVersion = $this->getVersion(); + if (null === $this->pendingPreflight) { + $this->pendingPreflight = $this->runPreflightChecks($targetVersion); + } + if (!empty($this->pendingPreflight['blocking'] ?? [])) { + $this->relayProgress('error', 'Upgrade blocked by preflight checks.', null); + Installer::setError('Upgrade preflight checks failed.'); + + return; + } $snapshotManifest = null; if ($safeUpgradeRequested) { $snapshotManifest = $this->captureCoreSnapshot($targetVersion); @@ -338,6 +388,8 @@ ERR; $this->relayProgress('complete', 'Grav standard installer finished.', 100); } catch (Exception $e) { Installer::setError($e->getMessage()); + } finally { + self::$allowPendingOverride = false; } $errorCode = Installer::lastErrorCode(); @@ -551,6 +603,8 @@ ERR; */ public function finalize(): void { + $start = microtime(true); + $this->relayProgress('postflight', 'Running postflight tasks...', null); // Finalize can be run without installing Grav first. if (null === $this->updater) { $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); @@ -568,6 +622,9 @@ ERR; if (function_exists('opcache_reset')) { @opcache_reset(); } + + $elapsed = microtime(true) - $start; + $this->relayProgress('postflight', sprintf('Postflight tasks complete in %.3fs.', $elapsed), null); } /** @@ -700,4 +757,390 @@ ERR; { return $this->lastManifest; } + + public function generatePreflightReport(): array + { + $this->ensureLocation(); + $version = $this->getVersion(); + + $report = $this->runPreflightChecks($version ?: GRAV_VERSION); + $this->pendingPreflight = $report; + + return $report; + } + + public function getPreflightReport(): ?array + { + return $this->pendingPreflight; + } + + private function runPreflightChecks(string $targetVersion): array + { + $start = microtime(true); + $this->relayProgress('preflight', 'Running preflight checks...', null); + $report = [ + 'warnings' => [], + 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], + 'plugins_pending' => [], + 'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion), + 'blocking' => [], + ]; + + $report['plugins_pending'] = $this->detectPendingPackageUpdates(); + $report['psr_log_conflicts'] = $this->detectPsrLogConflicts(); + $report['monolog_conflicts'] = $this->detectMonologConflicts(); + + if ($report['plugins_pending']) { + if (self::$allowPendingOverride) { + $report['warnings'][] = 'Pending plugin/theme updates ignored for this upgrade run.'; + } elseif ($report['is_major_minor_upgrade']) { + $report['blocking'][] = 'Plugin and theme updates must be applied before upgrading Grav.'; + } else { + $report['warnings'][] = 'Pending plugin/theme updates detected; updating before upgrading Grav is recommended.'; + } + } + + $elapsed = microtime(true) - $start; + $this->relayProgress('preflight', sprintf('Preflight checks complete in %.3fs.', $elapsed), null); + + return $report; + } + + private function isMajorMinorUpgrade(string $targetVersion): bool + { + [$currentMajor, $currentMinor] = array_map('intval', array_pad(explode('.', GRAV_VERSION), 2, 0)); + [$targetMajor, $targetMinor] = array_map('intval', array_pad(explode('.', $targetVersion), 2, 0)); + + return $currentMajor !== $targetMajor || $currentMinor !== $targetMinor; + } + + private function detectPendingPackageUpdates(): array + { + $pending = []; + + if (!class_exists(GPM::class)) { + return $pending; + } + + try { + $gpm = new GPM(); + } catch (Throwable $e) { + $this->relayProgress('warning', 'Unable to query GPM: ' . $e->getMessage(), null); + + return $pending; + } + + $repoPlugins = $this->packagesToArray($gpm->getRepositoryPlugins()); + $repoThemes = $this->packagesToArray($gpm->getRepositoryThemes()); + + $scanRoot = GRAV_ROOT ?: getcwd(); + + $localPlugins = $this->scanLocalPackageVersions($scanRoot . '/user/plugins'); + foreach ($localPlugins as $slug => $version) { + $remote = $repoPlugins[$slug] ?? null; + if (!$this->isGpmPackagePublished($remote)) { + continue; + } + $remoteVersion = $this->resolveRemotePackageVersion($remote); + if (!$remoteVersion || !$version) { + continue; + } + if (!$this->isPluginEnabled($slug)) { + if (str_contains($version, 'dev-')) { + $this->relayProgress('warning', sprintf('Skipping dev plugin %s (%s).', $slug, $version), null); + continue; + } + } + + if (version_compare($remoteVersion, $version, '>')) { + $pending[$slug] = [ + 'type' => 'plugins', + 'current' => $version, + 'available' => $remoteVersion, + ]; + } + } + + $localThemes = $this->scanLocalPackageVersions($scanRoot . '/user/themes'); + foreach ($localThemes as $slug => $version) { + $remote = $repoThemes[$slug] ?? null; + if (!$this->isGpmPackagePublished($remote)) { + if (str_contains($version, 'dev-')) { + $this->relayProgress('warning', sprintf('Skipping dev theme %s (%s).', $slug, $version), null); + continue; + } + } + $remoteVersion = $this->resolveRemotePackageVersion($remote); + if (!$remoteVersion || !$version) { + continue; + } + if (!$this->isThemeEnabled($slug)) { + continue; + } + + if (version_compare($remoteVersion, $version, '>')) { + $pending[$slug] = [ + 'type' => 'themes', + 'current' => $version, + 'available' => $remoteVersion, + ]; + } + } + + $this->relayProgress('preflight', sprintf('Detected %d updatable packages (including symlinks).', count($pending)), null); + + return $pending; + } + + private function scanLocalPackageVersions(string $path): array + { + $versions = []; + if (!is_dir($path)) { + return $versions; + } + + $entries = glob($path . '/*', GLOB_ONLYDIR) ?: []; + foreach ($entries as $dir) { + $slug = basename($dir); + $version = $this->readBlueprintVersion($dir) ?? $this->readComposerVersion($dir); + if ($version !== null) { + $versions[$slug] = $version; + } + } + + return $versions; + } + + private function readBlueprintVersion(string $dir): ?string + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return null; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return null; + } + $data = Yaml::parse($contents); + if (is_array($data) && isset($data['version'])) { + return (string)$data['version']; + } + } catch (Throwable $e) { + // ignore parse errors + } + + return null; + } + + private function readComposerVersion(string $dir): ?string + { + $file = $dir . '/composer.json'; + if (!is_file($file)) { + return null; + } + + $data = json_decode(file_get_contents($file), true); + if (is_array($data) && isset($data['version'])) { + return (string)$data['version']; + } + + return null; + } + + private function packagesToArray($packages): array + { + if (!$packages) { + return []; + } + + if (is_array($packages)) { + return $packages; + } + + if ($packages instanceof \Traversable) { + return iterator_to_array($packages, true); + } + + return []; + } + + private function resolveRemotePackageVersion($package): ?string + { + if (!$package) { + return null; + } + + if (is_array($package)) { + return $package['version'] ?? null; + } + + if (is_object($package)) { + if (isset($package->version)) { + return (string)$package->version; + } + if (method_exists($package, 'offsetGet')) { + try { + return (string)$package->offsetGet('version'); + } catch (Throwable $e) { + return null; + } + } + } + + return null; + } + + private function detectPsrLogConflicts(): array + { + $conflicts = []; + $pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginRoots as $path) { + $composerFile = $path . '/composer.json'; + if (!is_file($composerFile)) { + continue; + } + + $json = json_decode(file_get_contents($composerFile), true); + if (!is_array($json)) { + continue; + } + + $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } + $rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null); + if (!$rawConstraint) { + continue; + } + + $constraint = strtolower((string)$rawConstraint); + $compatible = $constraint === '*' + || false !== strpos($constraint, '3') + || false !== strpos($constraint, '4') + || (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint)); + + if ($compatible) { + continue; + } + + $conflicts[$slug] = [ + 'composer' => $composerFile, + 'requires' => $rawConstraint, + ]; + } + + return $conflicts; + } + + private function detectMonologConflicts(): array + { + $conflicts = []; + $pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + $pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i'; + + foreach ($pluginRoots as $path) { + $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (!$file->isFile() || strtolower($file->getExtension()) !== 'php') { + continue; + } + + $contents = @file_get_contents($file->getPathname()); + if ($contents === false) { + continue; + } + + if (preg_match($pattern, $contents, $match)) { + $relative = str_replace(GRAV_ROOT . '/', '', $file->getPathname()); + $conflicts[$slug][] = [ + 'file' => $relative, + 'method' => trim($match[0]), + ]; + } + } + } + + return $conflicts; + } + + private function isPluginEnabled(string $slug): bool + { + $configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml'; + if (is_file($configPath)) { + try { + $contents = @file_get_contents($configPath); + if ($contents !== false) { + $data = Yaml::parse($contents); + if (is_array($data) && array_key_exists('enabled', $data)) { + return (bool)$data['enabled']; + } + } + } catch (Throwable $e) { + // ignore parse errors + } + } + + return true; + } + + private function isThemeEnabled(string $slug): bool + { + $configPath = GRAV_ROOT . '/user/config/system.yaml'; + if (is_file($configPath)) { + try { + $contents = @file_get_contents($configPath); + if ($contents !== false) { + $data = Yaml::parse($contents); + if (is_array($data)) { + $active = $data['pages']['theme'] ?? ($data['system']['pages']['theme'] ?? null); + if ($active !== null) { + return $active === $slug; + } + } + } + } catch (Throwable $e) { + // ignore parse errors + } + } + + return true; + } + + private function isGpmPackagePublished($package): bool + { + if (is_object($package) && method_exists($package, 'getData')) { + $data = $package->getData(); + if ($data instanceof \Grav\Common\Data\Data) { + $published = $data->get('published'); + + return $published !== false; + } + } + + if (is_array($package)) { + if (array_key_exists('published', $package)) { + return $package['published'] !== false; + } + + return true; + } + + if (is_object($package) && property_exists($package, 'published')) { + return $package->published !== false; + } + + return true; + } }