diff --git a/bin/gpm-cache-inject b/bin/gpm-cache-inject new file mode 100755 index 000000000..65f5b0d51 --- /dev/null +++ b/bin/gpm-cache-inject @@ -0,0 +1,160 @@ +#!/usr/bin/env php +delete($cacheKey); + echo "Cache cleared. Next GPM call will fetch live data from getgrav.org.\n"; + exit(0); +} + +// ── Inject mode ─────────────────────────────────────────────────────────────── + +if (!$fakeVersion) { + fwrite(STDERR, "ERROR: Provide a version to inject, e.g.:\n php bin/gpm-cache-inject 2.0.0\n"); + exit(1); +} + +$fakeJson = json_encode([ + 'version' => $fakeVersion, + 'date' => date('Y-m-d\TH:i:s\Z'), + 'assets' => [ + 'grav' => [ + 'name' => "grav-v{$fakeVersion}.zip", + 'type' => 'application/zip', + 'size' => 1000000, + 'download' => "https://getgrav.org/download/core/grav/{$fakeVersion}", + ], + 'grav-admin' => [ + 'name' => "grav-admin-v{$fakeVersion}.zip", + 'type' => 'application/zip', + 'size' => 1500000, + 'download' => "https://getgrav.org/download/core/grav-admin/{$fakeVersion}", + ], + ], + 'url' => "https://github.com/getgrav/grav/releases/tag/{$fakeVersion}", + 'min_php' => '8.1.0', + 'changelog' => new stdClass(), +], JSON_PRETTY_PRINT); + +$lifetime = 86400; // match GravCore's lifetime +$saved = $cache->save($cacheKey, $fakeJson, $lifetime); + +if ($saved) { + echo "Injected fake GPM response:\n"; + echo " Fake version : $fakeVersion\n"; + echo " Channel : $channel\n\n"; + echo "Now run:\n"; + echo " bin/gpm selfupgrade\n\n"; + echo "Expected results with the family gate active:\n"; + $localParts = explode('.', $gravVersion); + $remoteParts = explode('.', ltrim($fakeVersion, 'v')); + $localFamily = ($localParts[0] ?? '0') . '.' . ($localParts[1] ?? '0'); + $remoteFamily = ($remoteParts[0] ?? '0') . '.' . ($remoteParts[1] ?? '0'); + if ($localFamily !== $remoteFamily) { + echo " isUpgradable() → FALSE (cross-family: $localFamily → $remoteFamily)\n"; + $localMajor = (int)($localParts[0] ?? 0); + $remoteMajor = (int)($remoteParts[0] ?? 0); + $notice = $remoteMajor > $localMajor ? 'TRUE' : 'false'; + echo " isNextMajorAvailable()→ $notice\n"; + } else { + $upgradable = version_compare($gravVersion, $fakeVersion, '<') ? 'TRUE' : 'false'; + echo " isUpgradable() → $upgradable (same family: $localFamily)\n"; + echo " isNextMajorAvailable()→ false\n"; + } + echo "\nClean up with:\n"; + echo " php bin/gpm-cache-inject --clear\n"; +} else { + fwrite(STDERR, "ERROR: Failed to write cache. Check that $cacheDir is writable.\n"); + exit(1); +} diff --git a/bin/restore b/bin/restore deleted file mode 100755 index 0a8e7d373..000000000 --- a/bin/restore +++ /dev/null @@ -1,634 +0,0 @@ -#!/usr/bin/env php - [--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 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; - -/** - * @param array $args - * @return array{command:string,arguments:array,options:array} - */ -function parseArguments(array $args): array -{ - array_shift($args); // remove script name - - $command = null; - $arguments = []; - $options = []; - - 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; - } - - if (null === $command) { - $command = $arg; - } else { - $arguments[] = $arg; - } - } - - if (null === $command) { - $command = 'interactive'; - } - - return [ - 'command' => $command, - 'arguments' => $arguments, - 'options' => $options, - ]; -} - -/** - * @param array $options - * @return SafeUpgradeService - */ -function createUpgradeService(array $options): SafeUpgradeService -{ - $serviceOptions = ['root' => GRAV_ROOT]; - - if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') { - $serviceOptions['staging_root'] = $options['staging-root']; - } - - return new SafeUpgradeService($serviceOptions); -} - -/** - * @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'], - 'label' => $decoded['label'] ?? null, - 'source_version' => $decoded['source_version'] ?? null, - 'target_version' => $decoded['target_version'] ?? null, - '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']); - $label = $snapshot['label'] ?? null; - $display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id']; - - return sprintf('%s (restore to Grav %s, %s)', $display, $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 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 - */ -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) { - 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) { - 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) { - echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n"; - exit(1); - } - - applySnapshot($snapshotId, $options); - break; - - case 'snapshot': - createManualSnapshot($options); - break; - - 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"; - - $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: - echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n"; - exit(1); - } - - case 'help': - default: - echo RESTORE_USAGE . "\n"; - exit($command === 'help' ? 0 : 1); -} diff --git a/system/recovery.php b/system/recovery.php index 50a5411db..7b4189d3b 100644 --- a/system/recovery.php +++ b/system/recovery.php @@ -2,7 +2,6 @@ use Grav\Common\Filesystem\Folder; use Grav\Common\Recovery\RecoveryManager; -use Grav\Common\Upgrade\SafeUpgradeService; if (!\defined('GRAV_ROOT')) { \define('GRAV_ROOT', dirname(__DIR__)); @@ -17,28 +16,15 @@ session_start([ $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 ($action === 'clear-flag') { - // Clear recovery flag - allowed without authentication + if ($action === 'clear-flag') { $manager->clear(); - $_SESSION['grav_recovery_authenticated'] = null; $notice = 'Recovery flag cleared. Reload the page to continue.'; } elseif ($action === 'disable-recovery') { - // Disable recovery mode in config (updates.recovery_mode) - allowed without authentication $configDir = GRAV_ROOT . '/user/config'; $configFile = $configDir . '/system.yaml'; Folder::create($configDir); @@ -47,7 +33,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (is_file($configFile)) { $content = file_get_contents($configFile); if ($content !== false) { - // Simple YAML parsing for this specific case $config = \Symfony\Component\Yaml\Yaml::parse($content) ?? []; } } @@ -59,68 +44,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $yaml = \Symfony\Component\Yaml\Yaml::dump($config, 4, 2); file_put_contents($configFile, $yaml); - // Also clear the recovery flag $manager->clear(); - $_SESSION['grav_recovery_authenticated'] = null; $notice = 'Recovery mode has been disabled. Reload the page to continue.'; - } 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.'; - } - } catch (\Throwable $e) { - $errorMessage = $e->getMessage(); - } - } else { - $errorMessage = 'Authentication required for this action.'; } } -$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'; -$snapshots = []; -if (is_dir($manifestDir)) { - $files = glob($manifestDir . '/*.json'); - if ($files) { - foreach ($files as $file) { - $decoded = json_decode(file_get_contents($file), true); - if (!is_array($decoded)) { - continue; - } - - $id = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME); - if (!is_string($id) || $id === '' || strncmp($id, 'snapshot-', 9) !== 0) { - continue; - } - - $decoded['id'] = $id; - $decoded['file'] = basename($file); - $decoded['created_at'] = (int)($decoded['created_at'] ?? filemtime($file) ?: 0); - $snapshots[] = $decoded; - } - - if ($snapshots) { - usort($snapshots, static function (array $a, array $b): int { - return ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0); - }); - } - } -} - -$latestSnapshot = $snapshots[0] ?? null; - // Determine base URL for assets $scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php'; $baseUrl = rtrim(dirname($scriptName), '/\\'); @@ -333,24 +261,6 @@ header('Content-Type: text/html; charset=utf-8'); color: #e8e8e8; word-break: break-word; } - input[type="text"] { - width: 100%; - padding: 12px 16px; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - background: rgba(0, 0, 0, 0.2); - color: #fff; - font-size: 0.95rem; - margin-top: 8px; - } - input[type="text"]:focus { - outline: none; - border-color: #3b82f6; - } - label { - color: #94a3b8; - font-size: 0.9rem; - } .help-text { color: #64748b; font-size: 0.85rem; @@ -363,39 +273,6 @@ header('Content-Type: text/html; charset=utf-8'); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.85rem; } - .quarantine-list { - list-style: none; - padding: 0; - margin: 0; - } - .quarantine-list li { - padding: 12px; - background: rgba(245, 158, 11, 0.1); - border-radius: 6px; - margin-bottom: 8px; - } - .quarantine-list .plugin-name { - font-weight: 600; - color: #fcd34d; - } - .quarantine-list .plugin-time { - color: #94a3b8; - font-size: 0.85rem; - } - .snapshot-info { - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; - padding: 12px 16px; - margin: 12px 0; - } - .snapshot-info code { - color: #60a5fa; - } - .snapshot-info small { - color: #64748b; - display: block; - margin-top: 4px; - } @media (max-width: 600px) { body { padding: 12px; } .card { padding: 16px; } @@ -470,23 +347,6 @@ header('Content-Type: text/html; charset=utf-8'); - -
-

