440 lines
14 KiB
PHP
440 lines
14 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Core\Uptelligence\Services;
|
||
|
|
|
||
|
|
use Carbon\Carbon;
|
||
|
|
use Illuminate\Support\Facades\Http;
|
||
|
|
use Illuminate\Support\Facades\Log;
|
||
|
|
use Illuminate\Support\Facades\Process;
|
||
|
|
use Illuminate\Support\Facades\RateLimiter;
|
||
|
|
use Core\Uptelligence\Models\Asset;
|
||
|
|
use Core\Uptelligence\Models\AssetVersion;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Asset Tracker Service - monitors and updates package dependencies.
|
||
|
|
*
|
||
|
|
* Checks Packagist, NPM, and custom registries for updates.
|
||
|
|
*/
|
||
|
|
class AssetTrackerService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* Check all active assets for updates.
|
||
|
|
*/
|
||
|
|
public function checkAllForUpdates(): array
|
||
|
|
{
|
||
|
|
$results = [];
|
||
|
|
|
||
|
|
foreach (Asset::active()->get() as $asset) {
|
||
|
|
$results[$asset->slug] = $this->checkForUpdate($asset);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $results;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check a single asset for updates.
|
||
|
|
*/
|
||
|
|
public function checkForUpdate(Asset $asset): array
|
||
|
|
{
|
||
|
|
$result = match ($asset->type) {
|
||
|
|
Asset::TYPE_COMPOSER => $this->checkComposerPackage($asset),
|
||
|
|
Asset::TYPE_NPM => $this->checkNpmPackage($asset),
|
||
|
|
Asset::TYPE_FONT => $this->checkFontAsset($asset),
|
||
|
|
default => ['status' => 'skipped', 'message' => 'No auto-check for this type'],
|
||
|
|
};
|
||
|
|
|
||
|
|
$asset->update(['last_checked_at' => now()]);
|
||
|
|
|
||
|
|
return $result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check Composer package for updates with rate limiting and retry logic.
|
||
|
|
*/
|
||
|
|
protected function checkComposerPackage(Asset $asset): array
|
||
|
|
{
|
||
|
|
if (! $asset->package_name) {
|
||
|
|
return ['status' => 'error', 'message' => 'No package name configured'];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check rate limit before making API call
|
||
|
|
if (RateLimiter::tooManyAttempts('upstream-registry', 30)) {
|
||
|
|
$seconds = RateLimiter::availableIn('upstream-registry');
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'rate_limited',
|
||
|
|
'message' => "Rate limit exceeded. Retry after {$seconds} seconds",
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
RateLimiter::hit('upstream-registry');
|
||
|
|
|
||
|
|
// Try Packagist first with retry logic
|
||
|
|
$response = Http::timeout(30)
|
||
|
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||
|
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||
|
|
|
||
|
|
Log::warning('Uptelligence: Packagist API retry', [
|
||
|
|
'attempt' => $attempt,
|
||
|
|
'delay_ms' => $delay,
|
||
|
|
'error' => $exception->getMessage(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
return $delay;
|
||
|
|
}, function (\Exception $exception) {
|
||
|
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||
|
|
$status = $exception->response?->status();
|
||
|
|
|
||
|
|
return $status >= 500 || $status === 429;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
})
|
||
|
|
->get("https://repo.packagist.org/p2/{$asset->package_name}.json");
|
||
|
|
|
||
|
|
if ($response->successful()) {
|
||
|
|
$data = $response->json();
|
||
|
|
$packages = $data['packages'][$asset->package_name] ?? [];
|
||
|
|
|
||
|
|
if (! empty($packages)) {
|
||
|
|
// Get latest stable version
|
||
|
|
$latest = collect($packages)
|
||
|
|
->filter(fn ($p) => ! str_contains($p['version'] ?? '', 'dev'))
|
||
|
|
->sortByDesc('version')
|
||
|
|
->first();
|
||
|
|
|
||
|
|
if ($latest) {
|
||
|
|
$latestVersion = ltrim($latest['version'], 'v');
|
||
|
|
$hasUpdate = $asset->installed_version &&
|
||
|
|
version_compare($latestVersion, $asset->installed_version, '>');
|
||
|
|
|
||
|
|
$asset->update(['latest_version' => $latestVersion]);
|
||
|
|
|
||
|
|
// Record version if new
|
||
|
|
$this->recordVersion($asset, $latestVersion, $latest);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'success',
|
||
|
|
'latest' => $latestVersion,
|
||
|
|
'has_update' => $hasUpdate,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Log::warning('Uptelligence: Packagist API request failed', [
|
||
|
|
'package' => $asset->package_name,
|
||
|
|
'status' => $response->status(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try custom registry (e.g., Flux Pro)
|
||
|
|
if ($asset->registry_url) {
|
||
|
|
return $this->checkCustomComposerRegistry($asset);
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['status' => 'error', 'message' => 'Could not fetch package info'];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check custom Composer registry (like Flux Pro).
|
||
|
|
*/
|
||
|
|
protected function checkCustomComposerRegistry(Asset $asset): array
|
||
|
|
{
|
||
|
|
// For licensed packages, we need to check the installed version via composer show
|
||
|
|
$result = Process::run("composer show {$asset->package_name} --format=json 2>/dev/null");
|
||
|
|
|
||
|
|
if ($result->successful()) {
|
||
|
|
$data = json_decode($result->output(), true);
|
||
|
|
$installedVersion = $data['versions'][0] ?? null;
|
||
|
|
|
||
|
|
if ($installedVersion) {
|
||
|
|
$asset->update(['installed_version' => $installedVersion]);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'success',
|
||
|
|
'installed' => $installedVersion,
|
||
|
|
'message' => 'Check registry manually for latest version',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['status' => 'info', 'message' => 'Licensed package - check registry manually'];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check NPM package for updates with rate limiting and retry logic.
|
||
|
|
*/
|
||
|
|
protected function checkNpmPackage(Asset $asset): array
|
||
|
|
{
|
||
|
|
if (! $asset->package_name) {
|
||
|
|
return ['status' => 'error', 'message' => 'No package name configured'];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check rate limit before making API call
|
||
|
|
if (RateLimiter::tooManyAttempts('upstream-registry', 30)) {
|
||
|
|
$seconds = RateLimiter::availableIn('upstream-registry');
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'rate_limited',
|
||
|
|
'message' => "Rate limit exceeded. Retry after {$seconds} seconds",
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
RateLimiter::hit('upstream-registry');
|
||
|
|
|
||
|
|
// Check npm registry with retry logic
|
||
|
|
$response = Http::timeout(30)
|
||
|
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||
|
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||
|
|
|
||
|
|
Log::warning('Uptelligence: NPM registry API retry', [
|
||
|
|
'attempt' => $attempt,
|
||
|
|
'delay_ms' => $delay,
|
||
|
|
'error' => $exception->getMessage(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
return $delay;
|
||
|
|
}, function (\Exception $exception) {
|
||
|
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||
|
|
$status = $exception->response?->status();
|
||
|
|
|
||
|
|
return $status >= 500 || $status === 429;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
})
|
||
|
|
->get("https://registry.npmjs.org/{$asset->package_name}");
|
||
|
|
|
||
|
|
if ($response->successful()) {
|
||
|
|
$data = $response->json();
|
||
|
|
$latestVersion = $data['dist-tags']['latest'] ?? null;
|
||
|
|
|
||
|
|
if ($latestVersion) {
|
||
|
|
$hasUpdate = $asset->installed_version &&
|
||
|
|
version_compare($latestVersion, $asset->installed_version, '>');
|
||
|
|
|
||
|
|
$asset->update(['latest_version' => $latestVersion]);
|
||
|
|
|
||
|
|
// Record version if new
|
||
|
|
$versionData = $data['versions'][$latestVersion] ?? [];
|
||
|
|
$this->recordVersion($asset, $latestVersion, $versionData);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'success',
|
||
|
|
'latest' => $latestVersion,
|
||
|
|
'has_update' => $hasUpdate,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Log::warning('Uptelligence: NPM registry API request failed', [
|
||
|
|
'package' => $asset->package_name,
|
||
|
|
'status' => $response->status(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for scoped/private packages via npm view
|
||
|
|
$result = Process::run("npm view {$asset->package_name} version 2>/dev/null");
|
||
|
|
if ($result->successful()) {
|
||
|
|
$latestVersion = trim($result->output());
|
||
|
|
if ($latestVersion) {
|
||
|
|
$asset->update(['latest_version' => $latestVersion]);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'success',
|
||
|
|
'latest' => $latestVersion,
|
||
|
|
'has_update' => $asset->installed_version &&
|
||
|
|
version_compare($latestVersion, $asset->installed_version, '>'),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['status' => 'error', 'message' => 'Could not fetch package info'];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check Font Awesome kit for updates.
|
||
|
|
*/
|
||
|
|
protected function checkFontAsset(Asset $asset): array
|
||
|
|
{
|
||
|
|
// Font Awesome kits auto-update, just verify the kit is valid
|
||
|
|
$kitId = $asset->licence_meta['kit_id'] ?? null;
|
||
|
|
|
||
|
|
if (! $kitId) {
|
||
|
|
return ['status' => 'info', 'message' => 'No kit ID configured'];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Can't easily check FA API without auth, mark as checked
|
||
|
|
return [
|
||
|
|
'status' => 'success',
|
||
|
|
'message' => 'Font kit configured - auto-updates via CDN',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse a release timestamp safely.
|
||
|
|
*
|
||
|
|
* Handles various timestamp formats from Packagist and NPM.
|
||
|
|
*/
|
||
|
|
protected function parseReleaseTimestamp(?string $time): ?Carbon
|
||
|
|
{
|
||
|
|
if (empty($time)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
return Carbon::parse($time);
|
||
|
|
} catch (\Exception $e) {
|
||
|
|
Log::warning('Uptelligence: Failed to parse release timestamp', [
|
||
|
|
'time' => $time,
|
||
|
|
'error' => $e->getMessage(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record a new version in history.
|
||
|
|
*/
|
||
|
|
protected function recordVersion(Asset $asset, string $version, array $data = []): void
|
||
|
|
{
|
||
|
|
$releasedAt = $this->parseReleaseTimestamp($data['time'] ?? null);
|
||
|
|
|
||
|
|
AssetVersion::updateOrCreate(
|
||
|
|
[
|
||
|
|
'asset_id' => $asset->id,
|
||
|
|
'version' => $version,
|
||
|
|
],
|
||
|
|
[
|
||
|
|
'changelog' => $data['description'] ?? null,
|
||
|
|
'download_url' => $data['dist']['url'] ?? null,
|
||
|
|
'released_at' => $releasedAt,
|
||
|
|
]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update an asset to its latest version.
|
||
|
|
*/
|
||
|
|
public function updateAsset(Asset $asset): array
|
||
|
|
{
|
||
|
|
return match ($asset->type) {
|
||
|
|
Asset::TYPE_COMPOSER => $this->updateComposerPackage($asset),
|
||
|
|
Asset::TYPE_NPM => $this->updateNpmPackage($asset),
|
||
|
|
default => ['status' => 'skipped', 'message' => 'Manual update required'],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update a Composer package.
|
||
|
|
*/
|
||
|
|
protected function updateComposerPackage(Asset $asset): array
|
||
|
|
{
|
||
|
|
if (! $asset->package_name) {
|
||
|
|
return ['status' => 'error', 'message' => 'No package name'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = Process::timeout(300)->run(
|
||
|
|
"composer update {$asset->package_name} --no-interaction"
|
||
|
|
);
|
||
|
|
|
||
|
|
if ($result->successful()) {
|
||
|
|
// Get new installed version
|
||
|
|
$showResult = Process::run("composer show {$asset->package_name} --format=json");
|
||
|
|
if ($showResult->successful()) {
|
||
|
|
$data = json_decode($showResult->output(), true);
|
||
|
|
$newVersion = $data['versions'][0] ?? $asset->latest_version;
|
||
|
|
$asset->update(['installed_version' => $newVersion]);
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['status' => 'success', 'message' => 'Package updated'];
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['status' => 'error', 'message' => $result->errorOutput()];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update an NPM package.
|
||
|
|
*/
|
||
|
|
protected function updateNpmPackage(Asset $asset): array
|
||
|
|
{
|
||
|
|
if (! $asset->package_name) {
|
||
|
|
return ['status' => 'error', 'message' => 'No package name'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = Process::timeout(300)->run("npm update {$asset->package_name}");
|
||
|
|
|
||
|
|
if ($result->successful()) {
|
||
|
|
$asset->update(['installed_version' => $asset->latest_version]);
|
||
|
|
|
||
|
|
return ['status' => 'success', 'message' => 'Package updated'];
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['status' => 'error', 'message' => $result->errorOutput()];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sync installed versions from composer.lock and package-lock.json.
|
||
|
|
*/
|
||
|
|
public function syncInstalledVersions(string $projectPath): array
|
||
|
|
{
|
||
|
|
$synced = [];
|
||
|
|
|
||
|
|
// Sync from composer.lock
|
||
|
|
$composerLock = $projectPath.'/composer.lock';
|
||
|
|
if (file_exists($composerLock)) {
|
||
|
|
$lock = json_decode(file_get_contents($composerLock), true);
|
||
|
|
$packages = array_merge(
|
||
|
|
$lock['packages'] ?? [],
|
||
|
|
$lock['packages-dev'] ?? []
|
||
|
|
);
|
||
|
|
|
||
|
|
foreach ($packages as $package) {
|
||
|
|
$asset = Asset::where('package_name', $package['name'])
|
||
|
|
->where('type', Asset::TYPE_COMPOSER)
|
||
|
|
->first();
|
||
|
|
|
||
|
|
if ($asset) {
|
||
|
|
$version = ltrim($package['version'], 'v');
|
||
|
|
$asset->update(['installed_version' => $version]);
|
||
|
|
$synced[] = $asset->slug;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sync from package-lock.json
|
||
|
|
$packageLock = $projectPath.'/package-lock.json';
|
||
|
|
if (file_exists($packageLock)) {
|
||
|
|
$lock = json_decode(file_get_contents($packageLock), true);
|
||
|
|
$packages = $lock['packages'] ?? [];
|
||
|
|
|
||
|
|
foreach ($packages as $name => $data) {
|
||
|
|
// Skip root package and nested deps
|
||
|
|
if (! $name || str_starts_with($name, 'node_modules/node_modules')) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$packageName = str_replace('node_modules/', '', $name);
|
||
|
|
$asset = Asset::where('package_name', $packageName)
|
||
|
|
->where('type', Asset::TYPE_NPM)
|
||
|
|
->first();
|
||
|
|
|
||
|
|
if ($asset) {
|
||
|
|
$asset->update(['installed_version' => $data['version']]);
|
||
|
|
$synced[] = $asset->slug;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $synced;
|
||
|
|
}
|
||
|
|
}
|