From c82645a42a937e08661427a38b992b29c4d505fe Mon Sep 17 00:00:00 2001 From: "pmoreno.rodriguez" Date: Mon, 13 Oct 2025 21:35:33 +0200 Subject: [PATCH 01/46] wordCount Filter for Grav (#3957) --- .../Common/Twig/Extension/GravExtension.php | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/system/src/Grav/Common/Twig/Extension/GravExtension.php b/system/src/Grav/Common/Twig/Extension/GravExtension.php index 9a8eeb22e..9dcb49af4 100644 --- a/system/src/Grav/Common/Twig/Extension/GravExtension.php +++ b/system/src/Grav/Common/Twig/Extension/GravExtension.php @@ -140,6 +140,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface new TwigFilter('starts_with', [$this, 'startsWithFilter']), new TwigFilter('truncate', [Utils::class, 'truncate']), new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']), + new TwigFilter('wordcount', [$this, 'wordCountFilter']), new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']), new TwigFilter('array_unique', 'array_unique'), new TwigFilter('basename', 'basename'), @@ -578,6 +579,62 @@ class GravExtension extends AbstractExtension implements GlobalsInterface return $str; } + /** + * Count words in text with improved accuracy for multiple languages + * + * @param string $text The text to count words from + * @param string $locale Optional locale for language-specific counting (default: 'en') + * @return int Number of words + */ + public function wordCountFilter($text, string $locale = 'en'): int + { + if (empty($text)) { + return 0; + } + + // Strip HTML tags and decode entities + $cleanText = html_entity_decode(strip_tags($text), ENT_QUOTES, 'UTF-8'); + + // Remove extra whitespace and normalize + $cleanText = trim(preg_replace('/\s+/', ' ', $cleanText)); + + if (empty($cleanText)) { + return 0; + } + + // Handle different languages + switch (strtolower($locale)) { + case 'zh': + case 'zh-cn': + case 'zh-tw': + case 'chinese': + // Chinese: count characters (excluding spaces and punctuation) + return mb_strlen(preg_replace('/[\s\p{P}]/u', '', $cleanText), 'UTF-8'); + + case 'ja': + case 'japanese': + // Japanese: count characters (excluding spaces) + return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8'); + + case 'ko': + case 'korean': + // Korean: count characters (excluding spaces) + return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8'); + + default: + // Western languages: use improved word counting + // Handle contractions, hyphenated words, and numbers better + $words = preg_split('/\s+/', $cleanText, -1, PREG_SPLIT_NO_EMPTY); + + // Filter out pure punctuation + $words = array_filter($words, function($word) { + return preg_match('/\w/', $word); + }); + + return count($words); + } + } + /** * Get Cron object for a crontab 'at' format * From 75d8356f1b335b781b8789740b4826472f2bd833 Mon Sep 17 00:00:00 2001 From: Nakkouch Tarek <98561646+nakkouchtarek@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:36:49 +0100 Subject: [PATCH 02/46] Fixed Twig Sandbox Bypass due to nested expression (#3939) --- system/src/Grav/Common/Security.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php index 4dcf8fb7d..14cb772e3 100644 --- a/system/src/Grav/Common/Security.php +++ b/system/src/Grav/Common/Security.php @@ -281,7 +281,13 @@ class Security 'twig.safe_functions', 'read_file', ]; + $string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string); + + foreach ($bad_twig as $func) { + $string = preg_replace('/\b' . preg_quote($func, '/') . '(\s*\([^)]*\))?\b/i', '{# $1 #}', $string); + } + return $string; } } From 250568bae588b24632232eec91fdfb3ffee389bd Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 10:29:26 -0600 Subject: [PATCH 03/46] initial safeupgrade work --- CHANGELOG.md | 8 + index.php | 6 + system/UPGRADE_PROTOTYPE.md | 49 ++ system/blueprints/config/system.yaml | 17 +- system/config/system.yaml | 3 + system/languages/en.yaml | 5 + system/recovery.php | 181 +++++++ system/src/Grav/Common/Grav.php | 2 + .../Common/Processors/InitializeProcessor.php | 6 + .../Grav/Common/Recovery/RecoveryManager.php | 289 +++++++++++ .../Common/Upgrade/SafeUpgradeService.php | 491 ++++++++++++++++++ .../Console/Application/GpmApplication.php | 4 + .../src/Grav/Console/Gpm/PreflightCommand.php | 79 +++ .../src/Grav/Console/Gpm/RollbackCommand.php | 135 +++++ .../Grav/Console/Gpm/SelfupgradeCommand.php | 82 +++ system/src/Grav/Installer/Install.php | 42 +- .../Common/Recovery/RecoveryManagerTest.php | 123 +++++ .../Common/Upgrade/SafeUpgradeServiceTest.php | 193 +++++++ .../Grav/Console/Gpm/PreflightCommandTest.php | 147 ++++++ .../Grav/Console/Gpm/RollbackCommandTest.php | 244 +++++++++ .../Console/Gpm/SelfupgradeCommandTest.php | 160 ++++++ 21 files changed, 2258 insertions(+), 8 deletions(-) create mode 100644 system/UPGRADE_PROTOTYPE.md create mode 100644 system/recovery.php create mode 100644 system/src/Grav/Common/Recovery/RecoveryManager.php create mode 100644 system/src/Grav/Common/Upgrade/SafeUpgradeService.php create mode 100644 system/src/Grav/Console/Gpm/PreflightCommand.php create mode 100644 system/src/Grav/Console/Gpm/RollbackCommand.php create mode 100644 tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php create mode 100644 tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php create mode 100644 tests/unit/Grav/Console/Gpm/PreflightCommandTest.php create mode 100644 tests/unit/Grav/Console/Gpm/RollbackCommandTest.php create mode 100644 tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a532fb8..07889b242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v1.7.50 +## UNRELEASED + +1. [](#new) + * Added staged self-upgrade pipeline with manifest snapshots and atomic swaps for Grav core updates. + * Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support. + * Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility. + # v1.7.49.5 ## 09/10/2025 diff --git a/index.php b/index.php index 242794a05..3ee55fab8 100644 --- a/index.php +++ b/index.php @@ -36,6 +36,12 @@ date_default_timezone_set(@date_default_timezone_get()); @ini_set('default_charset', 'UTF-8'); mb_internal_encoding('UTF-8'); +$recoveryFlag = __DIR__ . '/system/recovery.flag'; +if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) { + require __DIR__ . '/system/recovery.php'; + return 0; +} + use Grav\Common\Grav; use RocketTheme\Toolbox\Event\Event; diff --git a/system/UPGRADE_PROTOTYPE.md b/system/UPGRADE_PROTOTYPE.md new file mode 100644 index 000000000..2047ee284 --- /dev/null +++ b/system/UPGRADE_PROTOTYPE.md @@ -0,0 +1,49 @@ +# Grav Safe Self-Upgrade Prototype + +This document tracks the design decisions behind the new self-upgrade prototype for Grav 1.8. + +## Goals + +- Prevent in-place mutation of the running Grav tree. +- Guarantee a restorable snapshot before any destructive change. +- Detect high-risk plugin incompatibilities (eg. `psr/log`) prior to upgrading. +- Provide a recovery surface that does not depend on a working Admin plugin. + +## High-Level Flow + +1. **Preflight** + - Ensure PHP & extensions satisfy the target release requirements. + - Refresh GPM metadata and require all plugins/themes to be on their latest compatible release. + - Scan plugin `composer.json` files for dependencies that are known to break under Grav 1.8 (eg. `psr/log` < 3) and surface actionable warnings. +2. **Stage** + - Download the Grav update archive into a staging area outside the live tree (`{parent}/grav-upgrades/{timestamp}`). + - Extract the package, then write a manifest describing the target version, PHP info, and enabled packages. + - Snapshot the live `user/` directory and relevant metadata into the same stage folder. +3. **Promote** + - Switch the installation by renaming the live tree to a rollback folder and promoting the staged tree into place via atomic renames. + - Clear caches in the staged tree before promotion. + - Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; swap back automatically on failure. +4. **Finalize** + - Record the manifest under `user/data/upgrades`. + - Resume normal traffic by removing the maintenance flag. + - Leave the previous tree and manifest available for manual rollback commands. + +## Recovery Mode + +- Introduce a `system/recovery.flag` sentinel written whenever a fatal error occurs during bootstrap or when a promoted release fails validation. +- While the flag is present, Grav forces a minimal Recovery UI served outside of Admin, protected by a short-lived signed token. +- The Recovery UI lists recent manifests, quarantined plugins, and offers rollback/disabling actions. +- Clearing the flag requires either a successful rollback or a full Grav request cycle without fatal errors. + +## CLI Additions + +- `bin/gpm preflight grav@`: runs the same preflight checks without executing the upgrade. +- `bin/gpm rollback []`: swaps the live tree with a stored rollback snapshot. +- Existing `self-upgrade` command now wraps the stage/promote pipeline and respects the snapshot manifest. + +## Open Items + +- Finalize compatibility heuristics (initial pass focuses on `psr/log` and removed logging APIs). +- UX polish for the Recovery UI (initial prototype will expose basic actions only). +- Decide retention policy for old manifests and snapshots (prototype keeps the most recent three). + diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 13b04d2f2..3eef8fc61 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1598,6 +1598,22 @@ form: validate: type: bool + updates_section: + type: section + title: PLUGIN_ADMIN.UPDATES_SECTION + + updates.safe_upgrade: + type: toggle + label: PLUGIN_ADMIN.SAFE_UPGRADE + help: PLUGIN_ADMIN.SAFE_UPGRADE_HELP + highlight: 1 + default: true + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + http_section: type: section title: PLUGIN_ADMIN.HTTP_SECTION @@ -1914,4 +1930,3 @@ form: # type: hidden - diff --git a/system/config/system.yaml b/system/config/system.yaml index 984f46771..87b6d4c1c 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -203,6 +203,9 @@ gpm: releases: stable # Set to either 'stable' or 'testing' official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security +updates: + safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates + http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL enable_proxy: true # Enable proxy server configuration diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 7a8a68c00..3b34a93db 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -119,3 +119,8 @@ GRAV: ERROR2: Bad number of elements ERROR3: The jquery_element should be set into jqCron settings ERROR4: Unrecognized expression + +PLUGIN_ADMIN: + UPDATES_SECTION: Updates + SAFE_UPGRADE: Safe self-upgrade + SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support. diff --git a/system/recovery.php b/system/recovery.php new file mode 100644 index 000000000..528359695 --- /dev/null +++ b/system/recovery.php @@ -0,0 +1,181 @@ + 'grav-recovery', + 'cookie_httponly' => true, + 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'cookie_samesite' => 'Lax', +]); + +$manager = new RecoveryManager(); +$context = $manager->getContext() ?? []; +$token = $context['token'] ?? null; +$authenticated = $token && isset($_SESSION['grav_recovery_authenticated']) && hash_equals($_SESSION['grav_recovery_authenticated'], $token); +$errorMessage = null; +$notice = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + if ($action === 'authenticate') { + $provided = trim($_POST['token'] ?? ''); + if ($token && hash_equals($token, $provided)) { + $_SESSION['grav_recovery_authenticated'] = $token; + header('Location: ' . $_SERVER['REQUEST_URI']); + exit; + } + $errorMessage = 'Invalid recovery token.'; + } elseif ($authenticated) { + $service = new SafeUpgradeService(); + try { + if ($action === 'rollback' && !empty($_POST['manifest'])) { + $service->rollback(trim($_POST['manifest'])); + $manager->clear(); + $_SESSION['grav_recovery_authenticated'] = null; + $notice = 'Rollback complete. Please reload Grav.'; + } + if ($action === 'clear-flag') { + $manager->clear(); + $_SESSION['grav_recovery_authenticated'] = null; + $notice = 'Recovery flag cleared.'; + } + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + } + } else { + $errorMessage = 'Authentication required.'; + } +} + +$quarantineFile = GRAV_ROOT . '/user/data/upgrades/quarantine.json'; +$quarantine = []; +if (is_file($quarantineFile)) { + $decoded = json_decode(file_get_contents($quarantineFile), true); + if (is_array($decoded)) { + $quarantine = $decoded; + } +} + +$manifestDir = GRAV_ROOT . '/user/data/upgrades'; +$manifests = []; +if (is_dir($manifestDir)) { + $files = glob($manifestDir . '/*.json'); + if ($files) { + rsort($files); + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file), true); + if (is_array($decoded)) { + $decoded['file'] = basename($file); + $manifests[] = $decoded; + } + } + } +} + +header('Content-Type: text/html; charset=utf-8'); + +?> + + + + + Grav Recovery Mode + + + +
+

Grav Recovery Mode

+ +
+ + +
+ + + +

This site is running in recovery mode because Grav detected a fatal error.

+

Locate the recovery token in system/recovery.flag and enter it below.

+
+ + + + +
+ +
+

Failure Details

+
    +
  • Message:
  • +
  • File:
  • +
  • Line:
  • + +
  • Quarantined plugin:
  • + +
+
+ + +
+

Quarantined Plugins

+
    + +
  • + + (disabled at )
    + +
  • + +
+
+ + +
+

Rollback

+ +
+ + + + +
+ +

No upgrade snapshots were found.

+ +
+ +
+ + +
+ +
+ + diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index 892f1d065..f11e90c9a 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -46,6 +46,7 @@ use Grav\Common\Service\SessionServiceProvider; use Grav\Common\Service\StreamsServiceProvider; use Grav\Common\Service\TaskServiceProvider; use Grav\Common\Twig\Twig; +use Grav\Common\Recovery\RecoveryManager; use Grav\Framework\DI\Container; use Grav\Framework\Psr7\Response; use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport; @@ -110,6 +111,7 @@ class Grav extends Container 'scheduler' => Scheduler::class, 'taxonomy' => Taxonomy::class, 'themes' => Themes::class, + 'recovery' => RecoveryManager::class, 'twig' => Twig::class, 'uri' => Uri::class, ]; diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php index 43a88d05d..bf869be22 100644 --- a/system/src/Grav/Common/Processors/InitializeProcessor.php +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -78,6 +78,9 @@ class InitializeProcessor extends ProcessorBase // Initialize error handlers. $this->initializeErrors(); + // Register recovery shutdown handler early in the lifecycle. + $this->container['recovery']->registerHandlers(); + // Initialize debugger. $debugger = $this->initializeDebugger(); @@ -145,6 +148,9 @@ class InitializeProcessor extends ProcessorBase // Disable debugger. $this->container['debugger']->enabled(false); + // Register recovery handler for CLI commands as well. + $this->container['recovery']->registerHandlers(); + // Set timezone, locale. $this->initializeLocale($config); diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php new file mode 100644 index 000000000..0d78a3174 --- /dev/null +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -0,0 +1,289 @@ +rootPath = rtrim($root, DIRECTORY_SEPARATOR); + $this->userPath = $this->rootPath . '/user'; + } + + /** + * Register shutdown handler to capture fatal errors at runtime. + * + * @return void + */ + public function registerHandlers(): void + { + if ($this->registered) { + return; + } + + register_shutdown_function([$this, 'handleShutdown']); + $this->registered = true; + } + + /** + * Check if recovery mode flag is active. + * + * @return bool + */ + public function isActive(): bool + { + return is_file($this->flagPath()); + } + + /** + * Remove recovery flag. + * + * @return void + */ + public function clear(): void + { + $flag = $this->flagPath(); + if (is_file($flag)) { + @unlink($flag); + } + } + + /** + * Shutdown handler capturing fatal errors. + * + * @return void + */ + public function handleShutdown(): void + { + $error = $this->resolveLastError(); + if (!$error) { + return; + } + + $type = $error['type'] ?? 0; + if (!$this->isFatal($type)) { + return; + } + + $file = $error['file'] ?? ''; + $plugin = $this->detectPluginFromPath($file); + $context = [ + 'created_at' => time(), + 'message' => $error['message'] ?? '', + 'file' => $file, + 'line' => $error['line'] ?? null, + 'type' => $type, + 'plugin' => $plugin, + ]; + + $this->activate($context); + if ($plugin) { + $this->quarantinePlugin($plugin, $context); + } + } + + /** + * Activate recovery mode and record context. + * + * @param array $context + * @return void + */ + public function activate(array $context): void + { + $flag = $this->flagPath(); + if (empty($context['token'])) { + $context['token'] = $this->generateToken(); + } + if (!is_file($flag)) { + file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } else { + // Merge context if flag already exists. + $existing = json_decode(file_get_contents($flag), true); + if (is_array($existing)) { + $context = $context + $existing; + if (empty($context['token'])) { + $context['token'] = $this->generateToken(); + } + } + file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + } + + /** + * Return last recorded recovery context. + * + * @return array|null + */ + public function getContext(): ?array + { + $flag = $this->flagPath(); + if (!is_file($flag)) { + return null; + } + + $decoded = json_decode(file_get_contents($flag), true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + private function quarantinePlugin(string $slug, array $context): void + { + $slug = trim($slug); + if ($slug === '') { + return; + } + + $configPath = $this->userPath . '/config/plugins/' . $slug . '.yaml'; + Folder::create(dirname($configPath)); + + $configuration = is_file($configPath) ? Yaml::parse(file_get_contents($configPath)) : []; + if (!is_array($configuration)) { + $configuration = []; + } + + if (($configuration['enabled'] ?? true) === false) { + return; + } + + $configuration['enabled'] = false; + $yaml = Yaml::dump($configuration); + file_put_contents($configPath, $yaml); + + $quarantineFile = $this->userPath . '/data/upgrades/quarantine.json'; + Folder::create(dirname($quarantineFile)); + + $quarantine = []; + if (is_file($quarantineFile)) { + $decoded = json_decode(file_get_contents($quarantineFile), true); + if (is_array($decoded)) { + $quarantine = $decoded; + } + } + + $quarantine[$slug] = [ + 'slug' => $slug, + 'disabled_at' => time(), + 'message' => $context['message'] ?? '', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + ]; + + file_put_contents($quarantineFile, json_encode($quarantine, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + /** + * Determine if error type is fatal. + * + * @param int $type + * @return bool + */ + private function isFatal(int $type): bool + { + return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true); + } + + /** + * Attempt to derive plugin slug from file path. + * + * @param string $file + * @return string|null + */ + private function detectPluginFromPath(string $file): ?string + { + if (!$file) { + return null; + } + + if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) { + return $matches[1] ?? null; + } + + return null; + } + + /** + * @return string + */ + private function flagPath(): string + { + return $this->rootPath . '/system/recovery.flag'; + } + + /** + * @return string + */ + protected function generateToken(): string + { + try { + return bin2hex($this->randomBytes(10)); + } catch (\Throwable $e) { + return md5(uniqid('grav-recovery', true)); + } + } + + /** + * @param int $length + * @return string + */ + protected function randomBytes(int $length): string + { + return random_bytes($length); + } + + /** + * @return array|null + */ + protected function resolveLastError(): ?array + { + return error_get_last(); + } +} diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php new file mode 100644 index 000000000..6e5a5df12 --- /dev/null +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -0,0 +1,491 @@ +rootPath = rtrim($root, DIRECTORY_SEPARATOR); + $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); + $this->stagingRoot = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'); + $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); + if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { + $this->ignoredDirs = $options['ignored_dirs']; + } + } + + /** + * Run preflight validations before attempting an upgrade. + * + * @return array{plugins_pending: array, psr_log_conflicts: array, warnings: string[]} + */ + public function preflight(): array + { + $warnings = []; + try { + $pending = $this->detectPendingPluginUpdates(); + } catch (RuntimeException $e) { + $pending = []; + $warnings[] = $e->getMessage(); + } + + $psrLogConflicts = $this->detectPsrLogConflicts(); + if ($pending) { + $warnings[] = 'One or more plugins/themes are not up to date.'; + } + if ($psrLogConflicts) { + $warnings[] = 'Potential psr/log signature conflicts detected.'; + } + + return [ + 'plugins_pending' => $pending, + 'psr_log_conflicts' => $psrLogConflicts, + 'warnings' => $warnings, + ]; + } + + /** + * Stage and promote a Grav update from an extracted folder. + * + * @param string $extractedPath Path to the extracted update package. + * @param string $targetVersion Target Grav version. + * @param array $ignores + * @return array Manifest data. + */ + public function promote(string $extractedPath, string $targetVersion, array $ignores): array + { + if (!is_dir($extractedPath)) { + throw new InvalidArgumentException(sprintf('Extracted package path "%s" is not a directory.', $extractedPath)); + } + + $stageId = uniqid('stage-', false); + $stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId; + $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId; + + Folder::create($packagePath); + + // Copy extracted package into staging area. + Folder::rcopy($extractedPath, $packagePath); + + // Ensure ignored directories are replaced with live copies. + $this->hydrateIgnoredDirectories($packagePath, $ignores); + + $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath); + $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; + Folder::create(dirname($manifestPath)); + file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); + + // Promote staged package into place. + $this->promoteStagedTree($packagePath, $backupPath); + $this->persistManifest($manifest); + $this->pruneOldSnapshots(); + Folder::delete($stagePath); + + return $manifest; + } + + /** + * Roll back to the most recent snapshot. + * + * @param string|null $id + * @return array|null + */ + public function rollback(?string $id = null): ?array + { + $manifest = $this->resolveManifest($id); + if (!$manifest) { + return null; + } + + $backupPath = $manifest['backup_path'] ?? null; + if (!$backupPath || !is_dir($backupPath)) { + throw new RuntimeException('Rollback snapshot is no longer available.'); + } + + // Put the current tree aside before flip. + $rotated = $this->rotateCurrentTree(); + + $this->promoteBackup($backupPath); + $this->markRollback($manifest['id']); + if ($rotated && is_dir($rotated)) { + Folder::delete($rotated); + } + + return $manifest; + } + + /** + * @return void + */ + public function clearRecoveryFlag(): void + { + $flag = $this->rootPath . '/system/recovery.flag'; + if (is_file($flag)) { + @unlink($flag); + } + } + + /** + * @return array + */ + protected function detectPendingPluginUpdates(): array + { + try { + $gpm = new GPM(); + } catch (Throwable $e) { + throw new RuntimeException('Unable to query GPM: ' . $e->getMessage(), 0, $e); + } + $updates = $gpm->getUpdatable(['plugins' => true, 'themes' => true]); + $pending = []; + foreach ($updates as $type => $packages) { + if (!is_array($packages)) { + continue; + } + foreach ($packages as $slug => $package) { + $pending[$slug] = [ + 'type' => $type, + 'current' => $package->version ?? null, + 'available' => $package->available ?? null, + ]; + } + } + + return $pending; + } + + /** + * Check plugins for psr/log requirements that conflict with Grav 1.8 vendor stack. + * + * @return array + */ + protected function detectPsrLogConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/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); + $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; + } + + /** + * Ensure directories flagged for ignoring get hydrated from the current installation. + * + * @param string $packagePath + * @param array $ignores + * @return void + */ + private function hydrateIgnoredDirectories(string $packagePath, array $ignores): void + { + $strategic = $ignores ?: $this->ignoredDirs; + + foreach ($strategic as $relative) { + $relative = trim($relative, '/'); + if ($relative === '') { + continue; + } + + $live = $this->rootPath . '/' . $relative; + $stage = $packagePath . '/' . $relative; + + Folder::delete($stage); + + if (!is_dir($live)) { + continue; + } + + // Skip caches to avoid stale data. + if (in_array($relative, ['cache', 'tmp'], true)) { + Folder::create($stage); + continue; + } + + Folder::create(dirname($stage)); + Folder::rcopy($live, $stage); + } + } + + /** + * Build manifest metadata for a staged upgrade. + * + * @param string $stageId + * @param string $targetVersion + * @param string $packagePath + * @param string $backupPath + * @return array + */ + private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath): array + { + $plugins = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginRoots as $path) { + $slug = basename($path); + $blueprint = $path . '/blueprints.yaml'; + $details = [ + 'version' => null, + 'name' => $slug, + ]; + + if (is_file($blueprint)) { + try { + $yaml = Yaml::parse(file_get_contents($blueprint)); + if (isset($yaml['version'])) { + $details['version'] = $yaml['version']; + } + if (isset($yaml['name'])) { + $details['name'] = $yaml['name']; + } + } catch (\RuntimeException $e) { + // ignore parse errors, keep defaults + } + } + + $plugins[$slug] = $details; + } + + return [ + 'id' => $stageId, + 'created_at' => time(), + 'source_version' => GRAV_VERSION, + 'target_version' => $targetVersion, + 'php_version' => PHP_VERSION, + 'package_path' => $packagePath, + 'backup_path' => $backupPath, + 'plugins' => $plugins, + ]; + } + + /** + * Promote staged package by swapping directory names. + * + * @param string $packagePath + * @param string $backupPath + * @return void + */ + private function promoteStagedTree(string $packagePath, string $backupPath): void + { + $liveRoot = $this->rootPath; + Folder::create(dirname($backupPath)); + + if (!rename($liveRoot, $backupPath)) { + throw new RuntimeException('Failed to move current Grav directory into backup.'); + } + + if (!rename($packagePath, $liveRoot)) { + // Attempt to restore live tree. + rename($backupPath, $liveRoot); + throw new RuntimeException('Failed to promote staged Grav release.'); + } + } + + /** + * Move existing tree aside to allow rollback swap. + * + * @return void + */ + private function rotateCurrentTree(): string + { + $liveRoot = $this->rootPath; + $target = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rotated-' . time(); + Folder::create($this->stagingRoot); + if (!rename($liveRoot, $target)) { + throw new RuntimeException('Unable to rotate live tree during rollback.'); + } + + return $target; + } + + /** + * Promote a backup tree into the live position. + * + * @param string $backupPath + * @return void + */ + private function promoteBackup(string $backupPath): void + { + if (!rename($backupPath, $this->rootPath)) { + throw new RuntimeException('Rollback failed: unable to move backup into live position.'); + } + } + + /** + * Persist manifest into Grav data directory. + * + * @param array $manifest + * @return void + */ + private function persistManifest(array $manifest): void + { + Folder::create($this->manifestStore); + $target = $this->manifestStore . DIRECTORY_SEPARATOR . $manifest['id'] . '.json'; + file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * @param string|null $id + * @return array|null + */ + private function resolveManifest(?string $id): ?array + { + $path = null; + + if ($id) { + $candidate = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json'; + if (!is_file($candidate)) { + return null; + } + $path = $candidate; + } else { + $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; + if (!$files) { + return null; + } + rsort($files); + $path = $files[0]; + } + + $decoded = json_decode(file_get_contents($path), true); + + return $decoded ?: null; + } + + /** + * Record rollback event in manifest store. + * + * @param string $id + * @return void + */ + private function markRollback(string $id): void + { + $target = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json'; + if (!is_file($target)) { + return; + } + + $manifest = json_decode(file_get_contents($target), true); + if (!is_array($manifest)) { + return; + } + + $manifest['rolled_back_at'] = time(); + file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * Keep only the three newest snapshots. + * + * @return void + */ + private function pruneOldSnapshots(): void + { + $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; + if (count($files) <= 3) { + return; + } + + sort($files); + $excess = array_slice($files, 0, count($files) - 3); + foreach ($excess as $file) { + $data = json_decode(file_get_contents($file), true); + if (isset($data['backup_path']) && is_dir($data['backup_path'])) { + Folder::delete($data['backup_path']); + } + @unlink($file); + } + } +} diff --git a/system/src/Grav/Console/Application/GpmApplication.php b/system/src/Grav/Console/Application/GpmApplication.php index 61e73d23b..4dc6f779d 100644 --- a/system/src/Grav/Console/Application/GpmApplication.php +++ b/system/src/Grav/Console/Application/GpmApplication.php @@ -13,6 +13,8 @@ use Grav\Console\Gpm\DirectInstallCommand; use Grav\Console\Gpm\IndexCommand; use Grav\Console\Gpm\InfoCommand; use Grav\Console\Gpm\InstallCommand; +use Grav\Console\Gpm\PreflightCommand; +use Grav\Console\Gpm\RollbackCommand; use Grav\Console\Gpm\SelfupgradeCommand; use Grav\Console\Gpm\UninstallCommand; use Grav\Console\Gpm\UpdateCommand; @@ -36,6 +38,8 @@ class GpmApplication extends Application new UninstallCommand(), new UpdateCommand(), new SelfupgradeCommand(), + new PreflightCommand(), + new RollbackCommand(), new DirectInstallCommand(), ]); } diff --git a/system/src/Grav/Console/Gpm/PreflightCommand.php b/system/src/Grav/Console/Gpm/PreflightCommand.php new file mode 100644 index 000000000..63728a5ca --- /dev/null +++ b/system/src/Grav/Console/Gpm/PreflightCommand.php @@ -0,0 +1,79 @@ +setName('preflight') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output report as JSON') + ->setDescription('Run Grav upgrade preflight checks without modifying the installation.'); + } + + protected function serve(): int + { + $io = $this->getIO(); + $service = $this->createSafeUpgradeService(); + $report = $service->preflight(); + + $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['warnings']); + + if ($this->getInput()->getOption('json')) { + $io->writeln(json_encode($report, JSON_PRETTY_PRINT)); + + return $hasIssues ? 2 : 0; + } + + $io->title('Grav Upgrade Preflight'); + + if (!empty($report['warnings'])) { + $io->writeln('Warnings'); + foreach ($report['warnings'] as $warning) { + $io->writeln(' - ' . $warning); + } + $io->newLine(); + } + + if (!empty($report['plugins_pending'])) { + $io->writeln('Packages pending update'); + foreach ($report['plugins_pending'] as $slug => $info) { + $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $info['type'] ?? 'plugin', $info['current'] ?? 'unknown', $info['available'] ?? 'unknown')); + } + $io->newLine(); + } + + if (!empty($report['psr_log_conflicts'])) { + $io->writeln('Potential psr/log conflicts'); + foreach ($report['psr_log_conflicts'] as $slug => $info) { + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $info['requires'] ?? '*')); + } + $io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.'); + $io->newLine(); + } + + if (!$hasIssues) { + $io->success('No blocking issues detected.'); + } else { + $io->warning('Resolve the findings above before upgrading Grav.'); + } + + return $hasIssues ? 2 : 0; + } + + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + return new SafeUpgradeService(); + } +} diff --git a/system/src/Grav/Console/Gpm/RollbackCommand.php b/system/src/Grav/Console/Gpm/RollbackCommand.php new file mode 100644 index 000000000..ffe60fc0b --- /dev/null +++ b/system/src/Grav/Console/Gpm/RollbackCommand.php @@ -0,0 +1,135 @@ +setName('rollback') + ->addArgument('manifest', InputArgument::OPTIONAL, 'Manifest identifier to roll back to. Defaults to the latest snapshot.') + ->addOption('list', 'l', InputOption::VALUE_NONE, 'List available snapshots') + ->addOption('all-yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompts') + ->setDescription('Rollback Grav to a previously staged snapshot.'); + } + + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + $this->allYes = (bool)$input->getOption('all-yes'); + + $snapshots = $this->collectSnapshots(); + if ($input->getOption('list')) { + if (!$snapshots) { + $io->writeln('No snapshots found.'); + return 0; + } + + $io->writeln('Available snapshots:'); + foreach ($snapshots as $snapshot) { + $io->writeln(sprintf(' - %s (Grav %s)', $snapshot['id'], $snapshot['target_version'] ?? 'unknown')); + } + + return 0; + } + + if (!$snapshots) { + $io->error('No snapshots available to roll back to.'); + + return 1; + } + + $targetId = $input->getArgument('manifest') ?: $snapshots[0]['id']; + $target = null; + foreach ($snapshots as $snapshot) { + if ($snapshot['id'] === $targetId) { + $target = $snapshot; + break; + } + } + + if (!$target) { + $io->error(sprintf('Snapshot %s not found.', $targetId)); + + return 1; + } + + if (!$this->allYes) { + $question = new ConfirmationQuestion(sprintf('Rollback to snapshot %s (Grav %s)? [y|N] ', $target['id'], $target['target_version'] ?? 'unknown'), false); + if (!$io->askQuestion($question)) { + $io->writeln('Rollback aborted.'); + + return 1; + } + } + + $service = $this->createSafeUpgradeService(); + + try { + $service->rollback($target['id']); + $service->clearRecoveryFlag(); + } catch (RuntimeException $e) { + $io->error($e->getMessage()); + return 1; + } + + $io->success(sprintf('Rolled back to snapshot %s.', $target['id'])); + + return 0; + } + + /** + * @return array + */ + protected function collectSnapshots(): array + { + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + $files = glob($manifestDir . '/*.json'); + if (!$files) { + return []; + } + + rsort($files); + $snapshots = []; + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file), true); + if (!is_array($decoded)) { + continue; + } + + $decoded['id'] = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME); + $decoded['file'] = basename($file); + $snapshots[] = $decoded; + } + + return $snapshots; + } + + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + return new SafeUpgradeService(); + } +} diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index a3c7c6f14..367c70de5 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -15,6 +15,7 @@ use Grav\Common\HTTP\Response; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; use Grav\Common\Grav; +use Grav\Common\Upgrade\SafeUpgradeService; use Grav\Console\GpmCommand; use Grav\Installer\Install; use RuntimeException; @@ -22,6 +23,7 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; use ZipArchive; +use function count; use function is_callable; use function strlen; @@ -108,6 +110,12 @@ class SelfupgradeCommand extends GpmCommand $this->displayGPMRelease(); + $safeUpgrade = $this->createSafeUpgradeService(); + $preflight = $safeUpgrade->preflight(); + if (!$this->handlePreflightReport($preflight)) { + return 1; + } + $update = $this->upgrader->getAssets()['grav-update']; $local = $this->upgrader->getLocalVersion(); @@ -227,6 +235,7 @@ class SelfupgradeCommand extends GpmCommand } else { $io->writeln(" '- Success! "); $io->newLine(); + $safeUpgrade->clearRecoveryFlag(); } if ($this->tmp && is_dir($this->tmp)) { @@ -263,6 +272,79 @@ class SelfupgradeCommand extends GpmCommand return $this->tmp . DS . $package['name']; } + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + return new SafeUpgradeService(); + } + + /** + * @param array $preflight + * @return bool + */ + protected function handlePreflightReport(array $preflight): bool + { + $io = $this->getIO(); + $pending = $preflight['plugins_pending'] ?? []; + $conflicts = $preflight['psr_log_conflicts'] ?? []; + $warnings = $preflight['warnings'] ?? []; + + if (empty($pending) && empty($conflicts)) { + return true; + } + + if ($warnings) { + $io->newLine(); + $io->writeln('Preflight warnings detected:'); + foreach ($warnings as $warning) { + $io->writeln(' • ' . $warning); + } + } + + if ($pending) { + $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)); + } + + if (!$this->all_yes) { + $question = new ConfirmationQuestion('Continue without updating these packages? [y|N] ', false); + if (!$io->askQuestion($question)) { + $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + + return false; + } + } + } + + if ($conflicts) { + $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)); + } + $io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.'); + + if (!$this->all_yes) { + $question = new ConfirmationQuestion('Continue despite psr/log warnings? [y|N] ', false); + if (!$io->askQuestion($question)) { + $io->writeln('Aborting self-upgrade. Adjust composer requirements or update affected plugins.'); + + return false; + } + } + } + + return true; + } + /** * @return bool */ diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 0db7036b9..02aee48b7 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -15,6 +15,7 @@ use Grav\Common\Cache; use Grav\Common\GPM\Installer; use Grav\Common\Grav; use Grav\Common\Plugins; +use Grav\Common\Upgrade\SafeUpgradeService; use RuntimeException; use function class_exists; use function dirname; @@ -260,13 +261,19 @@ ERR; // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema. $this->updater->install(); - Installer::install( - $this->zip ?? '', - GRAV_ROOT, - ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], - $this->location, - !($this->zip && is_file($this->zip)) - ); + if ($this->shouldUseSafeUpgrade()) { + $service = new SafeUpgradeService(); + $service->promote($this->location, $this->getVersion(), $this->ignores); + Installer::setError(Installer::OK); + } else { + Installer::install( + $this->zip ?? '', + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], + $this->location, + !($this->zip && is_file($this->zip)) + ); + } } catch (Exception $e) { Installer::setError($e->getMessage()); } @@ -280,6 +287,27 @@ ERR; } } + /** + * @return bool + */ + private function shouldUseSafeUpgrade(): bool + { + if (!class_exists(SafeUpgradeService::class)) { + return false; + } + + try { + $grav = Grav::instance(); + if ($grav && isset($grav['config'])) { + return (bool) $grav['config']->get('system.updates.safe_upgrade', true); + } + } catch (\Throwable $e) { + // Grav container may not be initialised yet, default to safe upgrade. + } + + return true; + } + /** * @return void * @throws RuntimeException diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php new file mode 100644 index 000000000..88cc54d2e --- /dev/null +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -0,0 +1,123 @@ +tmpDir = sys_get_temp_dir() . '/grav-recovery-' . uniqid('', true); + Folder::create($this->tmpDir); + Folder::create($this->tmpDir . '/user'); + Folder::create($this->tmpDir . '/system'); + } + + protected function _after(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testHandleShutdownQuarantinesPluginAndCreatesFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/bad'; + Folder::create($plugin); + file_put_contents($plugin . '/plugin.php', 'tmpDir) extends RecoveryManager { + protected $error; + public function __construct(string $rootPath) + { + parent::__construct($rootPath); + $this->error = [ + 'type' => E_ERROR, + 'file' => $this->getRootPath() . '/user/plugins/bad/plugin.php', + 'message' => 'Fatal failure', + 'line' => 42, + ]; + } + + public function getRootPath(): string + { + $prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath'); + $prop->setAccessible(true); + + return $prop->getValue($this); + } + + protected function resolveLastError(): ?array + { + return $this->error; + } + }; + + $manager->handleShutdown(); + + $flag = $this->tmpDir . '/system/recovery.flag'; + self::assertFileExists($flag); + $context = json_decode(file_get_contents($flag), true); + self::assertSame('Fatal failure', $context['message']); + self::assertSame('bad', $context['plugin']); + self::assertNotEmpty($context['token']); + + $configFile = $this->tmpDir . '/user/config/plugins/bad.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertArrayHasKey('bad', $decoded); + } + + public function testHandleShutdownIgnoresNonFatalErrors(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected function resolveLastError(): ?array + { + return ['type' => E_USER_WARNING, 'message' => 'Notice']; + } + }; + + $manager->handleShutdown(); + + self::assertFileDoesNotExist($this->tmpDir . '/system/recovery.flag'); + } + + public function testClearRemovesFlag(): void + { + $flag = $this->tmpDir . '/system/recovery.flag'; + file_put_contents($flag, 'flag'); + + $manager = new RecoveryManager($this->tmpDir); + $manager->clear(); + + self::assertFileDoesNotExist($flag); + } + + public function testGenerateTokenFallbackOnRandomFailure(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected function randomBytes(int $length): string + { + throw new \RuntimeException('No randomness'); + } + }; + + $manager->activate([]); + $context = $manager->getContext(); + + self::assertNotEmpty($context['token']); + } + + public function testGetContextWithoutFlag(): void + { + $manager = new RecoveryManager($this->tmpDir); + self::assertNull($manager->getContext()); + } +} diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php new file mode 100644 index 000000000..15b03f842 --- /dev/null +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -0,0 +1,193 @@ +tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true); + Folder::create($this->tmpDir); + } + + protected function _after(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testPreflightAggregatesWarnings(): void + { + $service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService { + public $pending = [ + 'alpha' => ['type' => 'plugins', 'current' => '1.0.0', 'available' => '1.1.0'] + ]; + public $conflicts = [ + 'beta' => ['requires' => '^1.0'] + ]; + + protected function detectPendingPluginUpdates(): array + { + return $this->pending; + } + + protected function detectPsrLogConflicts(): array + { + return $this->conflicts; + } + }; + + $result = $service->preflight(); + + self::assertArrayHasKey('warnings', $result); + self::assertCount(2, $result['warnings']); + self::assertArrayHasKey('alpha', $result['plugins_pending']); + self::assertArrayHasKey('beta', $result['psr_log_conflicts']); + } + + public function testPreflightHandlesDetectionFailure(): void + { + $service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService { + protected function detectPendingPluginUpdates(): array + { + throw new RuntimeException('Cannot reach GPM'); + } + + protected function detectPsrLogConflicts(): array + { + return []; + } + }; + + $result = $service->preflight(); + + self::assertSame([], $result['plugins_pending']); + self::assertSame([], $result['psr_log_conflicts']); + self::assertCount(1, $result['warnings']); + self::assertStringContainsString('Cannot reach GPM', $result['warnings'][0]); + } + + public function testPromoteAndRollback(): void + { + [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $staging, + 'manifest_store' => $manifestStore, + ]); + + $package = $this->preparePackage(); + $manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); + + self::assertFileExists($root . '/system/new.txt'); + self::assertFileDoesNotExist($root . '/ORIGINAL'); + + $manifestFile = $manifestStore . '/' . $manifest['id'] . '.json'; + self::assertFileExists($manifestFile); + + $service->rollback($manifest['id']); + + self::assertFileExists($root . '/ORIGINAL'); + self::assertFileDoesNotExist($root . '/system/new.txt'); + + $rotated = glob($staging . '/rotated-*'); + self::assertEmpty($rotated); + } + + public function testPrunesOldSnapshots(): void + { + [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $staging, + 'manifest_store' => $manifestStore, + ]); + + $manifests = []; + for ($i = 0; $i < 4; $i++) { + $package = $this->preparePackage((string)$i); + $manifests[] = $service->promote($package, '1.8.' . $i, ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); + // Ensure subsequent promotions have a marker to restore. + file_put_contents($root . '/ORIGINAL', 'state-' . $i); + } + + $files = glob($manifestStore . '/*.json'); + self::assertCount(3, $files); + self::assertFalse(is_dir($manifests[0]['backup_path'])); + } + + public function testDetectsPsrLogConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/problem'; + Folder::create($plugin); + file_put_contents($plugin . '/composer.json', json_encode(['require' => ['psr/log' => '^1.0']], JSON_PRETTY_PRINT)); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + + $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); + $method->setAccessible(true); + $conflicts = $method->invoke($service); + + self::assertArrayHasKey('problem', $conflicts); + } + + public function testClearRecoveryFlagRemovesFile(): void + { + [$root] = $this->prepareLiveEnvironment(); + $flag = $root . '/system/recovery.flag'; + Folder::create(dirname($flag)); + file_put_contents($flag, 'flag'); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + $service->clearRecoveryFlag(); + + self::assertFileDoesNotExist($flag); + } + + /** + * @return array{0:string,1:string,2:string} + */ + private function prepareLiveEnvironment(): array + { + $root = $this->tmpDir . '/root'; + $staging = $this->tmpDir . '/staging'; + $manifestStore = $root . '/user/data/upgrades'; + + Folder::create($root . '/user/plugins/sample'); + Folder::create($root . '/system'); + file_put_contents($root . '/system/original.txt', 'original'); + file_put_contents($root . '/ORIGINAL', 'original-root'); + file_put_contents($root . '/user/plugins/sample/blueprints.yaml', "name: Sample Plugin\nversion: 1.0.0\n"); + file_put_contents($root . '/user/plugins/sample/composer.json', json_encode(['require' => ['php' => '^8.0']], JSON_PRETTY_PRINT)); + + return [$root, $staging, $manifestStore]; + } + + /** + * @param string $suffix + * @return string + */ + private function preparePackage(string $suffix = ''): string + { + $package = $this->tmpDir . '/package-' . uniqid('', true); + Folder::create($package . '/system'); + Folder::create($package . '/user'); + file_put_contents($package . '/index.php', 'new-release' . $suffix); + file_put_contents($package . '/system/new.txt', 'release' . $suffix); + + return $package; + } +} + diff --git a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php new file mode 100644 index 000000000..442166838 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php @@ -0,0 +1,147 @@ + [], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + $command = new TestPreflightCommand($service); + + [$style, $output] = $this->injectIo($command, new ArrayInput(['--json' => true])); + $status = $command->runServe(); + + self::assertSame(0, $status); + $buffer = $output->fetch(); + self::assertJson(trim($buffer)); + } + + public function testServeWarnsWhenIssuesDetected(): void + { + $service = new StubSafeUpgradeService([ + 'plugins_pending' => ['alpha' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => ['beta' => ['requires' => '^1']], + 'warnings' => ['pending updates'] + ]); + $command = new TestPreflightCommand($service); + + [$style] = $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(2, $status); + $output = implode("\n", $style->messages); + self::assertStringContainsString('pending updates', $output); + self::assertStringContainsString('beta', $output); + } + + /** + * @param TestPreflightCommand $command + * @param ArrayInput $input + * @return array{0:PreflightMemoryStyle,1:BufferedOutput} + */ + private function injectIo(TestPreflightCommand $command, ArrayInput $input): array + { + $buffer = new BufferedOutput(); + $style = new PreflightMemoryStyle($input, $buffer); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style, $buffer]; + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestPreflightCommand extends PreflightCommand +{ + /** @var SafeUpgradeService */ + private $service; + + public function __construct(SafeUpgradeService $service) + { + parent::__construct(); + $this->service = $service; + } + + protected function createSafeUpgradeService(): SafeUpgradeService + { + return $this->service; + } + + public function runServe(): int + { + return $this->serve(); + } +} + +class StubSafeUpgradeService extends SafeUpgradeService +{ + /** @var array */ + private $report; + + public function __construct(array $report) + { + $this->report = $report; + parent::__construct([]); + } + + public function preflight(): array + { + return $this->report; + } +} + +class PreflightMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + + public function __construct(InputInterface $input, BufferedOutput $output) + { + parent::__construct($input, $output); + } + + public function title($message): void + { + $this->messages[] = 'title:' . $message; + parent::title($message); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function warning($message): void + { + $this->messages[] = 'warning:' . $message; + parent::warning($message); + } + + public function success($message): void + { + $this->messages[] = 'success:' . $message; + parent::success($message); + } +} diff --git a/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php b/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php new file mode 100644 index 000000000..5a7807417 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php @@ -0,0 +1,244 @@ +setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'], + ['id' => 'snap-2', 'target_version' => '1.7.50'] + ]); + + [$style] = $this->injectIo($command, new ArrayInput(['--list' => true])); + $status = $command->runServe(); + + self::assertSame(0, $status); + $output = implode("\n", $style->messages); + self::assertStringContainsString('snap-1', $output); + self::assertStringContainsString('snap-2', $output); + self::assertFalse($service->rollbackCalled); + } + + public function testListSnapshotsHandlesAbsence(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service); + + [$style] = $this->injectIo($command, new ArrayInput(['--list' => true])); + $status = $command->runServe(); + + self::assertSame(0, $status); + self::assertStringContainsString('No snapshots found', implode("\n", $style->messages)); + } + + public function testRollbackAbortsWhenNoSnapshotsAvailable(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service); + + [$style] = $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(1, $status); + self::assertStringContainsString('No snapshots available', implode("\n", $style->messages)); + } + + public function testRollbackAbortsWhenSnapshotMissing(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service); + $command->setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'] + ]); + + [$style] = $this->injectIo($command, new ArrayInput(['manifest' => 'missing'])); + $status = $command->runServe(); + + self::assertSame(1, $status); + self::assertStringContainsString('Snapshot missing not found.', implode("\n", $style->messages)); + } + + public function testRollbackCancelsWhenUserDeclines(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service, [false]); + $command->setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'] + ]); + + [$style] = $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(1, $status); + self::assertStringContainsString('Rollback aborted.', implode("\n", $style->messages)); + } + + public function testRollbackSucceedsAndClearsRecoveryFlag(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service, [true]); + $command->setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'] + ]); + $this->setAllYes($command, true); + + $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(0, $status); + self::assertTrue($service->rollbackCalled); + self::assertTrue($service->clearFlagCalled); + } + + private function setAllYes(RollbackCommand $command, bool $value): void + { + $ref = new \ReflectionProperty(RollbackCommand::class, 'allYes'); + $ref->setAccessible(true); + $ref->setValue($command, $value); + } + + /** + * @param TestRollbackCommand $command + * @param ArrayInput $input + * @return array{0:RollbackMemoryStyle} + */ + private function injectIo(TestRollbackCommand $command, ArrayInput $input): array + { + $buffer = new BufferedOutput(); + $style = new RollbackMemoryStyle($input, $buffer, $command->responses); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style]; + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestRollbackCommand extends RollbackCommand +{ + /** @var SafeUpgradeService */ + private $service; + /** @var array */ + private $snapshots = []; + /** @var array */ + public $responses = []; + + public function __construct(SafeUpgradeService $service, array $responses = []) + { + parent::__construct(); + $this->service = $service; + $this->responses = $responses; + } + + public function setSnapshots(array $snapshots): void + { + $this->snapshots = $snapshots; + } + + protected function createSafeUpgradeService(): SafeUpgradeService + { + return $this->service; + } + + protected function collectSnapshots(): array + { + return $this->snapshots; + } + + public function runServe(): int + { + return $this->serve(); + } +} + +class StubRollbackService extends SafeUpgradeService +{ + public $rollbackCalled = false; + public $clearFlagCalled = false; + + public function __construct() + { + parent::__construct([]); + } + + public function rollback(?string $id = null): ?array + { + $this->rollbackCalled = true; + + return ['id' => $id]; + } + + public function clearRecoveryFlag(): void + { + $this->clearFlagCalled = true; + } +} + +class RollbackMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + /** @var array */ + private $responses; + + public function __construct(InputInterface $input, BufferedOutput $output, array $responses = []) + { + parent::__construct($input, $output); + $this->responses = $responses; + } + + public function newLine($count = 1): void + { + for ($i = 0; $i < $count; $i++) { + $this->messages[] = ''; + } + parent::newLine($count); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function error($message): void + { + $this->messages[] = 'error:' . $message; + parent::error($message); + } + + public function success($message): void + { + $this->messages[] = 'success:' . $message; + parent::success($message); + } + + public function askQuestion($question) + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::askQuestion($question); + } +} diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php new file mode 100644 index 000000000..85dc966d0 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -0,0 +1,160 @@ +injectIo($command); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame([], $style->messages); + } + + public function testHandlePreflightReportSkipsPromptsWhenAllYes(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command); + $this->setAllYes($command, true); + + $result = $command->runHandle([ + 'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => ['bar' => ['requires' => '^1.0']], + 'warnings' => ['pending'] + ]); + + self::assertTrue($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('pending', $output); + self::assertStringContainsString('psr/log', $output); + } + + public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, [false]); + $this->setAllYes($command, false); + + $result = $command->runHandle([ + 'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + + self::assertFalse($result); + self::assertStringContainsString('Run `bin/gpm update` first', implode("\n", $style->messages)); + } + + public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, [false]); + $this->setAllYes($command, false); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertFalse($result); + self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages)); + } + + /** + * @param TestSelfupgradeCommand $command + * @param array $responses + * @return array{0:SelfUpgradeMemoryStyle,1:InputInterface} + */ + private function injectIo(TestSelfupgradeCommand $command, array $responses = []): array + { + $input = new ArrayInput([]); + $buffer = new BufferedOutput(); + $style = new SelfUpgradeMemoryStyle($input, $buffer, $responses); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style, $input]; + } + + private function setAllYes(SelfupgradeCommand $command, bool $value): void + { + $ref = new \ReflectionProperty(SelfupgradeCommand::class, 'all_yes'); + $ref->setAccessible(true); + $ref->setValue($command, $value); + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestSelfupgradeCommand extends SelfupgradeCommand +{ + public function runHandle(array $report): bool + { + return $this->handlePreflightReport($report); + } +} + +class SelfUpgradeMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + /** @var array */ + private $responses; + + /** + * @param InputInterface $input + * @param BufferedOutput $output + * @param array $responses + */ + public function __construct(InputInterface $input, BufferedOutput $output, array $responses = []) + { + parent::__construct($input, $output); + $this->responses = $responses; + } + + public function newLine($count = 1): void + { + for ($i = 0; $i < $count; $i++) { + $this->messages[] = ''; + } + parent::newLine($count); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function askQuestion($question) + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::askQuestion($question); + } +} From 2b1a7d3fb6a2f428e1f6af53c68cf9cccf58c549 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 10:44:19 -0600 Subject: [PATCH 04/46] upgrade manager fix Signed-off-by: Andy Miller --- .../src/Grav/Common/Recovery/RecoveryManager.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index 0d78a3174..4be5331fa 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -47,9 +47,19 @@ class RecoveryManager /** @var string */ private $userPath; - public function __construct(?string $rootPath = null) + /** + * @param mixed $context Container or root path. + */ + public function __construct($context = null) { - $root = $rootPath ?? GRAV_ROOT; + if ($context instanceof \Grav\Common\Grav) { + $root = GRAV_ROOT; + } elseif (is_string($context) && $context !== '') { + $root = $context; + } else { + $root = GRAV_ROOT; + } + $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->userPath = $this->rootPath . '/user'; } From b55e86a8baa9af29cbda62159560977da5bd3aab Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 11:00:54 -0600 Subject: [PATCH 05/46] force upgrades before updating Signed-off-by: Andy Miller --- system/src/Grav/Console/Gpm/SelfupgradeCommand.php | 10 +++------- tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php | 7 +++---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 367c70de5..2c991e93c 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -313,14 +313,10 @@ class SelfupgradeCommand extends GpmCommand $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available)); } - if (!$this->all_yes) { - $question = new ConfirmationQuestion('Continue without updating these packages? [y|N] ', false); - if (!$io->askQuestion($question)) { - $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + $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.'); - return false; - } - } + return false; } if ($conflicts) { diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php index 85dc966d0..c11d0c65d 100644 --- a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -23,7 +23,7 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test self::assertSame([], $style->messages); } - public function testHandlePreflightReportSkipsPromptsWhenAllYes(): void + public function testHandlePreflightReportFailsWhenPendingEvenWithAllYes(): void { $command = new TestSelfupgradeCommand(); [$style] = $this->injectIo($command); @@ -35,10 +35,9 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test 'warnings' => ['pending'] ]); - self::assertTrue($result); + self::assertFalse($result); $output = implode("\n", $style->messages); - self::assertStringContainsString('pending', $output); - self::assertStringContainsString('psr/log', $output); + self::assertStringContainsString('Run `bin/gpm update` first', $output); } public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void From 57212ec9a5458db1123f92f016f66ddfb15868cd Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 11:24:50 -0600 Subject: [PATCH 06/46] better plugin checks Signed-off-by: Andy Miller --- .../Grav/Common/Recovery/RecoveryManager.php | 20 +++- .../Common/Upgrade/SafeUpgradeService.php | 49 ++++++++ .../src/Grav/Console/Gpm/PreflightCommand.php | 15 ++- .../Grav/Console/Gpm/SelfupgradeCommand.php | 109 +++++++++++++++--- .../Common/Recovery/RecoveryManagerTest.php | 21 ++++ .../Common/Upgrade/SafeUpgradeServiceTest.php | 51 +++++++- .../Grav/Console/Gpm/PreflightCommandTest.php | 3 + .../Console/Gpm/SelfupgradeCommandTest.php | 58 +++++++++- 8 files changed, 303 insertions(+), 23 deletions(-) diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index 4be5331fa..c179e4b24 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -185,7 +185,25 @@ class RecoveryManager * @param array $context * @return void */ - private function quarantinePlugin(string $slug, array $context): void + public function disablePlugin(string $slug, array $context = []): void + { + $context += [ + 'message' => $context['message'] ?? 'Disabled during upgrade preflight', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + 'created_at' => $context['created_at'] ?? time(), + 'plugin' => $context['plugin'] ?? $slug, + ]; + + $this->quarantinePlugin($slug, $context); + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + protected function quarantinePlugin(string $slug, array $context): void { $slug = trim($slug); if ($slug === '') { diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 6e5a5df12..c3092b013 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -15,6 +15,9 @@ use Grav\Common\Yaml; use InvalidArgumentException; use RuntimeException; use Throwable; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use FilesystemIterator; use function basename; use function count; use function dirname; @@ -94,16 +97,21 @@ class SafeUpgradeService } $psrLogConflicts = $this->detectPsrLogConflicts(); + $monologConflicts = $this->detectMonologConflicts(); if ($pending) { $warnings[] = 'One or more plugins/themes are not up to date.'; } if ($psrLogConflicts) { $warnings[] = 'Potential psr/log signature conflicts detected.'; } + if ($monologConflicts) { + $warnings[] = 'Potential Monolog logger API incompatibilities detected.'; + } return [ 'plugins_pending' => $pending, 'psr_log_conflicts' => $psrLogConflicts, + 'monolog_conflicts' => $monologConflicts, 'warnings' => $warnings, ]; } @@ -263,6 +271,47 @@ class SafeUpgradeService return $conflicts; } + /** + * Detect usage of deprecated Monolog `add*` methods removed in newer releases. + * + * @return array + */ + protected function detectMonologConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + $pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i'; + + foreach ($pluginRoots as $path) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $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)) { + $slug = basename($path); + $relative = str_replace($this->rootPath . '/', '', $file->getPathname()); + $conflicts[$slug][] = [ + 'file' => $relative, + 'method' => trim($match[0]), + ]; + } + } + } + + return $conflicts; + } + /** * Ensure directories flagged for ignoring get hydrated from the current installation. * diff --git a/system/src/Grav/Console/Gpm/PreflightCommand.php b/system/src/Grav/Console/Gpm/PreflightCommand.php index 63728a5ca..50a33331d 100644 --- a/system/src/Grav/Console/Gpm/PreflightCommand.php +++ b/system/src/Grav/Console/Gpm/PreflightCommand.php @@ -25,7 +25,7 @@ class PreflightCommand extends GpmCommand $service = $this->createSafeUpgradeService(); $report = $service->preflight(); - $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['warnings']); + $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']); if ($this->getInput()->getOption('json')) { $io->writeln(json_encode($report, JSON_PRETTY_PRINT)); @@ -60,6 +60,19 @@ class PreflightCommand extends GpmCommand $io->newLine(); } + if (!empty($report['monolog_conflicts'])) { + $io->writeln('Potential Monolog logger conflicts'); + foreach ($report['monolog_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)); + } + } + $io->writeln(' › Update the plugin to use PSR-3 style logger calls (e.g. $logger->error()).'); + $io->newLine(); + } + if (!$hasIssues) { $io->success('No blocking issues detected.'); } else { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 2c991e93c..99f863adc 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -22,6 +22,7 @@ use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use ZipArchive; use function count; use function is_callable; @@ -289,9 +290,10 @@ class SelfupgradeCommand extends GpmCommand $io = $this->getIO(); $pending = $preflight['plugins_pending'] ?? []; $conflicts = $preflight['psr_log_conflicts'] ?? []; + $monologConflicts = $preflight['monolog_conflicts'] ?? []; $warnings = $preflight['warnings'] ?? []; - if (empty($pending) && empty($conflicts)) { + if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { return true; } @@ -319,25 +321,98 @@ class SelfupgradeCommand extends GpmCommand return false; } - if ($conflicts) { - $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)); - } - $io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.'); - - if (!$this->all_yes) { - $question = new ConfirmationQuestion('Continue despite psr/log warnings? [y|N] ', false); - if (!$io->askQuestion($question)) { - $io->writeln('Aborting self-upgrade. Adjust composer requirements or update affected plugins.'); - - return false; + $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 and reinstall dependencies.', + 'Aborting self-upgrade. Adjust composer requirements or update affected plugins.', + 'Proceeding with potential psr/log incompatibilities still active.', + 'Disabled before upgrade because of psr/log conflict' + ); + + 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 (e.g. $logger->error()) before upgrading.', + 'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.', + 'Proceeding with potential Monolog API incompatibilities still active.', + 'Disabled before upgrade because of Monolog API conflict' + ); + + if (!$handledMonolog) { + return false; + } + + return true; + } + + /** + * @param array $conflicts + * @param callable $printer + * @param string $advice + * @param string $abortMessage + * @param string $continueMessage + * @param string $disableNote + * @return bool + */ + private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage, string $disableNote): 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; + } + + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + + if ($choice === 'disable') { + foreach (array_keys($conflicts) as $slug) { + $recovery->disablePlugin($slug, ['message' => $disableNote]); + $io->writeln(sprintf(' - Disabled plugin %s.', $slug)); + } + $io->writeln('Continuing with conflicted plugins disabled.'); + + return true; + } + + $io->writeln($continueMessage); + return true; } diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index 88cc54d2e..f7e7e7375 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -120,4 +120,25 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test $manager = new RecoveryManager($this->tmpDir); self::assertNull($manager->getContext()); } + + public function testDisablePluginRecordsQuarantineWithoutFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/problem'; + Folder::create($plugin); + + $manager = new RecoveryManager($this->tmpDir); + $manager->disablePlugin('problem', ['message' => 'Manual disable']); + + $flag = $this->tmpDir . '/system/recovery.flag'; + self::assertFileDoesNotExist($flag); + + $configFile = $this->tmpDir . '/user/config/plugins/problem.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertSame('Manual disable', $decoded['problem']['message']); + } } diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index 15b03f842..b69bef31e 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -30,6 +30,11 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test public $conflicts = [ 'beta' => ['requires' => '^1.0'] ]; + public $monolog = [ + 'gamma' => [ + ['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError('] + ] + ]; protected function detectPendingPluginUpdates(): array { @@ -40,14 +45,20 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test { return $this->conflicts; } + + protected function detectMonologConflicts(): array + { + return $this->monolog; + } }; $result = $service->preflight(); self::assertArrayHasKey('warnings', $result); - self::assertCount(2, $result['warnings']); + self::assertCount(3, $result['warnings']); self::assertArrayHasKey('alpha', $result['plugins_pending']); self::assertArrayHasKey('beta', $result['psr_log_conflicts']); + self::assertArrayHasKey('gamma', $result['monolog_conflicts']); } public function testPreflightHandlesDetectionFailure(): void @@ -62,12 +73,18 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test { return []; } + + protected function detectMonologConflicts(): array + { + return []; + } }; $result = $service->preflight(); self::assertSame([], $result['plugins_pending']); self::assertSame([], $result['psr_log_conflicts']); + self::assertSame([], $result['monolog_conflicts']); self::assertCount(1, $result['warnings']); self::assertStringContainsString('Cannot reach GPM', $result['warnings'][0]); } @@ -140,6 +157,37 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertArrayHasKey('problem', $conflicts); } + public function testDetectsMonologConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/logger'; + Folder::create($plugin . '/src'); + $code = <<<'PHP' +addError('deprecated'); + } +} +PHP; + file_put_contents($plugin . '/src/logger.php', $code); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + + $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); + $method->setAccessible(true); + $conflicts = $method->invoke($service); + + self::assertArrayHasKey('logger', $conflicts); + self::assertNotEmpty($conflicts['logger']); + self::assertStringContainsString('addError', $conflicts['logger'][0]['method']); + } + public function testClearRecoveryFlagRemovesFile(): void { [$root] = $this->prepareLiveEnvironment(); @@ -190,4 +238,3 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test return $package; } } - diff --git a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php index 442166838..85b1fb6eb 100644 --- a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php @@ -14,6 +14,7 @@ class PreflightCommandTest extends \Codeception\TestCase\Test $service = new StubSafeUpgradeService([ 'plugins_pending' => [], 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], 'warnings' => [] ]); $command = new TestPreflightCommand($service); @@ -31,6 +32,7 @@ class PreflightCommandTest extends \Codeception\TestCase\Test $service = new StubSafeUpgradeService([ 'plugins_pending' => ['alpha' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], 'psr_log_conflicts' => ['beta' => ['requires' => '^1']], + 'monolog_conflicts' => ['gamma' => [['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']]], 'warnings' => ['pending updates'] ]); $command = new TestPreflightCommand($service); @@ -42,6 +44,7 @@ class PreflightCommandTest extends \Codeception\TestCase\Test $output = implode("\n", $style->messages); self::assertStringContainsString('pending updates', $output); self::assertStringContainsString('beta', $output); + self::assertStringContainsString('gamma', $output); } /** diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php index c11d0c65d..0542f0870 100644 --- a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -1,5 +1,6 @@ injectIo($command, [false]); + [$style] = $this->injectIo($command); $this->setAllYes($command, false); $result = $command->runHandle([ @@ -59,7 +60,7 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void { $command = new TestSelfupgradeCommand(); - [$style] = $this->injectIo($command, [false]); + [$style] = $this->injectIo($command, ['abort']); $this->setAllYes($command, false); $result = $command->runHandle([ @@ -72,6 +73,50 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages)); } + public function testHandlePreflightReportDisablesPluginsWhenRequested(): void + { + $gravFactory = Fixtures::get('grav'); + $grav = $gravFactory(); + $stub = new class { + public $disabled = []; + public function disablePlugin(string $slug, array $context = []): void + { + $this->disabled[] = $slug; + } + }; + $grav['recovery'] = $stub; + + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['disable']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame(['foo'], $stub->disabled); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Continuing with conflicted plugins disabled.', $output); + } + + public function testHandlePreflightReportContinuesWhenRequested(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['continue']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output); + } + /** * @param TestSelfupgradeCommand $command * @param array $responses @@ -156,4 +201,13 @@ class SelfUpgradeMemoryStyle extends SymfonyStyle return parent::askQuestion($question); } + + public function choice($question, array $choices, $default = null, $attempts = null, $errorMessage = 'Invalid value.') + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::choice($question, $choices, $default, $attempts, $errorMessage); + } } From 43ddf450579d981ede8fd01e18cfffefd93ae539 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 12:42:38 -0600 Subject: [PATCH 07/46] latest tweak Signed-off-by: Andy Miller --- system/src/Grav/Common/Upgrade/SafeUpgradeService.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index c3092b013..26d3ee32b 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -74,7 +74,7 @@ class SafeUpgradeService $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); - $this->stagingRoot = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'); + $this->stagingRoot = $options['staging_root'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'grav-upgrades'); $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { $this->ignoredDirs = $options['ignored_dirs']; @@ -135,6 +135,7 @@ class SafeUpgradeService $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId; + Folder::create($this->stagingRoot); Folder::create($packagePath); // Copy extracted package into staging area. @@ -179,6 +180,7 @@ class SafeUpgradeService $rotated = $this->rotateCurrentTree(); $this->promoteBackup($backupPath); + $this->syncGitDirectory($rotated, $this->rootPath); $this->markRollback($manifest['id']); if ($rotated && is_dir($rotated)) { Folder::delete($rotated); @@ -420,6 +422,8 @@ class SafeUpgradeService rename($backupPath, $liveRoot); throw new RuntimeException('Failed to promote staged Grav release.'); } + + $this->syncGitDirectory($backupPath, $liveRoot); } /** From cf2ac28be2e74f4417427ce2f925383fcb8abb11 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 12:50:54 -0600 Subject: [PATCH 08/46] bugfixes in safeupgradeservice Signed-off-by: Andy Miller --- .../Common/Upgrade/SafeUpgradeService.php | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 26d3ee32b..9346ce23a 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -34,6 +34,7 @@ use function rename; use function rsort; use function sort; use function time; +use function rtrim; use function uniqid; use function trim; use function strpos; @@ -74,7 +75,13 @@ class SafeUpgradeService $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); - $this->stagingRoot = $options['staging_root'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'grav-upgrades'); + $defaultStaging = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'); + try { + Folder::create($defaultStaging); + } catch (\RuntimeException $e) { + throw new RuntimeException(sprintf('Unable to create staging directory at %s. Adjust permissions or configure system.updates.staging_root.', $defaultStaging)); + } + $this->stagingRoot = realpath($defaultStaging) ?: $defaultStaging; $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { $this->ignoredDirs = $options['ignored_dirs']; @@ -135,7 +142,6 @@ class SafeUpgradeService $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId; - Folder::create($this->stagingRoot); Folder::create($packagePath); // Copy extracted package into staging area. @@ -456,6 +462,32 @@ class SafeUpgradeService } } + /** + * Ensure Git metadata is retained after stage promotion. + * + * @param string $source + * @param string $destination + * @return void + */ + private function syncGitDirectory(string $source, string $destination): void + { + if (!$source || !$destination) { + return; + } + + $sourceGit = rtrim($source, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git'; + if (!is_dir($sourceGit)) { + return; + } + + $destinationGit = rtrim($destination, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git'; + if (is_dir($destinationGit)) { + Folder::delete($destinationGit); + } + + Folder::rcopy($sourceGit, $destinationGit); + } + /** * Persist manifest into Grav data directory. * From 7dd5c8a0ba83e1814c8ab0077ab33e675f8bcead Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 13:00:05 -0600 Subject: [PATCH 09/46] staging root config option Signed-off-by: Andy Miller --- system/blueprints/config/system.yaml | 9 +++++- system/config/system.yaml | 1 + system/languages/en.yaml | 2 ++ .../Common/Upgrade/SafeUpgradeService.php | 31 ++++++++++++++++--- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 3eef8fc61..67c77362d 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1614,6 +1614,14 @@ form: validate: type: bool + updates.staging_root: + type: text + label: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING + help: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING_HELP + placeholder: '/absolute/path/to/grav-upgrades' + validate: + type: string + http_section: type: section title: PLUGIN_ADMIN.HTTP_SECTION @@ -1929,4 +1937,3 @@ form: # pages.type: # type: hidden - diff --git a/system/config/system.yaml b/system/config/system.yaml index 87b6d4c1c..50cbcd28f 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -205,6 +205,7 @@ gpm: updates: safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates + staging_root: '' # Optional absolute path for staging backups (default: /grav-upgrades) http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 3b34a93db..973199ff3 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -124,3 +124,5 @@ PLUGIN_ADMIN: UPDATES_SECTION: Updates SAFE_UPGRADE: Safe self-upgrade SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support. + SAFE_UPGRADE_STAGING: Staging directory + SAFE_UPGRADE_STAGING_HELP: Optional absolute path for storing upgrade backups. Leave empty to use the default inside the parent directory. diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 9346ce23a..026a9d07f 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -76,10 +76,12 @@ class SafeUpgradeService $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); $defaultStaging = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'); - try { - Folder::create($defaultStaging); - } catch (\RuntimeException $e) { - throw new RuntimeException(sprintf('Unable to create staging directory at %s. Adjust permissions or configure system.updates.staging_root.', $defaultStaging)); + if (!$this->ensureWritable($defaultStaging)) { + $fallback = getenv('HOME') ? getenv('HOME') . DIRECTORY_SEPARATOR . 'grav-upgrades' : sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'grav-upgrades'; + if (!$this->ensureWritable($fallback)) { + throw new RuntimeException(sprintf('Unable to create staging directory at %s or fallback %s. Adjust permissions or configure system.updates.staging_root.', $defaultStaging, $fallback)); + } + $defaultStaging = $fallback; } $this->stagingRoot = realpath($defaultStaging) ?: $defaultStaging; $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); @@ -501,6 +503,27 @@ class SafeUpgradeService file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); } + /** + * Ensure directory exists and is writable. + * + * @param string $path + * @return bool + */ + private function ensureWritable(string $path): bool + { + try { + Folder::create($path); + } catch (\RuntimeException $e) { + return false; + } + + if (!is_writable($path)) { + return false; + } + + return true; + } + /** * @param string|null $id * @return array|null From f88c09adca585994297afd78e874235819b375b7 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 13:12:28 -0600 Subject: [PATCH 10/46] update GRAV_VERSION for testing Signed-off-by: Andy Miller --- system/defines.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/defines.php b/system/defines.php index 7c1e89007..8b28519a2 100644 --- a/system/defines.php +++ b/system/defines.php @@ -9,7 +9,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '1.7.49.5'); +define('GRAV_VERSION', '1.7.50'); define('GRAV_SCHEMA', '1.7.0_2020-11-20_1'); define('GRAV_TESTING', false); From 23da92d0ffcbaaf1b95cb75bb35beaea2fd6fa38 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 13:49:36 -0600 Subject: [PATCH 11/46] honor staging_root Signed-off-by: Andy Miller --- .../Common/Upgrade/SafeUpgradeService.php | 77 +++++++++++++++---- .../Grav/Console/Gpm/SelfupgradeCommand.php | 13 +++- system/src/Grav/Installer/Install.php | 12 ++- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 026a9d07f..fbbee6714 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -39,6 +39,8 @@ use function uniqid; use function trim; use function strpos; use function unlink; +use function ltrim; +use function preg_replace; use const GRAV_ROOT; use const GLOB_ONLYDIR; use const JSON_PRETTY_PRINT; @@ -75,15 +77,29 @@ class SafeUpgradeService $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); - $defaultStaging = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'); - if (!$this->ensureWritable($defaultStaging)) { - $fallback = getenv('HOME') ? getenv('HOME') . DIRECTORY_SEPARATOR . 'grav-upgrades' : sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'grav-upgrades'; - if (!$this->ensureWritable($fallback)) { - throw new RuntimeException(sprintf('Unable to create staging directory at %s or fallback %s. Adjust permissions or configure system.updates.staging_root.', $defaultStaging, $fallback)); - } - $defaultStaging = $fallback; + + $candidates = []; + if (!empty($options['staging_root'])) { + $candidates[] = $options['staging_root']; + } + $candidates[] = $this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'; + if (getenv('HOME')) { + $candidates[] = getenv('HOME') . DIRECTORY_SEPARATOR . 'grav-upgrades'; + } + $candidates[] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'grav-upgrades'; + + $this->stagingRoot = null; + foreach ($candidates as $candidate) { + $resolved = $this->resolveStagingPath($candidate); + if ($resolved) { + $this->stagingRoot = $resolved; + break; + } + } + + if (null === $this->stagingRoot) { + throw new RuntimeException('Unable to locate writable staging directory. Configure system.updates.staging_root or adjust permissions.'); } - $this->stagingRoot = realpath($defaultStaging) ?: $defaultStaging; $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { $this->ignoredDirs = $options['ignored_dirs']; @@ -509,19 +525,54 @@ class SafeUpgradeService * @param string $path * @return bool */ - private function ensureWritable(string $path): bool + private function resolveStagingPath(?string $path): ?string { + if (null === $path || $path === '') { + return null; + } + + $expanded = $path; + if (0 === strpos($expanded, '~')) { + $home = getenv('HOME'); + if ($home) { + $expanded = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($expanded, '~\/'); + } + } + if (!$this->isAbsolutePath($expanded)) { + $expanded = $this->rootPath . DIRECTORY_SEPARATOR . ltrim($expanded, DIRECTORY_SEPARATOR); + } + + $expanded = $this->normalizePath($expanded); + try { - Folder::create($path); + Folder::create($expanded); } catch (\RuntimeException $e) { + return null; + } + + return is_writable($expanded) ? $expanded : null; + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { return false; } - if (!is_writable($path)) { - return false; + if ($path[0] === '/' || $path[0] === '\\') { + return true; } - return true; + return (bool)preg_match('#^[A-Za-z]:[\\/]#', $path); + } + + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $path = preg_replace('#/+#', '/', $path); + $path = rtrim($path, '/'); + + return str_replace('/', DIRECTORY_SEPARATOR, $path); } /** diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 99f863adc..e2eac0acf 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -278,7 +278,18 @@ class SelfupgradeCommand extends GpmCommand */ protected function createSafeUpgradeService(): SafeUpgradeService { - return new SafeUpgradeService(); + $config = null; + try { + $config = Grav::instance()['config'] ?? null; + } catch (\Throwable $e) { + $config = null; + } + + $stagingRoot = $config ? $config->get('system.updates.staging_root') : null; + + return new SafeUpgradeService([ + 'staging_root' => $stagingRoot, + ]); } /** diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 02aee48b7..794e6f67e 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -262,7 +262,17 @@ ERR; $this->updater->install(); if ($this->shouldUseSafeUpgrade()) { - $service = new SafeUpgradeService(); + $options = []; + try { + $grav = Grav::instance(); + if ($grav && isset($grav['config'])) { + $options['staging_root'] = $grav['config']->get('system.updates.staging_root'); + } + } catch (\Throwable $e) { + // ignore + } + + $service = new SafeUpgradeService($options); $service->promote($this->location, $this->getVersion(), $this->ignores); Installer::setError(Installer::OK); } else { From 77114ecdd0b637471925c2b52ffa2262e4f8d92f Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 14:20:30 -0600 Subject: [PATCH 12/46] grav/restore dedicated binary Signed-off-by: Andy Miller --- bin/grav-restore | 204 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 bin/grav-restore diff --git a/bin/grav-restore b/bin/grav-restore new file mode 100644 index 000000000..3c823824d --- /dev/null +++ b/bin/grav-restore @@ -0,0 +1,204 @@ +#!/usr/bin/env php + [--staging-root=/absolute/path] + Restores the specified snapshot created by safe-upgrade. + +Options: + --staging-root Overrides the staging directory (defaults to configured value). + +Examples: + bin/grav-restore list + bin/grav-restore apply stage-68eff31cc4104 + bin/grav-restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups +USAGE; + +/** + * @param array $args + * @return array{command:string,arguments:array,options:array} + */ +function parseArguments(array $args): array +{ + array_shift($args); // remove script name + + $command = $args[0] ?? 'help'; + $arguments = []; + $options = []; + + foreach (array_slice($args, 1) as $arg) { + if (strncmp($arg, '--staging-root=', 15) === 0) { + $options['staging_root'] = substr($arg, 15); + continue; + } + + if (substr($arg, 0, 2) === '--') { + echo "Unknown option: {$arg}\n"; + exit(1); + } + + $arguments[] = $arg; + } + + return [ + 'command' => $command, + 'arguments' => $arguments, + 'options' => $options, + ]; +} + +/** + * @return string|null + */ +function readConfiguredStagingRoot(): ?string +{ + $configFiles = [ + GRAV_ROOT . '/user/config/system.yaml', + GRAV_ROOT . '/system/config/system.yaml' + ]; + + foreach ($configFiles as $file) { + if (!is_file($file)) { + continue; + } + + try { + $data = Yaml::parseFile($file); + } catch (\Throwable $e) { + continue; + } + + if (!is_array($data)) { + continue; + } + + $current = $data['system']['updates']['staging_root'] ?? null; + if (null !== $current && $current !== '') { + return $current; + } + } + + return null; +} + +/** + * @param array $options + * @return SafeUpgradeService + */ +function createUpgradeService(array $options): SafeUpgradeService +{ + $config = readConfiguredStagingRoot(); + if ($config !== null && empty($options['staging_root'])) { + $options['staging_root'] = $config; + } elseif (isset($options['staging_root']) && $options['staging_root'] === '') { + unset($options['staging_root']); + } + + $options['root'] = GRAV_ROOT; + + return new SafeUpgradeService($options); +} + +/** + * @return list + */ +function loadSnapshots(): array +{ + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + if (!is_dir($manifestDir)) { + return []; + } + + $files = glob($manifestDir . '/*.json') ?: []; + rsort($files); + + $snapshots = []; + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file) ?: '', true); + if (!is_array($decoded) || empty($decoded['id'])) { + continue; + } + + $snapshots[] = [ + 'id' => $decoded['id'], + 'target_version' => $decoded['target_version'] ?? null, + 'created_at' => $decoded['created_at'] ?? 0, + ]; + } + + return $snapshots; +} + +$cli = parseArguments($argv); +$command = $cli['command']; +$arguments = $cli['arguments']; +$options = $cli['options']; + +switch ($command) { + case 'list': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n"; + exit(0); + } + + echo "Available snapshots:\n"; + foreach ($snapshots as $snapshot) { + $time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown'; + $version = $snapshot['target_version'] ?? 'unknown'; + echo sprintf(" - %s (Grav %s, %s)\n", $snapshot['id'], $version, $time); + } + exit(0); + + case 'apply': + $snapshotId = $arguments[0] ?? null; + if (!$snapshotId) { + echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n"; + exit(1); + } + + try { + $service = createUpgradeService($options); + $manifest = $service->rollback($snapshotId); + } catch (\Throwable $e) { + fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n"); + exit(1); + } + + if (!$manifest) { + fwrite(STDERR, "Snapshot {$snapshotId} not found.\n"); + exit(1); + } + + $version = $manifest['target_version'] ?? 'unknown'; + echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; + exit(0); + + case 'help': + default: + echo RESTORE_USAGE . "\n"; + exit($command === 'help' ? 0 : 1); +} From c8227b38fc0b515cffa12848af0ceab3767c6e3a Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Wed, 15 Oct 2025 20:14:15 -0600 Subject: [PATCH 13/46] standalone grav-restore fixes Signed-off-by: Andy Miller --- bin/grav-restore | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) mode change 100644 => 100755 bin/grav-restore diff --git a/bin/grav-restore b/bin/grav-restore old mode 100644 new mode 100755 index 3c823824d..a8087148a --- a/bin/grav-restore +++ b/bin/grav-restore @@ -9,11 +9,21 @@ */ $root = dirname(__DIR__); -define('GRAV_CLI', true); -define('GRAV_ROOT', $root); -require $root . '/system/defines.php'; -require $root . '/vendor/autoload.php'; +define('GRAV_CLI', true); +define('GRAV_REQUEST_TIME', microtime(true)); + +if (!file_exists($root . '/vendor/autoload.php')) { + fwrite(STDERR, "Unable to locate vendor/autoload.php. Run composer install first.\n"); + exit(1); +} + +$autoload = require $root . '/vendor/autoload.php'; + +if (!file_exists($root . '/index.php')) { + fwrite(STDERR, "FATAL: Must be run from Grav root directory.\n"); + exit(1); +} use Grav\Common\Upgrade\SafeUpgradeService; use Symfony\Component\Yaml\Yaml; From 7192cfe5495be0dcf317acc9613adfa69fffc700 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 08:09:47 -0600 Subject: [PATCH 14/46] synced restore changes Signed-off-by: Andy Miller --- bin/{grav-restore => restore} | 63 ++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) rename bin/{grav-restore => restore} (70%) diff --git a/bin/grav-restore b/bin/restore similarity index 70% rename from bin/grav-restore rename to bin/restore index a8087148a..e949471fe 100755 --- a/bin/grav-restore +++ b/bin/restore @@ -25,6 +25,7 @@ if (!file_exists($root . '/index.php')) { exit(1); } +use Grav\Common\Recovery\RecoveryManager; use Grav\Common\Upgrade\SafeUpgradeService; use Symfony\Component\Yaml\Yaml; @@ -32,19 +33,24 @@ const RESTORE_USAGE = << [--staging-root=/absolute/path] + bin/restore apply [--staging-root=/absolute/path] Restores the specified snapshot created by safe-upgrade. + bin/restore recovery [status|clear] + Shows the recovery flag context or clears it. + Options: --staging-root Overrides the staging directory (defaults to configured value). Examples: - bin/grav-restore list - bin/grav-restore apply stage-68eff31cc4104 - bin/grav-restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups + bin/restore list + bin/restore apply stage-68eff31cc4104 + bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups + bin/restore recovery status + bin/restore recovery clear USAGE; /** @@ -207,6 +213,53 @@ switch ($command) { echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; exit(0); + case 'recovery': + $action = strtolower($arguments[0] ?? 'status'); + $manager = new RecoveryManager(GRAV_ROOT); + + switch ($action) { + case 'clear': + if ($manager->isActive()) { + $manager->clear(); + echo "Recovery flag cleared.\n"; + } else { + echo "Recovery mode is not active.\n"; + } + exit(0); + + case 'status': + if (!$manager->isActive()) { + echo "Recovery mode is not active.\n"; + exit(0); + } + + $context = $manager->getContext(); + if (!$context) { + echo "Recovery flag present but context could not be parsed.\n"; + exit(1); + } + + $created = isset($context['created_at']) ? date('c', (int)$context['created_at']) : 'unknown'; + $token = $context['token'] ?? '(missing)'; + $message = $context['message'] ?? '(no message)'; + $plugin = $context['plugin'] ?? '(none detected)'; + $file = $context['file'] ?? '(unknown file)'; + $line = $context['line'] ?? '(unknown line)'; + + echo "Recovery flag context:\n"; + echo " Token: {$token}\n"; + echo " Message: {$message}\n"; + echo " Plugin: {$plugin}\n"; + echo " File: {$file}\n"; + echo " Line: {$line}\n"; + echo " Created: {$created}\n"; + exit(0); + + default: + echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n"; + exit(1); + } + case 'help': default: echo RESTORE_USAGE . "\n"; From c56d24c0d72fec73109b4a2b8a5cf4d73d1156e9 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 09:08:53 -0600 Subject: [PATCH 15/46] timelimt on recovery status Signed-off-by: Andy Miller --- bin/restore | 9 ++ .../Grav/Common/Recovery/RecoveryManager.php | 118 ++++++++++++++++++ .../Grav/Console/Gpm/SelfupgradeCommand.php | 7 ++ system/src/Grav/Console/Gpm/UpdateCommand.php | 5 + .../Common/Recovery/RecoveryManagerTest.php | 1 + 5 files changed, 140 insertions(+) diff --git a/bin/restore b/bin/restore index e949471fe..6ef0cfc9f 100755 --- a/bin/restore +++ b/bin/restore @@ -253,6 +253,15 @@ switch ($command) { echo " File: {$file}\n"; echo " Line: {$line}\n"; echo " Created: {$created}\n"; + + $window = $manager->getUpgradeWindow(); + if ($window) { + $expires = isset($window['expires_at']) ? date('c', (int)$window['expires_at']) : 'unknown'; + $reason = $window['reason'] ?? '(unknown)'; + echo " Window: active ({$reason}, expires {$expires})\n"; + } else { + echo " Window: inactive\n"; + } exit(0); default: diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index c179e4b24..75691b9e5 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -20,6 +20,7 @@ use function is_array; use function is_file; use function json_decode; use function json_encode; +use function max; use function md5; use function preg_match; use function random_bytes; @@ -100,6 +101,8 @@ class RecoveryManager if (is_file($flag)) { @unlink($flag); } + + $this->closeUpgradeWindow(); } /** @@ -121,6 +124,10 @@ class RecoveryManager $file = $error['file'] ?? ''; $plugin = $this->detectPluginFromPath($file); + if (!$plugin) { + return; + } + $context = [ 'created_at' => time(), 'message' => $error['message'] ?? '', @@ -130,6 +137,10 @@ class RecoveryManager 'plugin' => $plugin, ]; + if (!$this->shouldEnterRecovery($context)) { + return; + } + $this->activate($context); if ($plugin) { $this->quarantinePlugin($plugin, $context); @@ -286,6 +297,63 @@ class RecoveryManager return $this->rootPath . '/system/recovery.flag'; } + /** + * @return string + */ + private function windowPath(): string + { + return $this->rootPath . '/system/recovery.window'; + } + + /** + * @return array|null + */ + private function resolveUpgradeWindow(): ?array + { + $path = $this->windowPath(); + if (!is_file($path)) { + return null; + } + + $decoded = json_decode(file_get_contents($path), true); + if (!is_array($decoded)) { + @unlink($path); + + return null; + } + + $expiresAt = (int)($decoded['expires_at'] ?? 0); + if ($expiresAt > 0 && $expiresAt < time()) { + @unlink($path); + + return null; + } + + return $decoded; + } + + /** + * @param array $context + * @return bool + */ + private function shouldEnterRecovery(array $context): bool + { + $window = $this->resolveUpgradeWindow(); + if (null === $window) { + return false; + } + + $scope = $window['scope'] ?? null; + if ($scope === 'plugin') { + $expected = $window['plugin'] ?? null; + if ($expected && ($context['plugin'] ?? null) !== $expected) { + return false; + } + } + + return true; + } + /** * @return string */ @@ -314,4 +382,54 @@ class RecoveryManager { return error_get_last(); } + + /** + * Begin an upgrade window; during this window fatal plugin errors may trigger recovery mode. + * + * @param string $reason + * @param array $metadata + * @param int $ttlSeconds + * @return void + */ + public function markUpgradeWindow(string $reason, array $metadata = [], int $ttlSeconds = 604800): void + { + $ttl = max(60, $ttlSeconds); + $createdAt = time(); + + $payload = $metadata + [ + 'reason' => $reason, + 'created_at' => $createdAt, + 'expires_at' => $createdAt + $ttl, + ]; + + file_put_contents($this->windowPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + /** + * @return bool + */ + public function isUpgradeWindowActive(): bool + { + return $this->resolveUpgradeWindow() !== null; + } + + /** + * @return array|null + */ + public function getUpgradeWindow(): ?array + { + return $this->resolveUpgradeWindow(); + } + + /** + * @return void + */ + public function closeUpgradeWindow(): void + { + $window = $this->windowPath(); + if (is_file($window)) { + @unlink($window); + } + } + } diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index e2eac0acf..ea42f4b3d 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -222,6 +222,13 @@ class SelfupgradeCommand extends GpmCommand $io->newLine(); $io->writeln("Preparing to upgrade to v{$remote}.."); + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + $recovery->markUpgradeWindow('core-upgrade', [ + 'scope' => 'core', + 'target_version' => $remote, + ]); + $io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%"); $this->file = $this->download($update); diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 6e20c9391..897026f67 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -12,6 +12,7 @@ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; +use Grav\Common\Grav; use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; @@ -212,6 +213,10 @@ class UpdateCommand extends GpmCommand } } + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + $recovery->markUpgradeWindow('package-update', ['scope' => 'core']); + // finally update $install_command = $this->getApplication()->find('install'); diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index f7e7e7375..1c21cc67d 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -56,6 +56,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test } }; + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); $manager->handleShutdown(); $flag = $this->tmpDir . '/system/recovery.flag'; From d6cbc263e706010389c15c2138c4ed2afa82b9a3 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 11:56:40 -0600 Subject: [PATCH 16/46] source fix in restore bin + missing dot files after upgrade Signed-off-by: Andy Miller --- bin/restore | 9 ++-- .../Common/Upgrade/SafeUpgradeService.php | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/bin/restore b/bin/restore index e949471fe..93b11b017 100755 --- a/bin/restore +++ b/bin/restore @@ -139,7 +139,7 @@ function createUpgradeService(array $options): SafeUpgradeService } /** - * @return list + * @return list */ function loadSnapshots(): array { @@ -160,6 +160,7 @@ function loadSnapshots(): array $snapshots[] = [ 'id' => $decoded['id'], + 'source_version' => $decoded['source_version'] ?? null, 'target_version' => $decoded['target_version'] ?? null, 'created_at' => $decoded['created_at'] ?? 0, ]; @@ -184,8 +185,8 @@ switch ($command) { echo "Available snapshots:\n"; foreach ($snapshots as $snapshot) { $time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown'; - $version = $snapshot['target_version'] ?? 'unknown'; - echo sprintf(" - %s (Grav %s, %s)\n", $snapshot['id'], $version, $time); + $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; + echo sprintf(" - %s (restore to Grav %s, %s)\n", $snapshot['id'], $restoreVersion, $time); } exit(0); @@ -209,7 +210,7 @@ switch ($command) { exit(1); } - $version = $manifest['target_version'] ?? 'unknown'; + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; exit(0); diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index fbbee6714..a7385ac29 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -9,6 +9,7 @@ namespace Grav\Common\Upgrade; +use DirectoryIterator; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; use Grav\Common\Yaml; @@ -19,6 +20,7 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use FilesystemIterator; use function basename; +use function copy; use function count; use function dirname; use function file_get_contents; @@ -165,6 +167,8 @@ class SafeUpgradeService // Copy extracted package into staging area. Folder::rcopy($extractedPath, $packagePath); + $this->carryOverRootDotfiles($packagePath); + // Ensure ignored directories are replaced with live copies. $this->hydrateIgnoredDirectories($packagePath, $ignores); @@ -375,6 +379,49 @@ class SafeUpgradeService } } + /** + * Preserve critical root-level dotfiles that may not ship in update packages. + * + * @param string $packagePath + * @return void + */ + private function carryOverRootDotfiles(string $packagePath): void + { + $skip = [ + '.git', + '.DS_Store', + ]; + + $iterator = new DirectoryIterator($this->rootPath); + foreach ($iterator as $entry) { + if ($entry->isDot()) { + continue; + } + + $name = $entry->getFilename(); + if ($name === '' || $name[0] !== '.') { + continue; + } + + if (in_array($name, $skip, true)) { + continue; + } + + $target = $packagePath . DIRECTORY_SEPARATOR . $name; + if (file_exists($target)) { + continue; + } + + $source = $entry->getPathname(); + if ($entry->isDir()) { + Folder::rcopy($source, $target); + } elseif ($entry->isFile()) { + Folder::create(dirname($target)); + copy($source, $target); + } + } + } + /** * Build manifest metadata for a staged upgrade. * From 43126b09e4579469265397db6d317d821665d6d6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 14:19:16 -0600 Subject: [PATCH 17/46] fixes for 1.8 upgrades Signed-off-by: Andy Miller --- index.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/index.php b/index.php index 3ee55fab8..71ea33148 100644 --- a/index.php +++ b/index.php @@ -29,6 +29,10 @@ if (!is_file($autoload)) { // Register the auto-loader. $loader = require $autoload; +if (!class_exists(\Symfony\Component\ErrorHandler\Exception\FlattenException::class, false) && class_exists(\Symfony\Component\HttpKernel\Exception\FlattenException::class)) { + class_alias(\Symfony\Component\HttpKernel\Exception\FlattenException::class, \Symfony\Component\ErrorHandler\Exception\FlattenException::class); +} + // Set timezone to default, falls back to system if php.ini not set date_default_timezone_set(@date_default_timezone_get()); From b68872e3fd2f1911376363f2197bc2be0298a31c Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 14:32:05 -0600 Subject: [PATCH 18/46] Monolog 3 compatible shim to handle upgrades Signed-off-by: Andy Miller --- index.php | 8 + .../Grav/Framework/Compat/Monolog/Utils.php | 183 ++++++++++++++++++ .../Framework/Compat/Monolog/bootstrap.php | 28 +++ 3 files changed, 219 insertions(+) create mode 100644 system/src/Grav/Framework/Compat/Monolog/Utils.php create mode 100644 system/src/Grav/Framework/Compat/Monolog/bootstrap.php diff --git a/index.php b/index.php index 71ea33148..1a65598f7 100644 --- a/index.php +++ b/index.php @@ -33,6 +33,14 @@ if (!class_exists(\Symfony\Component\ErrorHandler\Exception\FlattenException::cl class_alias(\Symfony\Component\HttpKernel\Exception\FlattenException::class, \Symfony\Component\ErrorHandler\Exception\FlattenException::class); } +if (!class_exists(\Monolog\Logger::class, false)) { + class_exists(\Monolog\Logger::class); +} + +if (defined('Monolog\Logger::API') && \Monolog\Logger::API < 3) { + require_once __DIR__ . '/system/src/Grav/Framework/Compat/Monolog/bootstrap.php'; +} + // Set timezone to default, falls back to system if php.ini not set date_default_timezone_set(@date_default_timezone_get()); diff --git a/system/src/Grav/Framework/Compat/Monolog/Utils.php b/system/src/Grav/Framework/Compat/Monolog/Utils.php new file mode 100644 index 000000000..529ab06a7 --- /dev/null +++ b/system/src/Grav/Framework/Compat/Monolog/Utils.php @@ -0,0 +1,183 @@ + Date: Thu, 16 Oct 2025 15:24:12 -0600 Subject: [PATCH 19/46] fixes for permission retention Signed-off-by: Andy Miller --- system/src/Grav/Common/Filesystem/Folder.php | 34 +++++++++++++++++-- .../Common/Upgrade/SafeUpgradeService.php | 8 ++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php index 019342f9e..c01ab36bb 100644 --- a/system/src/Grav/Common/Filesystem/Folder.php +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -478,12 +478,22 @@ abstract class Folder * @return bool * @throws RuntimeException */ - public static function rcopy($src, $dest) + public static function rcopy($src, $dest, $preservePermissions = false) { // If the src is not a directory do a simple file copy if (!is_dir($src)) { copy($src, $dest); + if ($preservePermissions) { + $perm = @fileperms($src); + if ($perm !== false) { + @chmod($dest, $perm & 0777); + } + $mtime = @filemtime($src); + if ($mtime !== false) { + @touch($dest, $mtime); + } + } return true; } @@ -492,14 +502,32 @@ abstract class Folder static::create($dest); } + if ($preservePermissions) { + $perm = @fileperms($src); + if ($perm !== false) { + @chmod($dest, $perm & 0777); + } + } + // Open the source directory to read in files $i = new DirectoryIterator($src); foreach ($i as $f) { if ($f->isFile()) { - copy($f->getRealPath(), "{$dest}/" . $f->getFilename()); + $target = "{$dest}/" . $f->getFilename(); + copy($f->getRealPath(), $target); + if ($preservePermissions) { + $perm = @fileperms($f->getRealPath()); + if ($perm !== false) { + @chmod($target, $perm & 0777); + } + $mtime = @filemtime($f->getRealPath()); + if ($mtime !== false) { + @touch($target, $mtime); + } + } } else { if (!$f->isDot() && $f->isDir()) { - static::rcopy($f->getRealPath(), "{$dest}/{$f}"); + static::rcopy($f->getRealPath(), "{$dest}/{$f}", $preservePermissions); } } } diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a7385ac29..8f9098763 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -165,7 +165,7 @@ class SafeUpgradeService Folder::create($packagePath); // Copy extracted package into staging area. - Folder::rcopy($extractedPath, $packagePath); + Folder::rcopy($extractedPath, $packagePath, true); $this->carryOverRootDotfiles($packagePath); @@ -375,7 +375,7 @@ class SafeUpgradeService } Folder::create(dirname($stage)); - Folder::rcopy($live, $stage); + Folder::rcopy($live, $stage, true); } } @@ -414,7 +414,7 @@ class SafeUpgradeService $source = $entry->getPathname(); if ($entry->isDir()) { - Folder::rcopy($source, $target); + Folder::rcopy($source, $target, true); } elseif ($entry->isFile()) { Folder::create(dirname($target)); copy($source, $target); @@ -550,7 +550,7 @@ class SafeUpgradeService Folder::delete($destinationGit); } - Folder::rcopy($sourceGit, $destinationGit); + Folder::rcopy($sourceGit, $destinationGit, true); } /** From 3f0b204728bbf9e9e5d402467712f6b70f506a58 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 17:32:43 -0600 Subject: [PATCH 20/46] Add new SafeUpgradeRun CLI command Signed-off-by: Andy Miller --- .../Console/Application/GravApplication.php | 2 + .../Console/Cli/SafeUpgradeRunCommand.php | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php diff --git a/system/src/Grav/Console/Application/GravApplication.php b/system/src/Grav/Console/Application/GravApplication.php index 81e320d3b..3045e7d9c 100644 --- a/system/src/Grav/Console/Application/GravApplication.php +++ b/system/src/Grav/Console/Application/GravApplication.php @@ -19,6 +19,7 @@ use Grav\Console\Cli\NewProjectCommand; use Grav\Console\Cli\PageSystemValidatorCommand; use Grav\Console\Cli\SandboxCommand; use Grav\Console\Cli\SchedulerCommand; +use Grav\Console\Cli\SafeUpgradeRunCommand; use Grav\Console\Cli\SecurityCommand; use Grav\Console\Cli\ServerCommand; use Grav\Console\Cli\YamlLinterCommand; @@ -47,6 +48,7 @@ class GravApplication extends Application new YamlLinterCommand(), new ServerCommand(), new PageSystemValidatorCommand(), + new SafeUpgradeRunCommand(), ]); } } diff --git a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php new file mode 100644 index 000000000..7fab957c7 --- /dev/null +++ b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php @@ -0,0 +1,94 @@ +setName('safe-upgrade:run') + ->setDescription('Execute a queued Grav safe-upgrade job') + ->addOption( + 'job', + null, + InputOption::VALUE_REQUIRED, + 'Job identifier to execute' + ); + } + + protected function serve(): int + { + $input = $this->getInput(); + /** @var SymfonyStyle $io */ + $io = $this->getIO(); + + $jobId = $input->getOption('job'); + if (!$jobId) { + $io->error('Missing required --job option.'); + + return 1; + } + + if (method_exists($this, 'initializePlugins')) { + $this->initializePlugins(); + } + + if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) { + $path = GRAV_ROOT . '/user/plugins/admin/classes/plugin/SafeUpgradeManager.php'; + if (is_file($path)) { + require_once $path; + } + } + + if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) { + $io->error('SafeUpgradeManager is not available. Ensure the Admin plugin is installed.'); + + return 1; + } + + $manager = new \Grav\Plugin\Admin\SafeUpgradeManager(); + $manifest = $manager->loadJob($jobId); + + if (!$manifest) { + $io->error(sprintf('Safe upgrade job "%s" could not be found.', $jobId)); + + return 1; + } + + $options = $manifest['options'] ?? []; + $manager->updateJob([ + 'status' => 'running', + 'started_at' => $manifest['started_at'] ?? time(), + ]); + + try { + $result = $manager->run($options); + $manager->updateJob([ + 'result' => $result, + ]); + + return ($result['status'] ?? null) === 'success' ? 0 : 1; + } catch (Throwable $e) { + $manager->updateJob([ + 'status' => 'error', + 'error' => $e->getMessage(), + ]); + $manager->markJobError($e->getMessage()); + $io->error($e->getMessage()); + + return 1; + } + } +} From 09aa2fb8fd61439bdad7e9e77dd2b41c573410f6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 21:28:08 -0600 Subject: [PATCH 21/46] ensureJobResult Signed-off-by: Andy Miller --- system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php index 7fab957c7..91a8aae11 100644 --- a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php +++ b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php @@ -75,17 +75,14 @@ class SafeUpgradeRunCommand extends GravCommand try { $result = $manager->run($options); - $manager->updateJob([ - 'result' => $result, - ]); + $manager->ensureJobResult($result); return ($result['status'] ?? null) === 'success' ? 0 : 1; } catch (Throwable $e) { - $manager->updateJob([ + $manager->ensureJobResult([ 'status' => 'error', - 'error' => $e->getMessage(), + 'message' => $e->getMessage(), ]); - $manager->markJobError($e->getMessage()); $io->error($e->getMessage()); return 1; From b6a37cfff3db5ba542222f1fb1b9bfb644a72735 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 23:17:34 -0600 Subject: [PATCH 22/46] preserver root files --- .../Common/Upgrade/SafeUpgradeService.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 8f9098763..a9eface4a 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -171,6 +171,7 @@ class SafeUpgradeService // Ensure ignored directories are replaced with live copies. $this->hydrateIgnoredDirectories($packagePath, $ignores); + $this->carryOverRootFiles($packagePath, $ignores); $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath); $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; @@ -422,6 +423,57 @@ class SafeUpgradeService } } + /** + * Carry over non-dot root files that are absent from the staged package. + * + * @param string $packagePath + * @param array $ignores + * @return void + */ + private function carryOverRootFiles(string $packagePath, array $ignores): void + { + $strategic = $ignores ?: $this->ignoredDirs; + $skip = array_map(static function ($value) { + return trim((string)$value, '/'); + }, $strategic); + $skip = array_filter($skip, static function ($value) { + return $value !== ''; + }); + $skip = array_values(array_unique($skip)); + + $iterator = new DirectoryIterator($this->rootPath); + foreach ($iterator as $entry) { + if ($entry->isDot()) { + continue; + } + + $name = $entry->getFilename(); + if ($name === '' || $name[0] === '.') { + continue; + } + + if (in_array($name, $skip, true)) { + continue; + } + + $target = $packagePath . DIRECTORY_SEPARATOR . $name; + if (file_exists($target)) { + continue; + } + + $source = $entry->getPathname(); + Folder::create(dirname($target)); + + if ($entry->isDir() && !$entry->isLink()) { + Folder::rcopy($source, $target, true); + } elseif ($entry->isFile()) { + copy($source, $target); + } elseif ($entry->isLink()) { + @symlink(readlink($source), $target); + } + } + } + /** * Build manifest metadata for a staged upgrade. * From 9dd507b717b888054feb5ad53a59535fc6a4246c Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 23:31:05 -0600 Subject: [PATCH 23/46] route safeupgrade status --- index.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/index.php b/index.php index 1a65598f7..2f75c8c03 100644 --- a/index.php +++ b/index.php @@ -20,6 +20,36 @@ if (PHP_SAPI === 'cli-server') { } } +if (PHP_SAPI !== 'cli') { + $requestUri = $_SERVER['REQUEST_URI'] ?? ''; + $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; + $path = parse_url($requestUri, PHP_URL_PATH) ?? '/'; + $path = str_replace('\\', '/', $path); + + $scriptDir = str_replace('\\', '/', dirname($scriptName)); + if ($scriptDir && $scriptDir !== '/' && $scriptDir !== '.') { + if (strpos($path, $scriptDir) === 0) { + $path = substr($path, strlen($scriptDir)); + $path = $path === '' ? '/' : $path; + } + } + + if ($path === '/___safe-upgrade-status') { + $statusEndpoint = __DIR__ . '/user/plugins/admin/safe-upgrade-status.php'; + header('Content-Type: application/json; charset=utf-8'); + if (is_file($statusEndpoint)) { + require $statusEndpoint; + } else { + http_response_code(404); + echo json_encode([ + 'status' => 'error', + 'message' => 'Safe upgrade status endpoint unavailable.', + ]); + } + exit; + } +} + // Ensure vendor libraries exist $autoload = __DIR__ . '/vendor/autoload.php'; if (!is_file($autoload)) { From 70d6aec1a7b3b42d669ac259f5a6c6c6565e4669 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:07:17 -0600 Subject: [PATCH 24/46] another fix for safe upgrade --- index.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/index.php b/index.php index 2f75c8c03..233701c9c 100644 --- a/index.php +++ b/index.php @@ -11,6 +11,9 @@ namespace Grav; \define('GRAV_REQUEST_TIME', microtime(true)); \define('GRAV_PHP_MIN', '7.3.6'); +if (!\defined('GRAV_ROOT')) { + \define('GRAV_ROOT', __DIR__); +} if (PHP_SAPI === 'cli-server') { $symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'] ?? '', 'symfony') !== false || stripos($_ENV['SERVER_SOFTWARE'] ?? '', 'symfony') !== false; From 286b5a5179c6a4d3099604059a15dec0f22e7a41 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:26:43 -0600 Subject: [PATCH 25/46] fix for binary permissions in CLI --- .../Grav/Console/Gpm/SelfupgradeCommand.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index ea42f4b3d..109028247 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -457,6 +457,8 @@ class SelfupgradeCommand extends GpmCommand // extra white spaces to clear out the buffer properly $io->writeln(' |- Installing upgrade... ok '); + $this->ensureExecutablePermissions(); + return true; } @@ -512,4 +514,28 @@ class SelfupgradeCommand extends GpmCommand Installer::setError($e->getMessage()); } } + + private function ensureExecutablePermissions(): void + { + $executables = [ + 'bin/grav', + 'bin/plugin', + 'bin/gpm', + 'bin/restore', + 'bin/composer.phar' + ]; + + foreach ($executables as $relative) { + $path = GRAV_ROOT . '/' . $relative; + if (!is_file($path) || is_link($path)) { + continue; + } + + $mode = @fileperms($path); + $desired = ($mode & 0777) | 0111; + if (($mode & 0111) !== 0111) { + @chmod($path, $desired); + } + } + } } From 9230a5a40f6217922f3ffbac37f94fee351e1de2 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:32:38 -0600 Subject: [PATCH 26/46] ingore recovery window Signed-off-by: Andy Miller --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a2a78f5f2..87b0eb18c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ tests/cache/* tests/error.log system/templates/testing/* /user/config/versions.yaml +/system/recovery.window From 5e7b482972899e774ba350e9c6b07716f11cc8c6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:34:35 -0600 Subject: [PATCH 27/46] test fix Signed-off-by: Andy Miller --- .../src/Grav/Common/Upgrade/SafeUpgradeService.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a9eface4a..c334c5567 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -456,6 +456,10 @@ class SafeUpgradeService continue; } + if (!$entry->isDir() || $entry->isLink()) { + continue; + } + $target = $packagePath . DIRECTORY_SEPARATOR . $name; if (file_exists($target)) { continue; @@ -464,13 +468,7 @@ class SafeUpgradeService $source = $entry->getPathname(); Folder::create(dirname($target)); - if ($entry->isDir() && !$entry->isLink()) { - Folder::rcopy($source, $target, true); - } elseif ($entry->isFile()) { - copy($source, $target); - } elseif ($entry->isLink()) { - @symlink(readlink($source), $target); - } + Folder::rcopy($source, $target, true); } } From d97b2d70bda7ce44f607a61e350cc7e52cfd9abb Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 16:18:40 -0600 Subject: [PATCH 28/46] logic fixes --- .../Common/Upgrade/SafeUpgradeService.php | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index c334c5567..fe781880b 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -60,6 +60,8 @@ class SafeUpgradeService private $stagingRoot; /** @var string */ private $manifestStore; + /** @var \Grav\Common\Config\ConfigInterface|null */ + private $config; /** @var array */ private $ignoredDirs = [ @@ -79,6 +81,7 @@ class SafeUpgradeService $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); + $this->config = $options['config'] ?? null; $candidates = []; if (!empty($options['staging_root'])) { @@ -278,6 +281,9 @@ class SafeUpgradeService } $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } $rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null); if (!$rawConstraint) { continue; @@ -302,6 +308,34 @@ class SafeUpgradeService return $conflicts; } + protected function isPluginEnabled(string $slug): bool + { + if ($this->config) { + try { + $value = $this->config->get("plugins.{$slug}.enabled"); + if ($value !== null) { + return (bool)$value; + } + } catch (Throwable $e) { + // ignore and fall back to file checks + } + } + + $configPath = $this->rootPath . '/user/config/plugins/' . $slug . '.yaml'; + if (is_file($configPath)) { + try { + $data = Yaml::parseFile($configPath); + if (is_array($data) && array_key_exists('enabled', $data)) { + return (bool)$data['enabled']; + } + } catch (Throwable $e) { + // ignore parse errors and treat as enabled + } + } + + return true; + } + /** * Detect usage of deprecated Monolog `add*` methods removed in newer releases. * @@ -314,6 +348,11 @@ class SafeUpgradeService $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) ); @@ -330,7 +369,6 @@ class SafeUpgradeService } if (preg_match($pattern, $contents, $match)) { - $slug = basename($path); $relative = str_replace($this->rootPath . '/', '', $file->getPathname()); $conflicts[$slug][] = [ 'file' => $relative, From 2999c06a3a6a88061dc3ac8e02acbc8a8cc849b7 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 16:49:42 -0600 Subject: [PATCH 29/46] change snapshot storage --- system/blueprints/config/system.yaml | 9 ----- system/config/system.yaml | 1 - system/languages/en.yaml | 2 -- .../Common/Upgrade/SafeUpgradeService.php | 36 +++++++++++-------- .../Grav/Console/Gpm/SelfupgradeCommand.php | 4 +-- system/src/Grav/Installer/Install.php | 2 +- .../Common/Upgrade/SafeUpgradeServiceTest.php | 7 +--- 7 files changed, 24 insertions(+), 37 deletions(-) diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 67c77362d..c90bb2d5f 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1614,14 +1614,6 @@ form: validate: type: bool - updates.staging_root: - type: text - label: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING - help: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING_HELP - placeholder: '/absolute/path/to/grav-upgrades' - validate: - type: string - http_section: type: section title: PLUGIN_ADMIN.HTTP_SECTION @@ -1936,4 +1928,3 @@ form: # # pages.type: # type: hidden - diff --git a/system/config/system.yaml b/system/config/system.yaml index 50cbcd28f..87b6d4c1c 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -205,7 +205,6 @@ gpm: updates: safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates - staging_root: '' # Optional absolute path for staging backups (default: /grav-upgrades) http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 973199ff3..3b34a93db 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -124,5 +124,3 @@ PLUGIN_ADMIN: UPDATES_SECTION: Updates SAFE_UPGRADE: Safe self-upgrade SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support. - SAFE_UPGRADE_STAGING: Staging directory - SAFE_UPGRADE_STAGING_HELP: Optional absolute path for storing upgrade backups. Leave empty to use the default inside the parent directory. diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index fe781880b..fe43a5147 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -12,6 +12,7 @@ namespace Grav\Common\Upgrade; use DirectoryIterator; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; +use Grav\Common\Grav; use Grav\Common\Yaml; use InvalidArgumentException; use RuntimeException; @@ -83,27 +84,30 @@ class SafeUpgradeService $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); $this->config = $options['config'] ?? null; - $candidates = []; - if (!empty($options['staging_root'])) { - $candidates[] = $options['staging_root']; + $locator = null; + try { + $locator = Grav::instance()['locator'] ?? null; + } catch (Throwable $e) { + $locator = null; } - $candidates[] = $this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'; - if (getenv('HOME')) { - $candidates[] = getenv('HOME') . DIRECTORY_SEPARATOR . 'grav-upgrades'; - } - $candidates[] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'grav-upgrades'; - $this->stagingRoot = null; - foreach ($candidates as $candidate) { - $resolved = $this->resolveStagingPath($candidate); - if ($resolved) { - $this->stagingRoot = $resolved; - break; + $primary = null; + if ($locator && method_exists($locator, 'findResource')) { + try { + $primary = $locator->findResource('tmp://grav-upgrades', true, true); + } catch (Throwable $e) { + $primary = null; } } + if (!$primary) { + $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-upgrades'; + } + + $this->stagingRoot = $this->resolveStagingPath($primary); + if (null === $this->stagingRoot) { - throw new RuntimeException('Unable to locate writable staging directory. Configure system.updates.staging_root or adjust permissions.'); + throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-upgrades is writable.'); } $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { @@ -671,6 +675,8 @@ class SafeUpgradeService $home = getenv('HOME'); if ($home) { $expanded = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($expanded, '~\/'); + } else { + return null; } } if (!$this->isAbsolutePath($expanded)) { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 109028247..e98fb15da 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -292,10 +292,8 @@ class SelfupgradeCommand extends GpmCommand $config = null; } - $stagingRoot = $config ? $config->get('system.updates.staging_root') : null; - return new SafeUpgradeService([ - 'staging_root' => $stagingRoot, + 'config' => $config, ]); } diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 794e6f67e..e9c59b6e9 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -266,7 +266,7 @@ ERR; try { $grav = Grav::instance(); if ($grav && isset($grav['config'])) { - $options['staging_root'] = $grav['config']->get('system.updates.staging_root'); + $options['config'] = $grav['config']; } } catch (\Throwable $e) { // ignore diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index b69bef31e..e46cbb65a 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -94,7 +94,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -121,7 +120,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -147,7 +145,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); @@ -176,7 +173,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); @@ -197,7 +193,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $service->clearRecoveryFlag(); @@ -210,7 +205,7 @@ PHP; private function prepareLiveEnvironment(): array { $root = $this->tmpDir . '/root'; - $staging = $this->tmpDir . '/staging'; + $staging = $root . '/tmp/grav-upgrades'; $manifestStore = $root . '/user/data/upgrades'; Folder::create($root . '/user/plugins/sample'); From 920642411ca7b9b2492f6f2e84499629559f707a Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 17:53:48 -0600 Subject: [PATCH 30/46] move back to cp instead of mv for snapshots Signed-off-by: Andy Miller --- .gitignore | 1 + bin/restore | 43 ----- system/UPGRADE_PROTOTYPE.md | 7 +- .../Common/Upgrade/SafeUpgradeService.php | 176 ++++++++++-------- .../Common/Upgrade/SafeUpgradeServiceTest.php | 12 +- 5 files changed, 115 insertions(+), 124 deletions(-) diff --git a/.gitignore b/.gitignore index 87b0eb18c..7b556f1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ tests/error.log system/templates/testing/* /user/config/versions.yaml /system/recovery.window +tmp/* diff --git a/bin/restore b/bin/restore index 7de81cfcf..b63d2d88d 100755 --- a/bin/restore +++ b/bin/restore @@ -66,11 +66,6 @@ function parseArguments(array $args): array $options = []; foreach (array_slice($args, 1) as $arg) { - if (strncmp($arg, '--staging-root=', 15) === 0) { - $options['staging_root'] = substr($arg, 15); - continue; - } - if (substr($arg, 0, 2) === '--') { echo "Unknown option: {$arg}\n"; exit(1); @@ -89,50 +84,12 @@ function parseArguments(array $args): array /** * @return string|null */ -function readConfiguredStagingRoot(): ?string -{ - $configFiles = [ - GRAV_ROOT . '/user/config/system.yaml', - GRAV_ROOT . '/system/config/system.yaml' - ]; - - foreach ($configFiles as $file) { - if (!is_file($file)) { - continue; - } - - try { - $data = Yaml::parseFile($file); - } catch (\Throwable $e) { - continue; - } - - if (!is_array($data)) { - continue; - } - - $current = $data['system']['updates']['staging_root'] ?? null; - if (null !== $current && $current !== '') { - return $current; - } - } - - return null; -} - /** * @param array $options * @return SafeUpgradeService */ function createUpgradeService(array $options): SafeUpgradeService { - $config = readConfiguredStagingRoot(); - if ($config !== null && empty($options['staging_root'])) { - $options['staging_root'] = $config; - } elseif (isset($options['staging_root']) && $options['staging_root'] === '') { - unset($options['staging_root']); - } - $options['root'] = GRAV_ROOT; return new SafeUpgradeService($options); diff --git a/system/UPGRADE_PROTOTYPE.md b/system/UPGRADE_PROTOTYPE.md index 2047ee284..6444a4f26 100644 --- a/system/UPGRADE_PROTOTYPE.md +++ b/system/UPGRADE_PROTOTYPE.md @@ -16,13 +16,13 @@ This document tracks the design decisions behind the new self-upgrade prototype - Refresh GPM metadata and require all plugins/themes to be on their latest compatible release. - Scan plugin `composer.json` files for dependencies that are known to break under Grav 1.8 (eg. `psr/log` < 3) and surface actionable warnings. 2. **Stage** - - Download the Grav update archive into a staging area outside the live tree (`{parent}/grav-upgrades/{timestamp}`). + - Download the Grav update archive into a staging area (`tmp://grav-snapshots/{timestamp}`). - Extract the package, then write a manifest describing the target version, PHP info, and enabled packages. - Snapshot the live `user/` directory and relevant metadata into the same stage folder. 3. **Promote** - - Switch the installation by renaming the live tree to a rollback folder and promoting the staged tree into place via atomic renames. + - Copy the staged package into place, overwriting Grav core files while leaving hydrated user content intact. - Clear caches in the staged tree before promotion. - - Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; swap back automatically on failure. + - Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; restore from the snapshot automatically on failure. 4. **Finalize** - Record the manifest under `user/data/upgrades`. - Resume normal traffic by removing the maintenance flag. @@ -46,4 +46,3 @@ This document tracks the design decisions behind the new self-upgrade prototype - Finalize compatibility heuristics (initial pass focuses on `psr/log` and removed logging APIs). - UX polish for the Recovery UI (initial prototype will expose basic actions only). - Decide retention policy for old manifests and snapshots (prototype keeps the most recent three). - diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index fe43a5147..a14ddca02 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -56,8 +56,6 @@ class SafeUpgradeService /** @var string */ private $rootPath; /** @var string */ - private $parentDir; - /** @var string */ private $stagingRoot; /** @var string */ private $manifestStore; @@ -81,7 +79,6 @@ class SafeUpgradeService { $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); - $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); $this->config = $options['config'] ?? null; $locator = null; @@ -94,20 +91,20 @@ class SafeUpgradeService $primary = null; if ($locator && method_exists($locator, 'findResource')) { try { - $primary = $locator->findResource('tmp://grav-upgrades', true, true); + $primary = $locator->findResource('tmp://grav-snapshots', true, true); } catch (Throwable $e) { $primary = null; } } if (!$primary) { - $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-upgrades'; + $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-snapshots'; } $this->stagingRoot = $this->resolveStagingPath($primary); if (null === $this->stagingRoot) { - throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-upgrades is writable.'); + throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-snapshots is writable.'); } $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { @@ -167,7 +164,7 @@ class SafeUpgradeService $stageId = uniqid('stage-', false); $stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId; $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; - $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId; + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; Folder::create($packagePath); @@ -180,13 +177,28 @@ class SafeUpgradeService $this->hydrateIgnoredDirectories($packagePath, $ignores); $this->carryOverRootFiles($packagePath, $ignores); - $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath); + $entries = $this->collectPackageEntries($packagePath); + if (!$entries) { + throw new RuntimeException('Staged package does not contain any files to promote.'); + } + + $this->createBackupSnapshot($entries, $backupPath); + $this->syncGitDirectory($this->rootPath, $backupPath); + + $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $entries); $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; Folder::create(dirname($manifestPath)); file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); - // Promote staged package into place. - $this->promoteStagedTree($packagePath, $backupPath); + try { + $this->copyEntries($entries, $packagePath, $this->rootPath); + } catch (Throwable $e) { + $this->copyEntries($entries, $backupPath, $this->rootPath); + $this->syncGitDirectory($backupPath, $this->rootPath); + throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); + } + + $this->syncGitDirectory($backupPath, $this->rootPath); $this->persistManifest($manifest); $this->pruneOldSnapshots(); Folder::delete($stagePath); @@ -194,6 +206,74 @@ class SafeUpgradeService return $manifest; } + private function collectPackageEntries(string $packagePath): array + { + $entries = []; + $iterator = new DirectoryIterator($packagePath); + foreach ($iterator as $fileinfo) { + if ($fileinfo->isDot()) { + continue; + } + + $entries[] = $fileinfo->getFilename(); + } + + sort($entries); + + return $entries; + } + + private function createBackupSnapshot(array $entries, string $backupPath): void + { + Folder::create($backupPath); + $this->copyEntries($entries, $this->rootPath, $backupPath); + } + + private function copyEntries(array $entries, string $sourceBase, string $targetBase): void + { + foreach ($entries as $entry) { + $source = $sourceBase . DIRECTORY_SEPARATOR . $entry; + if (!is_file($source) && !is_dir($source) && !is_link($source)) { + continue; + } + + $destination = $targetBase . DIRECTORY_SEPARATOR . $entry; + $this->removeEntry($destination); + + if (is_link($source)) { + Folder::create(dirname($destination)); + if (!@symlink(readlink($source), $destination)) { + throw new RuntimeException(sprintf('Failed to replicate symlink "%s".', $source)); + } + } elseif (is_dir($source)) { + Folder::create(dirname($destination)); + Folder::rcopy($source, $destination, true); + } else { + Folder::create(dirname($destination)); + if (!@copy($source, $destination)) { + throw new RuntimeException(sprintf('Failed to copy file "%s" to "%s".', $source, $destination)); + } + $perm = @fileperms($source); + if ($perm !== false) { + @chmod($destination, $perm & 0777); + } + $mtime = @filemtime($source); + if ($mtime !== false) { + @touch($destination, $mtime); + } + } + } + } + + private function removeEntry(string $path): void + { + if (is_link($path) || is_file($path)) { + @unlink($path); + } elseif (is_dir($path)) { + Folder::delete($path); + } + } + /** * Roll back to the most recent snapshot. * @@ -212,15 +292,17 @@ class SafeUpgradeService throw new RuntimeException('Rollback snapshot is no longer available.'); } - // Put the current tree aside before flip. - $rotated = $this->rotateCurrentTree(); - - $this->promoteBackup($backupPath); - $this->syncGitDirectory($rotated, $this->rootPath); - $this->markRollback($manifest['id']); - if ($rotated && is_dir($rotated)) { - Folder::delete($rotated); + $entries = $manifest['entries'] ?? []; + if (!$entries) { + $entries = $this->collectPackageEntries($backupPath); } + if (!$entries) { + throw new RuntimeException('Rollback snapshot entries are missing from the manifest.'); + } + + $this->copyEntries($entries, $backupPath, $this->rootPath); + $this->syncGitDirectory($backupPath, $this->rootPath); + $this->markRollback($manifest['id']); return $manifest; } @@ -523,7 +605,7 @@ class SafeUpgradeService * @param string $backupPath * @return array */ - private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath): array + private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath, array $entries): array { $plugins = []; $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; @@ -560,65 +642,11 @@ class SafeUpgradeService 'php_version' => PHP_VERSION, 'package_path' => $packagePath, 'backup_path' => $backupPath, + 'entries' => array_values($entries), 'plugins' => $plugins, ]; } - /** - * Promote staged package by swapping directory names. - * - * @param string $packagePath - * @param string $backupPath - * @return void - */ - private function promoteStagedTree(string $packagePath, string $backupPath): void - { - $liveRoot = $this->rootPath; - Folder::create(dirname($backupPath)); - - if (!rename($liveRoot, $backupPath)) { - throw new RuntimeException('Failed to move current Grav directory into backup.'); - } - - if (!rename($packagePath, $liveRoot)) { - // Attempt to restore live tree. - rename($backupPath, $liveRoot); - throw new RuntimeException('Failed to promote staged Grav release.'); - } - - $this->syncGitDirectory($backupPath, $liveRoot); - } - - /** - * Move existing tree aside to allow rollback swap. - * - * @return void - */ - private function rotateCurrentTree(): string - { - $liveRoot = $this->rootPath; - $target = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rotated-' . time(); - Folder::create($this->stagingRoot); - if (!rename($liveRoot, $target)) { - throw new RuntimeException('Unable to rotate live tree during rollback.'); - } - - return $target; - } - - /** - * Promote a backup tree into the live position. - * - * @param string $backupPath - * @return void - */ - private function promoteBackup(string $backupPath): void - { - if (!rename($backupPath, $this->rootPath)) { - throw new RuntimeException('Rollback failed: unable to move backup into live position.'); - } - } - /** * Ensure Git metadata is retained after stage promotion. * diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index e46cbb65a..368a001ff 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -94,6 +94,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -111,8 +112,9 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertFileExists($root . '/ORIGINAL'); self::assertFileDoesNotExist($root . '/system/new.txt'); - $rotated = glob($staging . '/rotated-*'); - self::assertEmpty($rotated); + $snapshots = glob($staging . '/snapshot-*'); + self::assertNotEmpty($snapshots); + self::assertEmpty(glob($staging . '/stage-*')); } public function testPrunesOldSnapshots(): void @@ -120,6 +122,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -145,6 +148,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); @@ -173,6 +177,7 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); @@ -193,6 +198,7 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', ]); $service->clearRecoveryFlag(); @@ -205,7 +211,7 @@ PHP; private function prepareLiveEnvironment(): array { $root = $this->tmpDir . '/root'; - $staging = $root . '/tmp/grav-upgrades'; + $staging = $this->tmpDir . '/staging'; $manifestStore = $root . '/user/data/upgrades'; Folder::create($root . '/user/plugins/sample'); From 44fd1172b815a5df031fbceb4ef5a14811465226 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 18:18:53 -0600 Subject: [PATCH 31/46] more granular install for self upgrade Signed-off-by: Andy Miller --- .../Common/Upgrade/SafeUpgradeService.php | 22 +++++++++++++++++ .../Grav/Console/Gpm/SelfupgradeCommand.php | 24 +++++++++++++++++++ system/src/Grav/Installer/Install.php | 21 ++++++++++++++++ .../Common/Upgrade/SafeUpgradeServiceTest.php | 20 +++++----------- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a14ddca02..7a43f4c20 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -71,6 +71,8 @@ class SafeUpgradeService 'cache', 'user', ]; + /** @var callable|null */ + private $progressCallback = null; /** * @param array $options @@ -170,6 +172,7 @@ class SafeUpgradeService // Copy extracted package into staging area. Folder::rcopy($extractedPath, $packagePath, true); + $this->reportProgress('installing', 'Preparing staged package...', null); $this->carryOverRootDotfiles($packagePath); @@ -182,6 +185,7 @@ class SafeUpgradeService throw new RuntimeException('Staged package does not contain any files to promote.'); } + $this->reportProgress('snapshot', 'Creating backup snapshot...', null); $this->createBackupSnapshot($entries, $backupPath); $this->syncGitDirectory($this->rootPath, $backupPath); @@ -190,6 +194,8 @@ class SafeUpgradeService Folder::create(dirname($manifestPath)); file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); + $this->reportProgress('installing', 'Copying update files...', null); + try { $this->copyEntries($entries, $packagePath, $this->rootPath); } catch (Throwable $e) { @@ -198,6 +204,7 @@ class SafeUpgradeService throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); } + $this->reportProgress('finalizing', 'Finalizing upgrade...', null); $this->syncGitDirectory($backupPath, $this->rootPath); $this->persistManifest($manifest); $this->pruneOldSnapshots(); @@ -274,6 +281,20 @@ class SafeUpgradeService } } + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function reportProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent, $extra); + } + } + /** * Roll back to the most recent snapshot. * @@ -300,6 +321,7 @@ class SafeUpgradeService throw new RuntimeException('Rollback snapshot entries are missing from the manifest.'); } + $this->reportProgress('rollback', 'Restoring snapshot...', null); $this->copyEntries($entries, $backupPath, $this->rootPath); $this->syncGitDirectory($backupPath, $this->rootPath); $this->markRollback($manifest['id']); diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index e98fb15da..f34c1870e 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -44,6 +44,8 @@ class SelfupgradeCommand extends GpmCommand private $tmp; /** @var Upgrader */ private $upgrader; + /** @var string|null */ + private $lastProgressMessage = null; /** @var string */ protected $all_yes; @@ -438,6 +440,7 @@ class SelfupgradeCommand extends GpmCommand private function upgrade(): bool { $io = $this->getIO(); + $this->lastProgressMessage = null; $this->upgradeGrav($this->file); @@ -496,14 +499,24 @@ class SelfupgradeCommand extends GpmCommand */ private function upgradeGrav(string $zip): void { + $io = $this->getIO(); + try { + $io->write("\x0D |- Extracting update... "); $folder = Installer::unZip($zip, $this->tmp . '/zip'); if ($folder === false) { throw new RuntimeException(Installer::lastErrorMsg()); } + $io->write("\x0D"); + $io->writeln(' |- Extracting update... ok '); $script = $folder . '/system/install.php'; if ((file_exists($script) && $install = include $script) && is_callable($install)) { + if (is_object($install) && method_exists($install, 'setProgressCallback')) { + $install->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { + $this->handleServiceProgress($stage, $message, $percent); + }); + } $install($zip); } else { throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); @@ -513,6 +526,17 @@ class SelfupgradeCommand extends GpmCommand } } + private function handleServiceProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void + { + if ($this->lastProgressMessage === $message) { + return; + } + + $this->lastProgressMessage = $message; + $io = $this->getIO(); + $io->writeln(sprintf(' |- %s', $message)); + } + private function ensureExecutablePermissions(): void { $executables = [ diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index e9c59b6e9..9de229c02 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -122,6 +122,8 @@ final class Install /** @var static */ private static $instance; + /** @var callable|null */ + private $progressCallback = null; /** * @return static @@ -187,6 +189,20 @@ ERR; $this->finalize(); } + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function relayProgress(string $stage, string $message, ?int $percent = null): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent); + } + } + /** * NOTE: This method can only be called after $grav['plugins']->init(). * @@ -273,6 +289,11 @@ ERR; } $service = new SafeUpgradeService($options); + if ($this->progressCallback) { + $service->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { + $this->relayProgress($stage, $message, $percent); + }); + } $service->promote($this->location, $this->getVersion(), $this->ignores); Installer::setError(Installer::OK); } else { diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index 368a001ff..4873770c3 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -91,10 +91,9 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test public function testPromoteAndRollback(): void { - [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + [$root, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -102,7 +101,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); self::assertFileExists($root . '/system/new.txt'); - self::assertFileDoesNotExist($root . '/ORIGINAL'); + self::assertFileExists($root . '/ORIGINAL'); $manifestFile = $manifestStore . '/' . $manifest['id'] . '.json'; self::assertFileExists($manifestFile); @@ -112,17 +111,14 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertFileExists($root . '/ORIGINAL'); self::assertFileDoesNotExist($root . '/system/new.txt'); - $snapshots = glob($staging . '/snapshot-*'); - self::assertNotEmpty($snapshots); - self::assertEmpty(glob($staging . '/stage-*')); + self::assertDirectoryExists($manifest['backup_path']); } public function testPrunesOldSnapshots(): void { - [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + [$root, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -148,7 +144,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); @@ -177,7 +172,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); @@ -198,7 +192,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $service->clearRecoveryFlag(); @@ -206,12 +199,11 @@ PHP; } /** - * @return array{0:string,1:string,2:string} + * @return array{0:string,1:string} */ private function prepareLiveEnvironment(): array { $root = $this->tmpDir . '/root'; - $staging = $this->tmpDir . '/staging'; $manifestStore = $root . '/user/data/upgrades'; Folder::create($root . '/user/plugins/sample'); @@ -221,7 +213,7 @@ PHP; file_put_contents($root . '/user/plugins/sample/blueprints.yaml', "name: Sample Plugin\nversion: 1.0.0\n"); file_put_contents($root . '/user/plugins/sample/composer.json', json_encode(['require' => ['php' => '^8.0']], JSON_PRETTY_PRINT)); - return [$root, $staging, $manifestStore]; + return [$root, $manifestStore]; } /** From 4cab0a7ba1fa60d3c8434888645e0364cea61757 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 19:33:03 -0600 Subject: [PATCH 32/46] optimized staged package Signed-off-by: Andy Miller --- .../Common/Upgrade/SafeUpgradeService.php | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 7a43f4c20..cf0e0cce2 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -168,11 +168,10 @@ class SafeUpgradeService $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; - Folder::create($packagePath); + Folder::create(dirname($packagePath)); - // Copy extracted package into staging area. - Folder::rcopy($extractedPath, $packagePath, true); $this->reportProgress('installing', 'Preparing staged package...', null); + $this->stageExtractedPackage($extractedPath, $packagePath); $this->carryOverRootDotfiles($packagePath); @@ -197,9 +196,9 @@ class SafeUpgradeService $this->reportProgress('installing', 'Copying update files...', null); try { - $this->copyEntries($entries, $packagePath, $this->rootPath); + $this->copyEntries($entries, $packagePath, $this->rootPath, 'installing', 'Deploying'); } catch (Throwable $e) { - $this->copyEntries($entries, $backupPath, $this->rootPath); + $this->copyEntries($entries, $backupPath, $this->rootPath, 'installing', 'Restoring'); $this->syncGitDirectory($backupPath, $this->rootPath); throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); } @@ -230,20 +229,53 @@ class SafeUpgradeService return $entries; } + private function stageExtractedPackage(string $sourcePath, string $packagePath): void + { + if (is_dir($packagePath)) { + Folder::delete($packagePath); + } + + if (@rename($sourcePath, $packagePath)) { + return; + } + + Folder::create($packagePath); + $entries = $this->collectPackageEntries($sourcePath); + $this->copyEntries($entries, $sourcePath, $packagePath, 'installing', 'Staging'); + Folder::delete($sourcePath); + } + private function createBackupSnapshot(array $entries, string $backupPath): void { Folder::create($backupPath); - $this->copyEntries($entries, $this->rootPath, $backupPath); + $this->copyEntries($entries, $this->rootPath, $backupPath, 'snapshot', 'Snapshotting'); } - private function copyEntries(array $entries, string $sourceBase, string $targetBase): void + private function copyEntries(array $entries, string $sourceBase, string $targetBase, ?string $progressStage = null, ?string $progressPrefix = null): void { - foreach ($entries as $entry) { + $total = count($entries); + foreach ($entries as $index => $entry) { $source = $sourceBase . DIRECTORY_SEPARATOR . $entry; if (!is_file($source) && !is_dir($source) && !is_link($source)) { continue; } + if ($progressStage) { + $message = sprintf( + '%s %s (%d/%d)', + $progressPrefix ?? 'Processing', + $entry, + $index + 1, + max($total, 1) + ); + $percent = $total > 0 ? (int)floor(($index / $total) * 100) : null; + $this->reportProgress($progressStage, $message, $percent ?: null, [ + 'entry' => $entry, + 'index' => $index + 1, + 'total' => $total, + ]); + } + $destination = $targetBase . DIRECTORY_SEPARATOR . $entry; $this->removeEntry($destination); @@ -322,7 +354,7 @@ class SafeUpgradeService } $this->reportProgress('rollback', 'Restoring snapshot...', null); - $this->copyEntries($entries, $backupPath, $this->rootPath); + $this->copyEntries($entries, $backupPath, $this->rootPath, 'rollback', 'Restoring'); $this->syncGitDirectory($backupPath, $this->rootPath); $this->markRollback($manifest['id']); From 4650bd073e18d163230e708554e6ffea575d3b9f Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 19:54:05 -0600 Subject: [PATCH 33/46] filter out extra folders Signed-off-by: Andy Miller --- system/src/Grav/Common/Upgrade/SafeUpgradeService.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index cf0e0cce2..a7913b4b9 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -221,7 +221,12 @@ class SafeUpgradeService continue; } - $entries[] = $fileinfo->getFilename(); + $name = $fileinfo->getFilename(); + if (in_array($name, $this->ignoredDirs, true)) { + continue; + } + + $entries[] = $name; } sort($entries); @@ -268,7 +273,7 @@ class SafeUpgradeService $index + 1, max($total, 1) ); - $percent = $total > 0 ? (int)floor(($index / $total) * 100) : null; + $percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null; $this->reportProgress($progressStage, $message, $percent ?: null, [ 'entry' => $entry, 'index' => $index + 1, From a0b64b6d88d1b276e7ff704ca2f5c8a0b7ba7258 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 20:47:48 -0600 Subject: [PATCH 34/46] more refactoring of safe install Signed-off-by: Andy Miller --- .../Common/Upgrade/SafeUpgradeService.php | 42 ++------- .../Grav/Console/Gpm/SelfupgradeCommand.php | 93 ++++++++++++++++++- 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a7913b4b9..ca4c7b811 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -171,7 +171,8 @@ class SafeUpgradeService Folder::create(dirname($packagePath)); $this->reportProgress('installing', 'Preparing staged package...', null); - $this->stageExtractedPackage($extractedPath, $packagePath); + $stagingMode = $this->stageExtractedPackage($extractedPath, $packagePath); + $this->reportProgress('installing', 'Preparing staged package...', null, ['mode' => $stagingMode]); $this->carryOverRootDotfiles($packagePath); @@ -186,7 +187,6 @@ class SafeUpgradeService $this->reportProgress('snapshot', 'Creating backup snapshot...', null); $this->createBackupSnapshot($entries, $backupPath); - $this->syncGitDirectory($this->rootPath, $backupPath); $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $entries); $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; @@ -199,12 +199,10 @@ class SafeUpgradeService $this->copyEntries($entries, $packagePath, $this->rootPath, 'installing', 'Deploying'); } catch (Throwable $e) { $this->copyEntries($entries, $backupPath, $this->rootPath, 'installing', 'Restoring'); - $this->syncGitDirectory($backupPath, $this->rootPath); throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); } $this->reportProgress('finalizing', 'Finalizing upgrade...', null); - $this->syncGitDirectory($backupPath, $this->rootPath); $this->persistManifest($manifest); $this->pruneOldSnapshots(); Folder::delete($stagePath); @@ -234,20 +232,22 @@ class SafeUpgradeService return $entries; } - private function stageExtractedPackage(string $sourcePath, string $packagePath): void + private function stageExtractedPackage(string $sourcePath, string $packagePath): string { if (is_dir($packagePath)) { Folder::delete($packagePath); } if (@rename($sourcePath, $packagePath)) { - return; + return 'move'; } Folder::create($packagePath); $entries = $this->collectPackageEntries($sourcePath); $this->copyEntries($entries, $sourcePath, $packagePath, 'installing', 'Staging'); Folder::delete($sourcePath); + + return 'copy'; } private function createBackupSnapshot(array $entries, string $backupPath): void @@ -360,7 +360,6 @@ class SafeUpgradeService $this->reportProgress('rollback', 'Restoring snapshot...', null); $this->copyEntries($entries, $backupPath, $this->rootPath, 'rollback', 'Restoring'); - $this->syncGitDirectory($backupPath, $this->rootPath); $this->markRollback($manifest['id']); return $manifest; @@ -552,14 +551,8 @@ class SafeUpgradeService continue; } - // Skip caches to avoid stale data. - if (in_array($relative, ['cache', 'tmp'], true)) { - Folder::create($stage); - continue; - } - - Folder::create(dirname($stage)); - Folder::rcopy($live, $stage, true); + // Use empty placeholders to preserve directory structure without duplicating data. + Folder::create($stage); } } @@ -713,25 +706,6 @@ class SafeUpgradeService * @param string $destination * @return void */ - private function syncGitDirectory(string $source, string $destination): void - { - if (!$source || !$destination) { - return; - } - - $sourceGit = rtrim($source, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git'; - if (!is_dir($sourceGit)) { - return; - } - - $destinationGit = rtrim($destination, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git'; - if (is_dir($destinationGit)) { - Folder::delete($destinationGit); - } - - Folder::rcopy($sourceGit, $destinationGit, true); - } - /** * Persist manifest into Grav data directory. * diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index f34c1870e..f6fb1a7dd 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -46,6 +46,14 @@ class SelfupgradeCommand extends GpmCommand private $upgrader; /** @var string|null */ private $lastProgressMessage = null; + /** @var float|null */ + private $operationTimerStart = null; + /** @var string|null */ + private $currentProgressStage = null; + /** @var float|null */ + private $currentStageStartedAt = null; + /** @var array */ + private $currentStageExtras = []; /** @var string */ protected $all_yes; @@ -235,6 +243,7 @@ class SelfupgradeCommand extends GpmCommand $this->file = $this->download($update); $io->write(' |- Installing upgrade... '); + $this->operationTimerStart = microtime(true); $installation = $this->upgrade(); $error = 0; @@ -443,6 +452,13 @@ class SelfupgradeCommand extends GpmCommand $this->lastProgressMessage = null; $this->upgradeGrav($this->file); + $this->finalizeStageTracking(); + + $elapsed = null; + if (null !== $this->operationTimerStart) { + $elapsed = microtime(true) - $this->operationTimerStart; + $this->operationTimerStart = null; + } $errorCode = Installer::lastErrorCode(); if ($errorCode) { @@ -454,6 +470,10 @@ class SelfupgradeCommand extends GpmCommand return false; } + if (null !== $elapsed) { + $io->writeln(sprintf(' |- Safe upgrade staging completed in %s', $this->formatDuration($elapsed))); + } + $io->write("\x0D"); // extra white spaces to clear out the buffer properly $io->writeln(' |- Installing upgrade... ok '); @@ -528,13 +548,19 @@ class SelfupgradeCommand extends GpmCommand private function handleServiceProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void { + $this->trackStageProgress($stage, $message, $extra); + if ($this->lastProgressMessage === $message) { return; } $this->lastProgressMessage = $message; $io = $this->getIO(); - $io->writeln(sprintf(' |- %s', $message)); + $suffix = ''; + if (null !== $percent) { + $suffix = sprintf(' (%d%%)', $percent); + } + $io->writeln(sprintf(' |- %s%s', $message, $suffix)); } private function ensureExecutablePermissions(): void @@ -560,4 +586,69 @@ class SelfupgradeCommand extends GpmCommand } } } + + private function trackStageProgress(string $stage, string $message, array $extra = []): void + { + $now = microtime(true); + + if (null !== $this->currentProgressStage && $stage !== $this->currentProgressStage && null !== $this->currentStageStartedAt) { + $elapsed = $now - $this->currentStageStartedAt; + $this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras); + $this->currentStageExtras = []; + } + + if ($stage !== $this->currentProgressStage) { + $this->currentProgressStage = $stage; + $this->currentStageStartedAt = $now; + $this->currentStageExtras = []; + } + + if (!isset($this->currentStageExtras['label'])) { + $this->currentStageExtras['label'] = $message; + } + + if ($extra) { + $this->currentStageExtras = array_merge($this->currentStageExtras, $extra); + } + } + + private function finalizeStageTracking(): void + { + if (null !== $this->currentProgressStage && null !== $this->currentStageStartedAt) { + $elapsed = microtime(true) - $this->currentStageStartedAt; + $this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras); + } + + $this->currentProgressStage = null; + $this->currentStageStartedAt = null; + $this->currentStageExtras = []; + } + + private function emitStageSummary(string $stage, float $seconds, array $extra = []): void + { + $io = $this->getIO(); + $label = $extra['label'] ?? ucfirst($stage); + $modeText = ''; + if (isset($extra['mode'])) { + $modeText = sprintf(' [%s]', $extra['mode']); + } + + $io->writeln(sprintf(' |- %s completed in %s%s', $label, $this->formatDuration($seconds), $modeText)); + } + + private function formatDuration(float $seconds): string + { + if ($seconds < 1) { + return sprintf('%0.3fs', $seconds); + } + + $minutes = (int)floor($seconds / 60); + $remaining = $seconds - ($minutes * 60); + + if ($minutes === 0) { + return sprintf('%0.1fs', $remaining); + } + + return sprintf('%dm %0.1fs', $minutes, $remaining); + } } From 17706d564709944b1fc18fd398e9b3db4c01169d Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 21:08:08 -0600 Subject: [PATCH 35/46] stop cache clearing snapshots Signed-off-by: Andy Miller --- system/src/Grav/Common/Cache.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php index c7afcbe55..337733ae1 100644 --- a/system/src/Grav/Common/Cache.php +++ b/system/src/Grav/Common/Cache.php @@ -550,6 +550,9 @@ class Cache extends Getters $anything = true; } } elseif (is_dir($file)) { + if (basename($file) === 'grav-snapshots') { + continue; + } if (Folder::delete($file, false)) { $anything = true; } From f30cd269569c6570bb29b52a87a554d7d0e0e601 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 22:18:54 -0600 Subject: [PATCH 36/46] bin/restore enhancement Signed-off-by: Andy Miller --- bin/restore | 411 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 380 insertions(+), 31 deletions(-) diff --git a/bin/restore b/bin/restore index b63d2d88d..4322ab3f5 100755 --- a/bin/restore +++ b/bin/restore @@ -25,6 +25,7 @@ if (!file_exists($root . '/index.php')) { exit(1); } +use Grav\Common\Filesystem\Folder; use Grav\Common\Recovery\RecoveryManager; use Grav\Common\Upgrade\SafeUpgradeService; use Symfony\Component\Yaml\Yaml; @@ -39,6 +40,9 @@ Usage: bin/restore apply [--staging-root=/absolute/path] Restores the specified snapshot created by safe-upgrade. + bin/restore remove [ ...] [--staging-root=/absolute/path] + Deletes one or more snapshots (interactive selection when no id provided). + bin/restore recovery [status|clear] Shows the recovery flag context or clears it. @@ -61,17 +65,35 @@ function parseArguments(array $args): array { array_shift($args); // remove script name - $command = $args[0] ?? 'help'; + $command = null; $arguments = []; $options = []; - foreach (array_slice($args, 1) as $arg) { - if (substr($arg, 0, 2) === '--') { - echo "Unknown option: {$arg}\n"; - exit(1); + while ($args) { + $arg = array_shift($args); + if (strncmp($arg, '--', 2) === 0) { + $parts = explode('=', substr($arg, 2), 2); + $name = $parts[0] ?? ''; + if ($name === '') { + continue; + } + $value = $parts[1] ?? null; + if ($value === null && $args && substr($args[0], 0, 2) !== '--') { + $value = array_shift($args); + } + $options[$name] = $value ?? true; + continue; } - $arguments[] = $arg; + if (null === $command) { + $command = $arg; + } else { + $arguments[] = $arg; + } + } + + if (null === $command) { + $command = 'interactive'; } return [ @@ -81,18 +103,19 @@ function parseArguments(array $args): array ]; } -/** - * @return string|null - */ /** * @param array $options * @return SafeUpgradeService */ function createUpgradeService(array $options): SafeUpgradeService { - $options['root'] = GRAV_ROOT; + $serviceOptions = ['root' => GRAV_ROOT]; - return new SafeUpgradeService($options); + if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') { + $serviceOptions['staging_root'] = $options['staging-root']; + } + + return new SafeUpgradeService($serviceOptions); } /** @@ -119,19 +142,311 @@ function loadSnapshots(): array 'id' => $decoded['id'], 'source_version' => $decoded['source_version'] ?? null, 'target_version' => $decoded['target_version'] ?? null, - 'created_at' => $decoded['created_at'] ?? 0, + 'created_at' => (int)($decoded['created_at'] ?? 0), ]; } return $snapshots; } +/** + * @param list $snapshots + * @return string + */ +function formatSnapshotListLine(array $snapshot): string +{ + $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; + $timeLabel = formatSnapshotTimestamp($snapshot['created_at']); + + return sprintf('%s (restore to Grav %s, %s)', $snapshot['id'], $restoreVersion, $timeLabel); +} + +function formatSnapshotTimestamp(int $timestamp): string +{ + if ($timestamp <= 0) { + return 'time unknown'; + } + + try { + $timezone = resolveTimezone(); + $dt = new DateTime('@' . $timestamp); + $dt->setTimezone($timezone); + $formatted = $dt->format('Y-m-d H:i:s T'); + } catch (\Throwable $e) { + $formatted = date('Y-m-d H:i:s T', $timestamp); + } + + return $formatted . ' (' . formatRelative(time() - $timestamp) . ')'; +} + +function resolveTimezone(): DateTimeZone +{ + static $resolved = null; + if ($resolved instanceof DateTimeZone) { + return $resolved; + } + + $timezone = null; + $configFile = GRAV_ROOT . '/user/config/system.yaml'; + if (is_file($configFile)) { + try { + $data = Yaml::parse(file_get_contents($configFile) ?: '') ?: []; + if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) { + $timezone = $data['system']['timezone']; + } + } catch (\Throwable $e) { + // ignore parse errors, fallback below + } + } + + if (!$timezone) { + $timezone = ini_get('date.timezone') ?: 'UTC'; + } + + try { + $resolved = new DateTimeZone($timezone); + } catch (\Throwable $e) { + $resolved = new DateTimeZone('UTC'); + } + + return $resolved; +} + +function formatRelative(int $seconds): string +{ + if ($seconds < 5) { + return 'just now'; + } + $negative = $seconds < 0; + $seconds = abs($seconds); + $units = [ + 31536000 => 'y', + 2592000 => 'mo', + 604800 => 'w', + 86400 => 'd', + 3600 => 'h', + 60 => 'm', + 1 => 's', + ]; + foreach ($units as $size => $label) { + if ($seconds >= $size) { + $value = (int)floor($seconds / $size); + $suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second'))))); + if ($value !== 1) { + $suffix .= 's'; + } + $phrase = $value . ' ' . $suffix; + return $negative ? 'in ' . $phrase : $phrase . ' ago'; + } + } + + return $negative ? 'in 0 seconds' : '0 seconds ago'; +} + +/** + * @param string $snapshotId + * @param array $options + * @return void + */ +function applySnapshot(string $snapshotId, array $options): void +{ + try { + $service = createUpgradeService($options); + $manifest = $service->rollback($snapshotId); + } catch (\Throwable $e) { + fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n"); + exit(1); + } + + if (!$manifest) { + fwrite(STDERR, "Snapshot {$snapshotId} not found.\n"); + exit(1); + } + + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; + echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; + if (!empty($manifest['id'])) { + echo "Snapshot manifest: {$manifest['id']}\n"; + } + if (!empty($manifest['backup_path'])) { + echo "Snapshot path: {$manifest['backup_path']}\n"; + } + exit(0); +} + +/** + * @param list $snapshots + * @return string|null + */ +function promptSnapshotSelection(array $snapshots): ?string +{ + echo "Available snapshots:\n"; + foreach ($snapshots as $index => $snapshot) { + $line = formatSnapshotListLine($snapshot); + $number = $index + 1; + echo sprintf(" [%d] %s\n", $number, $line); + } + + $default = $snapshots[0]['id']; + echo "\nSelect a snapshot to restore [1]: "; + $input = trim((string)fgets(STDIN)); + + if ($input === '') { + return $default; + } + + if (ctype_digit($input)) { + $idx = (int)$input - 1; + if (isset($snapshots[$idx])) { + return $snapshots[$idx]['id']; + } + } + + foreach ($snapshots as $snapshot) { + if (strcasecmp($snapshot['id'], $input) === 0) { + return $snapshot['id']; + } + } + + echo "Invalid selection. Aborting.\n"; + return null; +} + +/** + * @param list $snapshots + * @return array + */ +function promptSnapshotsRemoval(array $snapshots): array +{ + echo "Available snapshots:\n"; + foreach ($snapshots as $index => $snapshot) { + $line = formatSnapshotListLine($snapshot); + $number = $index + 1; + echo sprintf(" [%d] %s\n", $number, $line); + } + + echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): "; + $input = trim((string)fgets(STDIN)); + + if ($input === '') { + return []; + } + + $inputLower = strtolower($input); + if ($inputLower === 'all' || $inputLower === '*') { + return array_values(array_unique(array_column($snapshots, 'id'))); + } + + $tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $selected = []; + foreach ($tokens as $token) { + if (ctype_digit($token)) { + $idx = (int)$token - 1; + if (isset($snapshots[$idx])) { + $selected[] = $snapshots[$idx]['id']; + continue; + } + } + + foreach ($snapshots as $snapshot) { + if (strcasecmp($snapshot['id'], $token) === 0) { + $selected[] = $snapshot['id']; + break; + } + } + } + + return array_values(array_unique(array_filter($selected))); +} + +/** + * @param string $snapshotId + * @return array{success:bool,message:string} + */ +function removeSnapshot(string $snapshotId): array +{ + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + $manifestPath = $manifestDir . '/' . $snapshotId . '.json'; + if (!is_file($manifestPath)) { + return [ + 'success' => false, + 'message' => "Snapshot {$snapshotId} not found." + ]; + } + + $manifest = json_decode(file_get_contents($manifestPath) ?: '', true); + if (!is_array($manifest)) { + return [ + 'success' => false, + 'message' => "Snapshot {$snapshotId} manifest is invalid." + ]; + } + + $pathsToDelete = []; + foreach (['package_path', 'backup_path'] as $key) { + if (!empty($manifest[$key]) && is_string($manifest[$key])) { + $pathsToDelete[] = $manifest[$key]; + } + } + + $errors = []; + + foreach ($pathsToDelete as $path) { + if (!$path) { + continue; + } + if (!file_exists($path)) { + continue; + } + try { + if (is_dir($path)) { + Folder::delete($path); + } else { + @unlink($path); + } + } catch (\Throwable $e) { + $errors[] = "Unable to remove {$path}: " . $e->getMessage(); + } + } + + if (!@unlink($manifestPath)) { + $errors[] = "Unable to delete manifest file {$manifestPath}."; + } + + if ($errors) { + return [ + 'success' => false, + 'message' => implode(' ', $errors) + ]; + } + + return [ + 'success' => true, + 'message' => "Removed snapshot {$snapshotId}." + ]; +} + $cli = parseArguments($argv); $command = $cli['command']; $arguments = $cli['arguments']; $options = $cli['options']; switch ($command) { + case 'interactive': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n"; + exit(0); + } + + $selection = promptSnapshotSelection($snapshots); + if (!$selection) { + exit(1); + } + + applySnapshot($selection, $options); + break; + case 'list': $snapshots = loadSnapshots(); if (!$snapshots) { @@ -141,12 +456,60 @@ switch ($command) { echo "Available snapshots:\n"; foreach ($snapshots as $snapshot) { - $time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown'; - $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; - echo sprintf(" - %s (restore to Grav %s, %s)\n", $snapshot['id'], $restoreVersion, $time); + echo ' - ' . formatSnapshotListLine($snapshot) . "\n"; } exit(0); + case 'remove': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Nothing to remove.\n"; + exit(0); + } + + $selectedIds = []; + if ($arguments) { + foreach ($arguments as $arg) { + if (!$arg) { + continue; + } + $selectedIds[] = $arg; + } + } else { + $selectedIds = promptSnapshotsRemoval($snapshots); + if (!$selectedIds) { + echo "No snapshots selected. Aborting.\n"; + exit(1); + } + } + + $selectedIds = array_values(array_unique($selectedIds)); + echo "Snapshots selected for removal:\n"; + foreach ($selectedIds as $id) { + echo " - {$id}\n"; + } + + $autoConfirm = isset($options['yes']) || isset($options['y']); + if (!$autoConfirm) { + echo "\nThis action cannot be undone. Proceed? [y/N] "; + $confirmation = strtolower(trim((string)fgets(STDIN))); + if (!in_array($confirmation, ['y', 'yes'], true)) { + echo "Aborted.\n"; + exit(1); + } + } + + $success = 0; + foreach ($selectedIds as $id) { + $result = removeSnapshot($id); + echo $result['message'] . "\n"; + if ($result['success']) { + $success++; + } + } + + exit($success > 0 ? 0 : 1); + case 'apply': $snapshotId = $arguments[0] ?? null; if (!$snapshotId) { @@ -154,22 +517,8 @@ switch ($command) { exit(1); } - try { - $service = createUpgradeService($options); - $manifest = $service->rollback($snapshotId); - } catch (\Throwable $e) { - fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n"); - exit(1); - } - - if (!$manifest) { - fwrite(STDERR, "Snapshot {$snapshotId} not found.\n"); - exit(1); - } - - $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; - echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; - exit(0); + applySnapshot($snapshotId, $options); + break; case 'recovery': $action = strtolower($arguments[0] ?? 'status'); From 7325eb2cfed40fbea0b2384a17150fb8691834a6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sat, 18 Oct 2025 13:48:02 -0600 Subject: [PATCH 37/46] run / restore feature Signed-off-by: Andy Miller --- system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php index 91a8aae11..0984767d5 100644 --- a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php +++ b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php @@ -74,7 +74,12 @@ class SafeUpgradeRunCommand extends GravCommand ]); try { - $result = $manager->run($options); + $operation = $options['operation'] ?? 'upgrade'; + if ($operation === 'restore') { + $result = $manager->runRestore($options); + } else { + $result = $manager->run($options); + } $manager->ensureJobResult($result); return ($result['status'] ?? null) === 'success' ? 0 : 1; From c9640d725865a548d63c8c1f4c26f0b275f734ae Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sat, 18 Oct 2025 18:42:08 -0600 Subject: [PATCH 38/46] create adhoc snapshot Signed-off-by: Andy Miller --- bin/restore | 48 +++++++++++++++++++ .../Common/Upgrade/SafeUpgradeService.php | 45 +++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/bin/restore b/bin/restore index 4322ab3f5..a0c1e80f6 100755 --- a/bin/restore +++ b/bin/restore @@ -43,16 +43,21 @@ Usage: bin/restore remove [ ...] [--staging-root=/absolute/path] Deletes one or more snapshots (interactive selection when no id provided). + bin/restore snapshot [--label=\"optional description\"] [--staging-root=/absolute/path] + Creates a manual snapshot of the current Grav core files. + bin/restore recovery [status|clear] Shows the recovery flag context or clears it. Options: --staging-root Overrides the staging directory (defaults to configured value). + --label Optional label to store with the manual snapshot. Examples: bin/restore list bin/restore apply stage-68eff31cc4104 bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups + bin/restore snapshot --label=\"Before plugin install\" bin/restore recovery status bin/restore recovery clear USAGE; @@ -274,6 +279,45 @@ function applySnapshot(string $snapshotId, array $options): void exit(0); } +/** + * @param array $options + * @return void + */ +function createManualSnapshot(array $options): void +{ + $label = null; + if (isset($options['label']) && is_string($options['label'])) { + $label = trim($options['label']); + if ($label === '') { + $label = null; + } + } + + try { + $service = createUpgradeService($options); + $manifest = $service->createSnapshot($label); + } catch (\Throwable $e) { + fwrite(STDERR, "Snapshot creation failed: " . $e->getMessage() . "\n"); + exit(1); + } + + $snapshotId = $manifest['id'] ?? null; + if (!$snapshotId) { + $snapshotId = 'unknown'; + } + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; + + echo "Created snapshot {$snapshotId} (Grav {$version}).\n"; + if ($label) { + echo "Label: {$label}\n"; + } + if (!empty($manifest['backup_path'])) { + echo "Snapshot path: {$manifest['backup_path']}\n"; + } + + exit(0); +} + /** * @param list $snapshots * @return string|null @@ -520,6 +564,10 @@ switch ($command) { applySnapshot($snapshotId, $options); break; + case 'snapshot': + createManualSnapshot($options); + break; + case 'recovery': $action = strtolower($arguments[0] ?? 'status'); $manager = new RecoveryManager(GRAV_ROOT); diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index ca4c7b811..233a83c58 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -210,6 +210,51 @@ class SafeUpgradeService return $manifest; } + /** + * Create a manual snapshot of the current Grav installation. + * + * @param string|null $label + * @return array + */ + public function createSnapshot(?string $label = null): array + { + $entries = $this->collectPackageEntries($this->rootPath); + if (!$entries) { + throw new RuntimeException('Unable to locate files to snapshot.'); + } + + $stageId = uniqid('snapshot-', false); + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; + + $this->reportProgress('snapshot', 'Creating manual snapshot...', null, [ + 'operation' => 'snapshot', + 'label' => $label, + 'mode' => 'manual', + ]); + + $this->createBackupSnapshot($entries, $backupPath); + + $manifest = $this->buildManifest($stageId, GRAV_VERSION, $this->rootPath, $backupPath, $entries); + $manifest['package_path'] = null; + if ($label !== null && $label !== '') { + $manifest['label'] = $label; + } + $manifest['operation'] = 'snapshot'; + $manifest['mode'] = 'manual'; + + $this->persistManifest($manifest); + $this->pruneOldSnapshots(); + + $this->reportProgress('complete', sprintf('Snapshot %s created.', $stageId), 100, [ + 'operation' => 'snapshot', + 'snapshot' => $stageId, + 'version' => $manifest['target_version'] ?? null, + 'mode' => 'manual', + ]); + + return $manifest; + } + private function collectPackageEntries(string $packagePath): array { $entries = []; From 6a4ab165294fe519fdb95765736475dac63e5969 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sat, 18 Oct 2025 18:55:33 -0600 Subject: [PATCH 39/46] more restore bin fixes Signed-off-by: Andy Miller --- .../Grav/Common/Upgrade/SafeUpgradeService.php | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 233a83c58..c03431ea0 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -879,19 +879,8 @@ class SafeUpgradeService */ private function pruneOldSnapshots(): void { - $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; - if (count($files) <= 3) { - return; - } - - sort($files); - $excess = array_slice($files, 0, count($files) - 3); - foreach ($excess as $file) { - $data = json_decode(file_get_contents($file), true); - if (isset($data['backup_path']) && is_dir($data['backup_path'])) { - Folder::delete($data['backup_path']); - } - @unlink($file); - } + // Retain all snapshots; administrators can prune manually if desired. + // Legacy behaviour removed to ensure full history remains available. + return; } } From da0fbf9dd67caaa96e799ac05a609d64a15b814f Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sat, 18 Oct 2025 19:06:27 -0600 Subject: [PATCH 40/46] better label handling for snapshots Signed-off-by: Andy Miller --- bin/restore | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bin/restore b/bin/restore index a0c1e80f6..0a8e7d373 100755 --- a/bin/restore +++ b/bin/restore @@ -124,7 +124,7 @@ function createUpgradeService(array $options): SafeUpgradeService } /** - * @return list + * @return list */ function loadSnapshots(): array { @@ -145,6 +145,7 @@ function loadSnapshots(): array $snapshots[] = [ 'id' => $decoded['id'], + 'label' => $decoded['label'] ?? null, 'source_version' => $decoded['source_version'] ?? null, 'target_version' => $decoded['target_version'] ?? null, 'created_at' => (int)($decoded['created_at'] ?? 0), @@ -155,15 +156,17 @@ function loadSnapshots(): array } /** - * @param list $snapshots + * @param list $snapshots * @return string */ function formatSnapshotListLine(array $snapshot): string { $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; $timeLabel = formatSnapshotTimestamp($snapshot['created_at']); + $label = $snapshot['label'] ?? null; + $display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id']; - return sprintf('%s (restore to Grav %s, %s)', $snapshot['id'], $restoreVersion, $timeLabel); + return sprintf('%s (restore to Grav %s, %s)', $display, $restoreVersion, $timeLabel); } function formatSnapshotTimestamp(int $timestamp): string From 997bdfff077675cadb3d2665e3f7420dcb757529 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sat, 18 Oct 2025 19:09:26 -0600 Subject: [PATCH 41/46] fix test Signed-off-by: Andy Miller --- tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index 4873770c3..f7909a5f1 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -114,7 +114,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertDirectoryExists($manifest['backup_path']); } - public function testPrunesOldSnapshots(): void + public function testKeepsAllSnapshots(): void { [$root, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ @@ -131,8 +131,8 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test } $files = glob($manifestStore . '/*.json'); - self::assertCount(3, $files); - self::assertFalse(is_dir($manifests[0]['backup_path'])); + self::assertCount(4, $files); + self::assertTrue(is_dir($manifests[0]['backup_path'])); } public function testDetectsPsrLogConflictsFromFilesystem(): void From cd5f3842ed3a0fc66f1507a2c791660303be52ef Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 10:51:23 -0600 Subject: [PATCH 42/46] ignore unpublished plugins --- system/src/Grav/Common/GPM/GPM.php | 46 +++++++++++++++++++ .../Common/Upgrade/SafeUpgradeService.php | 42 +++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php index d4690c272..4baeafe42 100644 --- a/system/src/Grav/Common/GPM/GPM.php +++ b/system/src/Grav/Common/GPM/GPM.php @@ -10,6 +10,7 @@ namespace Grav\Common\GPM; use Exception; +use Grav\Common\Data\Data; use Grav\Common\Grav; use Grav\Common\Filesystem\Folder; use Grav\Common\HTTP\Response; @@ -24,6 +25,7 @@ use function count; use function in_array; use function is_array; use function is_object; +use function property_exists; /** * Class GPM @@ -322,6 +324,10 @@ class GPM extends Iterator continue; } + if (!$this->isRemotePackagePublished($plugins[$slug])) { + continue; + } + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $plugins[$slug]->version; @@ -414,6 +420,10 @@ class GPM extends Iterator continue; } + if (!$this->isRemotePackagePublished($themes[$slug])) { + continue; + } + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $themes[$slug]->version; @@ -468,6 +478,42 @@ class GPM extends Iterator return null; } + /** + * Determine whether a remote package is marked as published. + * + * Remote package metadata introduced a `published` flag to hide releases that are not yet public. + * Older repository payloads may omit the key, so we default to treating packages as published + * unless the flag is explicitly set to `false`. + * + * @param object|array $package + * @return bool + */ + protected function isRemotePackagePublished($package): bool + { + if (is_object($package) && method_exists($package, 'getData')) { + $data = $package->getData(); + if ($data instanceof Data) { + $published = $data->get('published'); + return $published !== false; + } + } + + if (is_array($package)) { + if (array_key_exists('published', $package)) { + return $package['published'] !== false; + } + + return true; + } + + $value = null; + if (is_object($package) && property_exists($package, 'published')) { + $value = $package->published; + } + + return $value !== false; + } + /** * Returns true if the package latest release is stable * diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index c03431ea0..c724f5473 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -10,6 +10,7 @@ namespace Grav\Common\Upgrade; use DirectoryIterator; +use Grav\Common\Data\Data; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; use Grav\Common\Grav; @@ -20,6 +21,7 @@ use Throwable; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use FilesystemIterator; +use function array_key_exists; use function basename; use function copy; use function count; @@ -33,6 +35,7 @@ use function is_file; use function json_decode; use function json_encode; use function preg_match; +use function property_exists; use function rename; use function rsort; use function sort; @@ -438,6 +441,10 @@ class SafeUpgradeService continue; } foreach ($packages as $slug => $package) { + if (!$this->isGpmPackagePublished($package)) { + continue; + } + $pending[$slug] = [ 'type' => $type, 'current' => $package->version ?? null, @@ -449,6 +456,41 @@ class SafeUpgradeService return $pending; } + /** + * Determine if the provided GPM package metadata is marked as published. + * + * By default the GPM repository omits the `published` flag, so we only treat the package as unpublished + * when the value exists and evaluates to `false`. + * + * @param mixed $package + * @return bool + */ + protected function isGpmPackagePublished($package): bool + { + if (is_object($package) && method_exists($package, 'getData')) { + $data = $package->getData(); + if ($data instanceof Data) { + $published = $data->get('published'); + return $published !== false; + } + } + + if (is_array($package)) { + if (array_key_exists('published', $package)) { + return $package['published'] !== false; + } + + return true; + } + + $value = null; + if (is_object($package) && property_exists($package, 'published')) { + $value = $package->published; + } + + return $value !== false; + } + /** * Check plugins for psr/log requirements that conflict with Grav 1.8 vendor stack. * From 269bf78084e636a31992480c5f80be380344586e Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 11:04:18 -0600 Subject: [PATCH 43/46] ignore unpublished plugins - part 2 --- .../Common/Upgrade/SafeUpgradeService.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index c724f5473..dd93adc7a 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -445,6 +445,14 @@ class SafeUpgradeService continue; } + if ($type === 'plugins' && !$this->isPluginEnabled($slug)) { + continue; + } + + if ($type === 'themes' && !$this->isThemeEnabled($slug)) { + continue; + } + $pending[$slug] = [ 'type' => $type, 'current' => $package->version ?? null, @@ -567,6 +575,37 @@ class SafeUpgradeService return true; } + protected function isThemeEnabled(string $slug): bool + { + if ($this->config) { + try { + $active = $this->config->get('system.pages.theme'); + if ($active !== null) { + return $active === $slug; + } + } catch (Throwable $e) { + // ignore + } + } + + $configPath = $this->rootPath . '/user/config/system.yaml'; + if (is_file($configPath)) { + try { + $data = Yaml::parseFile($configPath); + 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 and assume current theme + } + } + + return true; + } + /** * Detect usage of deprecated Monolog `add*` methods removed in newer releases. * From 0ac77271cc4aae2fabdaa442f7b4159429c5c6a2 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 14:59:42 -0600 Subject: [PATCH 44/46] updated changelog Signed-off-by: Andy Miller --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07889b242..76e9b5e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # v1.7.50 -## UNRELEASED +## 10/19/2025 1. [](#new) - * Added staged self-upgrade pipeline with manifest snapshots and atomic swaps for Grav core updates. + * Added new **Safe Core Upgrade** process with snapshots for backup and restore, better preflight and postflight checks, as well as exception checking post-install for easy rollback. * Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support. * Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility. + * Added `wordCount` Twig filter [#3957](https://github.com/getgrav/grav/pulls/3957) # v1.7.49.5 ## 09/10/2025 From 5815c8cae52cc9be036f008f5db5585290415438 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 16:02:51 -0600 Subject: [PATCH 45/46] move recover.flag Signed-off-by: Andy Miller --- index.php | 2 +- system/recovery.php | 2 +- system/src/Grav/Common/Recovery/RecoveryManager.php | 3 ++- system/src/Grav/Common/Upgrade/SafeUpgradeService.php | 2 +- tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php | 9 +++++---- .../unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/index.php b/index.php index 233701c9c..efb32572a 100644 --- a/index.php +++ b/index.php @@ -81,7 +81,7 @@ date_default_timezone_set(@date_default_timezone_get()); @ini_set('default_charset', 'UTF-8'); mb_internal_encoding('UTF-8'); -$recoveryFlag = __DIR__ . '/system/recovery.flag'; +$recoveryFlag = __DIR__ . '/user/data/recovery.flag'; if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) { require __DIR__ . '/system/recovery.php'; return 0; diff --git a/system/recovery.php b/system/recovery.php index 528359695..9c62903dd 100644 --- a/system/recovery.php +++ b/system/recovery.php @@ -116,7 +116,7 @@ header('Content-Type: text/html; charset=utf-8');

