#!/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); }