diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index 4be5331fa..c179e4b24 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -185,7 +185,25 @@ class RecoveryManager * @param array $context * @return void */ - private function quarantinePlugin(string $slug, array $context): void + public function disablePlugin(string $slug, array $context = []): void + { + $context += [ + 'message' => $context['message'] ?? 'Disabled during upgrade preflight', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + 'created_at' => $context['created_at'] ?? time(), + 'plugin' => $context['plugin'] ?? $slug, + ]; + + $this->quarantinePlugin($slug, $context); + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + protected function quarantinePlugin(string $slug, array $context): void { $slug = trim($slug); if ($slug === '') { diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 6e5a5df12..c3092b013 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -15,6 +15,9 @@ use Grav\Common\Yaml; use InvalidArgumentException; use RuntimeException; use Throwable; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use FilesystemIterator; use function basename; use function count; use function dirname; @@ -94,16 +97,21 @@ class SafeUpgradeService } $psrLogConflicts = $this->detectPsrLogConflicts(); + $monologConflicts = $this->detectMonologConflicts(); if ($pending) { $warnings[] = 'One or more plugins/themes are not up to date.'; } if ($psrLogConflicts) { $warnings[] = 'Potential psr/log signature conflicts detected.'; } + if ($monologConflicts) { + $warnings[] = 'Potential Monolog logger API incompatibilities detected.'; + } return [ 'plugins_pending' => $pending, 'psr_log_conflicts' => $psrLogConflicts, + 'monolog_conflicts' => $monologConflicts, 'warnings' => $warnings, ]; } @@ -263,6 +271,47 @@ class SafeUpgradeService return $conflicts; } + /** + * Detect usage of deprecated Monolog `add*` methods removed in newer releases. + * + * @return array + */ + protected function detectMonologConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + $pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i'; + + foreach ($pluginRoots as $path) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile() || strtolower($file->getExtension()) !== 'php') { + continue; + } + + $contents = @file_get_contents($file->getPathname()); + if ($contents === false) { + continue; + } + + if (preg_match($pattern, $contents, $match)) { + $slug = basename($path); + $relative = str_replace($this->rootPath . '/', '', $file->getPathname()); + $conflicts[$slug][] = [ + 'file' => $relative, + 'method' => trim($match[0]), + ]; + } + } + } + + return $conflicts; + } + /** * Ensure directories flagged for ignoring get hydrated from the current installation. * diff --git a/system/src/Grav/Console/Gpm/PreflightCommand.php b/system/src/Grav/Console/Gpm/PreflightCommand.php index 63728a5ca..50a33331d 100644 --- a/system/src/Grav/Console/Gpm/PreflightCommand.php +++ b/system/src/Grav/Console/Gpm/PreflightCommand.php @@ -25,7 +25,7 @@ class PreflightCommand extends GpmCommand $service = $this->createSafeUpgradeService(); $report = $service->preflight(); - $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['warnings']); + $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']); if ($this->getInput()->getOption('json')) { $io->writeln(json_encode($report, JSON_PRETTY_PRINT)); @@ -60,6 +60,19 @@ class PreflightCommand extends GpmCommand $io->newLine(); } + if (!empty($report['monolog_conflicts'])) { + $io->writeln('Potential Monolog logger conflicts'); + foreach ($report['monolog_conflicts'] as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + $io->writeln(' › Update the plugin to use PSR-3 style logger calls (e.g. $logger->error()).'); + $io->newLine(); + } + if (!$hasIssues) { $io->success('No blocking issues detected.'); } else { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 2c991e93c..99f863adc 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -22,6 +22,7 @@ use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use ZipArchive; use function count; use function is_callable; @@ -289,9 +290,10 @@ class SelfupgradeCommand extends GpmCommand $io = $this->getIO(); $pending = $preflight['plugins_pending'] ?? []; $conflicts = $preflight['psr_log_conflicts'] ?? []; + $monologConflicts = $preflight['monolog_conflicts'] ?? []; $warnings = $preflight['warnings'] ?? []; - if (empty($pending) && empty($conflicts)) { + if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { return true; } @@ -319,25 +321,98 @@ class SelfupgradeCommand extends GpmCommand return false; } - if ($conflicts) { - $io->newLine(); - $io->writeln('Potential psr/log incompatibilities:'); - foreach ($conflicts as $slug => $info) { - $requires = $info['requires'] ?? '*'; - $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires)); - } - $io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.'); - - if (!$this->all_yes) { - $question = new ConfirmationQuestion('Continue despite psr/log warnings? [y|N] ', false); - if (!$io->askQuestion($question)) { - $io->writeln('Aborting self-upgrade. Adjust composer requirements or update affected plugins.'); - - return false; + $handled = $this->handleConflicts( + $conflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential psr/log incompatibilities:'); + foreach ($conflicts as $slug => $info) { + $requires = $info['requires'] ?? '*'; + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires)); } - } + }, + 'Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.', + 'Aborting self-upgrade. Adjust composer requirements or update affected plugins.', + 'Proceeding with potential psr/log incompatibilities still active.', + 'Disabled before upgrade because of psr/log conflict' + ); + + if (!$handled) { + return false; } + $handledMonolog = $this->handleConflicts( + $monologConflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential Monolog logger API incompatibilities:'); + foreach ($conflicts as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + }, + 'Update the plugin to use PSR-3 style logger methods (e.g. $logger->error()) before upgrading.', + 'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.', + 'Proceeding with potential Monolog API incompatibilities still active.', + 'Disabled before upgrade because of Monolog API conflict' + ); + + if (!$handledMonolog) { + return false; + } + + return true; + } + + /** + * @param array $conflicts + * @param callable $printer + * @param string $advice + * @param string $abortMessage + * @param string $continueMessage + * @param string $disableNote + * @return bool + */ + private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage, string $disableNote): bool + { + if (empty($conflicts)) { + return true; + } + + $io = $this->getIO(); + $printer($io, $conflicts); + $io->writeln(' › ' . $advice); + + $choice = $this->all_yes ? 'abort' : $io->choice( + 'How would you like to proceed?', + ['disable', 'continue', 'abort'], + 'abort' + ); + + if ($choice === 'abort') { + $io->writeln($abortMessage); + + return false; + } + + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + + if ($choice === 'disable') { + foreach (array_keys($conflicts) as $slug) { + $recovery->disablePlugin($slug, ['message' => $disableNote]); + $io->writeln(sprintf(' - Disabled plugin %s.', $slug)); + } + $io->writeln('Continuing with conflicted plugins disabled.'); + + return true; + } + + $io->writeln($continueMessage); + return true; } diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index 88cc54d2e..f7e7e7375 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -120,4 +120,25 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test $manager = new RecoveryManager($this->tmpDir); self::assertNull($manager->getContext()); } + + public function testDisablePluginRecordsQuarantineWithoutFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/problem'; + Folder::create($plugin); + + $manager = new RecoveryManager($this->tmpDir); + $manager->disablePlugin('problem', ['message' => 'Manual disable']); + + $flag = $this->tmpDir . '/system/recovery.flag'; + self::assertFileDoesNotExist($flag); + + $configFile = $this->tmpDir . '/user/config/plugins/problem.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertSame('Manual disable', $decoded['problem']['message']); + } } diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index 15b03f842..b69bef31e 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -30,6 +30,11 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test public $conflicts = [ 'beta' => ['requires' => '^1.0'] ]; + public $monolog = [ + 'gamma' => [ + ['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError('] + ] + ]; protected function detectPendingPluginUpdates(): array { @@ -40,14 +45,20 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test { return $this->conflicts; } + + protected function detectMonologConflicts(): array + { + return $this->monolog; + } }; $result = $service->preflight(); self::assertArrayHasKey('warnings', $result); - self::assertCount(2, $result['warnings']); + 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 @@ -62,12 +73,18 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test { 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]); } @@ -140,6 +157,37 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertArrayHasKey('problem', $conflicts); } + public function testDetectsMonologConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/logger'; + Folder::create($plugin . '/src'); + $code = <<<'PHP' +addError('deprecated'); + } +} +PHP; + file_put_contents($plugin . '/src/logger.php', $code); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + + $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(); @@ -190,4 +238,3 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test return $package; } } - diff --git a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php index 442166838..85b1fb6eb 100644 --- a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php @@ -14,6 +14,7 @@ class PreflightCommandTest extends \Codeception\TestCase\Test $service = new StubSafeUpgradeService([ 'plugins_pending' => [], 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], 'warnings' => [] ]); $command = new TestPreflightCommand($service); @@ -31,6 +32,7 @@ class PreflightCommandTest extends \Codeception\TestCase\Test $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); @@ -42,6 +44,7 @@ class PreflightCommandTest extends \Codeception\TestCase\Test $output = implode("\n", $style->messages); self::assertStringContainsString('pending updates', $output); self::assertStringContainsString('beta', $output); + self::assertStringContainsString('gamma', $output); } /** diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php index c11d0c65d..0542f0870 100644 --- a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -1,5 +1,6 @@ injectIo($command, [false]); + [$style] = $this->injectIo($command); $this->setAllYes($command, false); $result = $command->runHandle([ @@ -59,7 +60,7 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void { $command = new TestSelfupgradeCommand(); - [$style] = $this->injectIo($command, [false]); + [$style] = $this->injectIo($command, ['abort']); $this->setAllYes($command, false); $result = $command->runHandle([ @@ -72,6 +73,50 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages)); } + public function testHandlePreflightReportDisablesPluginsWhenRequested(): void + { + $gravFactory = Fixtures::get('grav'); + $grav = $gravFactory(); + $stub = new class { + public $disabled = []; + public function disablePlugin(string $slug, array $context = []): void + { + $this->disabled[] = $slug; + } + }; + $grav['recovery'] = $stub; + + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['disable']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame(['foo'], $stub->disabled); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Continuing with conflicted plugins disabled.', $output); + } + + public function testHandlePreflightReportContinuesWhenRequested(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['continue']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output); + } + /** * @param TestSelfupgradeCommand $command * @param array $responses @@ -156,4 +201,13 @@ class SelfUpgradeMemoryStyle extends SymfonyStyle return parent::askQuestion($question); } + + public function choice($question, array $choices, $default = null, $attempts = null, $errorMessage = 'Invalid value.') + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::choice($question, $choices, $default, $attempts, $errorMessage); + } }