Remove SafeUpgradeService, add family-aware GPM upgrade gate

- Remove SafeUpgradeService, RollbackCommand, SafeUpgradeRunCommand, and bin/restore
- Simplify Install.php: remove all snapshot logic; keep forceSafeUpgrade() as no-op stub
  for backward compat with pre-2.0 upgrade scripts that call it in finally blocks
- Simplify SelfupgradeCommand: remove --safe/--legacy flags, safe-upgrade window tracking,
  and manifest block; keep RecoveryManager plugin-disable calls
- Simplify PreflightCommand: use Install::instance()->generatePreflightReport() directly
- Simplify recovery.php: remove auth, rollback, and quarantine sections; keep
  clear-flag and disable-recovery actions
- Upgrader::isUpgradable() now returns false when remote is a different major.minor family
  (e.g. 1.8.x will not auto-upgrade to 2.0.x)
- Add Upgrader::isNextMajorAvailable() for future informational notices
- Add bin/gpm-cache-inject helper for local E2E testing without a live server
- Add UpgraderFamilyTest covering all cross-family and same-family scenarios (10/10)
This commit is contained in:
Andy Miller
2026-04-13 18:45:41 +01:00
parent 6324cca502
commit 98af1cb4e3
16 changed files with 332 additions and 3673 deletions

160
bin/gpm-cache-inject Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env php
<?php
/**
* GPM cache injector for testing family-aware upgrade behavior.
*
* Writes a fake GPM JSON response into the Grav file cache so you can test
* what happens when the server reports a particular version (e.g. 2.0.0)
* without actually deploying server-side changes.
*
* Usage:
* php bin/gpm-cache-inject [fake-version] [channel]
*
* Examples:
* php bin/gpm-cache-inject 2.0.0 # fake server returning 2.0.0 (stable channel)
* php bin/gpm-cache-inject 1.8.0-beta.29 # fake a specific 1.8 beta
* php bin/gpm-cache-inject 2.0.0 testing # fake 2.0.0 on the testing channel
* php bin/gpm-cache-inject --clear # delete cached GPM data (forces re-fetch)
*
* After running this, execute:
* bin/gpm selfupgrade
*
* Then restore real data with:
* php bin/gpm-cache-inject --clear
*/
if (!defined('GRAV_ROOT')) {
define('GRAV_ROOT', dirname(__DIR__));
}
define('DS', DIRECTORY_SEPARATOR);
// We only need the autoloader — no full Grav bootstrap
$autoload = GRAV_ROOT . '/vendor/autoload.php';
if (!file_exists($autoload)) {
fwrite(STDERR, "ERROR: vendor/autoload.php not found. Run: composer install\n");
exit(1);
}
require $autoload;
use Doctrine\Common\Cache\FilesystemCache;
// ── Parse arguments ───────────────────────────────────────────────────────────
$fakeVersion = null;
$channel = 'stable';
$clear = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--clear') {
$clear = true;
} elseif ($arg === 'stable' || $arg === 'testing') {
$channel = $arg;
} else {
$fakeVersion = $arg;
}
}
// ── Resolve GRAV_VERSION from this install ────────────────────────────────────
// Read from the system/src/defines.php or CHANGELOG rather than bootstrapping Grav
$changelogFile = GRAV_ROOT . '/CHANGELOG.md';
$gravVersion = 'unknown';
if (file_exists($changelogFile)) {
$first = fopen($changelogFile, 'r');
while (($line = fgets($first)) !== false) {
if (preg_match('/^#\s+([\d]+\.[\d]+\.[\d]+(?:[-\w.]+)?)/', $line, $m)) {
$gravVersion = $m[1];
break;
}
}
fclose($first);
}
if ($gravVersion === 'unknown') {
fwrite(STDERR, "ERROR: Could not determine GRAV_VERSION from CHANGELOG.md\n");
exit(1);
}
// ── Build the exact repository URL GravCore uses ─────────────────────────────
$repositoryUrl = 'https://getgrav.org/downloads/grav.json?v=' . $gravVersion . '&' . $channel . '=1';
$cacheKey = md5($repositoryUrl);
$cacheDir = GRAV_ROOT . '/cache/gpm';
echo "GRAV_VERSION : $gravVersion\n";
echo "Channel : $channel\n";
echo "URL (key) : $repositoryUrl\n";
echo "Cache key : $cacheKey\n";
echo "Cache dir : $cacheDir\n\n";
$cache = new FilesystemCache($cacheDir);
// ── Clear mode ────────────────────────────────────────────────────────────────
if ($clear) {
$cache->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);
}

View File

@@ -1,634 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Grav Snapshot Restore Utility
*
* Lightweight CLI that can list and apply safe-upgrade snapshots without
* bootstrapping the full Grav application (or any plugins).
*/
$root = dirname(__DIR__);
define('GRAV_CLI', true);
define('GRAV_REQUEST_TIME', microtime(true));
if (!file_exists($root . '/vendor/autoload.php')) {
fwrite(STDERR, "Unable to locate vendor/autoload.php. Run composer install first.\n");
exit(1);
}
$autoload = require $root . '/vendor/autoload.php';
if (!file_exists($root . '/index.php')) {
fwrite(STDERR, "FATAL: Must be run from Grav root directory.\n");
exit(1);
}
use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Yaml\Yaml;
const RESTORE_USAGE = <<<USAGE
Grav Restore Utility
Usage:
bin/restore list [--staging-root=/absolute/path]
Lists all available snapshots (most recent first).
bin/restore apply <snapshot-id> [--staging-root=/absolute/path]
Restores the specified snapshot created by safe-upgrade.
bin/restore remove [<snapshot-id> ...] [--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<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}>
*/
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<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}> $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<array{id:string,source_version:?string,target_version:?string,created_at:int}> $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<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
* @return array<string>
*/
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);
}

