8000 feature #51845 [AssetMapper] Add outdated command (Maelan LE BORGNE) · symfony/symfony@fc12885 · GitHub
[go: up one dir, main page]

Skip to content

Commit fc12885

Browse files
committed
feature #51845 [AssetMapper] Add outdated command (Maelan LE BORGNE)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [AssetMapper] Add outdated command | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | | License | MIT As suggested by `@WebMamba`, I added a new command for the AssetMapper component to list outdated 3rd party packages. It is inspired by the `composer outdated` command so I tried to replicate its options as much as I could. It reads the importmap.php and extract packages version to query the https://registry.npmjs.org/%package% API and read the latest version from metadata. ![image](https://github.com/symfony/symfony/assets/11990607/189f66a0-dda0-4916-a91b-988ebe8f9fb3) :warning: The code is base on #51650 branch so it is not ready to be merged yet, but I'd be happy to get some reviews and feedback in the meantime. This is my first PR on symfony, so there will probably be a lot to say ! - [ ] gather feedback - [x] wait for #51650 to be merged and rebase - [ ] write documentation Commits ------- 4d32a35 [AssetMapper] Add outdated command
2 parents 85fc3f7 + 4d32a35 commit fc12885

13 files changed

+571
-33
lines changed

src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php

+11
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand;
2121
use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand;
2222
use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand;
23+
use Symfony\Component\AssetMapper\Command\ImportMapOutdatedCommand;
2324
use Symfony\Component\AssetMapper\Command\ImportMapRemoveCommand;
2425
use Symfony\Component\AssetMapper\Command\ImportMapRequireCommand;
2526
use Symfony\Component\AssetMapper\Command\ImportMapUpdateCommand;
@@ -32,6 +33,7 @@
3233
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
3334
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
3435
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
36+
use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker;
3537
use Symfony\Component\AssetMapper\ImportMap\RemotePackageDownloader;
3638
use Symfony\Component\AssetMapper\ImportMap\Resolver\JsDelivrEsmResolver;
3739
use Symfony\Component\AssetMapper\MapperAwareAssetPackage;
@@ -179,6 +181,11 @@
179181
service('asset_mapper.importmap.config_reader'),
180182
service('http_client'),
181183
])
184+
->set('asset_mapper.importmap.update_checker', ImportMapUpdateChecker::class)
185+
->args([
186+
service('asset_mapper.importmap.config_reader'),
187+
service('http_client'),
188+
])
182189

