diff --git a/assets/modules/store/css/style.css b/assets/modules/store/css/style.css index 4b17223629..67784e6cf1 100644 --- a/assets/modules/store/css/style.css +++ b/assets/modules/store/css/style.css @@ -1064,6 +1064,7 @@ input#store_search:focus { display: block; min-width: 0; overflow-wrap: anywhere; + white-space: pre-wrap; color: #d8dee6; font-size: 13px; line-height: 1.35; diff --git a/core/src/Console/Packages/InstallPackageRequireCommand.php b/core/src/Console/Packages/InstallPackageRequireCommand.php index de2a9c65ef..53a7db4aff 100644 --- a/core/src/Console/Packages/InstallPackageRequireCommand.php +++ b/core/src/Console/Packages/InstallPackageRequireCommand.php @@ -28,6 +28,12 @@ class InstallPackageRequireCommand extends Command */ protected $composer = EVO_CORE_PATH . 'custom/composer.json'; + /** + * Packages touched by updateArray(); scopes the composer update to them. + * @var array + */ + protected $affectedPackages = []; + /** * @var array */ @@ -76,6 +82,7 @@ public function checkFile() public function updateArray() { $this->composerArray['require'][$this->argument('key')] = $this->argument('value'); + $this->affectedPackages[] = (string) $this->argument('key'); } public function putComposer() @@ -95,14 +102,7 @@ public function putComposer() public function runComposer() { putenv('COMPOSER_HOME=' . EVO_CORE_PATH . 'composer'); - $arguments = ['command' => 'update']; - if ($this->hasCommandOption('no-dev') && $this->option('no-dev')) { - $arguments['--no-dev'] = true; - } - if ($this->hasCommandOption('optimize-autoloader') && $this->option('optimize-autoloader')) { - $arguments['--optimize-autoloader'] = true; - } - $input = new ArrayInput($arguments); + $input = new ArrayInput($this->buildComposerArguments()); $application = new Application(); $application->setAutoExit(false); $originalCwd = function_exists('getcwd') ? getcwd() : false; @@ -121,6 +121,35 @@ public function runComposer() } + /** + * Build the composer update arguments. + * + * The update is limited to the changed packages and their dependencies, + * like `composer require`/`remove` do. A bare `update` would also bump every + * core dependency, including composer/composer running this very process. + * + * @return array + */ + public function buildComposerArguments(): array + { + $arguments = ['command' => 'update']; + + $packages = array_values(array_unique(array_filter(array_map('trim', $this->affectedPackages)))); + if ($packages !== []) { + $arguments['packages'] = $packages; + $arguments['--with-dependencies'] = true; + } + + if ($this->hasCommandOption('no-dev') && $this->option('no-dev')) { + $arguments['--no-dev'] = true; + } + if ($this->hasCommandOption('optimize-autoloader') && $this->option('optimize-autoloader')) { + $arguments['--optimize-autoloader'] = true; + } + + return $arguments; + } + protected function hasCommandOption(string $name): bool { return $this->getDefinition()->hasOption($name); diff --git a/core/src/Console/Packages/RemovePackageRequireCommand.php b/core/src/Console/Packages/RemovePackageRequireCommand.php index bba4047595..90c9f9d44c 100644 --- a/core/src/Console/Packages/RemovePackageRequireCommand.php +++ b/core/src/Console/Packages/RemovePackageRequireCommand.php @@ -27,6 +27,7 @@ public function updateArray() foreach (array_keys($this->composerArray['require']) as $requireKey) { if ($this->matchesRequirementKey((string) $requireKey, $target)) { unset($this->composerArray['require'][$requireKey]); + $this->affectedPackages[] = (string) $requireKey; $this->info('Removed package requirement: ' . $requireKey); return true; } diff --git a/core/src/Services/SystemTasks/ConsoleInstallFlowService.php b/core/src/Services/SystemTasks/ConsoleInstallFlowService.php index 1baceed04d..c10c3ac103 100644 --- a/core/src/Services/SystemTasks/ConsoleInstallFlowService.php +++ b/core/src/Services/SystemTasks/ConsoleInstallFlowService.php @@ -7,6 +7,8 @@ class ConsoleInstallFlowService implements SystemTaskHandlerInterface { + use ReportsProcessFailure; + protected CatalogService $catalogService; public function __construct(?CatalogService $catalogService = null) @@ -33,6 +35,7 @@ public function execute(SystemCliTask $task, ?callable $report = null) 'key' => $composerName, 'value' => $composerVersion, 'composer_run' => 1, + '--no-dev' => !$this->vendorHasDevPackages(), ], 'install_require', 30, @@ -107,7 +110,9 @@ protected function runArtisanCommand($command, array $arguments, $step, $progres } if ((int) $exitCode !== 0) { - $reason = $this->summarizeOutput($output); + $this->reportProcessFailure($report, $step, $progress, $command, $exitCode, $output); + + $reason = $this->summarizeOutput($output, true); if ($reason !== '') { throw new \RuntimeException($command . ' failed with exit code ' . (int) $exitCode . '. ' . $reason); } @@ -116,6 +121,23 @@ protected function runArtisanCommand($command, array $arguments, $step, $progres } } + /** + * Does vendor/ carry require-dev packages? Mirrors that state into the + * composer run so an install neither strips a dev checkout nor pulls the + * dev tree into a --no-dev production build. + */ + protected function vendorHasDevPackages(): bool + { + $installed = EVO_CORE_PATH . 'vendor/composer/installed.json'; + if (!file_exists($installed)) { + return true; + } + + $data = json_decode((string) file_get_contents($installed), true); + + return !is_array($data) || !array_key_exists('dev', $data) || (bool) $data['dev']; + } + protected function buildArtisanProcessArguments($command, array $arguments) { $parts = [PHP_BINARY, EVO_CORE_PATH . 'artisan', $command]; @@ -276,7 +298,7 @@ protected function isComposerDependencyName(string $name): bool return true; } - protected function summarizeOutput($output) + protected function summarizeOutput($output, bool $fromEnd = false) { $lines = preg_split('/\r\n|\r|\n/', trim((string) $output)); $lines = array_values(array_filter(array_map(function ($line) { @@ -303,7 +325,7 @@ protected function summarizeOutput($output) return true; })); - $output = implode(' ', array_slice($lines, 0, 3)); + $output = implode(' ', $this->pickSummaryLines($lines, $fromEnd, $fromEnd ? 5 : 3)); if ($output === '') { return ''; } diff --git a/core/src/Services/SystemTasks/ConsoleUninstallFlowService.php b/core/src/Services/SystemTasks/ConsoleUninstallFlowService.php index 77afcd1b34..08fb9b7334 100644 --- a/core/src/Services/SystemTasks/ConsoleUninstallFlowService.php +++ b/core/src/Services/SystemTasks/ConsoleUninstallFlowService.php @@ -6,6 +6,8 @@ class ConsoleUninstallFlowService implements SystemTaskHandlerInterface { + use ReportsProcessFailure; + protected string $corePath; protected string $providersDir; protected string $aliasesDir; @@ -100,7 +102,9 @@ protected function runArtisanCommand($command, array $arguments, $step, $progres } if ((int) $exitCode !== 0) { - $reason = $this->summarizeOutput($output); + $this->reportProcessFailure($report, $step, $progress, $command, $exitCode, $output); + + $reason = $this->summarizeOutput($output, true); if ($reason !== '') { throw new \RuntimeException($command . ' failed with exit code ' . (int) $exitCode . '. ' . $reason); } @@ -134,7 +138,7 @@ protected function buildArtisanProcessArguments($command, array $arguments) return $parts; } - protected function summarizeOutput($output) + protected function summarizeOutput($output, bool $fromEnd = false) { $lines = preg_split('/\r\n|\r|\n/', trim((string) $output)); $lines = array_values(array_filter(array_map(function ($line) { @@ -165,7 +169,7 @@ protected function summarizeOutput($output) return ''; } - return implode(' ', array_slice($lines, 0, 3)); + return implode(' ', $this->pickSummaryLines($lines, $fromEnd, $fromEnd ? 5 : 3)); } protected function purgeInvalidDiscoveryArtifacts(?callable $report = null) diff --git a/core/src/Services/SystemTasks/ReportsProcessFailure.php b/core/src/Services/SystemTasks/ReportsProcessFailure.php new file mode 100644 index 0000000000..009a14874e --- /dev/null +++ b/core/src/Services/SystemTasks/ReportsProcessFailure.php @@ -0,0 +1,36 @@ +report($report, $step, $progress, $tail, 'error', $context + [ + 'command' => $label, + 'exit_code' => (int) $exitCode, + 'output' => mb_substr((string) $output, -16000), + ]); + } + + /** + * The one-line reason for the exception: last lines on failure, first lines otherwise. + */ + protected function pickSummaryLines(array $lines, bool $fromEnd, int $count) + { + return $fromEnd ? array_slice($lines, -$count) : array_slice($lines, 0, $count); + } +} diff --git a/core/src/Services/SystemTasks/SiteUpdateFlowService.php b/core/src/Services/SystemTasks/SiteUpdateFlowService.php index a81c87c53e..bbc5904def 100644 --- a/core/src/Services/SystemTasks/SiteUpdateFlowService.php +++ b/core/src/Services/SystemTasks/SiteUpdateFlowService.php @@ -7,6 +7,8 @@ class SiteUpdateFlowService implements SystemTaskHandlerInterface { + use ReportsProcessFailure; + protected string $corePath; public function __construct(?string $corePath = null) @@ -79,7 +81,9 @@ public function execute(SystemCliTask $task, ?callable $report = null) } if ((int) $exitCode !== 0) { - $reason = $this->summarizeOutput($output); + $this->reportProcessFailure($report, 'site_update', 80, 'make:site', $exitCode, $output, $reportContext); + + $reason = $this->summarizeOutput($output, true); if ($reason !== '') { throw new \RuntimeException('Site update failed with exit code ' . (int) $exitCode . '. ' . $reason); } @@ -158,7 +162,7 @@ protected function buildArtisanProcessArguments($command, array $arguments) return $parts; } - protected function summarizeOutput($output) + protected function summarizeOutput($output, bool $fromEnd = false) { $lines = preg_split('/\r\n|\r|\n/', trim((string) $output)); $lines = array_values(array_filter(array_map(function ($line) { @@ -175,7 +179,7 @@ protected function summarizeOutput($output) return ''; } - return implode(' ', array_slice($lines, 0, 5)); + return implode(' ', $this->pickSummaryLines($lines, $fromEnd, 5)); } protected function report(?callable $report, $step, $progress, $message, $level = 'info', array $context = []) diff --git a/core/tests/Unit/Console/PackageRequireCommandTest.php b/core/tests/Unit/Console/PackageRequireCommandTest.php index 983fb4aa85..42dd869e72 100644 --- a/core/tests/Unit/Console/PackageRequireCommandTest.php +++ b/core/tests/Unit/Console/PackageRequireCommandTest.php @@ -95,3 +95,61 @@ function setPackageRequireComposerPath(InstallPackageRequireCommand $command, st ->toContain("hasCommandOption('no-dev')") ->toContain("hasCommandOption('optimize-autoloader')"); }); + +test('package install require scopes composer update to the installed package', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + file_put_contents($composer, json_encode(['name' => 'evolutioncms/custom', 'require' => []])); + + $command = new InstallPackageRequireCommand(); + setPackageRequireComposerPath($command, $composer); + + $tester = new CommandTester($command); + $tester->execute([ + 'key' => 'evolution-cms/emcp', + 'value' => '*', + 'composer_run' => '0', + '--no-dev' => true, + ]); + + expect($command->buildComposerArguments())->toBe([ + 'command' => 'update', + 'packages' => ['evolution-cms/emcp'], + '--with-dependencies' => true, + '--no-dev' => true, + ]); + + @unlink($composer); +}); + +test('package remove require scopes composer update to the removed package', function () { + $composer = tempnam(sys_get_temp_dir(), 'evo-composer-'); + file_put_contents($composer, json_encode([ + 'name' => 'evolutioncms/custom', + 'require' => ['Seiger/sCommerce' => '*', 'seiger/stask' => '*'], + ])); + + $command = new RemovePackageRequireCommand(); + setPackageRequireComposerPath($command, $composer); + + $tester = new CommandTester($command); + $tester->execute(['key' => 'sCommerce', 'composer_run' => '0']); + + expect($command->buildComposerArguments())->toBe([ + 'command' => 'update', + 'packages' => ['Seiger/sCommerce'], + '--with-dependencies' => true, + ]); + + @unlink($composer); +}); + +test('composer update falls back to a full update when no package was changed', function () { + $command = new InstallPackageRequireCommand(); + $command->setLaravel(new PackageRequireTestContainer()); + + $input = new ReflectionProperty($command, 'input'); + $input->setAccessible(true); + $input->setValue($command, new \Symfony\Component\Console\Input\ArrayInput(['key' => 'vendor/pkg', 'value' => '*'], $command->getDefinition())); + + expect($command->buildComposerArguments())->toBe(['command' => 'update']); +}); diff --git a/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php b/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php index bfb0bcd83a..9792082452 100644 --- a/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php +++ b/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php @@ -68,3 +68,20 @@ function invokeConsoleInstallFlowMethod(ConsoleInstallFlowService $service, stri 'Vendor\\Package\\SecondaryServiceProvider', ]); }); + +test('summarizeOutput reads the failure from the tail when asked', function () { + $service = new ConsoleInstallFlowService(); + + $output = implode("\n", [ + 'Evolution CMS 3.5.8', + 'Lock file operations: 1 install, 62 updates, 0 removals', + ' - Upgrading composer/ca-bundle (1.5.13 => 1.5.14)', + 'Your requirements could not be resolved to an installable set of packages.', + ' - elcreator/aimage 1.0.0 requires ext-imagick * -> it is missing from your system.', + ]); + + expect(invokeConsoleInstallFlowMethod($service, 'summarizeOutput', [$output])) + ->toStartWith('Lock file operations') + ->and(invokeConsoleInstallFlowMethod($service, 'summarizeOutput', [$output, true])) + ->toContain('ext-imagick'); +});