This site is running in recovery mode because Grav detected a fatal error.

-

Locate the recovery token in system/recovery.flag and enter it below.

+

Locate the recovery token in user/data/recovery.flag and enter it below.

diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index 75691b9e5..e75f7267e 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -156,6 +156,7 @@ class RecoveryManager public function activate(array $context): void { $flag = $this->flagPath(); + Folder::create(dirname($flag)); if (empty($context['token'])) { $context['token'] = $this->generateToken(); } @@ -294,7 +295,7 @@ class RecoveryManager */ private function flagPath(): string { - return $this->rootPath . '/system/recovery.flag'; + return $this->userPath . '/data/recovery.flag'; } /** diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index dd93adc7a..b8cab0a8a 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -418,7 +418,7 @@ class SafeUpgradeService */ public function clearRecoveryFlag(): void { - $flag = $this->rootPath . '/system/recovery.flag'; + $flag = $this->rootPath . '/user/data/recovery.flag'; if (is_file($flag)) { @unlink($flag); } diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index 1c21cc67d..24f8f3884 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -13,6 +13,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test $this->tmpDir = sys_get_temp_dir() . '/grav-recovery-' . uniqid('', true); Folder::create($this->tmpDir); Folder::create($this->tmpDir . '/user'); + Folder::create($this->tmpDir . '/user/data'); Folder::create($this->tmpDir . '/system'); } @@ -59,7 +60,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); $manager->handleShutdown(); - $flag = $this->tmpDir . '/system/recovery.flag'; + $flag = $this->tmpDir . '/user/data/recovery.flag'; self::assertFileExists($flag); $context = json_decode(file_get_contents($flag), true); self::assertSame('Fatal failure', $context['message']); @@ -87,12 +88,12 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test $manager->handleShutdown(); - self::assertFileDoesNotExist($this->tmpDir . '/system/recovery.flag'); + self::assertFileDoesNotExist($this->tmpDir . '/user/data/recovery.flag'); } public function testClearRemovesFlag(): void { - $flag = $this->tmpDir . '/system/recovery.flag'; + $flag = $this->tmpDir . '/user/data/recovery.flag'; file_put_contents($flag, 'flag'); $manager = new RecoveryManager($this->tmpDir); @@ -130,7 +131,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test $manager = new RecoveryManager($this->tmpDir); $manager->disablePlugin('problem', ['message' => 'Manual disable']); - $flag = $this->tmpDir . '/system/recovery.flag'; + $flag = $this->tmpDir . '/user/data/recovery.flag'; self::assertFileDoesNotExist($flag); $configFile = $this->tmpDir . '/user/config/plugins/problem.yaml'; diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index f7909a5f1..31c4882b9 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -186,7 +186,7 @@ PHP; public function testClearRecoveryFlagRemovesFile(): void { [$root] = $this->prepareLiveEnvironment(); - $flag = $root . '/system/recovery.flag'; + $flag = $root . '/user/data/recovery.flag'; Folder::create(dirname($flag)); file_put_contents($flag, 'flag'); From 80b83894323dec00ec342bfdc918476f5320422e Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 16:47:42 -0600 Subject: [PATCH 46/46] prepare for release Signed-off-by: Andy Miller --- system/UPGRADE_PROTOTYPE.md | 48 ------------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 system/UPGRADE_PROTOTYPE.md diff --git a/system/UPGRADE_PROTOTYPE.md b/system/UPGRADE_PROTOTYPE.md deleted file mode 100644 index 6444a4f26..000000000 --- a/system/UPGRADE_PROTOTYPE.md +++ /dev/null @@ -1,48 +0,0 @@ -# Grav Safe Self-Upgrade Prototype - -This document tracks the design decisions behind the new self-upgrade prototype for Grav 1.8. - -## Goals - -- Prevent in-place mutation of the running Grav tree. -- Guarantee a restorable snapshot before any destructive change. -- Detect high-risk plugin incompatibilities (eg. `psr/log`) prior to upgrading. -- Provide a recovery surface that does not depend on a working Admin plugin. - -## High-Level Flow - -1. **Preflight** - - Ensure PHP & extensions satisfy the target release requirements. - - Refresh GPM metadata and require all plugins/themes to be on their latest compatible release. - - Scan plugin `composer.json` files for dependencies that are known to break under Grav 1.8 (eg. `psr/log` < 3) and surface actionable warnings. -2. **Stage** - - Download the Grav update archive into a staging area (`tmp://grav-snapshots/{timestamp}`). - - Extract the package, then write a manifest describing the target version, PHP info, and enabled packages. - - Snapshot the live `user/` directory and relevant metadata into the same stage folder. -3. **Promote** - - Copy the staged package into place, overwriting Grav core files while leaving hydrated user content intact. - - Clear caches in the staged tree before promotion. - - Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; restore from the snapshot automatically on failure. -4. **Finalize** - - Record the manifest under `user/data/upgrades`. - - Resume normal traffic by removing the maintenance flag. - - Leave the previous tree and manifest available for manual rollback commands. - -## Recovery Mode - -- Introduce a `system/recovery.flag` sentinel written whenever a fatal error occurs during bootstrap or when a promoted release fails validation. -- While the flag is present, Grav forces a minimal Recovery UI served outside of Admin, protected by a short-lived signed token. -- The Recovery UI lists recent manifests, quarantined plugins, and offers rollback/disabling actions. -- Clearing the flag requires either a successful rollback or a full Grav request cycle without fatal errors. - -## CLI Additions - -- `bin/gpm preflight grav@`: runs the same preflight checks without executing the upgrade. -- `bin/gpm rollback []`: swaps the live tree with a stored rollback snapshot. -- Existing `self-upgrade` command now wraps the stage/promote pipeline and respects the snapshot manifest. - -## Open Items - -- Finalize compatibility heuristics (initial pass focuses on `psr/log` and removed logging APIs). -- UX polish for the Recovery UI (initial prototype will expose basic actions only). -- Decide retention policy for old manifests and snapshots (prototype keeps the most recent three).