183190
->set('asset_mapper.importmap.command.require', ImportMapRequireCommand::class)
184191
->args([
@@ -205,5 +212,9 @@
205212
->set('asset_mapper.importmap.command.audit', ImportMapAuditCommand::class)
206213
->args([service('asset_mapper.importmap.auditor')])
207214
->tag('console.command')
215+
216+
->set('asset_mapper.importmap.command.outdated', ImportMapOutdatedCommand::class)
217+
->args([service('asset_mapper.importmap.update_checker')])
218+
->tag('console.command')
208219
;
209220
};

src/Symfony/Component/AssetMapper/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ CHANGELOG
1515
* Add a `importmap:install` command to download all missing downloaded packages
1616
* Allow specifying packages to update for the `importmap:update` command
1717
* Add a `importmap:audit` command to check for security vulnerability advisories in dependencies
18+
* Add a `importmap:outdated` command to check for outdated packages
1819

1920
6.3
2021
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Command;
13+
14+
use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker;
15+
use Symfony\Component\AssetMapper\ImportMap\PackageUpdateInfo;
16+
use Symfony\Component\Console\Attribute\AsCommand;
17+
use Symfony\Component\Console\Command\Command;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Input\InputOption;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
use Symfony\Component\Console\Style\SymfonyStyle;
23+
24+
#[AsCommand(name: 'importmap:outdated', description: 'List outdated JavaScript packages and their latest versions')]
25+
final class ImportMapOutdatedCommand extends Command
26+
{
27+
private const COLOR_MAPPING = [
28+
'update-possible' => 'yellow',
29+
'semver-safe-update' => 'red',
30+
];
31+
32+
public function __construct(
33+
private readonly ImportMapUpdateChecker $updateChecker,
34+
) {
35+
parent::__construct();
36+
}
37+
38+
protected function configure(): void
39+
{
40+
$this
41+
->addArgument(
42+
name: 'packages',
43+
mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
44+
description: 'A list of packages to check',
45+
)
46+
->addOption(
47+
name: 'format',
48+
mode: InputOption::VALUE_REQUIRED,
49+
description: sprintf('The output format ("%s")', implode(', ', $this->getAvailableFormatOptions())),
50+
default: 'txt',
51+
)
52+
->setHelp(<<<'EOT'
53+
The <info>%command.name%</info> command will list the latest updates available for the 3rd party packages in <comment>importmap.php</comment>.
54+
Versions showing in <fg=red>red</> are semver compatible versions and you should upgrading.
55+
Versions showing in <fg=yellow>yellow</> are major updates that include backward compatibility breaks according to semver.
56+
57+
<info>php %command.full_name%</info>
58+
59+
Or specific packages only:
60+
61+
<info>php %command.full_name% <packages></info>
62+
EOT
63+
);
64+
}
65+
66+
protected function execute(InputInterface $input, OutputInterface $output): int
67+
{
68+
$io = new SymfonyStyle($input, $output);
69+
$packages = $input->getArgument('packages');
70+
$packagesUpdateInfos = $this->updateChecker->getAvailableUpdates($packages);
71+
$packagesUpdateInfos = array_filter($packagesUpdateInfos, fn ($packageUpdateInfo) => $packageUpdateInfo->hasUpdate());
72+
if (0 === \count($packagesUpdateInfos)) {
73+
return Command::SUCCESS;
74+
}
75+
76+
$displayData = array_map(fn ($importName, $packageUpdateInfo) => [
77+
'name' => $importName,
78+
'current' => $packageUpdateInfo->currentVersion,
79+
'latest' => $packageUpdateInfo->latestVersion,
80+
'latest-status' => PackageUpdateInfo::UPDATE_TYPE_MAJOR === $packageUpdateInfo->updateType ? 'update-possible' : 'semver-safe-update',
81+
], array_keys($packagesUpdateInfos), $packagesUpdateInfos);
82+
83+
if ('json' === $input->getOption('format')) {
84+
$io->writeln(json_encode($displayData, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
85+
} else {
86+
$table = $io->createTable();
87+
$table->setHeaders(['Package', 'Current', 'Latest']);
88+
foreach ($displayData as $datum) {
89+
$color = self::COLOR_MAPPING[$datum['latest-status']] ?? 'default';
90+
$table->addRow([
91+
sprintf('<fg=%s>%s</>', $color, $datum['name']),
92+
$datum['current'],
93+
sprintf('<fg=%s>%s</>', $color, $datum['latest']),
94+
]);
95+
}
96+
$table->render();
97+
}
98+
99+
return Command::FAILURE;
100+
}
101+
102+
private function getAvailableFormatOptions(): array
103+
{
104+
return ['txt', 'json'];
105+
}
106+
}

src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php

+18
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,16 @@ public function getEntries(): ImportMapEntries
6868
throw new RuntimeException(sprintf('The importmap entry "%s" cannot have both a "path" and "version" option.', $importName));
6969
}
7070

71+
[$packageName, $filePath] = self::splitPackageNameAndFilePath($importName);
72+
7173
$entries->add(new ImportMapEntry(
7274
$importName,
7375
path: $path,
7476
version: $version,
7577
type: $type,
7678
isEntrypoint: $isEntry,
79+
packageName: $packageName,
80+
filePath: $filePath,
7781
));
7882
}
7983

@@ -144,4 +148,18 @@ private function extractVersionFromLegacyUrl(string $url): ?string
144148

145149
return substr($url, $lastAt + 1, $nextSlash - $lastAt - 1);
146150
}
151+
152+
public static function splitPackageNameAndFilePath(string $packageName): array
153+
{
154+
$filePath = '';
155+
$i = strpos($packageName, '/');
156+
157+
if ($i && (!str_starts_with($packageName, '@') || $i = strpos($packageName, '/', $i + 1))) {
158+
// @vendor/package/filepath or package/filepath
159+
$filePath = substr($packageName, $i);
160+
$packageName = substr($packageName, 0, $i);
161+
}
162+
163+
return [$packageName, $filePath];
164+
}
147165
}

