diff --git a/bin/restore b/bin/restore index b63d2d88d..4322ab3f5 100755 --- a/bin/restore +++ b/bin/restore @@ -25,6 +25,7 @@ if (!file_exists($root . '/index.php')) { exit(1); } +use Grav\Common\Filesystem\Folder; use Grav\Common\Recovery\RecoveryManager; use Grav\Common\Upgrade\SafeUpgradeService; use Symfony\Component\Yaml\Yaml; @@ -39,6 +40,9 @@ Usage: bin/restore apply [--staging-root=/absolute/path] Restores the specified snapshot created by safe-upgrade. + bin/restore remove [ ...] [--staging-root=/absolute/path] + Deletes one or more snapshots (interactive selection when no id provided). + bin/restore recovery [status|clear] Shows the recovery flag context or clears it. @@ -61,17 +65,35 @@ function parseArguments(array $args): array { array_shift($args); // remove script name - $command = $args[0] ?? 'help'; + $command = null; $arguments = []; $options = []; - foreach (array_slice($args, 1) as $arg) { - if (substr($arg, 0, 2) === '--') { - echo "Unknown option: {$arg}\n"; - exit(1); + while ($args) { + $arg = array_shift($args); + if (strncmp($arg, '--', 2) === 0) { + $parts = explode('=', substr($arg, 2), 2); + $name = $parts[0] ?? ''; + if ($name === '') { + continue; + } + $value = $parts[1] ?? null; + if ($value === null && $args && substr($args[0], 0, 2) !== '--') { + $value = array_shift($args); + } + $options[$name] = $value ?? true; + continue; } - $arguments[] = $arg; + if (null === $command) { + $command = $arg; + } else { + $arguments[] = $arg; + } + } + + if (null === $command) { + $command = 'interactive'; } return [ @@ -81,18 +103,19 @@ function parseArguments(array $args): array ]; } -/** - * @return string|null - */ /** * @param array $options * @return SafeUpgradeService */ function createUpgradeService(array $options): SafeUpgradeService { - $options['root'] = GRAV_ROOT; + $serviceOptions = ['root' => GRAV_ROOT]; - return new SafeUpgradeService($options); + if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') { + $serviceOptions['staging_root'] = $options['staging-root']; + } + + return new SafeUpgradeService($serviceOptions); } /** @@ -119,19 +142,311 @@ function loadSnapshots(): array 'id' => $decoded['id'], 'source_version' => $decoded['source_version'] ?? null, 'target_version' => $decoded['target_version'] ?? null, - 'created_at' => $decoded['created_at'] ?? 0, + 'created_at' => (int)($decoded['created_at'] ?? 0), ]; } return $snapshots; } +/** + * @param list $snapshots + * @return string + */ +function formatSnapshotListLine(array $snapshot): string +{ + $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; + $timeLabel = formatSnapshotTimestamp($snapshot['created_at']); + + return sprintf('%s (restore to Grav %s, %s)', $snapshot['id'], $restoreVersion, $timeLabel); +} + +function formatSnapshotTimestamp(int $timestamp): string +{ + if ($timestamp <= 0) { + return 'time unknown'; + } + + try { + $timezone = resolveTimezone(); + $dt = new DateTime('@' . $timestamp); + $dt->setTimezone($timezone); + $formatted = $dt->format('Y-m-d H:i:s T'); + } catch (\Throwable $e) { + $formatted = date('Y-m-d H:i:s T', $timestamp); + } + + return $formatted . ' (' . formatRelative(time() - $timestamp) . ')'; +} + +function resolveTimezone(): DateTimeZone +{ + static $resolved = null; + if ($resolved instanceof DateTimeZone) { + return $resolved; + } + + $timezone = null; + $configFile = GRAV_ROOT . '/user/config/system.yaml'; + if (is_file($configFile)) { + try { + $data = Yaml::parse(file_get_contents($configFile) ?: '') ?: []; + if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) { + $timezone = $data['system']['timezone']; + } + } catch (\Throwable $e) { + // ignore parse errors, fallback below + } + } + + if (!$timezone) { + $timezone = ini_get('date.timezone') ?: 'UTC'; + } + + try { + $resolved = new DateTimeZone($timezone); + } catch (\Throwable $e) { + $resolved = new DateTimeZone('UTC'); + } + + return $resolved; +} + +function formatRelative(int $seconds): string +{ + if ($seconds < 5) { + return 'just now'; + } + $negative = $seconds < 0; + $seconds = abs($seconds); + $units = [ + 31536000 => 'y', + 2592000 => 'mo', + 604800 => 'w', + 86400 => 'd', + 3600 => 'h', + 60 => 'm', + 1 => 's', + ]; + foreach ($units as $size => $label) { + if ($seconds >= $size) { + $value = (int)floor($seconds / $size); + $suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second'))))); + if ($value !== 1) { + $suffix .= 's'; + } + $phrase = $value . ' ' . $suffix; + return $negative ? 'in ' . $phrase : $phrase . ' ago'; + } + } + + return $negative ? 'in 0 seconds' : '0 seconds ago'; +} + +/** + * @param string $snapshotId + * @param array $options + * @return void + */ +function applySnapshot(string $snapshotId, array $options): void +{ + try { + $service = createUpgradeService($options); + $manifest = $service->rollback($snapshotId); + } catch (\Throwable $e) { + fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n"); + exit(1); + } + + if (!$manifest) { + fwrite(STDERR, "Snapshot {$snapshotId} not found.\n"); + exit(1); + } + + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; + echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; + if (!empty($manifest['id'])) { + echo "Snapshot manifest: {$manifest['id']}\n"; + } + if (!empty($manifest['backup_path'])) { + echo "Snapshot path: {$manifest['backup_path']}\n"; + } + exit(0); +} + +/** + * @param list $snapshots + * @return string|null + */ +function promptSnapshotSelection(array $snapshots): ?string +{ + echo "Available snapshots:\n"; + foreach ($snapshots as $index => $snapshot) { + $line = formatSnapshotListLine($snapshot); + $number = $index + 1; + echo sprintf(" [%d] %s\n", $number, $line); + } + + $default = $snapshots[0]['id']; + echo "\nSelect a snapshot to restore [1]: "; + $input = trim((string)fgets(STDIN)); + + if ($input === '') { + return $default; + } + + if (ctype_digit($input)) { + $idx = (int)$input - 1; + if (isset($snapshots[$idx])) { + return $snapshots[$idx]['id']; + } + } + + foreach ($snapshots as $snapshot) { + if (strcasecmp($snapshot['id'], $input) === 0) { + return $snapshot['id']; + } + } + + echo "Invalid selection. Aborting.\n"; + return null; +} + +/** + * @param list $snapshots + * @return array + */ +function promptSnapshotsRemoval(array $snapshots): array +{ + echo "Available snapshots:\n"; + foreach ($snapshots as $index => $snapshot) { + $line = formatSnapshotListLine($snapshot); + $number = $index + 1; + echo sprintf(" [%d] %s\n", $number, $line); + } + + echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): "; + $input = trim((string)fgets(STDIN)); + + if ($input === '') { + return []; + } + + $inputLower = strtolower($input); + if ($inputLower === 'all' || $inputLower === '*') { + return array_values(array_unique(array_column($snapshots, 'id'))); + } + + $tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $selected = []; + foreach ($tokens as $token) { + if (ctype_digit($token)) { + $idx = (int)$token - 1; + if (isset($snapshots[$idx])) { + $selected[] = $snapshots[$idx]['id']; + continue; + } + } + + foreach ($snapshots as $snapshot) { + if (strcasecmp($snapshot['id'], $token) === 0) { + $selected[] = $snapshot['id']; + break; + } + } + } + + return array_values(array_unique(array_filter($selected))); +} + +/** + * @param string $snapshotId + * @return array{success:bool,message:string} + */ +function removeSnapshot(string $snapshotId): array +{ + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + $manifestPath = $manifestDir . '/' . $snapshotId . '.json'; + if (!is_file($manifestPath)) { + return [ + 'success' => false, + 'message' => "Snapshot {$snapshotId} not found." + ]; + } + + $manifest = json_decode(file_get_contents($manifestPath) ?: '', true); + if (!is_array($manifest)) { + return [ + 'success' => false, + 'message' => "Snapshot {$snapshotId} manifest is invalid." + ]; + } + + $pathsToDelete = []; + foreach (['package_path', 'backup_path'] as $key) { + if (!empty($manifest[$key]) && is_string($manifest[$key])) { + $pathsToDelete[] = $manifest[$key]; + } + } + + $errors = []; + + foreach ($pathsToDelete as $path) { + if (!$path) { + continue; + } + if (!file_exists($path)) { + continue; + } + try { + if (is_dir($path)) { + Folder::delete($path); + } else { + @unlink($path); + } + } catch (\Throwable $e) { + $errors[] = "Unable to remove {$path}: " . $e->getMessage(); + } + } + + if (!@unlink($manifestPath)) { + $errors[] = "Unable to delete manifest file {$manifestPath}."; + } + + if ($errors) { + return [ + 'success' => false, + 'message' => implode(' ', $errors) + ]; + } + + return [ + 'success' => true, + 'message' => "Removed snapshot {$snapshotId}." + ]; +} + $cli = parseArguments($argv); $command = $cli['command']; $arguments = $cli['arguments']; $options = $cli['options']; switch ($command) { + case 'interactive': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n"; + exit(0); + } + + $selection = promptSnapshotSelection($snapshots); + if (!$selection) { + exit(1); + } + + applySnapshot($selection, $options); + break; + case 'list': $snapshots = loadSnapshots(); if (!$snapshots) { @@ -141,12 +456,60 @@ switch ($command) { echo "Available snapshots:\n"; foreach ($snapshots as $snapshot) { - $time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown'; - $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; - echo sprintf(" - %s (restore to Grav %s, %s)\n", $snapshot['id'], $restoreVersion, $time); + echo ' - ' . formatSnapshotListLine($snapshot) . "\n"; } exit(0); + case 'remove': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Nothing to remove.\n"; + exit(0); + } + + $selectedIds = []; + if ($arguments) { + foreach ($arguments as $arg) { + if (!$arg) { + continue; + } + $selectedIds[] = $arg; + } + } else { + $selectedIds = promptSnapshotsRemoval($snapshots); + if (!$selectedIds) { + echo "No snapshots selected. Aborting.\n"; + exit(1); + } + } + + $selectedIds = array_values(array_unique($selectedIds)); + echo "Snapshots selected for removal:\n"; + foreach ($selectedIds as $id) { + echo " - {$id}\n"; + } + + $autoConfirm = isset($options['yes']) || isset($options['y']); + if (!$autoConfirm) { + echo "\nThis action cannot be undone. Proceed? [y/N] "; + $confirmation = strtolower(trim((string)fgets(STDIN))); + if (!in_array($confirmation, ['y', 'yes'], true)) { + echo "Aborted.\n"; + exit(1); + } + } + + $success = 0; + foreach ($selectedIds as $id) { + $result = removeSnapshot($id); + echo $result['message'] . "\n"; + if ($result['success']) { + $success++; + } + } + + exit($success > 0 ? 0 : 1); + case 'apply': $snapshotId = $arguments[0] ?? null; if (!$snapshotId) { @@ -154,22 +517,8 @@ switch ($command) { exit(1); } - try { - $service = createUpgradeService($options); - $manifest = $service->rollback($snapshotId); - } catch (\Throwable $e) { - fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n"); - exit(1); - } - - if (!$manifest) { - fwrite(STDERR, "Snapshot {$snapshotId} not found.\n"); - exit(1); - } - - $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; - echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; - exit(0); + applySnapshot($snapshotId, $options); + break; case 'recovery': $action = strtolower($arguments[0] ?? 'status');