View File

@@ -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. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> 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. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> 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');
<?php endif; ?>
</div>
<?php if ($quarantine): ?>
<div class="card">
<h2>Quarantined Plugins</h2>
<p class="help-text" style="margin-top: 0;">These plugins have been automatically disabled due to errors:</p>
<ul class="quarantine-list">
<?php foreach ($quarantine as $entry): ?>
<li>
<span class="plugin-name"><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></span>
<span class="plugin-time">Disabled at <?php echo date('Y-m-d H:i:s', $entry['disabled_at']); ?></span>
<?php if (!empty($entry['message'])): ?>
<div style="margin-top: 4px; font-size: 0.85rem; color: #94a3b8;"><?php echo htmlspecialchars($entry['message'], ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card">
<h2>What would you like to do?</h2>
@@ -508,40 +368,6 @@ header('Content-Type: text/html; charset=utf-8');
</p>
</div>
<?php if ($latestSnapshot): ?>
<div class="card">
<h2>Rollback to Previous Version</h2>
<p style="margin-top: 0; color: #94a3b8;">If the error persists, you can rollback to a previous Grav version.</p>
<div class="snapshot-info">
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
<?php if (!empty($latestSnapshot['label'])): ?>
<small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
<?php endif; ?>
<small>Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?> &mdash; Created <?php echo date('Y-m-d H:i:s', (int)$latestSnapshot['created_at']); ?></small>
</div>
<?php if (!$authenticated): ?>
<p class="help-text">To rollback, enter the recovery token found in <code>user/data/recovery.flag</code></p>
<form method="post">
<input type="hidden" name="action" value="authenticate">
<label for="token">Recovery Token</label>
<input id="token" name="token" type="text" autocomplete="one-time-code" placeholder="Enter token from recovery.flag" required>
<div class="btn-group">
<button type="submit" class="btn btn-secondary">Authenticate for Rollback</button>
</div>
</form>
<?php else: ?>
<form method="post">
<input type="hidden" name="action" value="rollback">
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
<div class="btn-group">
<button type="submit" class="btn btn-danger">Rollback to This Snapshot</button>
</div>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</body>
</html>

View File

@@ -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
*

File diff suppressed because it is too large Load Diff

View File

@@ -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(),
]);
}

View File

@@ -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(),
]);
}
}

View File

@@ -1,96 +0,0 @@
<?php
/**
* @package Grav\Console\Cli
*
* Background worker for Safe Upgrade jobs.
*/
namespace Grav\Console\Cli;
use Grav\Console\GravCommand;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
class SafeUpgradeRunCommand extends GravCommand
{
protected function configure(): void
{
$this
->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;
}
}
}

View File

@@ -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,
]);
}
}

View File

@@ -1,135 +0,0 @@
<?php
namespace Grav\Console\Gpm;
use Grav\Common\Upgrade\SafeUpgradeService;
use Grav\Console\GpmCommand;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use function basename;
use function file_get_contents;
use function glob;
use function is_array;
use function json_decode;
use function pathinfo;
use const PATHINFO_FILENAME;
use const GRAV_ROOT;
class RollbackCommand extends GpmCommand
{
/** @var bool */
private $allYes = false;
protected function configure(): void
{
$this
->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('<info>Available snapshots:</info>');
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<int, 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();
}
}

View File

@@ -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 <info>update</info> 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<cyan>{$remote}</cyan>..");
/** @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(" |- <green>Success!</green> ");
$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: <cyan>%s</cyan>", $snapshotId));
if (null !== $snapshotTimestamp) {
$io->writeln(sprintf(" |- Snapshot captured: <white>%s</white>", date('c', $snapshotTimestamp)));
}
if ($manifestPath) {
$io->writeln(sprintf(" |- Manifest stored at: <white>%s</white>", $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'
];

View File

@@ -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

View File

@@ -0,0 +1,124 @@
<?php
namespace Grav\Common\GPM;
use PHPUnit\Framework\TestCase;
/**
* Testable subclass that bypasses the GravCore HTTP constructor so we can
* inject arbitrary local/remote version strings without any HTTP calls.
*/
class TestableUpgrader extends Upgrader
{
private string $localVersion;
private string $remoteVersion;
public function __construct(string $local, string $remote)
{
// Intentionally skip parent constructor — no HTTP, no Grav bootstrap needed.
$this->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());
}
}

View File

@@ -1,250 +0,0 @@
<?php
use Grav\Common\Filesystem\Folder;
use Grav\Common\Upgrade\SafeUpgradeService;
class SafeUpgradeServiceTest extends \PHPUnit\Framework\TestCase
{
/** @var string */
private $tmpDir;
protected function setUp(): void
{
parent::setUp();
$this->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'
<?php
class LoggerTest {
public function test(
\Monolog\Logger $logger
) {
$logger->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;
}
}

View File

@@ -1,150 +0,0 @@
<?php
use Grav\Console\Gpm\PreflightCommand;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Style\SymfonyStyle;
class PreflightCommandTest extends \PHPUnit\Framework\TestCase
{
public function testServeOutputsJsonWhenRequested(): void
{
$service = new StubSafeUpgradeService([
'plugins_pending' => [],
'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<int, string> */
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);
}
}

View File

@@ -1,245 +0,0 @@
<?php
use Grav\Console\Gpm\RollbackCommand;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
class RollbackCommandTest extends \PHPUnit\Framework\TestCase
{
public function testListSnapshotsOutputsEntries(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service);
$command->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<int, array> */
private $snapshots = [];
/** @var array<int, mixed> */
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<int, string> */
public $messages = [];
/** @var array<int, mixed> */
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);
}
}