Skip to content

Commit a14ac9e

Browse files
Merge pull request #8 from magebitcom/feature/module-update-banner
feat(version-check): implemented automated version checks
2 parents 55f3916 + 0304a29 commit a14ac9e

17 files changed

Lines changed: 1318 additions & 0 deletions

Cron/CheckModuleUpdates.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
/**
3+
* @author Magebit <info@magebit.com>
4+
* @copyright Copyright (c) Magebit, Ltd. (https://magebit.com)
5+
* @license MIT
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Magebit\Mcp\Cron;
10+
11+
use Magebit\Mcp\Api\LoggerInterface;
12+
use Magebit\Mcp\Model\Config\ModuleConfig;
13+
use Magebit\Mcp\Model\ModuleUpdate\InstalledPackageProvider;
14+
use Magebit\Mcp\Model\ModuleUpdate\PackagistClient;
15+
use Magebit\Mcp\Model\ModuleUpdate\UpdateSummaryStorage;
16+
use Magebit\Mcp\Model\ModuleUpdate\VersionComparator;
17+
use Throwable;
18+
19+
/**
20+
* Daily check for newer Magebit MCP releases; caches the outdated set for the admin banner.
21+
*
22+
* Gated by store config; when disabled it clears the cached result so a stale banner can't linger.
23+
*/
24+
class CheckModuleUpdates
25+
{
26+
/**
27+
* @param ModuleConfig $config
28+
* @param InstalledPackageProvider $packages
29+
* @param PackagistClient $packagist
30+
* @param VersionComparator $versionComparator
31+
* @param UpdateSummaryStorage $storage
32+
* @param LoggerInterface $logger
33+
*/
34+
public function __construct(
35+
private readonly ModuleConfig $config,
36+
private readonly InstalledPackageProvider $packages,
37+
private readonly PackagistClient $packagist,
38+
private readonly VersionComparator $versionComparator,
39+
private readonly UpdateSummaryStorage $storage,
40+
private readonly LoggerInterface $logger
41+
) {
42+
}
43+
44+
/**
45+
* @return void
46+
*/
47+
public function execute(): void
48+
{
49+
try {
50+
if (!$this->config->isModuleUpdateCheckEnabled()) {
51+
$this->storage->clear();
52+
return;
53+
}
54+
55+
$outdated = [];
56+
foreach ($this->packages->getInstalledPackages() as $package => $installed) {
57+
$latest = $this->packagist->getLatestStableVersion($package);
58+
if ($latest === null) {
59+
continue;
60+
}
61+
if ($this->versionComparator->isNewer($latest, $installed)) {
62+
$outdated[] = ['package' => $package, 'installed' => $installed, 'latest' => $latest];
63+
}
64+
}
65+
66+
$this->storage->save($outdated);
67+
68+
if ($outdated !== []) {
69+
$this->logger->info('MCP update check found outdated modules.', ['count' => count($outdated)]);
70+
}
71+
} catch (Throwable $e) {
72+
$this->logger->error('MCP update check failed.', ['exception' => $e]);
73+
}
74+
}
75+
}

Model/Config/ModuleConfig.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class ModuleConfig
3434
public const XML_PATH_OAUTH_ACCESS_TOKEN_LIFETIME = 'magebit_mcp/oauth/access_token_lifetime';
3535
public const XML_PATH_OAUTH_REFRESH_TOKEN_LIFETIME_DAYS = 'magebit_mcp/oauth/refresh_token_lifetime_days';
3636
public const XML_PATH_OAUTH_REAUTH_BEHAVIOR = 'magebit_mcp/oauth/reauth_behavior';
37+
public const XML_PATH_MODULE_UPDATES_ENABLED = 'magebit_mcp/module_updates/enabled';
3738

