grav = $grav ?? Grav::instance(); $this->recovery = $this->grav['recovery']; $this->logger = $this->grav['log'] ?? null; $locator = $this->grav['locator']; $this->progressDir = $locator->findResource('user://data/upgrades', true, true); $this->jobsDir = $this->progressDir . '/jobs'; Folder::create($this->jobsDir); $this->setJobId(null); } protected function setJobId(?string $jobId): void { $this->jobId = $jobId ?: null; if ($this->jobId) { $jobDir = $this->getJobDir($this->jobId); Folder::create($jobDir); $this->jobManifestPath = $jobDir . '/' . self::JOB_MANIFEST; $this->progressPath = $jobDir . '/' . self::JOB_PROGRESS; $this->log(sprintf('Safe upgrade job %s activated', $this->jobId), 'debug'); } else { $this->jobManifestPath = null; $this->progressPath = $this->progressDir . '/' . self::PROGRESS_FILENAME; $this->log('Safe upgrade job context cleared', 'debug'); } } public function clearJobContext(): void { $this->setJobId(null); } /** * @return array */ public function listSnapshots(): 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; } $createdAt = isset($decoded['created_at']) ? (int)$decoded['created_at'] : 0; $snapshots[] = [ 'id' => (string)$decoded['id'], 'source_version' => $decoded['source_version'] ?? null, 'target_version' => $decoded['target_version'] ?? null, 'created_at' => $createdAt, 'created_at_iso' => $createdAt > 0 ? date('c', $createdAt) : null, 'backup_path' => $decoded['backup_path'] ?? null, 'package_path' => $decoded['package_path'] ?? null, ]; } return $snapshots; } public function hasSnapshots(): bool { return !empty($this->listSnapshots()); } /** * @param string $snapshotId * @return array{status:string,message:?string,manifest:array|null} */ public function restoreSnapshot(string $snapshotId): array { try { $safeUpgrade = $this->getSafeUpgradeService(); $manifest = $safeUpgrade->rollback($snapshotId); } catch (RuntimeException $e) { return [ 'status' => 'error', 'message' => $e->getMessage(), 'manifest' => null, ]; } catch (Throwable $e) { return [ 'status' => 'error', 'message' => $e->getMessage(), 'manifest' => null, ]; } if (!$manifest) { return [ 'status' => 'error', 'message' => sprintf('Snapshot %s not found.', $snapshotId), 'manifest' => null, ]; } return [ 'status' => 'success', 'message' => null, 'manifest' => $manifest, ]; } public function queueRestore(string $snapshotId): array { $snapshotId = trim($snapshotId); if ($snapshotId === '') { return [ 'status' => 'error', 'message' => 'Snapshot identifier is required.', ]; } $manifestPath = GRAV_ROOT . '/user/data/upgrades/' . $snapshotId . '.json'; if (!is_file($manifestPath)) { return [ 'status' => 'error', 'message' => sprintf('Snapshot %s not found.', $snapshotId), ]; } return $this->queue([ 'operation' => 'restore', 'snapshot_id' => $snapshotId, ]); } /** * @param array $snapshotIds * @return array */ public function deleteSnapshots(array $snapshotIds): array { $ids = array_values(array_unique(array_filter(array_map('strval', $snapshotIds)))); $results = []; foreach ($ids as $id) { $results[] = $this->deleteSnapshot($id); } return $results; } /** * @param string $snapshotId * @return array{id:string,status:string,message:?string} */ protected function deleteSnapshot(string $snapshotId): array { $manifestDir = GRAV_ROOT . '/user/data/upgrades'; $manifestPath = $manifestDir . '/' . $snapshotId . '.json'; if (!is_file($manifestPath)) { return [ 'id' => $snapshotId, 'status' => 'error', 'message' => sprintf('Snapshot %s not found.', $snapshotId), ]; } $manifest = json_decode(file_get_contents($manifestPath) ?: '', true); if (!is_array($manifest)) { return [ 'id' => $snapshotId, 'status' => 'error', 'message' => sprintf('Snapshot %s manifest is corrupted.', $snapshotId), ]; } $errors = []; foreach (['package_path', 'backup_path'] as $key) { $path = isset($manifest[$key]) ? (string)$manifest[$key] : ''; if ($path === '' || !file_exists($path)) { continue; } try { if (is_dir($path)) { Folder::delete($path); } else { @unlink($path); } } catch (Throwable $e) { $errors[] = $e->getMessage(); } } if (!@unlink($manifestPath)) { $errors[] = sprintf('Unable to delete manifest file %s.', $manifestPath); } if ($errors) { return [ 'id' => $snapshotId, 'status' => 'error', 'message' => implode(' ', $errors), ]; } return [ 'id' => $snapshotId, 'status' => 'success', 'message' => sprintf('Snapshot %s removed.', $snapshotId), ]; } protected function getJobDir(string $jobId): string { return $this->jobsDir . '/' . $jobId; } protected function generateJobId(): string { return 'job-' . gmdate('YmdHis') . '-' . substr(md5(uniqid('', true)), 0, 8); } protected function log(string $message, string $level = 'info'): void { if (!$this->logger) { return; } try { if (method_exists($this->logger, $level)) { $this->logger->$level('[SafeUpgrade] ' . $message); } else { $this->logger->info('[SafeUpgrade] ' . $message); } } catch (Throwable $e) { // ignore logging errors } } protected function writeManifest(array $data): void { if (!$this->jobManifestPath) { return; } try { $existing = []; if (is_file($this->jobManifestPath)) { $decoded = json_decode((string)file_get_contents($this->jobManifestPath), true); if (is_array($decoded)) { $existing = $decoded; } } $payload = $existing + [ 'id' => $this->jobId, 'created_at' => time(), ]; $payload = array_merge($payload, $data, [ 'updated_at' => time(), ]); Folder::create(dirname($this->jobManifestPath)); file_put_contents($this->jobManifestPath, json_encode($payload, JSON_PRETTY_PRINT)); if (!empty($data['status'])) { $this->log(sprintf('Job %s status -> %s', $payload['id'] ?? $this->jobId ?? 'unknown', $data['status']), 'debug'); } } catch (Throwable $e) { // ignore manifest write failures } } public function updateJob(array $data): void { $this->writeManifest($data); } public function ensureJobResult(array $result): void { if (!$this->jobManifestPath) { return; } $status = $result['status'] ?? null; $progress = $this->getProgress(); if ($status === 'success') { $targetVersion = $result['version'] ?? ($result['manifest']['target_version'] ?? null); $manifest = $result['manifest'] ?? null; if (($progress['stage'] ?? null) !== 'complete') { $extras = []; if ($targetVersion !== null) { $extras['target_version'] = $targetVersion; } if ($manifest !== null) { $extras['manifest'] = $manifest; } $this->setProgress('complete', 'Upgrade complete.', 100, $extras); $progress = $this->getProgress(); } $this->updateJob([ 'status' => 'success', 'completed_at' => time(), 'result' => $result, 'progress' => $progress, ]); return; } if ($status === 'error') { $message = $result['message'] ?? 'Safe upgrade failed.'; if (($progress['stage'] ?? null) !== 'error') { $this->setProgress('error', $message, null, ['message' => $message]); $progress = $this->getProgress(); } $this->updateJob([ 'status' => 'error', 'completed_at' => time(), 'result' => $result, 'progress' => $progress, 'error' => $message, ]); return; } if ($status === 'noop' || $status === 'finalized') { if (($progress['stage'] ?? null) !== 'complete') { $this->setProgress('complete', $progress['message'] ?? 'Upgrade complete.', 100, [ 'target_version' => $result['version'] ?? null, 'manifest' => $result['manifest'] ?? null, ]); $progress = $this->getProgress(); } $this->updateJob([ 'status' => $status, 'completed_at' => time(), 'result' => $result, 'progress' => $progress, ]); } } public function markJobError(string $message): void { $this->setProgress('error', $message, null, ['message' => $message]); } protected function readManifest(?string $path = null): array { $target = $path ?? $this->jobManifestPath; if (!$target || !is_file($target)) { return []; } $decoded = json_decode((string)file_get_contents($target), true); return is_array($decoded) ? $decoded : []; } public function loadJob(string $jobId): array { $this->setJobId($jobId); return $this->readManifest(); } public function getJobStatus(string $jobId): array { $manifest = $this->loadJob($jobId); $progress = $this->getProgress(); $result = [ 'job' => $manifest ?: null, 'progress' => $progress, 'context' => $this->buildStatusContext(), ]; $this->clearJobContext(); return $result; } public function queue(array $options = []): array { $operation = $options['operation'] ?? 'upgrade'; $options['operation'] = $operation; $this->resetProgress(); $jobId = $this->generateJobId(); $this->setJobId($jobId); $jobDir = $this->getJobDir($jobId); Folder::create($jobDir); $logPath = $jobDir . '/worker.log'; $timestamp = time(); $manifest = [ 'id' => $jobId, 'status' => 'queued', 'options' => $options, 'log' => $logPath, 'created_at' => $timestamp, 'started_at' => null, 'completed_at' => null, ]; $this->writeManifest($manifest); try { file_put_contents($logPath, '[' . gmdate('c') . "] Job {$jobId} queued\n"); } catch (Throwable $e) { // ignore log write failures } $this->log(sprintf('Queued safe upgrade job %s', $jobId)); $queueMessage = $operation === 'restore' ? 'Waiting for restore worker...' : 'Waiting for upgrade worker...'; $this->setProgress('queued', $queueMessage, 0, ['job_id' => $jobId, 'status' => 'queued', 'operation' => $operation]); if (!function_exists('proc_open')) { $message = 'proc_open() is disabled on this server; unable to run safe upgrade worker.'; $this->writeManifest([ 'status' => 'error', 'error' => $message, ]); $this->setProgress('error', $message, null, ['job_id' => $jobId, 'operation' => $operation]); $this->clearJobContext(); return [ 'status' => 'error', 'message' => $message, 'operation' => $operation, ]; } try { $finder = new PhpExecutableFinder(); $phpPath = $finder->find(false) ?: PHP_BINARY; if (!$phpPath) { throw new RuntimeException('Unable to locate PHP CLI to start safe upgrade worker.'); } $gravPath = Utils::isWindows() ? GRAV_ROOT . '\\bin\\grav' : GRAV_ROOT . '/bin/grav'; if (!is_file($gravPath)) { throw new RuntimeException('Unable to locate Grav CLI binary.'); } if (Utils::isWindows()) { $commandLine = sprintf( 'start /B "" %s %s safe-upgrade:run --job=%s >> %s 2>&1', escapeshellarg($phpPath), escapeshellarg($gravPath), escapeshellarg($jobId), escapeshellarg($logPath) ); } else { $commandLine = sprintf( 'nohup %s %s safe-upgrade:run --job=%s >> %s 2>&1 &', escapeshellarg($phpPath), escapeshellarg($gravPath), escapeshellarg($jobId), escapeshellarg($logPath) ); } try { file_put_contents($logPath, '[' . gmdate('c') . "] Command: {$commandLine}\n", FILE_APPEND); } catch (Throwable $e) { // ignore log write failures } $this->log(sprintf('Spawn command for job %s: %s', $jobId, $commandLine), 'debug'); $process = Process::fromShellCommandline($commandLine, GRAV_ROOT, null, null, 3); $process->disableOutput(); $process->run(); } catch (Throwable $e) { $message = $e->getMessage(); $this->writeManifest([ 'status' => 'error', 'error' => $message, ]); $this->setProgress('error', $message, null, ['job_id' => $jobId, 'operation' => $operation]); $this->clearJobContext(); return [ 'status' => 'error', 'message' => $message, 'operation' => $operation, ]; } $this->writeManifest([ 'status' => 'running', 'started_at' => time(), ]); $this->log(sprintf('Safe upgrade job %s worker started', $jobId)); return [ 'status' => 'queued', 'job_id' => $jobId, 'log' => $logPath, 'progress' => $this->getProgress(), 'job' => $this->readManifest(), 'context' => $this->buildStatusContext(), 'operation' => $operation, ]; } /** * Execute preflight checks and return upgrade readiness data. * * @param bool $force * @return array */ public function preflight(bool $force = false): array { $this->resetProgress(); if (!class_exists(ZipArchive::class)) { return [ 'status' => 'error', 'message' => 'php-zip extension needs to be enabled.', ]; } try { $this->upgrader = new Upgrader($force); } catch (Throwable $e) { return [ 'status' => 'error', 'message' => $e->getMessage(), ]; } $local = $this->upgrader->getLocalVersion(); $remote = $this->upgrader->getRemoteVersion(); $releaseDate = $this->upgrader->getReleaseDate(); $assets = $this->upgrader->getAssets(); $package = $this->resolveAsset($assets, 'grav-update'); $payload = [ 'status' => 'ready', 'version' => [ 'local' => $local, 'remote' => $remote, 'release_date' => $releaseDate ? strftime('%c', strtotime($releaseDate)) : null, 'package_size' => $package['size'] ?? null, ], 'upgrade_available' => $this->upgrader->isUpgradable(), 'requirements' => [ 'meets' => $this->upgrader->meetsRequirements(), 'minimum_php' => $this->upgrader->minPHPVersion(), ], 'symlinked' => false, 'safe_upgrade' => [ 'enabled' => $this->isSafeUpgradeEnabled(), 'staging_ready' => true, 'error' => null, ], 'preflight' => [ 'warnings' => [], 'plugins_pending' => [], 'psr_log_conflicts' => [], 'monolog_conflicts' => [], ], ]; Installer::isValidDestination(GRAV_ROOT . '/system'); $payload['symlinked'] = Installer::IS_LINK === Installer::lastErrorCode(); try { $safeUpgrade = $this->getSafeUpgradeService(); $payload['preflight'] = $safeUpgrade->preflight(); } catch (RuntimeException $e) { $payload['safe_upgrade']['staging_ready'] = false; $payload['safe_upgrade']['error'] = $e->getMessage(); } catch (Throwable $e) { $payload['safe_upgrade']['staging_ready'] = false; $payload['safe_upgrade']['error'] = $e->getMessage(); } return $payload; } /** * Run the safe upgrade lifecycle. * * @param array $options * @return array */ public function run(array $options = []): array { $operation = isset($options['operation']) ? (string)$options['operation'] : 'upgrade'; if ($operation === 'restore') { return $this->runRestore($options); } $force = (bool)($options['force'] ?? false); $timeout = (int)($options['timeout'] ?? 30); $overwrite = (bool)($options['overwrite'] ?? false); $decisions = is_array($options['decisions'] ?? null) ? $options['decisions'] : []; $this->setProgress('initializing', 'Preparing safe upgrade...', null); if (!class_exists(ZipArchive::class)) { return $this->errorResult('php-zip extension needs to be enabled.'); } try { $this->upgrader = new Upgrader($force); } catch (Throwable $e) { return $this->errorResult($e->getMessage()); } $safeUpgradeEnabled = $this->isSafeUpgradeEnabled(); if (!$safeUpgradeEnabled) { return $this->errorResult('Safe upgrade is disabled in configuration.'); } $remoteVersion = $this->upgrader->getRemoteVersion(); $localVersion = $this->upgrader->getLocalVersion(); if (!$this->upgrader->meetsRequirements()) { $minPhp = $this->upgrader->minPHPVersion(); $message = sprintf( 'Grav requires PHP %s, current PHP version is %s.', $minPhp, PHP_VERSION ); return $this->errorResult($message, [ 'minimum_php' => $minPhp, 'current_php' => PHP_VERSION, ]); } if (!$overwrite && !$this->upgrader->isUpgradable()) { $result = $this->runFinalizeIfNeeded($localVersion); if ($result) { return $result; } return [ 'status' => 'noop', 'version' => $localVersion, 'message' => 'Grav is already up to date.', 'context' => $this->buildStatusContext(), ]; } Installer::isValidDestination(GRAV_ROOT . '/system'); if (Installer::IS_LINK === Installer::lastErrorCode()) { return $this->errorResult('Grav installation is symlinked, cannot perform upgrade.'); } try { $safeUpgrade = $this->getSafeUpgradeService(); } catch (Throwable $e) { return $this->errorResult($e->getMessage()); } if (defined('Monolog\\Logger::API') && \Monolog\Logger::API < 3) { class_exists(\Monolog\Logger::class); class_exists(\Monolog\Handler\AbstractHandler::class); class_exists(\Monolog\Handler\AbstractProcessingHandler::class); class_exists(\Monolog\Handler\StreamHandler::class); class_exists(\Monolog\Formatter\LineFormatter::class); } $preflight = $safeUpgrade->preflight(); if (!empty($preflight['plugins_pending'])) { return $this->errorResult('Plugins and/or themes require updates before upgrading Grav.', [ 'plugins_pending' => $preflight['plugins_pending'], ]); } $conflictError = $this->handleConflictDecisions($preflight, $decisions); if ($conflictError !== null) { return $conflictError; } $assets = $this->upgrader->getAssets(); $package = $this->resolveAsset($assets, 'grav-update'); if (!$package) { return $this->errorResult('Unable to locate Grav update package information.'); } if ($this->recovery && method_exists($this->recovery, 'markUpgradeWindow')) { // Newer Grav exposes upgrade window helpers; guard for older cores. $this->recovery->markUpgradeWindow('core-upgrade', [ 'scope' => 'core', 'target_version' => $remoteVersion, ]); } try { $file = $this->download($package, $timeout); $this->performInstall($file); $this->setProgress('installing', 'Preparing promotion...', null); } catch (Throwable $e) { $this->setProgress('error', $e->getMessage(), null); return $this->errorResult($e->getMessage()); } finally { if ($this->tmp && is_dir($this->tmp)) { Folder::delete($this->tmp); } $this->tmp = null; } $this->setProgress('finalizing', 'Finalizing upgrade...', null); $safeUpgrade->clearRecoveryFlag(); if ($this->recovery && method_exists($this->recovery, 'closeUpgradeWindow')) { $this->recovery->closeUpgradeWindow(); } $this->ensureExecutablePermissions(); $this->setProgress('finalizing', 'Finalizing upgrade...', null); $manifest = $this->resolveLatestManifest(); $this->setProgress('complete', 'Upgrade complete.', 100, [ 'target_version' => $remoteVersion, 'manifest' => $manifest, ]); if ($this->jobManifestPath) { $this->updateJob([ 'result' => [ 'status' => 'success', 'version' => $remoteVersion, 'previous_version' => $localVersion, 'manifest' => $manifest, ], ]); } return [ 'status' => 'success', 'version' => $remoteVersion, 'manifest' => $manifest, 'previous_version' => $localVersion, 'context' => $this->buildStatusContext(), ]; } public function runRestore(array $options): array { $snapshotId = isset($options['snapshot_id']) ? (string)$options['snapshot_id'] : ''; if ($snapshotId === '') { return $this->errorResult('Snapshot identifier is required.', ['operation' => 'restore']); } $this->setProgress('rollback', sprintf('Restoring snapshot %s...', $snapshotId), null, [ 'operation' => 'restore', 'snapshot' => $snapshotId, ]); $result = $this->restoreSnapshot($snapshotId); if (($result['status'] ?? 'error') !== 'success') { $message = $result['message'] ?? 'Snapshot restore failed.'; return $this->errorResult($message, [ 'operation' => 'restore', 'snapshot' => $snapshotId, ]); } $manifest = $result['manifest'] ?? []; $version = $manifest['source_version'] ?? $manifest['target_version'] ?? null; $this->setProgress('complete', sprintf('Snapshot %s restored.', $snapshotId), 100, [ 'operation' => 'restore', 'snapshot' => $snapshotId, 'version' => $version, ]); if ($this->jobManifestPath) { $this->updateJob([ 'result' => [ 'status' => 'success', 'snapshot' => $snapshotId, 'version' => $version, 'manifest' => $manifest, ], ]); } return [ 'status' => 'success', 'snapshot' => $snapshotId, 'version' => $version, 'manifest' => $manifest, 'context' => $this->buildStatusContext(), ]; } /** * Retrieve current progress payload. * * @return array */ public function getProgress(): array { if (!is_file($this->progressPath)) { return [ 'stage' => 'idle', 'message' => '', 'percent' => null, 'timestamp' => time(), ]; } $decoded = json_decode((string)file_get_contents($this->progressPath), true); if (!is_array($decoded)) { return [ 'stage' => 'idle', 'message' => '', 'percent' => null, 'timestamp' => time(), ]; } return $decoded; } /** * Reset progress file to idle state. * * @return void */ public function resetProgress(): void { $this->setProgress('idle', '', null); } /** * @return SafeUpgradeService */ protected function getSafeUpgradeService(): SafeUpgradeService { if ($this->safeUpgrade instanceof SafeUpgradeService) { return $this->safeUpgrade; } $config = null; try { $config = $this->grav['config'] ?? null; } catch (Throwable $e) { $config = null; } $this->safeUpgrade = new SafeUpgradeService([ 'config' => $config, ]); if (method_exists($this->safeUpgrade, 'setProgressCallback')) { $this->safeUpgrade->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { $this->setProgress($stage, $message, $percent); }); } return $this->safeUpgrade; } /** * @return bool */ protected function isSafeUpgradeEnabled(): bool { try { $config = $this->grav['config'] ?? null; if ($config === null) { return true; } return (bool)$config->get('system.updates.safe_upgrade', true); } catch (Throwable $e) { return true; } } /** * @param array $preflight * @param array $decisions * @return array|null */ protected function handleConflictDecisions(array $preflight, array $decisions): ?array { $psrConflicts = $preflight['psr_log_conflicts'] ?? []; $monologConflicts = $preflight['monolog_conflicts'] ?? []; if ($psrConflicts) { $decision = $decisions['psr_log'] ?? null; $error = $this->applyConflictDecision( $decision, $psrConflicts, 'Disabled before upgrade because of psr/log conflict' ); if ($error !== null) { return $error; } } if ($monologConflicts) { $decision = $decisions['monolog'] ?? null; $error = $this->applyConflictDecision( $decision, $monologConflicts, 'Disabled before upgrade because of Monolog API conflict' ); if ($error !== null) { return $error; } } return null; } /** * @param string|null $decision * @param array $conflicts * @param string $disableNote * @return array|null */ protected function applyConflictDecision(?string $decision, array $conflicts, string $disableNote): ?array { if (!$conflicts) { return null; } $choice = $decision ?: 'abort'; if ($choice === 'abort') { return $this->errorResult('Upgrade aborted due to unresolved conflicts.', [ 'conflicts' => $conflicts, ]); } if ($choice === 'disable') { foreach (array_keys($conflicts) as $slug) { $this->recovery->disablePlugin($slug, ['message' => $disableNote]); } return null; } if ($choice === 'continue') { return null; } return $this->errorResult('Unknown conflict decision provided.', [ 'conflicts' => $conflicts, ]); } /** * @param array $package * @param int $timeout * @return string */ protected function download(array $package, int $timeout): string { $tmpDir = $this->grav['locator']->findResource('tmp://', true, true); $this->tmp = $tmpDir . '/grav-update-' . uniqid('', false); Folder::create($this->tmp); $this->setProgress('downloading', 'Downloading update...', 0, [ 'package_size' => $package['size'] ?? null, ]); $options = [ 'timeout' => max(0, $timeout), ]; $progressCallback = function (array $progress): void { $this->setProgress('downloading', 'Downloading update...', $progress['percent'], [ 'transferred' => $progress['transferred'], 'filesize' => $progress['filesize'], ]); }; $output = Response::get($package['download'], $options, $progressCallback); $this->setProgress('downloading', 'Download complete.', 100); $target = $this->tmp . '/' . $package['name']; file_put_contents($target, $output); return $target; } /** * @param string $zip * @return void */ protected function performInstall(string $zip): void { $this->setProgress('installing', 'Unpacking update...', null); $folder = Installer::unZip($zip, $this->tmp . '/zip'); if ($folder === false) { throw new RuntimeException(Installer::lastErrorMsg()); } $script = $folder . '/system/install.php'; if (!file_exists($script)) { throw new RuntimeException('Downloaded archive is not a valid Grav package.'); } $install = include $script; if (!is_callable($install)) { throw new RuntimeException('Unable to bootstrap installer from downloaded package.'); } try { $this->setProgress('installing', 'Running installer...', null); $install($zip); $this->setProgress('installing', 'Verifying files...', null); } catch (Throwable $e) { throw new RuntimeException($e->getMessage(), 0, $e); } $errorCode = Installer::lastErrorCode(); if ($errorCode) { throw new RuntimeException(Installer::lastErrorMsg()); } } /** * Attempt to run finalize scripts if Grav is already up to date but schema mismatched. * * @param string $localVersion * @return array|null */ protected function runFinalizeIfNeeded(string $localVersion): ?array { $config = $this->grav['config']; $schema = $config->get('versions.core.grav.schema'); if ($schema !== GRAV_SCHEMA && version_compare((string)$schema, GRAV_SCHEMA, '<')) { $this->setProgress('finalizing', 'Running post-install scripts...', null); Install::instance()->finalize(); $this->setProgress('complete', 'Post-install scripts executed.', 100, [ 'target_version' => $localVersion, ]); return [ 'status' => 'finalized', 'version' => $localVersion, 'message' => 'Post-install scripts completed.', 'context' => $this->buildStatusContext(), ]; } return null; } /** * Fetch most recent safe upgrade manifest if available. * * @return array|null */ protected function resolveLatestManifest(): ?array { $store = $this->grav['locator']->findResource('user://data/upgrades', false); if (!$store || !is_dir($store)) { return null; } $files = glob($store . '/*.json') ?: []; if (!$files) { return null; } rsort($files); $latest = $files[0]; $decoded = json_decode(file_get_contents($latest), true); return is_array($decoded) ? $decoded : null; } /** * Persist progress payload. * * @param string $stage * @param string $message * @param int|null $percent * @param array $extra * @return void */ protected function setProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void { $payload = [ 'stage' => $stage, 'message' => $message, 'percent' => $percent, 'timestamp' => time(), ] + $extra; if ($this->jobId) { $payload['job_id'] = $this->jobId; } try { Folder::create(dirname($this->progressPath)); file_put_contents($this->progressPath, json_encode($payload, JSON_PRETTY_PRINT)); if ($this->jobId) { $this->log(sprintf('Job %s stage -> %s (%s)', $this->jobId, $stage, $message), $stage === 'error' ? 'error' : 'debug'); } } catch (Throwable $e) { // ignore write failures } if ($this->jobManifestPath) { $status = 'running'; if ($stage === 'error') { $status = 'error'; } elseif ($stage === 'complete') { $status = 'success'; } $manifest = [ 'status' => $status, 'progress' => $payload, ]; if ($status === 'success') { $manifest['completed_at'] = time(); } if ($status === 'error' && isset($extra['message'])) { $manifest['error'] = $extra['message']; } $this->writeManifest($manifest); } } /** * Helper for building an error result payload. * * @param string $message * @param array $extra * @return array */ protected function errorResult(string $message, array $extra = []): array { $extraWithMessage = ['message' => $message] + $extra; $this->setProgress('error', $message, null, $extraWithMessage); if ($this->jobManifestPath) { $this->updateJob([ 'result' => [ 'status' => 'error', 'message' => $message, 'details' => $extra, ], 'status' => 'error', 'completed_at' => time(), ]); $this->log(sprintf('Safe upgrade job %s failed: %s', $this->jobId ?? 'n/a', $message), 'error'); } return [ 'status' => 'error', 'message' => $message, 'context' => $this->buildStatusContext(), ] + $extra; } protected function buildStatusContext(): ?string { $context = []; if ($this->jobManifestPath) { $context['manifest'] = $this->convertPathForContext($this->jobManifestPath); } if ($this->progressPath) { $context['progress'] = $this->convertPathForContext($this->progressPath); } if (!$context) { return null; } $encoded = json_encode($context); return $encoded === false ? null : base64_encode($encoded); } private function convertPathForContext(string $path): string { $normalized = str_replace('\\', '/', $path); $root = str_replace('\\', '/', GRAV_ROOT); if (strpos($normalized, $root) === 0) { $relative = substr($normalized, strlen($root)); return ltrim($relative, '/'); } return $normalized; } protected function ensureExecutablePermissions(): void { $executables = [ 'bin/grav', 'bin/plugin', 'bin/gpm', 'bin/restore', 'bin/composer.phar' ]; foreach ($executables as $relative) { $path = GRAV_ROOT . '/' . $relative; if (!is_file($path) || is_link($path)) { continue; } $perms = @fileperms($path); $mode = $perms !== false ? ($perms & 0777) : null; if ($mode !== 0755) { @chmod($path, 0755); $this->log(sprintf('Adjusted permissions on %s to 0755', $relative), 'debug'); } } } protected function resolveAsset(array $assets, string $prefix): ?array { if (isset($assets[$prefix])) { return $assets[$prefix]; } foreach ($assets as $key => $asset) { $name = is_array($asset) ? ($asset['name'] ?? '') : ''; $haystack = $key . ' ' . $name; if (stripos($haystack, $prefix) === 0 || stripos($haystack, '/' . $prefix) !== false) { return $asset; } } return null; } }