php-uptelligence/Services/AssetTrackerService.php
Snider e0d2325a20 refactor: move namespace from Core\Uptelligence to Core\Mod\Uptelligence
Aligns module namespace with Core PHP Framework conventions where
modules live under the Core\Mod\ namespace hierarchy. This follows
the monorepo separation work started in 40d893a.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:32:55 +00:00

439 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\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\Mod\Uptelligence\Models\Asset;
use Core\Mod\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;
}
}