Skip to content

Commit

Permalink
feature #51845 [AssetMapper] Add outdated command (Maelan LE BORGNE)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
fabpot committed Oct 10, 2023
2 parents 85fc3f7 + 4d32a35 commit fc12885
Show file tree
Hide file tree
Showing 13 changed files with 571 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand;
use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand;
use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand;
use Symfony\Component\AssetMapper\Command\ImportMapOutdatedCommand;
use Symfony\Component\AssetMapper\Command\ImportMapRemoveCommand;
use Symfony\Component\AssetMapper\Command\ImportMapRequireCommand;
use Symfony\Component\AssetMapper\Command\ImportMapUpdateCommand;
Expand All @@ -32,6 +33,7 @@
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker;
use Symfony\Component\AssetMapper\ImportMap\RemotePackageDownloader;
use Symfony\Component\AssetMapper\ImportMap\Resolver\JsDelivrEsmResolver;
use Symfony\Component\AssetMapper\MapperAwareAssetPackage;
Expand Down Expand Up @@ -179,6 +181,11 @@
service('asset_mapper.importmap.config_reader'),
service('http_client'),
])
->set('asset_mapper.importmap.update_checker', ImportMapUpdateChecker::class)
->args([
service('asset_mapper.importmap.config_reader'),
service('http_client'),
])

->set('asset_mapper.importmap.command.require', ImportMapRequireCommand::class)
->args([
Expand All @@ -205,5 +212,9 @@
->set('asset_mapper.importmap.command.audit', ImportMapAuditCommand::class)
->args([service('asset_mapper.importmap.auditor')])
->tag('console.command')

->set('asset_mapper.importmap.command.outdated', ImportMapOutdatedCommand::class)
->args([service('asset_mapper.importmap.update_checker')])
->tag('console.command')
;
};
1 change: 1 addition & 0 deletions src/Symfony/Component/AssetMapper/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ CHANGELOG
* Add a `importmap:install` command to download all missing downloaded packages
* Allow specifying packages to update for the `importmap:update` command
* Add a `importmap:audit` command to check for security vulnerability advisories in dependencies
* Add a `importmap:outdated` command to check for outdated packages

6.3
---
Expand Down
106 changes: 106 additions & 0 deletions src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\Command;

use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker;
use Symfony\Component\AssetMapper\ImportMap\PackageUpdateInfo;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'importmap:outdated', description: 'List outdated JavaScript packages and their latest versions')]
final class ImportMapOutdatedCommand extends Command
{
private const COLOR_MAPPING = [
'update-possible' => 'yellow',
'semver-safe-update' => 'red',
];

public function __construct(
private readonly ImportMapUpdateChecker $updateChecker,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument(
name: 'packages',
mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
description: 'A list of packages to check',
)
->addOption(
name: 'format',
mode: InputOption::VALUE_REQUIRED,
description: sprintf('The output format ("%s")', implode(', ', $this->getAvailableFormatOptions())),
default: 'txt',
)
->setHelp(<<<'EOT'
The <info>%command.name%</info> command will list the latest updates available for the 3rd party packages in <comment>importmap.php</comment>.
Versions showing in <fg=red>red</> are semver compatible versions and you should upgrading.
Versions showing in <fg=yellow>yellow</> are major updates that include backward compatibility breaks according to semver.
<info>php %command.full_name%</info>
Or specific packages only:
<info>php %command.full_name% <packages></info>
EOT
);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$packages = $input->getArgument('packages');
$packagesUpdateInfos = $this->updateChecker->getAvailableUpdates($packages);
$packagesUpdateInfos = array_filter($packagesUpdateInfos, fn ($packageUpdateInfo) => $packageUpdateInfo->hasUpdate());
if (0 === \count($packagesUpdateInfos)) {
return Command::SUCCESS;
}

$displayData = array_map(fn ($importName, $packageUpdateInfo) => [
'name' => $importName,
'current' => $packageUpdateInfo->currentVersion,
'latest' => $packageUpdateInfo->latestVersion,
'latest-status' => PackageUpdateInfo::UPDATE_TYPE_MAJOR === $packageUpdateInfo->updateType ? 'update-possible' : 'semver-safe-update',
], array_keys($packagesUpdateInfos), $packagesUpdateInfos);

if ('json' === $input->getOption('format')) {
$io->writeln(json_encode($displayData, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
} else {
$table = $io->createTable();
$table->setHeaders(['Package', 'Current', 'Latest']);
foreach ($displayData as $datum) {
$color = self::COLOR_MAPPING[$datum['latest-status']] ?? 'default';
$table->addRow([
sprintf('<fg=%s>%s</>', $color, $datum['name']),
$datum['current'],
sprintf('<fg=%s>%s</>', $color, $datum['latest']),
]);
}
$table->render();
}

return Command::FAILURE;
}

private function getAvailableFormatOptions(): array
{
return ['txt', 'json'];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,16 @@ public function getEntries(): ImportMapEntries
throw new RuntimeException(sprintf('The importmap entry "%s" cannot have both a "path" and "version" option.', $importName));
}

[$packageName, $filePath] = self::splitPackageNameAndFilePath($importName);

$entries->add(new ImportMapEntry(
$importName,
path: $path,
version: $version,
type: $type,
isEntrypoint: $isEntry,
packageName: $packageName,
filePath: $filePath,
));
}

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

return substr($url, $lastAt + 1, $nextSlash - $lastAt - 1);
}

public static function splitPackageNameAndFilePath(string $packageName): array
{
$filePath = '';
$i = strpos($packageName, '/');

if ($i && (!str_starts_with($packageName, '@') || $i = strpos($packageName, '/', $i + 1))) {
// @vendor/package/filepath or package/filepath
$filePath = substr($packageName, $i);
$packageName = substr($packageName, 0, $i);
}

return [$packageName, $filePath];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public function __construct(
public readonly ?string $version = null,
public readonly ImportMapType $type = ImportMapType::JS,
public readonly bool $isEntrypoint = false,
public readonly ?string $packageName = null,
public readonly ?string $filePath = null,
) {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\ImportMap;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class ImportMapUpdateChecker
{
private const URL_PACKAGE_METADATA = 'https://registry.npmjs.org/%s';

public function __construct(
private readonly ImportMapConfigReader $importMapConfigReader,
private readonly HttpClientInterface $httpClient,
) {
}

/**
* @param string[] $packages
*
* @return PackageUpdateInfo[]
*/
public function getAvailableUpdates(array $packages = []): array
{
$entries = $this->importMapConfigReader->getEntries();
$updateInfos = [];
$responses = [];
foreach ($entries as $entry) {
if (null === $entry->packageName || null === $entry->version) {
continue;
}
if (\count($packages) && !\in_array($entry->packageName, $packages, true)) {
continue;
}

$responses[$entry->importName] = $this->httpClient->request('GET', sprintf(self::URL_PACKAGE_METADATA, $entry->packageName), ['headers' => ['Accept' => 'application/vnd.npm.install-v1+json']]);
}

foreach ($responses as $importName => $response) {
$entry = $entries->get($importName);
if (200 !== $response->getStatusCode()) {
throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->packageName));
}
$updateInfo = new PackageUpdateInfo($entry->packageName, $entry->version);
try {
$updateInfo->latestVersion = json_decode($response->getContent(), true)['dist-tags']['latest'];
$updateInfo->updateType = $this->getUpdateType($updateInfo->currentVersion, $updateInfo->latestVersion);
} catch (\Exception $e) {
throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->packageName), 0, $e);
}
$updateInfos[$importName] = $updateInfo;
}

return $updateInfos;
}

