|
| 1 | +#!/usr/bin/env php |
| 2 | +<?php |
| 3 | + |
| 4 | +declare(strict_types=1); |
| 5 | + |
| 6 | +/** |
| 7 | + * This file is part of the guanguans/laravel-exception-notify. |
| 8 | + * |
| 9 | + * (c) guanguans <ityaozm@gmail.com> |
| 10 | + * |
| 11 | + * This source file is subject to the MIT license that is bundled. |
| 12 | + */ |
| 13 | + |
| 14 | +use Composer\InstalledVersions; |
| 15 | +use SebastianBergmann\Diff\Differ; |
| 16 | +use Symfony\Component\Console\Input\ArgvInput; |
| 17 | +use Symfony\Component\Console\Input\InputInterface; |
| 18 | +use Symfony\Component\Console\Input\InputOption; |
| 19 | +use Symfony\Component\Console\Output\ConsoleOutput; |
| 20 | +use Symfony\Component\Console\Output\OutputInterface; |
| 21 | +use Symfony\Component\Console\SingleCommandApplication; |
| 22 | +use Symfony\Component\Console\Style\SymfonyStyle; |
| 23 | +use Symfony\Component\Process\ExecutableFinder; |
| 24 | +use Symfony\Component\Process\PhpExecutableFinder; |
| 25 | +use Symfony\Component\Process\Process; |
| 26 | + |
| 27 | +require __DIR__.'/vendor/autoload.php'; |
| 28 | + |
| 29 | +/** @noinspection PhpUnhandledExceptionInspection */ |
| 30 | +$status = (new SingleCommandApplication()) |
| 31 | + ->setName('Composer Updater') |
| 32 | + ->addOption('composer-json-path', null, InputOption::VALUE_OPTIONAL) |
| 33 | + ->addOption('highest-php-binary', null, InputOption::VALUE_REQUIRED) |
| 34 | + ->addOption('composer-binary', null, InputOption::VALUE_OPTIONAL) |
| 35 | + ->addOption('except-packages', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL) |
| 36 | + ->addOption('except-dependency-versions', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL) |
| 37 | + ->addOption('dry-run', null, InputOption::VALUE_NONE) |
| 38 | + ->setCode(function (InputInterface $input, OutputInterface $output): void { |
| 39 | + assert_options(ASSERT_BAIL, 1); |
| 40 | + assert($this instanceof SingleCommandApplication); |
| 41 | + assert((bool) $input->getOption('highest-php-binary')); |
| 42 | + |
| 43 | + (new class($input->getOption('composer-json-path') ?: __DIR__.'/composer.json', $input->getOption('highest-php-binary'), $input->getOption('composer-binary'), $input->getOption('except-packages'), $input->getOption('except-dependency-versions'), $input->getOption('dry-run'), new SymfonyStyle($input, $output), new Differ(), ) { |
| 44 | + private $composerJsonPath; |
| 45 | + |
| 46 | + private $composerJsonContents; |
| 47 | + |
| 48 | + private $highestComposerBinary; |
| 49 | + |
| 50 | + private $composerBinary; |
| 51 | + |
| 52 | + private $exceptPackages; |
| 53 | + |
| 54 | + private $exceptDependencyVersions; |
| 55 | + |
| 56 | + private $dryRun; |
| 57 | + |
| 58 | + private $symfonyStyle; |
| 59 | + |
| 60 | + private $differ; |
| 61 | + |
| 62 | + /** |
| 63 | + * @noinspection ParameterDefaultsNullInspection |
| 64 | + */ |
| 65 | + public function __construct( |
| 66 | + string $composerJsonPath, |
| 67 | + ?string $highestPhpBinary = null, |
| 68 | + ?string $composerBinary = null, |
| 69 | + array $exceptPackages = [], |
| 70 | + array $exceptDependencyVersions = [], |
| 71 | + bool $dryRun = false, |
| 72 | + ?SymfonyStyle $symfonyStyle = null, |
| 73 | + ?Differ $differ = null |
| 74 | + ) { |
| 75 | + assert_options(ASSERT_BAIL, 1); |
| 76 | + assert((bool) $composerJsonPath); |
| 77 | + |
| 78 | + $this->composerJsonPath = $composerJsonPath; |
| 79 | + $this->composerJsonContents = file_get_contents($composerJsonPath); |
| 80 | + $this->highestComposerBinary = $this->getComposerBinary($composerBinary, $highestPhpBinary); |
| 81 | + $this->composerBinary = $this->getComposerBinary($composerBinary); |
| 82 | + $this->exceptPackages = array_merge([ |
| 83 | + 'php', |
| 84 | + 'ext-*', |
| 85 | + ], $exceptPackages); |
| 86 | + $this->exceptDependencyVersions = array_merge([ |
| 87 | + '\*', |
| 88 | + '*-*', |
| 89 | + '*@*', |
| 90 | + // '*|*', |
| 91 | + ], $exceptDependencyVersions); |
| 92 | + $this->dryRun = $dryRun; |
| 93 | + $this->symfonyStyle = $symfonyStyle ?? new SymfonyStyle(new ArgvInput(), new ConsoleOutput()); |
| 94 | + $this->differ = $differ ?? new Differ(); |
| 95 | + } |
| 96 | + |
| 97 | + public function __invoke(): void |
| 98 | + { |
| 99 | + $this |
| 100 | + ->updateComposerPackages() |
| 101 | + ->updateOutdatedComposerPackages() |
| 102 | + ->updateComposerPackages() |
| 103 | + ->updateOutdatedComposerPackages() |
| 104 | + ->updateComposerPackages() |
| 105 | + ->normalizeComposerJson() |
| 106 | + ->success(); |
| 107 | + } |
| 108 | + |
| 109 | + private function updateComposerPackages(): self |
| 110 | + { |
| 111 | + $this->mustRunCommand("$this->composerBinary update -W --ansi"); |
| 112 | + |
| 113 | + return $this; |
| 114 | + } |
| 115 | + |
| 116 | + /** |
| 117 | + * @noinspection JsonEncodingApiUsageInspection |
| 118 | + * |
| 119 | + * @return $this|never-return |
| 120 | + */ |
| 121 | + private function updateOutdatedComposerPackages(): self |
| 122 | + { |
| 123 | + $outdatedComposerJsonContents = json_encode( |
| 124 | + $this->getOutdatedDecodedComposerJson(), |
| 125 | + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES |
| 126 | + ).PHP_EOL; |
| 127 | + |
| 128 | + if ($this->dryRun) { |
| 129 | + $this->symfonyStyle->writeln($this->formatDiff($this->differ->diff( |
| 130 | + $this->composerJsonContents, |
| 131 | + $outdatedComposerJsonContents |
| 132 | + ))); |
| 133 | + |
| 134 | + exit(0); |
| 135 | + } |
| 136 | + |
| 137 | + file_put_contents($this->composerJsonPath, $outdatedComposerJsonContents); |
| 138 | + |
| 139 | + return $this; |
| 140 | + } |
| 141 | + |
| 142 | + private function normalizeComposerJson(): self |
| 143 | + { |
| 144 | + $this->mustRunCommand("$this->composerBinary normalize --diff --ansi"); |
| 145 | + |
| 146 | + return $this; |
| 147 | + } |
| 148 | + |
| 149 | + private function success(): void |
| 150 | + { |
| 151 | + $this->symfonyStyle->success('Composer packages updated successfully!'); |
| 152 | + } |
| 153 | + |
| 154 | + /** |
| 155 | + * @noinspection JsonEncodingApiUsageInspection |
| 156 | + */ |
| 157 | + private function getOutdatedDecodedComposerJson(): array |
| 158 | + { |
| 159 | + $outdatedComposerPackages = $this->getOutdatedComposerPackages(); |
| 160 | + $decodedComposerJson = json_decode(file_get_contents($this->composerJsonPath), true); |
| 161 | + (function () { |
| 162 | + return self::reload(null); |
| 163 | + })->call(new InstalledVersions()); |
| 164 | + |
| 165 | + foreach ($decodedComposerJson as $name => &$value) { |
| 166 | + if (! in_array($name, ['require', 'require-dev'], true)) { |
| 167 | + continue; |
| 168 | + } |
| 169 | + |
| 170 | + foreach ($value as $package => &$dependencyVersion) { |
| 171 | + if ( |
| 172 | + $this->strIs($this->exceptPackages, $package) |
| 173 | + || $this->strIs($this->exceptDependencyVersions, $dependencyVersion) |
| 174 | + ) { |
| 175 | + continue; |
| 176 | + } |
| 177 | + |
| 178 | + if ($version = InstalledVersions::getVersion($package)) { |
| 179 | + $dependencyVersion = $this->toDependencyVersion($version); |
| 180 | + } |
| 181 | + |
| 182 | + if (isset($outdatedComposerPackages[$package])) { |
| 183 | + $dependencyVersion = $outdatedComposerPackages[$package]['dependency_version']; |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + return $decodedComposerJson; |
| 189 | + } |
| 190 | + |
| 191 | + /** |
| 192 | + * @noinspection JsonEncodingApiUsageInspection |
| 193 | + */ |
| 194 | + private function getOutdatedComposerPackages(): array |
| 195 | + { |
| 196 | + return array_reduce( |
| 197 | + json_decode( |
| 198 | + $this |
| 199 | + ->mustRunCommand("$this->highestComposerBinary outdated --format=json --direct --ansi") |
| 200 | + ->getOutput(), |
| 201 | + true |
| 202 | + )['installed'], |
| 203 | + function (array $carry, array $package): array { |
| 204 | + $lowestArrayVersion = $this->toArrayVersion($package['version']); |
| 205 | + $highestArrayVersion = $this->toArrayVersion($package['latest']); |
| 206 | + $dependencyVersions = [$this->toDependencyVersion($package['version'])]; |
| 207 | + |
| 208 | + if ($lowestArrayVersion[0] !== $highestArrayVersion[0]) { |
| 209 | + $dependencyVersions = array_merge($dependencyVersions, array_map( |
| 210 | + static function (string $major): string { |
| 211 | + return "^$major.0"; |
| 212 | + }, |
| 213 | + range($lowestArrayVersion[0] + 1, $highestArrayVersion[0]) |
| 214 | + )); |
| 215 | + } |
| 216 | + |
| 217 | + $package['dependency_version'] = implode(' || ', $dependencyVersions); |
| 218 | + $carry[$package['name']] = $package; |
| 219 | + |
| 220 | + return $carry; |
| 221 | + }, |
| 222 | + [] |
| 223 | + ); |
| 224 | + } |
| 225 | + |
| 226 | + private function getComposerBinary(?string $composerBinary = null, ?string $phpBinary = null): string |
| 227 | + { |
| 228 | + return sprintf( |
| 229 | + '%s %s', |
| 230 | + $phpBinary ?? (new PhpExecutableFinder())->find(), |
| 231 | + $composerBinary ?? (new ExecutableFinder())->find('composer') |
| 232 | + ); |
| 233 | + } |
| 234 | + |
| 235 | + /** |
| 236 | + * @param array|string $command |
| 237 | + * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input |
| 238 | + * |
| 239 | + * @noinspection MissingParameterTypeDeclarationInspection |
| 240 | + * @noinspection PhpSameParameterValueInspection |
| 241 | + */ |
| 242 | + private function mustRunCommand( |
| 243 | + $command, |
| 244 | + ?string $cwd = null, |
| 245 | + ?array $env = null, |
| 246 | + $input = null, |
| 247 | + ?float $timeout = 300 |
| 248 | + ): Process { |
| 249 | + $process = is_string($command) |
| 250 | + ? Process::fromShellCommandline($command, $cwd, $env, $input, $timeout) |
| 251 | + : new Process($command, $cwd, $env, $input, $timeout); |
| 252 | + |
| 253 | + $this->symfonyStyle->warning($process->getCommandLine()); |
| 254 | + |
| 255 | + return $process |
| 256 | + ->setWorkingDirectory(dirname($this->composerJsonPath)) |
| 257 | + ->setEnv(['COMPOSER_MEMORY_LIMIT' => -1]) |
| 258 | + ->mustRun(function (string $type, string $buffer): void { |
| 259 | + $this->symfonyStyle->isVerbose() and $this->symfonyStyle->write($buffer); |
| 260 | + }); |
| 261 | + } |
| 262 | + |
| 263 | + private function toDependencyVersion(string $version): string |
| 264 | + { |
| 265 | + return '^'.implode('.', array_slice($this->toArrayVersion($version), 0, 2)); |
| 266 | + } |
| 267 | + |
| 268 | + private function toArrayVersion(string $version): array |
| 269 | + { |
| 270 | + return explode('.', ltrim($version, 'v')); |
| 271 | + } |
| 272 | + |
| 273 | + /** |
| 274 | + * @param array|string $pattern |
| 275 | + * |
| 276 | + * @noinspection SuspiciousLoopInspection |
| 277 | + * @noinspection ComparisonScalarOrderInspection |
| 278 | + * @noinspection MissingParameterTypeDeclarationInspection |
| 279 | + */ |
| 280 | + private function strIs($pattern, string $value): bool |
| 281 | + { |
| 282 | + $patterns = (array) $pattern; |
| 283 | + if (empty($patterns)) { |
| 284 | + return false; |
| 285 | + } |
| 286 | + |
| 287 | + foreach ($patterns as $pattern) { |
| 288 | + $pattern = (string) $pattern; |
| 289 | + if ($pattern === $value) { |
| 290 | + return true; |
| 291 | + } |
| 292 | + |
| 293 | + $pattern = preg_quote($pattern, '#'); |
| 294 | + $pattern = str_replace('\*', '.*', $pattern); |
| 295 | + if (1 === preg_match('#^'.$pattern.'\z#u', $value)) { |
| 296 | + return true; |
| 297 | + } |
| 298 | + } |
| 299 | + |
| 300 | + return false; |
| 301 | + } |
| 302 | + |
| 303 | + private function formatDiff(string $diff): string |
| 304 | + { |
| 305 | + $lines = explode( |
| 306 | + "\n", |
| 307 | + $diff, |
| 308 | + ); |
| 309 | + |
| 310 | + $formatted = array_map(static function (string $line): string { |
| 311 | + return preg_replace( |
| 312 | + [ |
| 313 | + '/^(\+.*)$/', |
| 314 | + '/^(-.*)$/', |
| 315 | + ], |
| 316 | + [ |
| 317 | + '<fg=green>$1</>', |
| 318 | + '<fg=red>$1</>', |
| 319 | + ], |
| 320 | + $line, |
| 321 | + ); |
| 322 | + }, $lines); |
| 323 | + |
| 324 | + return implode( |
| 325 | + "\n", |
| 326 | + $formatted, |
| 327 | + ); |
| 328 | + } |
| 329 | + })(); |
| 330 | + }) |
| 331 | + ->run(); |
| 332 | + |
| 333 | +exit($status); |
0 commit comments