├── .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 | [](https://github.com/vildanbina/composer-upgrader/actions) [](https://packagist.org/packages/vildanbina/composer-upgrader) [](https://packagist.org/packages/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 |
--------------------------------------------------------------------------------