From 84c731074103d80062e0c3640d6037f2dfca4e66 Mon Sep 17 00:00:00 2001
From: mychidarko <26604242+mychidarko@users.noreply.github.com>
Date: Mon, 9 Nov 2026 15:31:53 +0000
Subject: [PATCH] feat: point apps behind on Leaf majors to the upgrade guide
after install/update
---
src/InstallCommand.php | 21 ++++++-
src/UpdateCommand.php | 5 ++
src/Utils/Upgrade.php | 131 +++++++++++++++++++++++++++++++++++++++++
tests/upgrade.test.php | 81 +++++++++++++++++++++++++
4 files changed, 236 insertions(+), 2 deletions(-)
create mode 100644 src/Utils/Upgrade.php
create mode 100644 tests/upgrade.test.php
diff --git a/src/InstallCommand.php b/src/InstallCommand.php
index 2623c95..49151a3 100644
--- a/src/InstallCommand.php
+++ b/src/InstallCommand.php
@@ -4,6 +4,7 @@
namespace Leaf\Console;
+use Leaf\Console\Utils\Upgrade;
use Leaf\Sprout\Command;
class InstallCommand extends Command
@@ -33,9 +34,25 @@ protected function handle(): int
return 1;
}
- return 0;
+ return $this->finish();
}
- return sprout()->composer()->install()->isSuccessful() ? 0 : 1;
+ if (!sprout()->composer()->install()->isSuccessful()) {
+ return 1;
+ }
+
+ return $this->finish();
+ }
+
+ /**
+ * Print one plain line if the app is behind on Leaf majors.
+ */
+ protected function finish(): int
+ {
+ if ($nudge = Upgrade::nudge()) {
+ $this->writeln($nudge);
+ }
+
+ return 0;
}
}
diff --git a/src/UpdateCommand.php b/src/UpdateCommand.php
index 705d922..841febd 100644
--- a/src/UpdateCommand.php
+++ b/src/UpdateCommand.php
@@ -4,6 +4,7 @@
namespace Leaf\Console;
+use Leaf\Console\Utils\Upgrade;
use Leaf\Sprout\Command;
class UpdateCommand extends Command
@@ -20,6 +21,10 @@ protected function handle(): int
if (sprout()->composer(true)->install('leafs/cli --ansi')->isSuccessful()) {
$this->writeln('Leaf CLI installed successfully!');
+ if ($nudge = Upgrade::nudge()) {
+ $this->writeln($nudge);
+ }
+
return 0;
}
}
diff --git a/src/Utils/Upgrade.php b/src/Utils/Upgrade.php
new file mode 100644
index 0000000..63b71c4
--- /dev/null
+++ b/src/Utils/Upgrade.php
@@ -0,0 +1,131 @@
+ "$name {$versions[0]} -> {$versions[1]}",
+ array_keys($outdated),
+ $outdated
+ ));
+
+ return "Newer Leaf majors available: $list. See " . static::GUIDE;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Read a package's installed version from composer.lock. Returns null
+ * for missing packages and non-numeric (dev branch) versions.
+ */
+ public static function lockedVersion(string $directory, string $package): ?string
+ {
+ if (!is_file("$directory/composer.lock")) {
+ return null;
+ }
+
+ $lock = json_decode((string) file_get_contents("$directory/composer.lock"), true);
+
+ if (!is_array($lock)) {
+ return null;
+ }
+
+ foreach (array_merge($lock['packages'] ?? [], $lock['packages-dev'] ?? []) as $installed) {
+ if (($installed['name'] ?? null) === $package) {
+ return static::normalize((string) ($installed['version'] ?? ''));
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Direct leafs/* dependencies with a newer major, via
+ * `composer outdated --direct --major-only`. Empty on any failure.
+ *
+ * @return array
+ */
+ public static function outdatedLeafModules(string $directory): array
+ {
+ $process = Process::fromShellCommandline(
+ Core::findComposer() . ' outdated --direct --major-only --format=json --no-interaction --no-ansi',
+ $directory,
+ ['COMPOSER_NO_INTERACTION' => '1']
+ );
+ $process->setTimeout(30);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ return [];
+ }
+
+ $report = json_decode($process->getOutput(), true);
+ $found = [];
+
+ foreach ($report['installed'] ?? [] as $package) {
+ $name = $package['name'] ?? '';
+
+ if (strpos($name, 'leafs/') !== 0) {
+ continue;
+ }
+
+ $current = static::normalize((string) ($package['version'] ?? ''));
+ $latest = static::normalize((string) ($package['latest'] ?? ''));
+
+ if ($current && $latest && (int) $latest > (int) $current) {
+ $found[$name] = [$current, $latest];
+ }
+ }
+
+ return $found;
+ }
+
+ /**
+ * "v4.4.1" -> "4.4.1"; dev branches -> null.
+ */
+ protected static function normalize(string $version): ?string
+ {
+ $version = ltrim(trim($version), 'vV');
+
+ return preg_match('/^\d+(\.\d+)*$/', $version) ? $version : null;
+ }
+}
diff --git a/tests/upgrade.test.php b/tests/upgrade.test.php
new file mode 100644
index 0000000..9a024e7
--- /dev/null
+++ b/tests/upgrade.test.php
@@ -0,0 +1,81 @@
+ version pairs.
+ */
+function lockWith(array $packages): void
+{
+ file_put_contents('composer.lock', json_encode([
+ 'packages' => array_map(
+ fn ($name, $version) => ['name' => $name, 'version' => $version],
+ array_keys($packages),
+ $packages
+ ),
+ 'packages-dev' => [],
+ ]));
+}
+
+/**
+ * Core::findComposer() prefers ./composer.phar, so a PHP script there
+ * stands in for composer on every platform.
+ */
+function fakeComposer(string $outdatedJson, int $exitCode = 0): void
+{
+ file_put_contents('composer.phar', ' 'v4.4.1', 'leafs/http' => 'v4.2']);
+
+ expect(Upgrade::nudge())
+ ->toBe('leafs/leaf 4.4.1 installed, Leaf 5 is available. See https://leafphp.dev/docs/upgrade-guide');
+});
+
+test('lists leafs modules with a newer major', function () {
+ lockWith(['leafs/leaf' => 'v5.1.0']);
+ fakeComposer(json_encode(['installed' => [
+ ['name' => 'leafs/form', 'version' => 'v3.2', 'latest' => 'v5.0.1'],
+ ['name' => 'symfony/yaml', 'version' => 'v6.4.0', 'latest' => 'v7.1.0'],
+ ]]));
+
+ expect(Upgrade::nudge())
+ ->toBe('Newer Leaf majors available: leafs/form 3.2 -> 5.0.1. See https://leafphp.dev/docs/upgrade-guide');
+});
+
+test('stays silent when everything is current', function () {
+ lockWith(['leafs/leaf' => 'v5.1.0']);
+ fakeComposer(json_encode(['installed' => []]));
+
+ expect(Upgrade::nudge())->toBeNull();
+});
+
+test('stays silent when composer outdated fails (offline)', function () {
+ lockWith(['leafs/leaf' => 'v5.1.0']);
+ fakeComposer('The "https://repo.packagist.org/packages.json" file could not be downloaded', 1);
+
+ expect(Upgrade::nudge())->toBeNull();
+});
+
+test('stays silent outside a composer project', function () {
+ unlink('composer.json');
+
+ expect(Upgrade::nudge())->toBeNull();
+});
+
+test('ignores dev-branch versions of leafs/leaf', function () {
+ lockWith(['leafs/leaf' => 'dev-main']);
+ fakeComposer(json_encode(['installed' => []]));
+
+ expect(Upgrade::nudge())->toBeNull();
+});