├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── tests.yml │ └── php-compatibility.yml ├── phpunit.xml ├── src ├── Service │ ├── StabilityChecker.php │ ├── Config.php │ ├── ComposerFileService.php │ └── VersionService.php ├── Command │ ├── CommandProvider.php │ └── UpgradeAllCommand.php └── Plugin.php ├── tests ├── Command │ ├── CommandProviderTest.php │ └── UpgradeAllCommandTest.php ├── PluginTest.php └── Service │ ├── StabilityCheckerTest.php │ ├── ComposerFileServiceTest.php │ ├── ConfigTest.php │ └── VersionServiceTest.php ├── composer.json ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .phpunit.result.cache 3 | vendor 4 | composer.lock -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: vildanbina 2 | buy_me_a_coffee: vildanbina 3 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | tests 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Service/StabilityChecker.php: -------------------------------------------------------------------------------- 1 | stabilityOrder, true); 14 | $desiredIndex = array_search($desiredStability, $this->stabilityOrder, true); 15 | 16 | return $pkgIndex >= $desiredIndex || $desiredStability === 'dev'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run PHPUnit Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '8.4' 20 | extensions: mbstring, json 21 | tools: composer:v2 22 | 23 | - name: Install Dependencies 24 | run: composer install --prefer-dist --no-progress --no-interaction 25 | 26 | - name: Run PHPUnit Tests 27 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /tests/Command/CommandProviderTest.php: -------------------------------------------------------------------------------- 1 | getCommands(); 17 | 18 | $this->assertCount(1, $commands); 19 | $this->assertInstanceOf(UpgradeAllCommand::class, $commands[0]); 20 | $this->assertEquals('upgrade-all', $commands[0]->getName()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Command/CommandProvider.php: -------------------------------------------------------------------------------- 1 | Command\CommandProvider::class, 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vildanbina/composer-upgrader", 3 | "description": "Effortlessly upgrade all Composer dependencies to their latest versions with a single command.", 4 | "type": "composer-plugin", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.0", 8 | "composer-plugin-api": "^2.0" 9 | }, 10 | "require-dev": { 11 | "composer/composer": "^2.0", 12 | "phpunit/phpunit": "^9.5" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "Vildanbina\\ComposerUpgrader\\": "src/", 17 | "Vildanbina\\ComposerUpgrader\\Tests\\": "tests/" 18 | } 19 | }, 20 | "authors": [ 21 | { 22 | "name": "Vildan Bina", 23 | "email": "vildanbina@gmail.com" 24 | } 25 | ], 26 | "extra": { 27 | "class": "Vildanbina\\ComposerUpgrader\\Plugin" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/php-compatibility.yml: -------------------------------------------------------------------------------- 1 | name: PHP Compatibility Check 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | compatibility: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | php-version: [ '8.0', '8.1', '8.2', '8.3', '8.4' ] 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-version }} 24 | extensions: mbstring, json 25 | tools: composer:v2 26 | 27 | - name: Install Dependencies 28 | run: composer install --prefer-dist --no-progress --no-interaction 29 | 30 | - name: Run PHPUnit Tests 31 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Vildan Bina 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Service/Config.php: -------------------------------------------------------------------------------- 1 | dryRun = (bool) $input->getOption('dry-run'); 26 | $this->stability = (string) $input->getOption('stability'); 27 | $this->only = $input->getOption('only') ? explode(',', $input->getOption('only')) : null; 28 | $this->allowMajor = (bool) $input->getOption('major'); 29 | $this->allowMinor = (bool) $input->getOption('minor'); 30 | $this->allowPatch = (bool) $input->getOption('patch'); 31 | 32 | if (! $this->allowMajor && ! $this->allowMinor && ! $this->allowPatch) { 33 | $this->allowMajor = true; 34 | $this->allowMinor = true; 35 | $this->allowPatch = true; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/PluginTest.php: -------------------------------------------------------------------------------- 1 | getCapabilities(); 17 | 18 | $this->assertArrayHasKey(CommandProvider::class, $capabilities); 19 | $this->assertEquals(\Vildanbina\ComposerUpgrader\Command\CommandProvider::class, $capabilities[CommandProvider::class]); 20 | } 21 | 22 | public function test_activate_deactivate_uninstall(): void 23 | { 24 | $plugin = new Plugin(); 25 | $composer = $this->createMock(\Composer\Composer::class); 26 | $io = $this->createMock(\Composer\IO\IOInterface::class); 27 | 28 | // No exceptions should be thrown 29 | $plugin->activate($composer, $io); 30 | $plugin->deactivate($composer, $io); 31 | $plugin->uninstall($composer, $io); 32 | 33 | $this->assertTrue(true); // Just ensure no errors 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Service/ComposerFileService.php: -------------------------------------------------------------------------------- 1 | checker = new StabilityChecker(); 17 | } 18 | 19 | public function test_stable_allows_stable(): void 20 | { 21 | $this->assertTrue($this->checker->isAllowed('stable', 'stable')); 22 | } 23 | 24 | public function test_dev_allows_all(): void 25 | { 26 | $this->assertTrue($this->checker->isAllowed('stable', 'dev')); 27 | $this->assertTrue($this->checker->isAllowed('beta', 'dev')); 28 | $this->assertTrue($this->checker->isAllowed('alpha', 'dev')); 29 | } 30 | 31 | public function test_stable_rejects_unstable(): void 32 | { 33 | $this->assertFalse($this->checker->isAllowed('beta', 'stable')); 34 | $this->assertFalse($this->checker->isAllowed('dev', 'stable')); 35 | } 36 | 37 | public function test_beta_allows_beta_and_above(): void 38 | { 39 | $this->assertTrue($this->checker->isAllowed('beta', 'beta')); 40 | $this->assertTrue($this->checker->isAllowed('rc', 'beta')); 41 | $this->assertTrue($this->checker->isAllowed('stable', 'beta')); 42 | $this->assertFalse($this->checker->isAllowed('alpha', 'beta')); 43 | } 44 | 45 | public function test_alpha_allows_all_except_dev(): void 46 | { 47 | $this->assertTrue($this->checker->isAllowed('alpha', 'alpha')); 48 | $this->assertTrue($this->checker->isAllowed('beta', 'alpha')); 49 | $this->assertFalse($this->checker->isAllowed('dev', 'alpha')); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Service/ComposerFileServiceTest.php: -------------------------------------------------------------------------------- 1 | service = new ComposerFileService(); 19 | $this->tempFile = sys_get_temp_dir().'/composer_test.json'; 20 | } 21 | 22 | protected function tearDown(): void 23 | { 24 | if (file_exists($this->tempFile)) { 25 | unlink($this->tempFile); 26 | } 27 | } 28 | 29 | public function test_load_composer_json(): void 30 | { 31 | $data = ['require' => ['package/a' => '^1.0']]; 32 | file_put_contents($this->tempFile, json_encode($data)); 33 | $result = $this->service->loadComposerJson($this->tempFile); 34 | $this->assertEquals($data, $result); 35 | 36 | $this->assertNull($this->service->loadComposerJson('/nonexistent/file.json')); 37 | } 38 | 39 | public function test_get_dependencies(): void 40 | { 41 | $composerJson = [ 42 | 'require' => ['package/a' => '^1.0'], 43 | 'require-dev' => ['package/b' => '^2.0'], 44 | ]; 45 | $expected = [ 46 | 'package/a' => '^1.0', 47 | 'package/b' => '^2.0', 48 | ]; 49 | $this->assertEquals($expected, $this->service->getDependencies($composerJson)); 50 | } 51 | 52 | public function test_update_dependency(): void 53 | { 54 | $composerJson = [ 55 | 'require' => ['package/a' => '^1.0'], 56 | 'require-dev' => ['package/b' => '^2.0'], 57 | ]; 58 | $this->service->updateDependency($composerJson, 'package/a', '^1.1'); 59 | $this->assertEquals('^1.1', $composerJson['require']['package/a']); 60 | $this->service->updateDependency($composerJson, 'package/b', '^2.1'); 61 | $this->assertEquals('^2.1', $composerJson['require-dev']['package/b']); 62 | } 63 | 64 | public function test_save_composer_json(): void 65 | { 66 | $data = ['require' => ['package/a' => '^1.0']]; 67 | $this->service->saveComposerJson($data, $this->tempFile); 68 | $this->assertEquals(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), file_get_contents($this->tempFile)); 69 | } 70 | 71 | public function test_load_empty_composer_json(): void 72 | { 73 | file_put_contents($this->tempFile, json_encode([])); 74 | $result = $this->service->loadComposerJson($this->tempFile); 75 | $this->assertEquals([], $result); 76 | } 77 | 78 | public function test_load_invalid_json(): void 79 | { 80 | file_put_contents($this->tempFile, '{ invalid json }'); 81 | $result = $this->service->loadComposerJson($this->tempFile); 82 | $this->assertNull($result); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Service/ConfigTest.php: -------------------------------------------------------------------------------- 1 | definition = new InputDefinition([ 20 | new InputOption('major', null, InputOption::VALUE_NONE), 21 | new InputOption('minor', null, InputOption::VALUE_NONE), 22 | new InputOption('patch', null, InputOption::VALUE_NONE), 23 | new InputOption('dry-run', null, InputOption::VALUE_NONE), 24 | new InputOption('stability', null, InputOption::VALUE_REQUIRED, '', 'stable'), 25 | new InputOption('only', null, InputOption::VALUE_REQUIRED), 26 | ]); 27 | } 28 | 29 | public function test_default_config(): void 30 | { 31 | $input = new ArrayInput([], $this->definition); 32 | $config = new Config($input); 33 | 34 | $this->assertFalse($config->dryRun); 35 | $this->assertEquals('stable', $config->stability); 36 | $this->assertNull($config->only); 37 | $this->assertTrue($config->allowMajor); 38 | $this->assertTrue($config->allowMinor); 39 | $this->assertTrue($config->allowPatch); 40 | } 41 | 42 | public function test_minor_only(): void 43 | { 44 | $input = new ArrayInput(['--minor' => true], $this->definition); 45 | $config = new Config($input); 46 | 47 | $this->assertFalse($config->dryRun); 48 | $this->assertEquals('stable', $config->stability); 49 | $this->assertNull($config->only); 50 | $this->assertFalse($config->allowMajor); 51 | $this->assertTrue($config->allowMinor); 52 | $this->assertFalse($config->allowPatch); 53 | } 54 | 55 | public function test_dry_run_and_only(): void 56 | { 57 | $input = new ArrayInput(['--dry-run' => true, '--only' => 'package1,package2'], $this->definition); 58 | $config = new Config($input); 59 | 60 | $this->assertTrue($config->dryRun); 61 | $this->assertEquals('stable', $config->stability); 62 | $this->assertEquals(['package1', 'package2'], $config->only); 63 | $this->assertTrue($config->allowMajor); 64 | $this->assertTrue($config->allowMinor); 65 | $this->assertTrue($config->allowPatch); 66 | } 67 | 68 | public function test_major_minor_patch(): void 69 | { 70 | $input = new ArrayInput(['--major' => true, '--minor' => true, '--patch' => true], $this->definition); 71 | $config = new Config($input); 72 | 73 | $this->assertTrue($config->allowMajor); 74 | $this->assertTrue($config->allowMinor); 75 | $this->assertTrue($config->allowPatch); 76 | } 77 | 78 | public function test_stability_dev(): void 79 | { 80 | $input = new ArrayInput(['--stability' => 'dev'], $this->definition); 81 | $config = new Config($input); 82 | $this->assertEquals('dev', $config->stability); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Composer Upgrader 2 | 3 | [![GitHub Workflow Status (main)](https://img.shields.io/github/actions/workflow/status/vildanbina/composer-upgrader/tests.yml?label=Tests)](https://github.com/vildanbina/composer-upgrader/actions) [![Total Downloads](https://img.shields.io/packagist/dt/vildanbina/composer-upgrader)](https://packagist.org/packages/vildanbina/composer-upgrader) [![Latest Version](https://img.shields.io/packagist/v/vildanbina/composer-upgrader)](https://packagist.org/packages/vildanbina/composer-upgrader) [![License](https://img.shields.io/packagist/l/vildanbina/composer-upgrader)](https://packagist.org/packages/vildanbina/composer-upgrader) 4 | 5 | --- 6 | 7 | ## Introduction 8 | 9 | **Composer Upgrader** is a sleek and powerful Composer plugin designed to simplify dependency management in PHP projects. With a single command, upgrade all your dependencies to their latest versions effortlessly. Whether you're maintaining a small library or a large application, this tool offers: 10 | 11 | - **Flexible Upgrades**: Choose major, minor, or patch-level updates. 12 | - **Targeted Updates**: Focus on specific packages with precision. 13 | - **Stability Control**: Set your preferred stability level for peace of mind. 14 | - **Safe Previews**: Test changes before applying them. 15 | 16 | It updates your `composer.json` and prompts you to run `composer update`, keeping you in full control of your project! 17 | 18 | --- 19 | 20 | ## Requirements 21 | 22 | - **PHP**: `^8.0+` (Optimized for modern PHP versions) 23 | - **Composer**: `2.x` 24 | 25 | --- 26 | 27 | ## Installation 28 | 29 | You can install Composer Upgrader either **locally** in your project or **globally** on your system: 30 | 31 | ### Local Installation 32 | Add it to your project: 33 | 34 | ~~~bash 35 | composer require vildanbina/composer-upgrader 36 | ~~~ 37 | 38 | ### Global Installation 39 | Install it globally for use across all projects: 40 | 41 | ~~~bash 42 | composer global require vildanbina/composer-upgrader 43 | ~~~ 44 | 45 | > **Note**: Ensure your global Composer bin directory (e.g., `~/.composer/vendor/bin` or `~/.config/composer/vendor/bin`) is in your PATH to run `composer upgrade-all` from anywhere. Check with `echo $PATH` and update if needed (e.g., `export PATH="$HOME/.composer/vendor/bin:$PATH"`). 46 | 47 | No additional setup required—ready to use either way! 48 | 49 | --- 50 | 51 | ## Configuration 52 | 53 | No configuration files needed! Customize your upgrade experience directly through command-line options for a lightweight, hassle-free setup. 54 | 55 | --- 56 | 57 | ## Commands 58 | 59 | ### `upgrade-all` 60 | 61 | Upgrade your project dependencies with ease. This command scans your `composer.json`, updates it with the latest compatible versions, and advises you to run `composer update` to apply the changes. 62 | 63 | **Usage:** 64 | 65 | ~~~bash 66 | composer upgrade-all [options] 67 | ~~~ 68 | 69 | #### Options: 70 | - **`--major`**: Upgrade to the latest major versions (e.g., `1.0.0` → `2.0.0`). Enabled by default. 71 | - **`--minor`**: Upgrade to the latest minor versions (e.g., `1.0.0` → `1.1.0`). Enabled by default. 72 | - **`--patch`**: Upgrade to the latest patch versions (e.g., `1.0.0` → `1.0.1`). Enabled by default. 73 | - **`--dry-run`**: Preview upgrades without modifying files—ideal for testing. 74 | - **`--stability `**: Set minimum stability (`stable`, `beta`, `alpha`, `dev`). Defaults to `stable`. 75 | - **`--only `**: Upgrade specific packages (e.g., `vendor/package1,vendor/package2`). 76 | 77 | #### Examples: 78 | 79 | - **Patch-Only Upgrade:** 80 | ~~~bash 81 | composer upgrade-all --patch 82 | ~~~ 83 | **Output:** 84 | ~~~ 85 | Fetching latest package versions... 86 | Found vendor/package: ^1.0.0 -> 1.0.1 87 | Composer.json has been updated. Please run "composer update" to apply changes. 88 | ~~~ 89 | 90 | - **Preview Major Upgrades:** 91 | ~~~bash 92 | composer upgrade-all --major --dry-run 93 | ~~~ 94 | **Output:** 95 | ~~~ 96 | Fetching latest package versions... 97 | Found vendor/package: ^1.0.0 -> 2.0.0 98 | Dry run complete. No changes applied. 99 | ~~~ 100 | 101 | - **Specific Packages:** 102 | ~~~bash 103 | composer upgrade-all --only vendor/package1 --patch 104 | ~~~ 105 | **Output:** 106 | ~~~ 107 | Fetching latest package versions... 108 | Found vendor/package1: ^1.0.0 -> 1.0.1 109 | Composer.json has been updated. Please run "composer update" to apply changes. 110 | ~~~ 111 | 112 | After running, finalize the updates with: 113 | 114 | ~~~bash 115 | composer update 116 | ~~~ 117 | 118 | --- 119 | 120 | ## Features 121 | 122 | - **Precision Upgrades**: Tailor updates to major, minor, or patch levels with ease. 123 | - **Selective Targeting**: Use `--only` to upgrade just the packages you need. 124 | - **Stability Flexibility**: Match your project’s stability needs (`stable`, `beta`, etc.). 125 | - **Safe Previews**: Test changes with `--dry-run` before committing. 126 | - **Verbose Logs**: Add `-v` for detailed insights into the upgrade process. 127 | 128 | --- 129 | 130 | ## Contributing 131 | 132 | Want to make this tool even better? Contributions are welcome! Check out our [CONTRIBUTING](.github/CONTRIBUTING.md) guide for details on submitting bug fixes, features, or documentation improvements. 133 | 134 | --- 135 | 136 | ## Security Vulnerabilities 137 | 138 | Spot a security issue? Please email [vildanbina@gmail.com](mailto:vildanbina@gmail.com) directly instead of using the issue tracker. We’ll address it swiftly! 139 | 140 | --- 141 | 142 | ## Credits 143 | 144 | - **[Vildan Bina](https://github.com/vildanbina)** – Creator & Lead Developer 145 | - **All Contributors** – A huge thanks for your support! ([See contributors](../../contributors)) 146 | 147 | --- 148 | 149 | ## License 150 | 151 | Licensed under the MIT License (MIT). See the [License File](LICENSE.md) for more information. 152 | 153 | Upgrade smarter, not harder, with **Composer Upgrader**! 🎉 -------------------------------------------------------------------------------- /src/Command/UpgradeAllCommand.php: -------------------------------------------------------------------------------- 1 | versionService = $versionService; 25 | $this->composerFileService = $composerFileService; 26 | parent::__construct(); 27 | } 28 | 29 | protected function configure(): void 30 | { 31 | $this->setName('upgrade-all') 32 | ->setDescription('Upgrade all Composer dependencies to their latest versions.') 33 | ->addOption('major', null, InputOption::VALUE_NONE, 'Include major version upgrades') 34 | ->addOption('minor', null, InputOption::VALUE_NONE, 'Include minor version upgrades (default)') 35 | ->addOption('patch', null, InputOption::VALUE_NONE, 'Include patch version upgrades (default)') 36 | ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate the upgrade without applying changes') 37 | ->addOption('stability', null, InputOption::VALUE_REQUIRED, 'Set minimum stability (stable, beta, alpha, dev)', 'stable') 38 | ->addOption('only', null, InputOption::VALUE_REQUIRED, 'Upgrade only specific packages (comma-separated)'); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $config = new Config($input); 44 | $composer = $this->requireComposer(); 45 | 46 | $output->writeln('Fetching latest package versions...'); 47 | 48 | $composerJsonPath = getcwd().'/composer.json'; 49 | $composerJson = $this->composerFileService->loadComposerJson($composerJsonPath); 50 | if ($composerJson === null) { 51 | $output->writeln('Invalid or missing composer.json file.'); 52 | 53 | return 1; 54 | } 55 | 56 | if (! $config->dryRun && ! file_exists(getcwd().'/composer.lock')) { 57 | $output->writeln('No composer.lock found. Run "composer install" first.'); 58 | 59 | return 1; 60 | } 61 | 62 | $dependencies = $this->composerFileService->getDependencies($composerJson); 63 | 64 | $this->versionService->setComposer($composer); 65 | $this->versionService->setIO($this->getIO()); 66 | $hasUpdates = false; 67 | 68 | foreach ($dependencies as $package => $constraint) { 69 | if ($config->only && ! in_array($package, $config->only)) { 70 | continue; 71 | } 72 | 73 | if (preg_match('/^(php|ext-)/', $package)) { 74 | continue; 75 | } 76 | 77 | $latestVersion = $this->versionService->getLatestVersion( 78 | $package, 79 | $config->stability, 80 | $constraint, 81 | $config->allowMajor, 82 | $config->allowMinor, 83 | $config->allowPatch 84 | ); 85 | 86 | try { 87 | $currentVersion = $this->versionService->getCurrentVersion($package, $constraint); 88 | $versionToUse = null; 89 | $shouldUpdate = false; 90 | 91 | if ($latestVersion && $this->versionService->isUpgrade($currentVersion, $latestVersion)) { 92 | $output->writeln(sprintf('Found %s: %s -> %s', $package, $constraint, $latestVersion)); 93 | $versionToUse = $latestVersion; 94 | $shouldUpdate = true; 95 | $hasUpdates = true; 96 | } else { 97 | $currentPrettyVersion = $this->versionService->extractBaseVersionFromConstraint($currentVersion); 98 | $displayVersion = $latestVersion ?? $currentPrettyVersion; 99 | if ($output->isVerbose()) { 100 | $output->writeln(sprintf('Skipping %s: %s already satisfies %s', $package, $constraint, $displayVersion)); 101 | } 102 | $versionToUse = $displayVersion; 103 | $cleanVersion = preg_replace('/^v/', '', $versionToUse); 104 | $shouldUpdate = $constraint !== '^'.$cleanVersion; 105 | if ($shouldUpdate) { 106 | $hasUpdates = true; 107 | } 108 | } 109 | 110 | if (! $config->dryRun && $shouldUpdate && $versionToUse) { 111 | $cleanVersion = preg_replace('/^v/', '', $versionToUse); 112 | $this->composerFileService->updateDependency($composerJson, $package, '^'.$cleanVersion); 113 | } 114 | } catch (UnexpectedValueException $e) { 115 | if ($output->isVerbose()) { 116 | $output->writeln("Error processing $package: {$e->getMessage()}"); 117 | } 118 | } 119 | } 120 | 121 | if (! $config->dryRun) { 122 | if ($hasUpdates) { 123 | $this->composerFileService->saveComposerJson($composerJson, $composerJsonPath); 124 | $output->writeln('Composer.json has been updated. Please run "composer update" to apply changes.'); 125 | } else { 126 | $message = 'No dependency updates were required.'; 127 | if ($output->isVerbose()) { 128 | $message .= ' All dependencies already satisfy the requested constraints.'; 129 | } 130 | $output->writeln($message); 131 | } 132 | } else { 133 | $output->writeln('Dry run complete. No changes applied.'); 134 | } 135 | 136 | return 0; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Service/VersionService.php: -------------------------------------------------------------------------------- 1 | versionParser = new VersionParser(); 30 | $this->stabilityChecker = $stabilityChecker; 31 | } 32 | 33 | public function setComposer(Composer $composer): void 34 | { 35 | $this->composer = $composer; 36 | } 37 | 38 | public function setIO(IOInterface $io): void 39 | { 40 | $this->io = $io; 41 | } 42 | 43 | public function getLatestVersion(string $package, string $stability, string $currentConstraint, bool $allowMajor, bool $allowMinor, bool $allowPatch): ?string 44 | { 45 | $cacheKey = "$package:$stability:$currentConstraint:$allowMajor:$allowMinor:$allowPatch"; 46 | if (isset($this->versionCache[$cacheKey])) { 47 | return $this->versionCache[$cacheKey]; 48 | } 49 | 50 | if (! $this->composer) { 51 | throw new RuntimeException('Composer instance not set.'); 52 | } 53 | 54 | $packages = $this->composer 55 | ->getRepositoryManager() 56 | ->findPackages($package, new Constraint('>=', '0.0.0')); 57 | 58 | if (empty($packages)) { 59 | $this->versionCache[$cacheKey] = null; 60 | 61 | return null; 62 | } 63 | 64 | try { 65 | $currentVersion = $this->getCurrentVersion($package, $currentConstraint); 66 | if ($currentVersion === null) { 67 | $this->versionCache[$cacheKey] = null; 68 | 69 | return null; 70 | } 71 | } catch (UnexpectedValueException $e) { 72 | if ($this->io && $this->io->isVerbose()) { 73 | $this->io->writeError("Failed to get current version for $package: ".$e->getMessage()); 74 | } 75 | $this->versionCache[$cacheKey] = null; 76 | 77 | return null; 78 | } 79 | 80 | $versions = []; 81 | foreach ($packages as $pkg) { 82 | $version = $pkg->getPrettyVersion(); 83 | $pkgStability = $pkg->getStability(); 84 | 85 | if ($this->stabilityChecker->isAllowed($pkgStability, $stability)) { 86 | try { 87 | $normalizedVersion = $this->versionParser->normalize($version); 88 | if ( 89 | Comparator::greaterThan($normalizedVersion, $currentVersion) && 90 | $this->isUpgradeAllowed($currentVersion, $normalizedVersion, $allowMajor, $allowMinor, $allowPatch) 91 | ) { 92 | $versions[$normalizedVersion] = $version; 93 | if ($this->io && $this->io->isVerbose()) { 94 | $this->io->write("Considering $package: $version"); 95 | } 96 | } 97 | } catch (UnexpectedValueException $e) { 98 | if ($this->io && $this->io->isVerbose()) { 99 | $this->io->writeError("Invalid version $version for $package: ".$e->getMessage()); 100 | } 101 | 102 | continue; 103 | } 104 | } 105 | } 106 | 107 | if (empty($versions)) { 108 | $this->versionCache[$cacheKey] = null; 109 | 110 | return null; 111 | } 112 | 113 | uksort($versions, fn ($a, $b) => Comparator::compare($b, '>', $a) ? 1 : -1); 114 | $latestVersion = $versions[key($versions)] ?? null; 115 | $this->versionCache[$cacheKey] = $latestVersion; 116 | 117 | if ($this->io && $this->io->isVerbose() && $latestVersion) { 118 | $this->io->write(sprintf('Selected latest version for %s: %s (from %s)', $package, $latestVersion, $currentConstraint)); 119 | } 120 | 121 | return $latestVersion; 122 | } 123 | 124 | public function getCurrentVersion(string $package, string $currentConstraint): ?string 125 | { 126 | if ($this->composer && $this->composer->getLocker()->isLocked()) { 127 | $lockData = $this->composer->getLocker()->getLockData(); 128 | foreach (['packages', 'packages-dev'] as $section) { 129 | foreach ($lockData[$section] ?? [] as $lockedPackage) { 130 | if ($lockedPackage['name'] === $package) { 131 | return $this->versionParser->normalize($this->extractBaseVersionFromConstraint($lockedPackage['version'])); 132 | } 133 | } 134 | } 135 | } 136 | 137 | $version = $this->extractBaseVersionFromConstraint($currentConstraint); 138 | 139 | return $this->versionParser->normalize($version); 140 | } 141 | 142 | public function isUpgrade(string $currentVersion, string $newVersion): bool 143 | { 144 | return Comparator::greaterThan( 145 | $this->versionParser->normalize($newVersion), 146 | $this->versionParser->normalize($currentVersion) 147 | ); 148 | } 149 | 150 | public function extractBaseVersionFromConstraint(string $constraint): string 151 | { 152 | preg_match('/(\d+(?:\.\d+){0,2})/', $constraint, $matches); 153 | 154 | return $matches[1] ?? throw new UnexpectedValueException("Unable to extract base version from constraint: $constraint"); 155 | } 156 | 157 | private function isUpgradeAllowed(string $currentVersion, string $newVersion, bool $allowMajor, bool $allowMinor, bool $allowPatch): bool 158 | { 159 | $currentParts = explode('.', $currentVersion); 160 | $newParts = explode('.', $newVersion); 161 | 162 | $majorDiff = (int) $newParts[0] - (int) $currentParts[0]; 163 | $minorDiff = (int) $newParts[1] - (int) $currentParts[1]; 164 | $patchDiff = (int) $newParts[2] - (int) $currentParts[2]; 165 | 166 | if ($majorDiff > 0) { 167 | return $allowMajor; 168 | } 169 | if ($minorDiff > 0) { 170 | return $allowMinor; 171 | } 172 | if ($patchDiff > 0) { 173 | return $allowPatch; 174 | } 175 | 176 | return false; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /tests/Service/VersionServiceTest.php: -------------------------------------------------------------------------------- 1 | service = new VersionService(new StabilityChecker()); 30 | $this->composer = $this->createMock(Composer::class); 31 | $this->repository = new ArrayRepository(); 32 | 33 | $repoManager = $this->createMock(RepositoryManager::class); 34 | $repoManager->method('findPackages')->willReturnCallback(fn ($name, $constraint) => $this->repository->findPackages($name, $constraint)); 35 | $this->composer->method('getRepositoryManager')->willReturn($repoManager); 36 | 37 | $locker = $this->createMock(Locker::class); 38 | $locker->method('isLocked')->willReturn(false); 39 | $this->composer->method('getLocker')->willReturn($locker); 40 | 41 | $this->service->setComposer($this->composer); 42 | $this->service->setIO(new NullIO()); 43 | } 44 | 45 | public function test_get_latest_version_with_patch(): void 46 | { 47 | $this->repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 48 | $this->repository->addPackage(new Package('test/package', '1.0.1.0', '1.0.1')); 49 | $this->repository->addPackage(new Package('test/package', '1.1.0.0', '1.1.0')); 50 | 51 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^1.0.0', false, false, true); 52 | $this->assertEquals('1.0.1', $latest); 53 | } 54 | 55 | public function test_get_latest_version_with_minor(): void 56 | { 57 | $this->repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 58 | $this->repository->addPackage(new Package('test/package', '1.1.0.0', '1.1.0')); 59 | $this->repository->addPackage(new Package('test/package', '2.0.0.0', '2.0.0')); 60 | 61 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^1.0.0', false, true, false); 62 | $this->assertEquals('1.1.0', $latest); 63 | } 64 | 65 | public function test_get_latest_version_with_major(): void 66 | { 67 | $this->repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 68 | $this->repository->addPackage(new Package('test/package', '2.0.0.0', '2.0.0')); 69 | 70 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^1.0.0', true, false, false); 71 | $this->assertEquals('2.0.0', $latest); 72 | } 73 | 74 | public function test_get_latest_version_no_valid_upgrade(): void 75 | { 76 | $this->repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 77 | $this->repository->addPackage(new Package('test/package', '1.0.1.0', '1.0.1')); 78 | 79 | // Current version is already latest patch 80 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^1.0.1', false, false, true); 81 | $this->assertNull($latest); 82 | } 83 | 84 | public function test_get_latest_version_with_v_prefix(): void 85 | { 86 | $this->repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 87 | $this->repository->addPackage(new Package('test/package', '1.0.1.0', 'v1.0.1')); // v prefix 88 | 89 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^1.0.0', false, false, true); 90 | $this->assertEquals('v1.0.1', $latest); 91 | } 92 | 93 | public function test_get_latest_version_downgrade_prevented(): void 94 | { 95 | $this->repository->addPackage(new Package('test/package', '3.0.8.0', 'v3.0.8')); 96 | $this->repository->addPackage(new Package('test/package', '3.7.2.0', '3.7.2')); 97 | 98 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^3.7.2', false, false, true); 99 | $this->assertNull($latest); // No downgrade to 3.0.8 100 | } 101 | 102 | public function test_get_latest_version_with_stability(): void 103 | { 104 | $this->repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 105 | $this->repository->addPackage(new Package('test/package', '1.0.1-beta.0', '1.0.1-beta')); // Unstable 106 | 107 | $latest = $this->service->getLatestVersion('test/package', 'stable', '^1.0.0', false, false, true); 108 | $this->assertNull($latest); // No unstable versions with 'stable' 109 | 110 | $latest = $this->service->getLatestVersion('test/package', 'beta', '^1.0.0', false, false, true); 111 | $this->assertEquals('1.0.1-beta', $latest); // Allows beta with 'beta' stability 112 | } 113 | 114 | public function test_is_upgrade(): void 115 | { 116 | $this->assertTrue($this->service->isUpgrade('1.0.0', '1.0.1')); 117 | $this->assertFalse($this->service->isUpgrade('1.0.1', '1.0.0')); // Downgrade 118 | $this->assertFalse($this->service->isUpgrade('1.0.0', '1.0.0')); // Same version 119 | $this->assertTrue($this->service->isUpgrade('3.7.2', '3.7.3')); 120 | $this->assertFalse($this->service->isUpgrade('3.7.2', '3.0.8')); // Downgrade 121 | } 122 | 123 | public function test_get_current_version_from_constraint(): void 124 | { 125 | $version = $this->service->getCurrentVersion('test/package', '^1.2.3'); 126 | $this->assertEquals('1.2.3.0', $version); // Extracts base version 127 | } 128 | 129 | public function test_no_composer_set(): void 130 | { 131 | $service = new VersionService(new StabilityChecker()); 132 | $this->expectException(RuntimeException::class); 133 | $this->expectExceptionMessage('Composer instance not set.'); 134 | $service->getLatestVersion('test/package', 'stable', '^1.0.0', true, true, true); 135 | } 136 | 137 | public function test_invalid_constraint_throws_exception(): void 138 | { 139 | $this->expectException(UnexpectedValueException::class); 140 | $this->service->getCurrentVersion('test/package', 'invalid'); // Invalid constraint 141 | } 142 | 143 | public function test_empty_repository(): void 144 | { 145 | $latest = $this->service->getLatestVersion('test/empty', 'stable', '^1.0.0', true, true, true); 146 | $this->assertNull($latest); // No packages in repo 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/Command/UpgradeAllCommandTest.php: -------------------------------------------------------------------------------- 1 | fileService = $this->createMock(ComposerFileService::class); 34 | $this->versionService = new VersionService(new StabilityChecker); 35 | $this->command = new UpgradeAllCommand($this->versionService, $this->fileService); 36 | 37 | $composer = $this->createMock(Composer::class); 38 | $repoManager = $this->createMock(RepositoryManager::class); 39 | $composer->method('getRepositoryManager')->willReturn($repoManager); 40 | 41 | $repository = new ArrayRepository; 42 | $repository->addPackage(new Package('test/package', '1.0.0.0', '1.0.0')); 43 | $repository->addPackage(new Package('test/package', '1.0.1.0', '1.0.1')); 44 | $repository->addPackage(new Package('test/package', '1.1.0.0', '1.1.0')); 45 | $repository->addPackage(new Package('test/package', '2.0.0.0', '2.0.0')); 46 | $repository->addPackage(new Package('test/other', '2.0.0.0', '2.0.0')); 47 | $repository->addPackage(new Package('test/other', '2.0.1-beta', '2.0.1-beta')); 48 | $repoManager->method('findPackages')->willReturn($repository->getPackages()); 49 | 50 | $locker = $this->createMock(Locker::class); 51 | $locker->method('isLocked')->willReturn(false); 52 | $composer->method('getLocker')->willReturn($locker); 53 | 54 | $application = $this->createMock(Application::class); 55 | $application->method('getComposer')->willReturn($composer); 56 | $application->method('getHelperSet')->willReturn(new HelperSet([])); 57 | $application->method('getDefinition')->willReturn(new InputDefinition([])); 58 | 59 | $this->command->setApplication($application); 60 | $this->versionService->setComposer($composer); 61 | } 62 | 63 | public function test_execute_dry_run(): void 64 | { 65 | $this->fileService->expects($this->once()) 66 | ->method('loadComposerJson') 67 | ->willReturn([ 68 | 'require' => ['test/package' => '^1.0.0'], 69 | ]); 70 | $this->fileService->expects($this->once()) 71 | ->method('getDependencies') 72 | ->willReturn(['test/package' => '^1.0.0']); 73 | 74 | $tester = new CommandTester($this->command); 75 | $tester->execute(['--dry-run' => true, '--patch' => true]); 76 | 77 | $output = $tester->getDisplay(); 78 | $this->assertStringContainsString('Fetching latest package versions...', $output); 79 | $this->assertStringContainsString('Found test/package: ^1.0.0 -> 1.0.1', $output); 80 | $this->assertStringContainsString('Dry run complete. No changes applied.', $output); 81 | $this->assertEquals(0, $tester->getStatusCode()); 82 | } 83 | 84 | public function test_missing_composer_json(): void 85 | { 86 | $this->fileService->expects($this->once()) 87 | ->method('loadComposerJson') 88 | ->willReturn(null); 89 | 90 | $tester = new CommandTester($this->command); 91 | $tester->execute(['--dry-run' => true]); 92 | 93 | $output = $tester->getDisplay(); 94 | $this->assertStringContainsString('Invalid or missing composer.json file.', $output); 95 | $this->assertEquals(1, $tester->getStatusCode()); 96 | } 97 | 98 | public function test_execute_updates_composer_json(): void 99 | { 100 | $this->fileService->expects($this->once()) 101 | ->method('loadComposerJson') 102 | ->willReturn([ 103 | 'require' => ['test/package' => '^1.0.0'], 104 | ]); 105 | $this->fileService->expects($this->once()) 106 | ->method('getDependencies') 107 | ->willReturn(['test/package' => '^1.0.0']); 108 | $this->fileService->expects($this->once()) 109 | ->method('saveComposerJson'); 110 | 111 | $tester = new CommandTester($this->command); 112 | $tester->execute(['--patch' => true]); 113 | 114 | $output = $tester->getDisplay(); 115 | $this->assertStringContainsString('Fetching latest package versions...', $output); 116 | $this->assertStringContainsString('Found test/package: ^1.0.0 -> 1.0.1', $output); 117 | $this->assertStringContainsString('Composer.json has been updated. Please run "composer update" to apply changes.', $output); 118 | $this->assertEquals(0, $tester->getStatusCode()); 119 | } 120 | 121 | public function test_execute_with_only_option(): void 122 | { 123 | $this->fileService->expects($this->once()) 124 | ->method('loadComposerJson') 125 | ->willReturn([ 126 | 'require' => [ 127 | 'test/package' => '^1.0.0', 128 | 'test/other' => '^2.0.0', 129 | ], 130 | ]); 131 | $this->fileService->expects($this->once()) 132 | ->method('getDependencies') 133 | ->willReturn([ 134 | 'test/package' => '^1.0.0', 135 | 'test/other' => '^2.0.0', 136 | ]); 137 | 138 | $tester = new CommandTester($this->command); 139 | $tester->execute(['--dry-run' => true, '--patch' => true, '--only' => 'test/package']); 140 | 141 | $output = $tester->getDisplay(); 142 | $this->assertStringContainsString('Found test/package: ^1.0.0 -> 1.0.1', $output); 143 | $this->assertStringNotContainsString('test/other', $output); 144 | $this->assertStringContainsString('Dry run complete. No changes applied.', $output); 145 | } 146 | 147 | public function test_execute_with_stability_beta(): void 148 | { 149 | $this->fileService->expects($this->once()) 150 | ->method('loadComposerJson') 151 | ->willReturn([ 152 | 'require' => ['test/other' => '^2.0.0'], 153 | ]); 154 | $this->fileService->expects($this->once()) 155 | ->method('getDependencies') 156 | ->willReturn(['test/other' => '^2.0.0']); 157 | 158 | $tester = new CommandTester($this->command); 159 | $tester->execute(['--dry-run' => true, '--patch' => true, '--stability' => 'beta']); 160 | 161 | $output = $tester->getDisplay(); 162 | $this->assertStringContainsString('Found test/other: ^2.0.0 -> 2.0.1-beta', $output); 163 | } 164 | 165 | public function test_execute_with_all_flags(): void 166 | { 167 | $this->fileService->expects($this->once()) 168 | ->method('loadComposerJson') 169 | ->willReturn([ 170 | 'require' => ['test/package' => '^1.0.0'], 171 | ]); 172 | $this->fileService->expects($this->once()) 173 | ->method('getDependencies') 174 | ->willReturn(['test/package' => '^1.0.0']); 175 | 176 | $tester = new CommandTester($this->command); 177 | $tester->execute(['--dry-run' => true, '--major' => true, '--minor' => true, '--patch' => true]); 178 | 179 | $output = $tester->getDisplay(); 180 | $this->assertStringContainsString('Found test/package: ^1.0.0 -> 2.0.0', $output); 181 | } 182 | 183 | public function test_execute_no_upgrades_available(): void 184 | { 185 | $this->fileService->expects($this->once()) 186 | ->method('loadComposerJson') 187 | ->willReturn([ 188 | 'require' => ['test/package' => '^2.0.0'], 189 | ]); 190 | $this->fileService->expects($this->once()) 191 | ->method('getDependencies') 192 | ->willReturn(['test/package' => '^2.0.0']); 193 | 194 | $tester = new CommandTester($this->command); 195 | $tester->execute(['--dry-run' => true, '--patch' => true]); 196 | 197 | $output = $tester->getDisplay(); 198 | $this->assertStringContainsString('Fetching latest package versions...', $output); 199 | $this->assertStringNotContainsString('Found test/package', $output); 200 | $this->assertStringContainsString('Dry run complete. No changes applied.', $output); 201 | } 202 | 203 | public function test_execute_normalizes_constraint_even_when_no_upgrade(): void 204 | { 205 | $composerJson = [ 206 | 'require' => ['test/package' => '~2.0.0'], 207 | ]; 208 | 209 | $this->fileService->expects($this->once()) 210 | ->method('loadComposerJson') 211 | ->willReturn($composerJson); 212 | $this->fileService->expects($this->once()) 213 | ->method('getDependencies') 214 | ->willReturn(['test/package' => '~2.0.0']); 215 | $this->fileService->expects($this->once()) 216 | ->method('updateDependency') 217 | ->with($composerJson, 'test/package', '^2.0.0'); 218 | $this->fileService->expects($this->once()) 219 | ->method('saveComposerJson'); 220 | 221 | $tester = new CommandTester($this->command); 222 | $tester->execute(['--patch' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); 223 | 224 | $output = $tester->getDisplay(); 225 | $this->assertStringContainsString('Fetching latest package versions...', $output); 226 | $this->assertStringContainsString('Skipping test/package: ~2.0.0 already satisfies 2.0.0', $output); 227 | $this->assertStringContainsString('Composer.json has been updated. Please run "composer update" to apply changes.', $output); 228 | $this->assertEquals(0, $tester->getStatusCode()); 229 | } 230 | 231 | public function test_execute_skips_update_when_constraint_already_correct(): void 232 | { 233 | $composerJson = [ 234 | 'require' => ['test/package' => '^2.0.0'], 235 | ]; 236 | 237 | $this->fileService->expects($this->once()) 238 | ->method('loadComposerJson') 239 | ->willReturn($composerJson); 240 | $this->fileService->expects($this->once()) 241 | ->method('getDependencies') 242 | ->willReturn(['test/package' => '^2.0.0']); 243 | $this->fileService->expects($this->never()) 244 | ->method('updateDependency'); 245 | $this->fileService->expects($this->never()) 246 | ->method('saveComposerJson'); 247 | 248 | $tester = new CommandTester($this->command); 249 | $tester->execute(['--patch' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); 250 | 251 | $output = $tester->getDisplay(); 252 | $this->assertStringContainsString('Fetching latest package versions...', $output); 253 | $this->assertStringContainsString('Skipping test/package: ^2.0.0 already satisfies 2.0.0', $output); 254 | $this->assertStringContainsString('No dependency updates were required.', $output); 255 | $this->assertEquals(0, $tester->getStatusCode()); 256 | } 257 | } 258 | --------------------------------------------------------------------------------