private function getVersionPart(string $version, int $part): ?string
{
return explode('.', $version)[$part] ?? $version;
}

private function getUpdateType(string $currentVersion, string $latestVersion): string
{
if (version_compare($currentVersion, $latestVersion, '>')) {
return PackageUpdateInfo::UPDATE_TYPE_DOWNGRADE;
}
if (version_compare($currentVersion, $latestVersion, '==')) {
return PackageUpdateInfo::UPDATE_TYPE_UP_TO_DATE;
}
if ($this->getVersionPart($currentVersion, 0) < $this->getVersionPart($latestVersion, 0)) {
return PackageUpdateInfo::UPDATE_TYPE_MAJOR;
}
if ($this->getVersionPart($currentVersion, 1) < $this->getVersionPart($latestVersion, 1)) {
return PackageUpdateInfo::UPDATE_TYPE_MINOR;
}
if ($this->getVersionPart($currentVersion, 2) < $this->getVersionPart($latestVersion, 2)) {
return PackageUpdateInfo::UPDATE_TYPE_PATCH;
}

throw new \LogicException(sprintf('Unable to determine update type for "%s" and "%s".', $currentVersion, $latestVersion));
}
}
34 changes: 34 additions & 0 deletions src/Symfony/Component/AssetMapper/ImportMap/PackageUpdateInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\AssetMapper\ImportMap;

class PackageUpdateInfo
{
public const UPDATE_TYPE_DOWNGRADE = 'downgrade';
public const UPDATE_TYPE_UP_TO_DATE = 'up-to-date';
public const UPDATE_TYPE_MAJOR = 'major';
public const UPDATE_TYPE_MINOR = 'minor';
public const UPDATE_TYPE_PATCH = 'patch';

public function __construct(
public readonly string $packageName,
public readonly string $currentVersion,
public ?string $latestVersion = null,
public ?string $updateType = null,
) {
}

public function hasUpdate(): bool
{
return !\in_array($this->updateType, [self::UPDATE_TYPE_DOWNGRADE, self::UPDATE_TYPE_DOWNGRADE, self::UPDATE_TYPE_UP_TO_DATE]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\AssetMapper\ImportMap\Resolver;

use Symfony\Component\AssetMapper\Exception\RuntimeException;
use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader;
use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry;
use Symfony\Component\AssetMapper\ImportMap\ImportMapType;
use Symfony\Component\AssetMapper\ImportMap\PackageRequireOptions;
Expand Down Expand Up @@ -56,7 +57,7 @@ public function resolvePackages(array $packagesToRequire): array
continue;
}

[$packageName, $filePath] = self::splitPackageNameAndFilePath($packageName);
[$packageName, $filePath] = ImportMapConfigReader::splitPackageNameAndFilePath($packageName);

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

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

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

private static function splitPackageNameAndFilePath(string $packageName): array
{
$filePath = '';
$i = strpos($packageName, '/');

if ($i && (!str_starts_with($packageName, '@') || $i = strpos($packageName, '/', $i + 1))) {
// @vendor/package/filepath or package/filepath
$filePath = substr($packageName, $i);
$packageName = substr($packageName, 0, $i);
}

return [$packageName, $filePath];
}

/**
* Parses the very specific import syntax used by jsDelivr.
*
Expand Down

0 comments on commit fc12885

Please sign in to comment.