Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Leaf\Console;

use Leaf\Console\Utils\Upgrade;
use Leaf\Sprout\Command;

class InstallCommand extends Command
Expand Down Expand Up @@ -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;
}
}
5 changes: 5 additions & 0 deletions src/UpdateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Leaf\Console;

use Leaf\Console\Utils\Upgrade;
use Leaf\Sprout\Command;

class UpdateCommand extends Command
Expand All @@ -20,6 +21,10 @@ protected function handle(): int
if (sprout()->composer(true)->install('leafs/cli --ansi')->isSuccessful()) {
$this->writeln('<info>Leaf CLI installed successfully!</info>');

if ($nudge = Upgrade::nudge()) {
$this->writeln($nudge);
}

return 0;
}
}
Expand Down
131 changes: 131 additions & 0 deletions src/Utils/Upgrade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace Leaf\Console\Utils;

use Symfony\Component\Process\Process;

/**
* Upgrade
* ----
* Tell the user, once, when their app is behind on Leaf majors.
*/
class Upgrade
{
public const GUIDE = 'https://leafphp.dev/docs/upgrade-guide';

/**
* Build the nudge line for the app in $directory, or null when
* everything is current or we can't tell (no lock file, offline,
* composer error). Never throws.
*/
public static function nudge(?string $directory = null): ?string
{
try {
$directory = $directory ?? getcwd();

if (!$directory || !is_file("$directory/composer.json")) {
return null;
}

$leaf = static::lockedVersion($directory, 'leafs/leaf');

if ($leaf !== null && version_compare($leaf, '5.0.0', '<')) {
return "leafs/leaf $leaf installed, Leaf 5 is available. See " . static::GUIDE;
}

$outdated = static::outdatedLeafModules($directory);

if (empty($outdated)) {
return null;
}

$list = implode(', ', array_map(
fn ($name, $versions) => "$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<string, array{0: string, 1: string}>
*/
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;
}
}
81 changes: 81 additions & 0 deletions tests/upgrade.test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

use Leaf\Console\Utils\Upgrade;

beforeEach(function () {
sandboxSetup();
file_put_contents('composer.json', '{"name":"t/t"}');
});

afterEach(function () {
sandboxTeardown();
});

/**
* Write a composer.lock with the given name => 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', '<?php echo ' . var_export($outdatedJson, true) . "; exit($exitCode);");
}

test('nudges once when leafs/leaf is below 5', function () {
lockWith(['leafs/leaf' => '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();
});
Loading