3839
public const DEFAULT_SERVER_NAME = 'Magento MCP';
3940
public const DEFAULT_RATE_LIMITING_RPM = 60;
@@ -250,4 +251,13 @@ public function getOAuthReauthBehavior(): string
250251
{
251252
return ReauthBehavior::normalize($this->scopeConfig->getValue(self::XML_PATH_OAUTH_REAUTH_BEHAVIOR));
252253
}
254+
255+
/**
256+
* Whether the daily Packagist update check + admin banner is active.
257+
* @return bool
258+
*/
259+
public function isModuleUpdateCheckEnabled(): bool
260+
{
261+
return $this->scopeConfig->isSetFlag(self::XML_PATH_MODULE_UPDATES_ENABLED);
262+
}
253263
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
/**
3+
* @author Magebit <info@magebit.com>
4+
* @copyright Copyright (c) Magebit, Ltd. (https://magebit.com)
5+
* @license MIT
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Magebit\Mcp\Model\ModuleUpdate;
10+
11+
use Composer\InstalledVersions;
12+
13+
/**
14+
* Thin seam over the static Composer runtime API so callers stay unit-testable.
15+
*
16+
* Returns null when the package is not Composer-installed (e.g. modules mounted
17+
* directly into app/code), which the caller treats as "version unknown, skip".
18+
*/
19+
class ComposerVersionResolver
20+
{
21+
/**
22+
* @param string $packageName
23+
* @return ?string
24+
*/
25+
public function getInstalledVersion(string $packageName): ?string
26+
{
27+
if (!InstalledVersions::isInstalled($packageName)) {
28+
return null;
29+
}
30+
31+
$version = InstalledVersions::getPrettyVersion($packageName);
32+
return is_string($version) && $version !== '' ? $version : null;
33+
}
34+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
/**
3+
* @author Magebit <info@magebit.com>
4+
* @copyright Copyright (c) Magebit, Ltd. (https://magebit.com)
5+
* @license MIT
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Magebit\Mcp\Model\ModuleUpdate;
10+
11+
use Magebit\Mcp\Api\LoggerInterface;
12+
use Magento\Framework\Component\ComponentRegistrar;
13+
use Magento\Framework\Component\ComponentRegistrarInterface;
14+
use Magento\Framework\Filesystem\Driver\File;
15+
use Magento\Framework\Module\FullModuleList;
16+
use Throwable;
17+
18+
/**
19+
* Discovers the installed Magebit MCP package family for update checking.
20+
*
21+
* Derived dynamically so future satellites are covered without code changes. Packages
22+
* whose version can't be resolved (mounted into app/code, not Composer-installed) are skipped.
23+
*/
24+
class InstalledPackageProvider
25+
{
26+
private const MODULE_PREFIX = 'Magebit_Mcp';
27+
28+
/**
29+
* @param FullModuleList $moduleList
30+
* @param ComponentRegistrarInterface $componentRegistrar
31+
* @param File $fileDriver
32+
* @param ComposerVersionResolver $versionResolver
33+
* @param LoggerInterface $logger
34+
*/
35+
public function __construct(
36+
private readonly FullModuleList $moduleList,
37+
private readonly ComponentRegistrarInterface $componentRegistrar,
38+
private readonly File $fileDriver,
39+
private readonly ComposerVersionResolver $versionResolver,
40+
private readonly LoggerInterface $logger
41+
) {
42+
}
43+
44+
/**
45+
* Composer package name => installed pretty version, for resolvable MCP modules.
46+
*
47+
* @return array<string, string>
48+
*/
49+
public function getInstalledPackages(): array
50+
{
51+
$packages = [];
52+
53+
foreach ($this->moduleList->getNames() as $moduleName) {
54+
if (!str_starts_with($moduleName, self::MODULE_PREFIX)) {
55+
continue;
56+
}
57+
58+
try {
59+
$packageName = $this->readPackageName($moduleName);
60+
if ($packageName === null) {
61+
continue;
62+
}
63+
64+
$version = $this->versionResolver->getInstalledVersion($packageName);
65+
if ($version === null) {
66+
continue;
67+
}
68+
69+
$packages[$packageName] = $version;
70+
} catch (Throwable $e) {
71+
$this->logger->warning(
72+
'MCP update check could not inspect module, skipping.',
73+
['module' => $moduleName, 'exception' => $e]
74+
);
75+
}
76+
}
77+
78+
return $packages;
79+
}
80+
81+
/**
82+
* @param string $moduleName
83+
* @return ?string
84+
*/
85+
private function readPackageName(string $moduleName): ?string
86+
{
87+
$path = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $moduleName);
88+
if ($path === null) {
89+
return null;
90+
}
91+
92+
$composerFile = $path . '/composer.json';
93+
if (!$this->fileDriver->isExists($composerFile)) {
94+
return null;
95+
}
96+
97+
$contents = $this->fileDriver->fileGetContents($composerFile);
98+
$decoded = json_decode($contents, true);
99+
if (!is_array($decoded)) {
100+
return null;
101+
}
102+
103+
$name = $decoded['name'] ?? null;
104+
return is_string($name) && $name !== '' ? $name : null;
105+
}
106+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
/**
3+
* @author Magebit <info@magebit.com>
4+
* @copyright Copyright (c) Magebit, Ltd. (https://magebit.com)
5+
* @license MIT
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Magebit\Mcp\Model\ModuleUpdate;
10+
11+
use Composer\Semver\VersionParser;
12+
use Magebit\Mcp\Api\LoggerInterface;
13+
use Magento\Framework\HTTP\Client\Curl;
14+
use Throwable;
15+
16+
/**
17+
* Looks up the latest stable release of a package from the Packagist p2 metadata API.
18+
*
19+
* Hardened for unattended use: HTTPS-only, no redirects, bounded timeout, capped response.
20+
* Never throws — every failure path logs and returns null so cron can't be broken by Packagist.
21+
*/
22+
class PackagistClient
23+
{
24+
private const ENDPOINT = 'https://repo.packagist.org/p2/%s.json';
25+
private const TIMEOUT_SECONDS = 5;
26+
private const MAX_RESPONSE_BYTES = 1048576;
27+
28+
/**
29+
* Packagist's own package-name grammar; guards against a tampered composer.json
30+
* smuggling path traversal or a host into the URL.
31+
*/
32+
private const PACKAGE_NAME_PATTERN = '#^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$#';
33+
34+
/**
35+
* @param Curl $curl
36+
* @param VersionParser $versionParser
37+
* @param VersionComparator $versionComparator
38+
* @param LoggerInterface $logger
39+
*/
40+
public function __construct(
41+
private readonly Curl $curl,
42+
private readonly VersionParser $versionParser,
43+
private readonly VersionComparator $versionComparator,
44+
private readonly LoggerInterface $logger
45+
) {
46+
}
47+
48+
/**
49+
* Highest stable version published for the package, or null on any failure.
50+
*
51+
* @param string $packageName
52+
* @return ?string
53+
*/
54+
public function getLatestStableVersion(string $packageName): ?string
55+
{
56+
if (preg_match(self::PACKAGE_NAME_PATTERN, $packageName) !== 1) {
57+
$this->logger->warning('MCP update check rejected malformed package name.', ['package' => $packageName]);
58+
return null;
59+
}
60+
61+
try {
62+
$body = $this->fetch(sprintf(self::ENDPOINT, $packageName));
63+
if ($body === null) {
64+
return null;
65+
}
66+
67+
/** @var array<string, mixed> $decoded */
68+
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
69+
return $this->highestStableVersion($decoded, $packageName);
70+
} catch (Throwable $e) {
71+
$this->logger->warning(
72+
'MCP update check failed to query Packagist.',
73+
['package' => $packageName, 'exception' => $e]
74+
);
75+
return null;
76+
}
77+
}
78+
79+
/**
80+
* @param string $uri
81+
* @return ?string Raw response body, or null if the request did not return a usable 200.
82+
*/
83+
private function fetch(string $uri): ?string
84+
{
85+
$this->curl->setTimeout(self::TIMEOUT_SECONDS);
86+
$this->curl->setOptions([
87+
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT_SECONDS,
88+
CURLOPT_FOLLOWLOCATION => false,
89+
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
90+
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
91+
CURLOPT_SSL_VERIFYPEER => true,
92+
CURLOPT_SSL_VERIFYHOST => 2,
93+
]);
94+
95+
$this->curl->get($uri);
96+
97+
if ($this->curl->getStatus() !== 200) {
98+
return null;
99+
}
100+
101+
$body = $this->curl->getBody();
102+
if (strlen($body) > self::MAX_RESPONSE_BYTES) {
103+
$this->logger->warning('MCP update check ignored oversized Packagist response.', ['uri' => $uri]);
104+
return null;
105+
}
106+
107+
return $body;
108+
}
109+
110+
/**
111+
* Walks the p2 release list, keeps stable versions, returns the highest.
112+
*
113+
* @param array<string, mixed> $decoded
114+
* @param string $packageName
115+
* @return ?string
116+
*/
117+
private function highestStableVersion(array $decoded, string $packageName): ?string
118+
{
119+
$packages = $decoded['packages'] ?? null;
120+
if (!is_array($packages) || !isset($packages[$packageName]) || !is_array($packages[$packageName])) {
121+
return null;
122+
}
123+
124+
$highest = null;
125+
foreach ($packages[$packageName] as $release) {
126+
if (!is_array($release)) {
127+
continue;
128+
}
129+
$version = $release['version'] ?? null;
130+
if (!is_string($version) || $version === '') {
131+
continue;
132+
}
133+
if ($this->versionParser->parseStability($version) !== 'stable') {
134+
continue;
135+
}
136+
if ($highest === null || $this->versionComparator->isNewer($version, $highest)) {
137+
$highest = $version;
138+
}
139+
}
140+
141+
return $highest;
142+
}
143+
}

0 commit comments

Comments
 (0)