src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public function __construct(
2727
public readonly ?string $version = null,
2828
public readonly ImportMapType $type = ImportMapType::JS,
2929
public readonly bool $isEntrypoint = false,
30+
public readonly ?string $packageName = null,
31+
public readonly ?string $filePath = null,
3032
) {
3133
}
3234

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\ImportMap;
13+
14+
use Symfony\Contracts\HttpClient\HttpClientInterface;
15+
16+
class ImportMapUpdateChecker
17+
{
18+
private const URL_PACKAGE_METADATA = 'https://registry.npmjs.org/%s';
19+
20+
public function __construct(
21+
private readonly ImportMapConfigReader $importMapConfigReader,
22+
private readonly HttpClientInterface $httpClient,
23+
) {
24+
}
25+
26+
/**
27+
* @param string[] $packages
28+
*
29+
* @return PackageUpdateInfo[]
30+
*/
31+
public function getAvailableUpdates(array $packages = []): array
32+
{
33+
$entries = $this->importMapConfigReader->getEntries();
34+
$updateInfos = [];
35+
$responses = [];
36+
foreach ($entries as $entry) {
37+
if (null === $entry->packageName || null === $entry->version) {
38+
continue;
39+
}
40+
if (\count($packages) && !\in_array($entry->packageName, $packages, true)) {
41+
continue;
42+
}
43+
44+
$responses[$entry->importName] = $this->httpClient->request('GET', sprintf(self::URL_PACKAGE_METADATA, $entry->packageName), ['headers' => ['Accept' => 'application/vnd.npm.install-v1+json']]);
45+
}
46+
47+
foreach ($responses as $importName => $response) {
48+
$entry = $entries->get($importName);
49+
if (200 !== $response->getStatusCode()) {
50+
throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->packageName));
51+
}
52+
$updateInfo = new PackageUpdateInfo($entry->packageName, $entry->version);
53+
try {
54+
$updateInfo->latestVersion = json_decode($response->getContent(), true)['dist-tags']['latest'];
55+
$updateInfo->updateType = $this->getUpdateType($updateInfo->currentVersion, $updateInfo->latestVersion);
56+
} catch (\Exception $e) {
57+
throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->packageName), 0, $e);
58+
}
59+
$updateInfos[$importName] = $updateInfo;
60+
}
61+
62+
return $updateInfos;
63+
}
64+
65+
private function getVersionPart(string $version, int $part): ?string
66+
{
67+
return explode('.', $version)[$part] ?? $version;
68+
}
69+
70+
private function getUpdateType(string $currentVersion, string $latestVersion): string
71+
{
72+
if (version_compare($currentVersion, $latestVersion, '>')) {
73+
return PackageUpdateInfo::UPDATE_TYPE_DOWNGRADE;
74+
}
75+
if (version_compare($currentVersion, $latestVersion, '==')) {
76+
return PackageUpdateInfo::UPDATE_TYPE_UP_TO_DATE;
77+
}
78+
if ($this->getVersionPart($currentVersion, 0) < $this->getVersionPart($latestVersion, 0)) {
79+
return PackageUpdateInfo::UPDATE_TYPE_MAJOR;
80+
}
81+
if ($this->getVersionPart($currentVersion, 1) < $this->getVersionPart($latestVersion, 1)) {
82+
return PackageUpdateInfo::UPDATE_TYPE_MINOR;
83+
}
84+
if ($this->getVersionPart($currentVersion, 2) < $this->getVersionPart($latestVersion, 2)) {
85+
return PackageUpdateInfo::UPDATE_TYPE_PATCH;
86+
}
87+
88+
throw new \LogicException(sprintf('Unable to determine update type for "%s" and "%s".', $currentVersion, $latestVersion));
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\ImportMap;
13+
14+
class PackageUpdateInfo
15+
{
16+
public const UPDATE_TYPE_DOWNGRADE = 'downgrade';
17+
public const UPDATE_TYPE_UP_TO_DATE = 'up-to-date';
18+
public const UPDATE_TYPE_MAJOR = 'major';
19+
public const UPDATE_TYPE_MINOR = 'minor';
20+
public const UPDATE_TYPE_PATCH = 'patch';
21+
22+
public function __construct(
23+
public readonly string $packageName,
24+
public readonly string $currentVersion,
25+
public ?string $latestVersion = null,
26+
public ?string $updateType = null,
27+
) {
28+
}
29+
30+
public function hasUpdate(): bool
31+
{
32+
return !\in_array($this->updateType, [self::UPDATE_TYPE_DOWNGRADE, self::UPDATE_TYPE_DOWNGRADE, self::UPDATE_TYPE_UP_TO_DATE]);
33+
}
34+
}

src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php

+3-17
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\AssetMapper\ImportMap\Resolver;
1313

1414
use Symfony\Component\AssetMapper\Exception\RuntimeException;
15+
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
1516
use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry;
1617
use Symfony\Component\AssetMapper\ImportMap\ImportMapType;
1718
use Symfony\Component\AssetMapper\ImportMap\PackageRequireOptions;
@@ -56,7 +57,7 @@ public function resolvePackages(array $packagesToRequire): array
5657
continue;
5758
}
5859