Quarantined Plugins

-

These plugins have been automatically disabled due to errors:

-
    - -
  • - - Disabled at - -
    - -
  • - -
-
-

What would you like to do?

@@ -508,40 +368,6 @@ header('Content-Type: text/html; charset=utf-8');

- -
-

Rollback to Previous Version

-

If the error persists, you can rollback to a previous Grav version.

- -
- - - - - Grav — Created -
- - -

To rollback, enter the recovery token found in user/data/recovery.flag

-
- - - -
- -
-
- -
- - -
- -
-
- -
- diff --git a/system/src/Grav/Common/GPM/Upgrader.php b/system/src/Grav/Common/GPM/Upgrader.php index 33c7d9fdf..39746cdf8 100644 --- a/system/src/Grav/Common/GPM/Upgrader.php +++ b/system/src/Grav/Common/GPM/Upgrader.php @@ -117,15 +117,53 @@ class Upgrader } /** - * Checks if the currently installed Grav is upgradable to a newer version + * Checks if the currently installed Grav is upgradable to a newer version. * - * @return bool True if it's upgradable, False otherwise. + * Returns false when the remote version is from a different major.minor family + * (e.g. local is 1.8.x and remote is 2.0.x), so that installs are never + * silently jumped across a major boundary. + * + * @return bool True if it's upgradable within the same major.minor family. */ public function isUpgradable() { + if ($this->isCrossFamilyUpgrade()) { + return false; + } + return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), '<'); } + /** + * Returns true when a newer major version is available (e.g. currently on 1.x, remote offers 2.x). + * Intended for informational notices — does not imply an automatic upgrade will occur. + * + * @return bool + */ + public function isNextMajorAvailable(): bool + { + $localMajor = (int) explode('.', $this->getLocalVersion())[0]; + $remoteMajor = (int) explode('.', $this->getRemoteVersion())[0]; + + return $remoteMajor > $localMajor; + } + + /** + * Returns true when the remote version belongs to a different major.minor family than the local version. + * + * @return bool + */ + private function isCrossFamilyUpgrade(): bool + { + $localParts = explode('.', $this->getLocalVersion()); + $remoteParts = explode('.', $this->getRemoteVersion()); + + $localFamily = ($localParts[0] ?? '0') . '.' . ($localParts[1] ?? '0'); + $remoteFamily = ($remoteParts[0] ?? '0') . '.' . ($remoteParts[1] ?? '0'); + + return $localFamily !== $remoteFamily; + } + /** * Checks if Grav is currently symbolically linked * diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php deleted file mode 100644 index b4d5ed951..000000000 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ /dev/null @@ -1,1458 +0,0 @@ -rootPath = rtrim($root, DIRECTORY_SEPARATOR); - $this->config = $options['config'] ?? null; - - $locator = null; - try { - $locator = Grav::instance()['locator'] ?? null; - } catch (Throwable $e) { - $locator = null; - } - - $primary = null; - if ($locator && method_exists($locator, 'findResource')) { - try { - $primary = $locator->findResource('tmp://grav-snapshots', true, true); - } catch (Throwable $e) { - $primary = null; - } - } - - if (!$primary) { - $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-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'])) { - $this->ignoredDirs = $options['ignored_dirs']; - } - } - - /** - * Run preflight validations before attempting an upgrade. - * - * @param string|null $targetVersion The target Grav version being upgraded to (e.g., '1.8.0') - * @return array{plugins_pending: array, psr_log_conflicts: array, warnings: string[], is_major_minor_upgrade: bool} - */ - public function preflight(?string $targetVersion = null): array - { - $warnings = []; - $isMajorMinorUpgrade = false; - - // Determine if this is a major/minor version upgrade (e.g., 1.7.x -> 1.8.y) - if ($targetVersion !== null) { - $currentVersion = GRAV_VERSION; - $currentParts = explode('.', $currentVersion); - $targetParts = explode('.', $targetVersion); - - $currentMajor = (int)($currentParts[0] ?? 0); - $currentMinor = (int)($currentParts[1] ?? 0); - $targetMajor = (int)($targetParts[0] ?? 0); - $targetMinor = (int)($targetParts[1] ?? 0); - - $isMajorMinorUpgrade = ($currentMajor !== $targetMajor) || ($currentMinor !== $targetMinor); - } - - try { - $pending = $this->detectPendingPluginUpdates(); - } catch (RuntimeException $e) { - $pending = []; - $warnings[] = $e->getMessage(); - } - - $psrLogConflicts = $this->detectPsrLogConflicts(); - $monologConflicts = $this->detectMonologConflicts(); - - if ($pending) { - if ($isMajorMinorUpgrade) { - $warnings[] = 'Because this is a major Grav upgrade, update pending plugins and themes before continuing.'; - } else { - $warnings[] = 'Pending plugin/theme updates detected. Update them before running Grav upgrade.'; - } - } - if ($psrLogConflicts) { - $warnings[] = 'Potential psr/log signature conflicts detected.'; - } - if ($monologConflicts) { - $warnings[] = 'Potential Monolog logger API incompatibilities detected.'; - } - - $incompatible = ['blocking' => [], 'warnings' => [], 'target' => '']; - if ($isMajorMinorUpgrade && $targetVersion !== null) { - $incompatible = $this->detectIncompatiblePackages($targetVersion); - if (!empty($incompatible['blocking'])) { - $warnings[] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $incompatible['target'] . '.'; - } - } - - return [ - 'plugins_pending' => $pending, - 'psr_log_conflicts' => $psrLogConflicts, - 'monolog_conflicts' => $monologConflicts, - 'incompatible_packages' => $incompatible, - 'warnings' => $warnings, - 'is_major_minor_upgrade' => $isMajorMinorUpgrade, - ]; - } - - /** - * 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)); - } - - // Check PHP requirements from the package before proceeding - $phpCheck = $this->checkPackagePhpRequirements($extractedPath); - if (!$phpCheck['meets_requirements']) { - throw new RuntimeException(sprintf( - 'PHP version requirement not met. Grav %s requires PHP %s or higher, but you are running PHP %s.', - $targetVersion, - $phpCheck['required_version'], - PHP_VERSION - )); - } - - $stageId = uniqid('stage-', false); - $stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId; - $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; - $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; - - Folder::create(dirname($packagePath)); - - $this->reportProgress('installing', 'Preparing staged package...', null); - $stagingMode = $this->stageExtractedPackage($extractedPath, $packagePath); - $this->reportProgress('installing', 'Preparing staged package...', null, ['mode' => $stagingMode]); - - $packageEntries = $this->collectPackageEntries($packagePath); - - // CRITICAL SAFETY CHECK: Verify 'user' is never in package entries before proceeding - if (in_array('user', $packageEntries, true)) { - throw new RuntimeException( - 'SAFETY VIOLATION: user directory found in package entries. ' . - 'This should never happen and could result in data loss. Aborting upgrade.' - ); - } - - $this->carryOverRootDotfiles($packagePath); - - // Ensure ignored directories are replaced with live copies. - $this->hydrateIgnoredDirectories($packagePath, $ignores); - $this->carryOverRootFiles($packagePath, $ignores); - - // IMPORTANT: Snapshot should ONLY include files from the original package, NOT custom directories - // that were carried over. This prevents snapshotting huge custom folders like /media-test, /downloads, etc. - // The carryOverRootFiles() method preserves custom files during upgrade, but they should not be snapshotted - // because they are not being replaced and don't need to be rolled back. - if (!$packageEntries) { - throw new RuntimeException('Staged package does not contain any files to promote.'); - } - - // FINAL SAFETY CHECK: Verify 'user' is not in the entries that will be deployed - if (in_array('user', $packageEntries, true)) { - throw new RuntimeException( - 'SAFETY VIOLATION: user directory found in deployment entries. ' . - 'Aborting upgrade to protect user data.' - ); - } - - $this->reportProgress('snapshot', 'Creating backup snapshot...', null); - $this->createBackupSnapshot($packageEntries, $backupPath); - - $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $packageEntries); - $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; - Folder::create(dirname($manifestPath)); - file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); - - $this->reportProgress('installing', 'Copying update files...', null); - - try { - $this->copyEntries($packageEntries, $packagePath, $this->rootPath, 'installing', 'Deploying', true); - } catch (Throwable $e) { - // Rollback: restore from snapshot - $this->reportProgress('rollback', 'Upgrade failed, restoring from snapshot...', null); - $this->copyEntries($packageEntries, $backupPath, $this->rootPath, 'rollback', 'Restoring', false); - throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); - } - - $this->reportProgress('finalizing', 'Finalizing upgrade...', null); - $this->persistManifest($manifest); - $this->lastManifest = $manifest; - $this->pruneOldSnapshots(); - - // Clean up staging directory - // Wrap in try-catch because autoloader may have stale paths after file copy - try { - Folder::delete($stagePath); - } catch (\Throwable $e) { - // Staging cleanup failed, but upgrade succeeded - // Directory will be cleaned up on next request - error_log('Warning: Failed to delete staging directory: ' . $e->getMessage()); - } - - 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->lastManifest = $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; - } - - /** - * Create a snapshot specifically for automated upgrades. - * - * @param string $targetVersion - * @param string|null $label - * @return array - */ - public function createUpgradeSnapshot(string $targetVersion, ?string $label = null): array - { - $entries = $this->collectPackageEntries($this->rootPath); - if (!$entries) { - throw new RuntimeException('Unable to locate files to snapshot.'); - } - - $stageId = uniqid('upgrade-', false); - $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; - - $this->reportProgress('snapshot', sprintf('Capturing snapshot before upgrading to %s...', $targetVersion), null, [ - 'operation' => 'upgrade', - 'target_version' => $targetVersion, - ]); - - $this->createBackupSnapshot($entries, $backupPath); - - $manifest = $this->buildManifest($stageId, $targetVersion, $this->rootPath, $backupPath, $entries); - $manifest['package_path'] = null; - if ($label !== null && $label !== '') { - $manifest['label'] = $label; - } - $manifest['operation'] = 'upgrade'; - $manifest['mode'] = 'pre-upgrade'; - - $this->persistManifest($manifest); - $this->lastManifest = $manifest; - $this->pruneOldSnapshots(); - - $this->reportProgress('snapshot', sprintf('Snapshot %s captured.', $stageId), 100, [ - 'operation' => 'upgrade', - 'snapshot' => $stageId, - 'target_version' => $targetVersion, - ]); - - return $manifest; - } - - private function collectPackageEntries(string $packagePath): array - { - $entries = []; - $iterator = new DirectoryIterator($packagePath); - foreach ($iterator as $fileinfo) { - if ($fileinfo->isDot()) { - continue; - } - - $name = $fileinfo->getFilename(); - if (in_array($name, $this->ignoredDirs, true)) { - continue; - } - - // CRITICAL SAFETY CHECK: Never allow 'user' directory to be collected - // This prevents any scenario where user/ could be overwritten during upgrade - if ($name === 'user') { - continue; - } - - $entries[] = $name; - } - - sort($entries); - - return $entries; - } - - private function stageExtractedPackage(string $sourcePath, string $packagePath): string - { - if (is_dir($packagePath)) { - Folder::delete($packagePath); - } - - if (@rename($sourcePath, $packagePath)) { - return 'move'; - } - - Folder::create($packagePath); - $entries = $this->collectPackageEntries($sourcePath); - $this->copyEntries($entries, $sourcePath, $packagePath, 'installing', 'Staging', true); - Folder::delete($sourcePath); - - return 'copy'; - } - - private function createBackupSnapshot(array $entries, string $backupPath): void - { - Folder::create($backupPath); - $this->copyEntries($entries, $this->rootPath, $backupPath, 'snapshot', 'Snapshotting', false); - } - - private function copyEntries(array $entries, string $sourceBase, string $targetBase, ?string $progressStage = null, ?string $progressPrefix = null, bool $useMove = false): void - { - $total = count($entries); - foreach ($entries as $index => $entry) { - // CRITICAL SAFETY CHECK: Absolutely prevent any operations on 'user' directory - // This is a fail-safe to ensure user data is never touched during upgrades - if ($entry === 'user' || strpos($entry, 'user' . DIRECTORY_SEPARATOR) === 0) { - throw new RuntimeException( - 'SAFETY VIOLATION: Attempted to copy user directory during upgrade. ' . - 'This should never happen. Aborting upgrade to protect user data.' - ); - } - - $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 + 1) / $total) * 100) : null; - $this->reportProgress($progressStage, $message, $percent ?: null, [ - 'entry' => $entry, - 'index' => $index + 1, - 'total' => $total, - ]); - } - - $destination = $targetBase . DIRECTORY_SEPARATOR . $entry; - - // Use the same simple approach as traditional upgrade: - // Delete old, copy new, let filesystem handle ownership - $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)); - - if ($useMove) { - // Use move() like traditional upgrade - faster than copy - Folder::move($source, $destination); - } else { - Folder::rcopy($source, $destination, true); - } - - // Set bin/ permissions like traditional upgrade does - if ($entry === 'bin') { - $binFiles = glob($destination . DIRECTORY_SEPARATOR . '*') ?: []; - foreach ($binFiles as $binFile) { - @chmod($binFile, 0755); - } - } - } else { - Folder::create(dirname($destination)); - if ($useMove) { - if (!@rename($source, $destination)) { - if (!@copy($source, $destination)) { - throw new RuntimeException(sprintf('Failed to move file "%s" to "%s".', $source, $destination)); - } - @unlink($source); - } - } else { - if (!@copy($source, $destination)) { - throw new RuntimeException(sprintf('Failed to copy file "%s" to "%s".', $source, $destination)); - } - $perms = @fileperms($source); - if ($perms !== false) { - @chmod($destination, $perms & 0777); - } - } - } - } - } - - private function removeEntry(string $path): void - { - if (is_link($path) || is_file($path)) { - @unlink($path); - } elseif (is_dir($path)) { - Folder::delete($path); - } - } - - 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. - * - * @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.'); - } - - $entries = $manifest['entries'] ?? []; - if (!$entries) { - $entries = $this->collectPackageEntries($backupPath); - } - if (!$entries) { - throw new RuntimeException('Rollback snapshot entries are missing from the manifest.'); - } - - $this->reportProgress('rollback', 'Restoring snapshot...', null); - $this->copyEntries($entries, $backupPath, $this->rootPath, 'rollback', 'Restoring', false); - $this->markRollback($manifest['id']); - $this->lastManifest = $manifest; - - return $manifest; - } - - /** - * @return void - */ - public function clearRecoveryFlag(): void - { - $flag = $this->rootPath . '/user/data/recovery.flag'; - if (is_file($flag)) { - @unlink($flag); - } - } - - /** - * @return array|null - */ - public function getLastManifest(): ?array - { - return $this->lastManifest; - } - - /** - * @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) { - if (!$this->isGpmPackagePublished($package)) { - continue; - } - - if ($type === 'plugins' && !$this->isPluginEnabled($slug)) { - continue; - } - - if ($type === 'themes' && !$this->isThemeEnabled($slug)) { - continue; - } - - $pending[$slug] = [ - 'type' => $type, - 'current' => $package->version ?? null, - 'available' => $package->available ?? null, - ]; - } - } - - 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. - * - * @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); - if (!$this->isPluginEnabled($slug)) { - continue; - } - $rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null); - if (!$rawConstraint) { - continue; - } - - $constraint = strtolower((string)$rawConstraint); - $compatible = $constraint === '*' - || false !== strpos($constraint, '3') - || false !== strpos($constraint, '4') - || (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint)); - - if ($compatible) { - continue; - } - - $conflicts[$slug] = [ - 'composer' => $composerFile, - 'requires' => $rawConstraint, - ]; - } - - return $conflicts; - } - - 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; - } - - 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. - * - * @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) { - $slug = basename($path); - if (!$this->isPluginEnabled($slug)) { - continue; - } - - $directory = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS); - $filter = new RecursiveCallbackFilterIterator($directory, static function ($current, $key, $iterator) { - // Skip hidden files/dirs (starting with .) - if ($current->getFilename()[0] === '.') { - return false; - } - if ($iterator->hasChildren()) { - // Exclude vendor and node_modules directories - return !in_array($current->getFilename(), ['vendor', 'node_modules'], true); - } - // Only include PHP files - return $current->getExtension() === 'php'; - }); - - $iterator = new RecursiveIteratorIterator($filter); - - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - $contents = @file_get_contents($file->getPathname()); - if ($contents === false) { - continue; - } - - if (preg_match($pattern, $contents, $match)) { - $relative = str_replace($this->rootPath . '/', '', $file->getPathname()); - $conflicts[$slug][] = [ - 'file' => $relative, - 'method' => trim($match[0]), - ]; - } - } - } - - return $conflicts; - } - - /** - * Detect installed plugins/themes not compatible with the target Grav version. - * - * @param string $targetVersion Target Grav version (e.g. '1.8.0') - * @return array{blocking: array, warnings: array, target: string} - */ - protected function detectIncompatiblePackages(string $targetVersion): array - { - $parts = explode('.', $targetVersion); - $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7'); - - $blocking = []; - $warnings = []; - - $pluginDirs = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; - foreach ($pluginDirs as $dir) { - $slug = basename($dir); - $compat = $this->readBlueprintCompatibility($dir); - - if (in_array($targetMajorMinor, $compat['grav'], true)) { - continue; - } - - $version = $this->readBlueprintVersion($dir) ?? 'unknown'; - $enabled = $this->isPluginEnabled($slug); - - $entry = [ - 'type' => 'plugin', - 'version' => $version, - 'compatibility' => $compat, - 'enabled' => $enabled, - ]; - - if ($enabled) { - $blocking[$slug] = $entry; - } else { - $warnings[$slug] = $entry; - } - } - - $themeDirs = glob($this->rootPath . '/user/themes/*', GLOB_ONLYDIR) ?: []; - foreach ($themeDirs as $dir) { - $slug = basename($dir); - $compat = $this->readBlueprintCompatibility($dir); - - if (in_array($targetMajorMinor, $compat['grav'], true)) { - continue; - } - - $version = $this->readBlueprintVersion($dir) ?? 'unknown'; - $active = $this->isThemeEnabled($slug); - - $entry = [ - 'type' => 'theme', - 'version' => $version, - 'compatibility' => $compat, - 'enabled' => $active, - ]; - - if ($active) { - $blocking[$slug] = $entry; - } else { - $warnings[$slug] = $entry; - } - } - - return [ - 'blocking' => $blocking, - 'warnings' => $warnings, - 'target' => $targetMajorMinor, - ]; - } - - /** - * Read the compatible Grav versions from a package's blueprints.yaml. - * - * @param string $dir Package directory - * @return array{grav: string[], api: string[]} - */ - protected function readBlueprintCompatibility(string $dir): array - { - $file = $dir . '/blueprints.yaml'; - if (!is_file($file)) { - return ['grav' => [], 'api' => []]; - } - - try { - $contents = @file_get_contents($file); - if ($contents === false) { - return ['grav' => [], 'api' => []]; - } - $data = Yaml::parse($contents); - if (!is_array($data)) { - return ['grav' => [], 'api' => []]; - } - - if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) { - return [ - 'grav' => array_map('strval', $data['compatibility']['grav']), - 'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api']) - ? array_map('strval', $data['compatibility']['api']) - : [], - ]; - } - - return $this->inferCompatibleVersions($data['dependencies'] ?? []); - } catch (\Throwable $e) { - return ['grav' => [], 'api' => []]; - } - } - - /** - * Infer compatible Grav versions from a package's dependency list. - * - * @param array $dependencies - * @return array{grav: string[], api: string[]} - */ - protected function inferCompatibleVersions(array $dependencies): array - { - foreach ($dependencies as $dep) { - if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { - continue; - } - $version = $dep['version'] ?? ''; - - if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { - continue; - } - - if (version_compare($m[1], '1.8', '>=')) { - return ['grav' => ['1.8'], 'api' => []]; - } - - return ['grav' => ['1.7'], 'api' => []]; - } - - return ['grav' => ['1.7'], 'api' => []]; - } - - /** - * Read the version string from a package's blueprints.yaml. - * - * @param string $dir Package directory - * @return string|null - */ - protected function readBlueprintVersion(string $dir): ?string - { - $file = $dir . '/blueprints.yaml'; - if (!is_file($file)) { - return null; - } - - try { - $contents = @file_get_contents($file); - if ($contents === false) { - return null; - } - $data = Yaml::parse($contents); - if (is_array($data) && isset($data['version'])) { - return (string)$data['version']; - } - } catch (\Throwable $e) { - // ignore - } - - return null; - } - - /** - * 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; - } - - // CRITICAL: Ensure 'user' is always in the ignored directories list - if (!in_array('user', $strategic, true)) { - throw new RuntimeException( - 'SAFETY VIOLATION: user directory is not in the ignored directories list. ' . - 'This is a critical configuration error that could result in data loss.' - ); - } - - $live = $this->rootPath . '/' . $relative; - $stage = $packagePath . '/' . $relative; - - Folder::delete($stage); - - if (!is_dir($live)) { - continue; - } - - // Use empty placeholders to preserve directory structure without duplicating data. - Folder::create($stage); - } - } - - /** - * 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, true); - } elseif ($entry->isFile()) { - Folder::create(dirname($target)); - copy($source, $target); - } - } - } - - /** - * 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)); - - // CRITICAL: Ensure 'user' is always in the skip list - if (!in_array('user', $skip, true)) { - $skip[] = 'user'; - } - - $iterator = new DirectoryIterator($this->rootPath); - foreach ($iterator as $entry) { - if ($entry->isDot()) { - continue; - } - - $name = $entry->getFilename(); - if ($name === '' || $name[0] === '.') { - continue; - } - - // CRITICAL SAFETY CHECK: Never copy 'user' directory - if ($name === 'user') { - continue; - } - - if (in_array($name, $skip, true)) { - continue; - } - - if (!$entry->isDir() || $entry->isLink()) { - continue; - } - - $target = $packagePath . DIRECTORY_SEPARATOR . $name; - if (file_exists($target)) { - continue; - } - - $source = $entry->getPathname(); - Folder::create(dirname($target)); - - Folder::rcopy($source, $target, true); - } - } - - /** - * 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 $entries): 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, - 'entries' => array_values($entries), - 'plugins' => $plugins, - ]; - } - - /** - * Ensure Git metadata is retained after stage promotion. - * - * @param string $source - * @param string $destination - * @return void - */ - /** - * 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)); - } - - /** - * Ensure directory exists and is writable. - * - * @param string $path - * @return 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, '~\/'); - } else { - return null; - } - } - if (!$this->isAbsolutePath($expanded)) { - $expanded = $this->rootPath . DIRECTORY_SEPARATOR . ltrim($expanded, DIRECTORY_SEPARATOR); - } - - $expanded = $this->normalizePath($expanded); - - try { - 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 ($path[0] === '/' || $path[0] === '\\') { - 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); - } - - /** - * @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 newest snapshots based on configuration. - * - * @return void - */ - private function pruneOldSnapshots(): void - { - $limit = 5; - - if ($this->config) { - $limit = (int)$this->config->get('system.updates.safe_upgrade_snapshot_limit', 5); - } else { - try { - $grav = Grav::instance(); - if (isset($grav['config'])) { - $limit = (int)$grav['config']->get('system.updates.safe_upgrade_snapshot_limit', 5); - } - } catch (Throwable $e) { - // Fallback to default - } - } - - if ($limit <= 0) { - return; - } - - $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; - if (count($files) <= $limit) { - return; - } - - $manifests = []; - foreach ($files as $file) { - $content = file_get_contents($file); - if ($content === false) { - continue; - } - $data = json_decode($content, true); - if (is_array($data) && isset($data['created_at'])) { - $manifests[] = [ - 'path' => $file, - 'created_at' => $data['created_at'], - 'backup_path' => $data['backup_path'] ?? null - ]; - } - } - - // Sort by created_at descending - usort($manifests, static function ($a, $b) { - $result = $b['created_at'] <=> $a['created_at']; - if ($result === 0) { - return strcmp($b['path'], $a['path']); - } - - return $result; - }); - - $toDelete = array_slice($manifests, $limit); - - foreach ($toDelete as $item) { - // Delete manifest - @unlink($item['path']); - - // Delete backup directory if it exists - if ($item['backup_path'] && is_dir($item['backup_path'])) { - // Ensure we are deleting a directory inside staging root to be safe - if (strpos($item['backup_path'], $this->stagingRoot) === 0) { - Folder::delete($item['backup_path']); - } - } - } - } - - /** - * Check PHP requirements from the package's defines.php file. - * - * @param string $extractedPath Path to extracted package - * @return array{meets_requirements: bool, required_version: string, current_version: string} - */ - private function checkPackagePhpRequirements(string $extractedPath): array - { - $result = [ - 'meets_requirements' => true, - 'required_version' => GRAV_PHP_MIN, - 'current_version' => PHP_VERSION, - ]; - - // Look for defines.php in the package (could be at root or in system/) - $definesPath = null; - $candidates = [ - $extractedPath . DIRECTORY_SEPARATOR . 'system' . DIRECTORY_SEPARATOR . 'defines.php', - $extractedPath . DIRECTORY_SEPARATOR . 'defines.php', - ]; - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - $definesPath = $candidate; - break; - } - } - - if ($definesPath === null) { - // No defines.php found, fall back to current GRAV_PHP_MIN - $result['meets_requirements'] = version_compare(PHP_VERSION, GRAV_PHP_MIN, '>='); - return $result; - } - - // Read and parse the defines.php to extract GRAV_PHP_MIN - $content = file_get_contents($definesPath); - if ($content === false) { - $result['meets_requirements'] = version_compare(PHP_VERSION, GRAV_PHP_MIN, '>='); - return $result; - } - - // Match patterns like: define('GRAV_PHP_MIN', '8.3.0'); or define("GRAV_PHP_MIN", "8.3.0"); - if (preg_match('/define\s*\(\s*[\'"]GRAV_PHP_MIN[\'"]\s*,\s*[\'"]([^"\']+)[\'"]\s*\)/', $content, $matches)) { - $result['required_version'] = $matches[1]; - $result['meets_requirements'] = version_compare(PHP_VERSION, $matches[1], '>='); - } - - return $result; - } -} diff --git a/system/src/Grav/Console/Application/GpmApplication.php b/system/src/Grav/Console/Application/GpmApplication.php index 4dc6f779d..39049236b 100644 --- a/system/src/Grav/Console/Application/GpmApplication.php +++ b/system/src/Grav/Console/Application/GpmApplication.php @@ -14,7 +14,6 @@ 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; @@ -39,7 +38,6 @@ class GpmApplication extends Application new UpdateCommand(), new SelfupgradeCommand(), new PreflightCommand(), - new RollbackCommand(), new DirectInstallCommand(), ]); } diff --git a/system/src/Grav/Console/Application/GravApplication.php b/system/src/Grav/Console/Application/GravApplication.php index ad0d2be3f..850f453eb 100644 --- a/system/src/Grav/Console/Application/GravApplication.php +++ b/system/src/Grav/Console/Application/GravApplication.php @@ -20,7 +20,6 @@ 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; @@ -50,7 +49,6 @@ 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 deleted file mode 100644 index 0984767d5..000000000 --- a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php +++ /dev/null @@ -1,96 +0,0 @@ -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 { - $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; - } catch (Throwable $e) { - $manager->ensureJobResult([ - 'status' => 'error', - 'message' => $e->getMessage(), - ]); - $io->error($e->getMessage()); - - return 1; - } - } -} diff --git a/system/src/Grav/Console/Gpm/PreflightCommand.php b/system/src/Grav/Console/Gpm/PreflightCommand.php index 1ab13f6f0..8a22f3aeb 100644 --- a/system/src/Grav/Console/Gpm/PreflightCommand.php +++ b/system/src/Grav/Console/Gpm/PreflightCommand.php @@ -2,9 +2,8 @@ namespace Grav\Console\Gpm; -use Grav\Common\Grav; -use Grav\Common\Upgrade\SafeUpgradeService; use Grav\Console\GpmCommand; +use Grav\Installer\Install; use Symfony\Component\Console\Input\InputOption; use function json_encode; use const JSON_PRETTY_PRINT; @@ -22,8 +21,7 @@ class PreflightCommand extends GpmCommand protected function serve(): int { $io = $this->getIO(); - $service = $this->createSafeUpgradeService(); - $report = $service->preflight(); + $report = Install::instance()->generatePreflightReport(); $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']); @@ -81,21 +79,4 @@ class PreflightCommand extends GpmCommand return $hasIssues ? 2 : 0; } - - /** - * @return SafeUpgradeService - */ - protected function createSafeUpgradeService(): SafeUpgradeService - { - $config = null; - try { - $config = Grav::instance()['config'] ?? null; - } catch (\Throwable $e) { - $config = null; - } - - return new SafeUpgradeService([ - 'config' => $config, - ]); - } } diff --git a/system/src/Grav/Console/Gpm/RollbackCommand.php b/system/src/Grav/Console/Gpm/RollbackCommand.php deleted file mode 100644 index ffe60fc0b..000000000 --- a/system/src/Grav/Console/Gpm/RollbackCommand.php +++ /dev/null @@ -1,135 +0,0 @@ -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 b29364022..95b993515 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -16,8 +16,6 @@ use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; use Grav\Common\Grav; use Grav\Console\GpmCommand; -// NOTE: SafeUpgradeService removed - no longer used in this file -// Preflight is now handled in Install.php after downloading the package use Grav\Installer\Install; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; @@ -98,18 +96,6 @@ class SelfupgradeCommand extends GpmCommand 'Option to set the timeout in seconds when downloading the update (0 for no timeout)', 30 ) - ->addOption( - 'safe', - null, - InputOption::VALUE_NONE, - 'Force safe upgrade staging even if disabled in configuration' - ) - ->addOption( - 'legacy', - null, - InputOption::VALUE_NONE, - 'Force legacy in-place upgrade even if safe upgrade is enabled' - ) ->setDescription('Detects and performs an update of Grav itself when available') ->setHelp('The update command updates Grav itself when a new version is available'); } @@ -121,39 +107,8 @@ class SelfupgradeCommand extends GpmCommand { $input = $this->getInput(); $io = $this->getIO(); - $forceSafe = (bool) $input->getOption('safe'); - $forceLegacy = (bool) $input->getOption('legacy'); - $forcedMode = null; - if ($forceSafe && $forceLegacy) { - $io->error('Cannot force safe and legacy upgrade modes simultaneously.'); - - return 1; - } - - if ($forceSafe || $forceLegacy) { - $forcedMode = $forceSafe ? true : false; - // NOTE: Do not call Install::forceSafeUpgrade() here as it would load the old Install class - // before the upgrade package is extracted, causing a class redeclaration error. - // Instead, we set the config and also use an environment variable as a fallback. - putenv('GRAV_FORCE_SAFE_UPGRADE=' . ($forcedMode ? '1' : '0')); - try { - $grav = Grav::instance(); - if ($grav && isset($grav['config'])) { - $grav['config']->set('system.updates.safe_upgrade', $forcedMode); - } - } catch (\Throwable $e) { - // Ignore container bootstrap failures; mode override still applies via env var. - } - if ($forceSafe) { - $io->note('Safe upgrade staging forced for this run.'); - } else { - $io->warning('Legacy in-place upgrade forced for this run.'); - } - } - - try { - if (!class_exists(ZipArchive::class)) { + if (!class_exists(ZipArchive::class)) { $io->title('GPM Self Upgrade'); $io->error('php-zip extension needs to be enabled!'); @@ -167,10 +122,6 @@ class SelfupgradeCommand extends GpmCommand $this->displayGPMRelease(); - // NOTE: Preflight checks are now run in Install.php AFTER downloading the package. - // This ensures we use the NEW SafeUpgradeService from the package, not the old one. - // Running preflight here would load the OLD class into memory and prevent the new one from loading. - $update = $this->upgrader->getAssets()['grav-update']; $local = $this->upgrader->getLocalVersion(); @@ -276,13 +227,6 @@ 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); @@ -297,47 +241,7 @@ class SelfupgradeCommand extends GpmCommand $error = 1; } else { $io->writeln(" |- Success! "); - - $manifest = Install::instance()->getLastManifest(); - if (is_array($manifest) && ($manifest['id'] ?? null)) { - $snapshotId = (string) $manifest['id']; - $snapshotTimestamp = isset($manifest['created_at']) ? (int) $manifest['created_at'] : null; - $manifestPath = null; - if (isset($manifest['id'])) { - $manifestPath = 'user/data/upgrades/' . $manifest['id'] . '.json'; - } - $metadata = [ - 'scope' => 'core', - 'target_version' => $remote, - 'snapshot' => $snapshotId, - ]; - if (null !== $snapshotTimestamp) { - $metadata['snapshot_created_at'] = $snapshotTimestamp; - } - if ($manifestPath) { - $metadata['snapshot_manifest'] = $manifestPath; - } - - $recovery->markUpgradeWindow('core-upgrade', $metadata); - - $io->writeln(sprintf(" |- Recovery snapshot: %s", $snapshotId)); - if (null !== $snapshotTimestamp) { - $io->writeln(sprintf(" |- Snapshot captured: %s", date('c', $snapshotTimestamp))); - } - if ($manifestPath) { - $io->writeln(sprintf(" |- Manifest stored at: %s", $manifestPath)); - } - } else { - // Ensure recovery window remains active even if manifest could not be resolved. - $recovery->markUpgradeWindow('core-upgrade', [ - 'scope' => 'core', - 'target_version' => $remote, - ]); - } - $io->newLine(); - // Clear recovery flag - upgrade completed successfully - $recovery->closeUpgradeWindow(); } if ($this->tmp && is_dir($this->tmp)) { @@ -345,16 +249,6 @@ class SelfupgradeCommand extends GpmCommand } return $error; - } finally { - if (null !== $forcedMode) { - // Clean up environment variable - putenv('GRAV_FORCE_SAFE_UPGRADE'); - // Only call Install::forceSafeUpgrade if Install class has been loaded - if (class_exists(\Grav\Installer\Install::class, false)) { - Install::forceSafeUpgrade(null); - } - } - } } /** @@ -756,7 +650,6 @@ class SelfupgradeCommand extends GpmCommand 'bin/grav', 'bin/plugin', 'bin/gpm', - 'bin/restore', 'bin/composer.phar' ]; diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 6d8d1e445..121b10362 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -20,43 +20,28 @@ use Grav\Common\Plugins; use Grav\Common\Yaml; use RuntimeException; use Throwable; -use function array_slice; use function basename; use function class_exists; use function count; -use function date; use function dirname; use function explode; -use function floor; use function function_exists; use function file_get_contents; use function glob; use function iterator_to_array; use function is_dir; use function is_file; -use function is_link; use function method_exists; use function is_string; -use function is_writable; -use function json_encode; use function json_decode; -use function readlink; -use function array_fill_keys; use function array_map; use function array_pad; use function array_key_exists; -use function rsort; -use function sort; use function sprintf; use function strtolower; use function strpos; use function preg_match; -use function symlink; -use function time; -use function uniqid; -use function unlink; use const GRAV_ROOT; -use const JSON_PRETTY_PRINT; /** * Grav installer. @@ -156,19 +141,12 @@ final class Install /** @var VersionUpdater|null */ private $updater; - /** @var array|null */ - private $lastManifest = null; - /** @var static */ private static $instance; - /** @var bool|null */ - private static $forceSafeUpgrade = null; /** @var bool */ private static $allowPendingOverride = false; /** @var bool */ private static $allowIncompatibleOverride = false; - /** @var int|null */ - private static $snapshotLimit = null; /** @var callable|null */ private $progressCallback = null; /** @var array|null */ @@ -187,14 +165,10 @@ final class Install } /** - * Force safe-upgrade mode independently of system configuration. - * - * @param bool|null $state - * @return void + * @deprecated No-op stub kept for backward compatibility with pre-2.0 upgrade scripts. */ public static function forceSafeUpgrade(?bool $state = true): void { - self::$forceSafeUpgrade = $state; } private function __construct() @@ -370,8 +344,6 @@ ERR; throw new RuntimeException('Oops, installer was run without prepare()!', 500); } - $this->lastManifest = null; - try { if (null === $this->updater) { $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); @@ -381,7 +353,6 @@ ERR; // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema. $this->updater->install(); - $safeUpgradeRequested = $this->shouldUseSafeUpgrade(); $targetVersion = $this->getVersion(); if (null === $this->pendingPreflight) { $this->pendingPreflight = $this->runPreflightChecks($targetVersion); @@ -392,22 +363,7 @@ ERR; return; } - $snapshotManifest = null; - if ($safeUpgradeRequested) { - $snapshotManifest = $this->captureCoreSnapshot($targetVersion); - if ($snapshotManifest) { - $this->relayProgress('snapshot', sprintf('Snapshot %s captured.', $snapshotManifest['id']), 100); - } else { - $this->relayProgress('error', 'Safe upgrade requires a recovery snapshot, but snapshot capture failed.', null); - Installer::setError('Safe upgrade requires a recovery snapshot. Ensure tmp://grav-snapshots is writable and retry.'); - - return; - } - } - $progressMessage = $safeUpgradeRequested - ? 'Running Grav standard installer (safe mode)...' - : 'Running Grav standard installer...'; - $this->relayProgress('installing', $progressMessage, null); + $this->relayProgress('installing', 'Running Grav standard installer...', null); Installer::install( $this->zip ?? '', @@ -433,352 +389,6 @@ ERR; } } - /** - * @return bool - */ - private function shouldUseSafeUpgrade(): bool - { - if (null !== self::$forceSafeUpgrade) { - return self::$forceSafeUpgrade; - } - - $envValue = getenv('GRAV_FORCE_SAFE_UPGRADE'); - if (false !== $envValue && '' !== $envValue) { - return $envValue === '1'; - } - - try { - $grav = Grav::instance(); - if ($grav && isset($grav['config'])) { - $configValue = $grav['config']->get('system.updates.safe_upgrade'); - if ($configValue !== null) { - return (bool) $configValue; - } - } - } catch (\Throwable $e) { - // ignore bootstrap failures - } - - return false; - } - - private function getSafeUpgradeSnapshotLimit(): int - { - if (null !== self::$snapshotLimit) { - return self::$snapshotLimit; - } - - $limit = 5; - - try { - $grav = Grav::instance(); - if ($grav && isset($grav['config'])) { - $configured = $grav['config']->get('system.updates.safe_upgrade_snapshot_limit'); - if ($configured !== null) { - $limit = (int)$configured; - } - } - } catch (\Throwable $e) { - // ignore bootstrap failures - } - - if ($limit < 0) { - $limit = 0; - } - - self::$snapshotLimit = $limit; - - return $limit; - } - - private function captureCoreSnapshot(string $targetVersion): ?array - { - $entries = $this->collectSnapshotEntries(); - if (!$entries) { - return null; - } - - $snapshotRoot = $this->resolveSnapshotStore(); - if (!$snapshotRoot) { - return null; - } - - $snapshotId = 'snapshot-' . date('YmdHis'); - $snapshotPath = $snapshotRoot . '/' . $snapshotId; - try { - Folder::create($snapshotPath); - } catch (\Throwable $e) { - error_log('[Grav Upgrade] Unable to create snapshot directory: ' . $e->getMessage()); - - return null; - } - - $total = count($entries); - foreach ($entries as $index => $entry) { - $percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null; - $this->relayProgress('snapshot', sprintf('Snapshotting %s (%d/%d)', $entry, $index + 1, $total), $percent); - - $source = GRAV_ROOT . '/' . $entry; - $destination = $snapshotPath . '/' . $entry; - - try { - $this->snapshotCopyEntry($source, $destination); - } catch (\Throwable $e) { - error_log('[Grav Upgrade] Snapshot copy failed for ' . $entry . ': ' . $e->getMessage()); - - return null; - } - } - - $manifest = [ - 'id' => $snapshotId, - 'created_at' => time(), - 'source_version' => GRAV_VERSION, - 'target_version' => $targetVersion, - 'php_version' => PHP_VERSION, - 'entries' => $entries, - 'package_path' => null, - 'backup_path' => $snapshotPath, - 'operation' => 'upgrade', - 'mode' => 'pre-upgrade', - ]; - - $this->persistSnapshotManifest($manifest); - $this->lastManifest = $manifest; - $this->pruneOldSnapshots($snapshotRoot); - - return $manifest; - } - - private function collectSnapshotEntries(): array - { - $ignores = array_fill_keys($this->ignores, true); - $ignores['user'] = true; - - $entries = []; - try { - $iterator = new \DirectoryIterator(GRAV_ROOT); - foreach ($iterator as $item) { - if ($item->isDot()) { - continue; - } - - $name = $item->getFilename(); - if (isset($ignores[$name])) { - continue; - } - - $entries[] = $name; - } - } catch (\Throwable $e) { - error_log('[Grav Upgrade] Unable to enumerate snapshot entries: ' . $e->getMessage()); - - return []; - } - - sort($entries); - - return $entries; - } - - private function snapshotCopyEntry(string $source, string $destination): void - { - if (is_link($source)) { - $linkTarget = readlink($source); - Folder::create(dirname($destination)); - if (is_link($destination) || is_file($destination)) { - @unlink($destination); - } - if ($linkTarget !== false) { - @symlink($linkTarget, $destination); - } - - return; - } - - if (is_dir($source)) { - Folder::rcopy($source, $destination); - - return; - } - - Folder::create(dirname($destination)); - if (!@copy($source, $destination)) { - throw new RuntimeException(sprintf('Failed to copy file %s during snapshot.', $source)); - } - } - - private function resolveSnapshotStore(): ?string - { - $candidates = []; - try { - $grav = Grav::instance(); - if ($grav && isset($grav['locator'])) { - $path = $grav['locator']->findResource('tmp://grav-snapshots', true, true); - if ($path) { - $candidates[] = $path; - } - } - } catch (\Throwable $e) { - // ignore locator issues - } - $candidates[] = GRAV_ROOT . '/tmp/grav-snapshots'; - - foreach ($candidates as $candidate) { - if (!$candidate) { - continue; - } - - try { - Folder::create($candidate); - } catch (\Throwable $e) { - continue; - } - - if (is_dir($candidate) && is_writable($candidate)) { - return rtrim($candidate, '\\/'); - } - } - - error_log('[Grav Upgrade] Unable to locate writable snapshot directory; skipping snapshot.'); - - return null; - } - - private function persistSnapshotManifest(array $manifest): void - { - $store = GRAV_ROOT . '/user/data/upgrades'; - - try { - Folder::create($store); - $path = $store . '/' . $manifest['id'] . '.json'; - @file_put_contents($path, json_encode($manifest, JSON_PRETTY_PRINT)); - } catch (\Throwable $e) { - error_log('[Grav Upgrade] Unable to write snapshot manifest: ' . $e->getMessage()); - } - } - - private function pruneOldSnapshots(?string $snapshotRoot): void - { - $limit = $this->getSafeUpgradeSnapshotLimit(); - if ($limit <= 0) { - return; - } - - $manifestDir = GRAV_ROOT . '/user/data/upgrades'; - $files = glob($manifestDir . '/*.json'); - if (!$files) { - return; - } - - $manifests = []; - foreach ($files as $path) { - $decoded = null; - try { - $contents = @file_get_contents($path); - if ($contents !== false) { - $candidate = json_decode($contents, true); - if (is_array($candidate)) { - $decoded = $candidate; - } - } - } catch (\Throwable $e) { - $decoded = null; - } - - if (!$decoded || !$this->isSnapshotManifest($decoded)) { - continue; - } - - $manifests[] = [ - 'path' => $path, - 'manifest' => $decoded, - 'created_at' => (int)($decoded['created_at'] ?? 0), - ]; - } - - if (count($manifests) <= $limit) { - return; - } - - \usort($manifests, static function (array $a, array $b): int { - $delta = $b['created_at'] <=> $a['created_at']; - if ($delta !== 0) { - return $delta; - } - - return strcmp((string)$b['path'], (string)$a['path']); - }); - - $obsolete = array_slice($manifests, $limit); - $removed = 0; - - foreach ($obsolete as $entry) { - $manifestPath = (string)$entry['path']; - $manifest = $entry['manifest'] ?? null; - - $snapshotId = $manifest['id'] ?? basename($manifestPath, '.json'); - $backupPath = $manifest['backup_path'] ?? null; - - if ($backupPath && is_dir($backupPath)) { - try { - Folder::delete($backupPath); - } catch (\Throwable $e) { - error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $backupPath . ': ' . $e->getMessage()); - } - } elseif ($snapshotRoot && $snapshotId) { - $candidate = $snapshotRoot . '/' . $snapshotId; - if (is_dir($candidate)) { - try { - Folder::delete($candidate); - } catch (\Throwable $e) { - error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $candidate . ': ' . $e->getMessage()); - } - } - } - - if (!@unlink($manifestPath)) { - error_log('[Grav Upgrade] Unable to remove snapshot manifest: ' . $manifestPath); - continue; - } - - $removed++; - } - - if ($removed > 0) { - $this->relayProgress( - 'snapshot', - sprintf( - 'Pruned %d old snapshot%s (keeping latest %d).', - $removed, - $removed === 1 ? '' : 's', - $limit - ), - null - ); - } - } - - private function isSnapshotManifest(array $manifest): bool - { - $id = isset($manifest['id']) ? (string)$manifest['id'] : ''; - if ($id === '') { - return false; - } - - if (strpos($id, 'snapshot-') !== 0 && strpos($id, 'upgrade-') !== 0 && strpos($id, 'stage-') !== 0) { - return false; - } - - $backupPath = $manifest['backup_path'] ?? null; - if (!is_string($backupPath) || $backupPath === '') { - return false; - } - - return is_array($manifest['entries'] ?? null); - } - - /** * @return void * @throws RuntimeException @@ -912,7 +522,6 @@ ERR; 'bin/grav', 'bin/plugin', 'bin/gpm', - 'bin/restore', 'bin/composer.phar' ]; @@ -933,11 +542,11 @@ ERR; } /** - * @return array|null + * @deprecated Always returns null; snapshots removed in 2.0. */ public function getLastManifest(): ?array { - return $this->lastManifest; + return null; } public function generatePreflightReport(): array diff --git a/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php b/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php new file mode 100644 index 000000000..ec0e0fbc0 --- /dev/null +++ b/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php @@ -0,0 +1,124 @@ +localVersion = $local; + $this->remoteVersion = $remote; + } + + public function getLocalVersion(): string + { + return $this->localVersion; + } + + public function getRemoteVersion(): string + { + return $this->remoteVersion; + } +} + +class UpgraderFamilyTest extends TestCase +{ + private function make(string $local, string $remote): TestableUpgrader + { + return new TestableUpgrader($local, $remote); + } + + // ------------------------------------------------------------------ + // Cross-family: upgrades MUST be blocked + // ------------------------------------------------------------------ + + public function testOneEightToTwoZeroIsBlocked(): void + { + $u = $this->make('1.8.0-beta.28', '2.0.0'); + $this->assertFalse($u->isUpgradable(), '1.8→2.0 must be blocked'); + $this->assertTrue($u->isNextMajorAvailable(), '2.0 notice must fire'); + } + + public function testOneSevenToTwoZeroIsBlocked(): void + { + $u = $this->make('1.7.49', '2.0.0'); + $this->assertFalse($u->isUpgradable(), '1.7→2.0 must be blocked'); + $this->assertTrue($u->isNextMajorAvailable()); + } + + public function testOneSevenToOneEightIsBlocked(): void + { + // Different minor family — should also be blocked + $u = $this->make('1.7.49', '1.8.0-beta.1'); + $this->assertFalse($u->isUpgradable(), '1.7→1.8 must be blocked'); + $this->assertFalse($u->isNextMajorAvailable(), 'no major increment, so no notice'); + } + + // ------------------------------------------------------------------ + // Same family: upgrades MUST be allowed when remote is newer + // ------------------------------------------------------------------ + + public function testOneSevenSameFamilyUpgrade(): void + { + $u = $this->make('1.7.48', '1.7.49'); + $this->assertTrue($u->isUpgradable()); + $this->assertFalse($u->isNextMajorAvailable()); + } + + public function testOneEightPrereleaseUpgrade(): void + { + $u = $this->make('1.8.0-beta.28', '1.8.0-beta.29'); + $this->assertTrue($u->isUpgradable()); + $this->assertFalse($u->isNextMajorAvailable()); + } + + public function testTwoZeroSameFamilyUpgrade(): void + { + $u = $this->make('2.0.0', '2.0.1'); + $this->assertTrue($u->isUpgradable()); + $this->assertFalse($u->isNextMajorAvailable()); + } + + // ------------------------------------------------------------------ + // Same version: not upgradable + // ------------------------------------------------------------------ + + public function testSameVersionNotUpgradable(): void + { + $this->assertFalse($this->make('1.8.0-beta.28', '1.8.0-beta.28')->isUpgradable()); + $this->assertFalse($this->make('1.7.49', '1.7.49')->isUpgradable()); + $this->assertFalse($this->make('2.0.0', '2.0.0')->isUpgradable()); + } + + // ------------------------------------------------------------------ + // isNextMajorAvailable: only fires on a true major increment + // ------------------------------------------------------------------ + + public function testNextMajorNotFiredForMinorIncrement(): void + { + // 1.8 → 1.9 is a different minor, not a new major + $this->assertFalse($this->make('1.8.0', '1.9.0')->isNextMajorAvailable()); + } + + public function testNextMajorFiredCorrectly(): void + { + $this->assertTrue($this->make('1.9.0', '2.0.0')->isNextMajorAvailable()); + $this->assertTrue($this->make('2.1.0', '3.0.0')->isNextMajorAvailable()); + } + + public function testNextMajorNotFiredWhenOnTwoZero(): void + { + $this->assertFalse($this->make('2.0.0', '2.0.1')->isNextMajorAvailable()); + $this->assertFalse($this->make('2.0.0', '2.1.0')->isNextMajorAvailable()); + } +} diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php deleted file mode 100644 index da2f5d252..000000000 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ /dev/null @@ -1,250 +0,0 @@ -tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true); - Folder::create($this->tmpDir); - } - - protected function tearDown(): void - { - if (is_dir($this->tmpDir)) { - Folder::delete($this->tmpDir); - } - - parent::tearDown(); - } - - 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'] - ]; - public $monolog = [ - 'gamma' => [ - ['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError('] - ] - ]; - - protected function detectPendingPluginUpdates(): array - { - return $this->pending; - } - - protected function detectPsrLogConflicts(): array - { - return $this->conflicts; - } - - protected function detectMonologConflicts(): array - { - return $this->monolog; - } - }; - - $result = $service->preflight(); - - self::assertArrayHasKey('warnings', $result); - 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 - { - $service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService { - protected function detectPendingPluginUpdates(): array - { - throw new RuntimeException('Cannot reach GPM'); - } - - protected function detectPsrLogConflicts(): array - { - 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]); - } - - public function testPromoteAndRollback(): void - { - [$root, $manifestStore] = $this->prepareLiveEnvironment(); - $service = new SafeUpgradeService([ - 'root' => $root, - '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::assertFileExists($root . '/ORIGINAL'); - - $manifestFile = $manifestStore . '/' . $manifest['id'] . '.json'; - self::assertFileExists($manifestFile); - - $service->rollback($manifest['id']); - - self::assertFileExists($root . '/ORIGINAL'); - self::assertFileDoesNotExist($root . '/system/new.txt'); - - self::assertDirectoryExists($manifest['backup_path']); - } - - public function testPrunesOldSnapshots(): void - { - [$root, $manifestStore] = $this->prepareLiveEnvironment(); - $service = new SafeUpgradeService([ - 'root' => $root, - 'manifest_store' => $manifestStore, - ]); - - $manifests = []; - for ($i = 0; $i < 6; $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(5, $files); - - // Verify the oldest one (index 0) is gone - $oldestManifestId = $manifests[0]['id']; - self::assertFileDoesNotExist($manifestStore . '/' . $oldestManifestId . '.json'); - self::assertDirectoryDoesNotExist($manifests[0]['backup_path']); - - // Verify the newest one (index 5) exists - $newestManifestId = $manifests[5]['id']; - self::assertFileExists($manifestStore . '/' . $newestManifestId . '.json'); - self::assertDirectoryExists($manifests[5]['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, - ]); - - $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); - $method->setAccessible(true); - $conflicts = $method->invoke($service); - - 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, - ]); - - $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(); - $flag = $root . '/user/data/recovery.flag'; - $window = $root . '/user/data/recovery.window'; - Folder::create(dirname($flag)); - file_put_contents($flag, 'flag'); - Folder::create(dirname($window)); - file_put_contents($window, json_encode(['expires_at' => time() + 120])); - - $service = new SafeUpgradeService([ - 'root' => $root, - ]); - $service->clearRecoveryFlag(); - - self::assertFileDoesNotExist($flag); - self::assertFileExists($window); - } - - /** - * @return array{0:string,1:string} - */ - private function prepareLiveEnvironment(): array - { - $root = $this->tmpDir . '/root'; - $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, $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 deleted file mode 100644 index e8f70d7c0..000000000 --- a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php +++ /dev/null @@ -1,150 +0,0 @@ - [], - 'psr_log_conflicts' => [], - 'monolog_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']], - 'monolog_conflicts' => ['gamma' => [['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']]], - '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); - self::assertStringContainsString('gamma', $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(?string $targetVersion = null): 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 deleted file mode 100644 index d5fee85c6..000000000 --- a/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php +++ /dev/null @@ -1,245 +0,0 @@ -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 $question): mixed - { - if ($this->responses) { - return array_shift($this->responses); - } - - return parent::askQuestion($question); - } -}