59-
[$packageName, $filePath] = self::splitPackageNameAndFilePath($packageName);
60+
[$packageName, $filePath] = ImportMapConfigReader::splitPackageNameAndFilePath($packageName);
6061

6162
$response = $this->httpClient->request('GET', sprintf($this->versionUrlPattern, $packageName, urlencode($constraint)));
6263
$requiredPackages[] = [$options, $response, $packageName, $filePath, /* resolved version */ null];
@@ -159,9 +160,8 @@ public function downloadPackages(array $importMapEntries, callable $progressCall
159160
$responses = [];
160161

161162
foreach ($importMapEntries as $package => $entry) {
162-
[$packageName, $filePath] = self::splitPackageNameAndFilePath($entry->importName);
163163
$pattern = ImportMapType::CSS === $entry->type ? $this->distUrlCssPattern : $this->distUrlPattern;
164-
$url = sprintf($pattern, $packageName, $entry->version, $filePath);
164+
$url = sprintf($pattern, $entry->packageName, $entry->version, $entry->filePath);
165165

166166
$responses[$package] = $this->httpClient->request('GET', $url);
167167
}
@@ -218,20 +218,6 @@ private function fetchPackageRequirementsFromImports(string $content): array
218218
return $dependencies;
219219
}
220220

221-
private static function splitPackageNameAndFilePath(string $packageName): array
222-
{
223-
$filePath = '';
224-
$i = strpos($packageName, '/');
225-
226-
if ($i && (!str_starts_with($packageName, '@') || $i = strpos($packageName, '/', $i + 1))) {
227-
// @vendor/package/filepath or package/filepath
228-
$filePath = substr($packageName, $i);
229-
$packageName = substr($packageName, 0, $i);
230-
}
231-
232-
return [$packageName, $filePath];
233-
}
234-
235221
/**
236222
* Parses the very specific import syntax used by jsDelivr.
237223
*

0 commit comments

Comments
 (0)
0