diff --git a/app/Console/Commands/UpdateServiceVersions.php b/app/Console/Commands/UpdateServiceVersions.php
new file mode 100644
index 000000000..1bd6708fd
--- /dev/null
+++ b/app/Console/Commands/UpdateServiceVersions.php
@@ -0,0 +1,791 @@
+ 0,
+ 'updated' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ ];
+
+ protected array $registryCache = [];
+
+ protected array $majorVersionUpdates = [];
+
+ public function handle(): int
+ {
+ $this->info('Starting service version update...');
+
+ $templateFiles = $this->getTemplateFiles();
+
+ $this->stats['total'] = count($templateFiles);
+
+ foreach ($templateFiles as $file) {
+ $this->processTemplate($file);
+ }
+
+ $this->newLine();
+ $this->displayStats();
+
+ return self::SUCCESS;
+ }
+
+ protected function getTemplateFiles(): array
+ {
+ $pattern = base_path('templates/compose/*.yaml');
+ $files = glob($pattern);
+
+ if ($service = $this->option('service')) {
+ $files = array_filter($files, fn ($file) => basename($file) === "$service.yaml");
+ }
+
+ return $files;
+ }
+
+ protected function processTemplate(string $filePath): void
+ {
+ $filename = basename($filePath);
+ $this->info("Processing: {$filename}");
+
+ try {
+ $content = file_get_contents($filePath);
+ $yaml = Yaml::parse($content);
+
+ if (! isset($yaml['services'])) {
+ $this->warn(" No services found in {$filename}");
+ $this->stats['skipped']++;
+
+ return;
+ }
+
+ $updated = false;
+ $updatedYaml = $yaml;
+
+ foreach ($yaml['services'] as $serviceName => $serviceConfig) {
+ if (! isset($serviceConfig['image'])) {
+ continue;
+ }
+
+ $currentImage = $serviceConfig['image'];
+
+ // Check if using 'latest' tag and log for manual review
+ if (str_contains($currentImage, ':latest')) {
+ $registryUrl = $this->getRegistryUrl($currentImage);
+ $this->warn(" {$serviceName}: {$currentImage} (using 'latest' tag)");
+ if ($registryUrl) {
+ $this->line(" → Manual review: {$registryUrl}");
+ }
+ }
+
+ $latestVersion = $this->getLatestVersion($currentImage);
+
+ if ($latestVersion && $latestVersion !== $currentImage) {
+ $this->line(" {$serviceName}: {$currentImage} → {$latestVersion}");
+ $updatedYaml['services'][$serviceName]['image'] = $latestVersion;
+ $updated = true;
+ } else {
+ $this->line(" {$serviceName}: {$currentImage} (up to date)");
+ }
+ }
+
+ if ($updated) {
+ if (! $this->option('dry-run')) {
+ $this->updateYamlFile($filePath, $content, $updatedYaml);
+ $this->stats['updated']++;
+ } else {
+ $this->warn(' [DRY RUN] Would update this file');
+ $this->stats['updated']++;
+ }
+ } else {
+ $this->stats['skipped']++;
+ }
+
+ } catch (\Throwable $e) {
+ $this->error(" Failed: {$e->getMessage()}");
+ $this->stats['failed']++;
+ }
+
+ $this->newLine();
+ }
+
+ protected function getLatestVersion(string $image): ?string
+ {
+ // Parse the image string
+ [$repository, $currentTag] = $this->parseImage($image);
+
+ // Determine registry and fetch latest version
+ $result = null;
+ if (str_starts_with($repository, 'ghcr.io/')) {
+ $result = $this->getGhcrLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'quay.io/')) {
+ $result = $this->getQuayLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'codeberg.org/')) {
+ $result = $this->getCodebergLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'lscr.io/')) {
+ $result = $this->getDockerHubLatestVersion($repository, $currentTag);
+ } elseif ($this->isCustomRegistry($repository)) {
+ // Custom registries - skip for now, log warning
+ $this->warn(" Skipping custom registry: {$repository}");
+ $result = null;
+ } else {
+ // DockerHub (default registry - no prefix or docker.io/index.docker.io)
+ $result = $this->getDockerHubLatestVersion($repository, $currentTag);
+ }
+
+ return $result;
+ }
+
+ protected function isCustomRegistry(string $repository): bool
+ {
+ // List of custom/private registries that we can't query
+ $customRegistries = [
+ 'docker.elastic.co/',
+ 'docker.n8n.io/',
+ 'docker.flipt.io/',
+ 'docker.getoutline.com/',
+ 'cr.weaviate.io/',
+ 'downloads.unstructured.io/',
+ 'budibase.docker.scarf.sh/',
+ 'calcom.docker.scarf.sh/',
+ 'code.forgejo.org/',
+ 'registry.supertokens.io/',
+ 'registry.rocket.chat/',
+ 'nabo.codimd.dev/',
+ 'gcr.io/',
+ ];
+
+ foreach ($customRegistries as $registry) {
+ if (str_starts_with($repository, $registry)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getRegistryUrl(string $image): ?string
+ {
+ [$repository] = $this->parseImage($image);
+
+ // GitHub Container Registry
+ if (str_starts_with($repository, 'ghcr.io/')) {
+ $parts = explode('/', str_replace('ghcr.io/', '', $repository));
+ if (count($parts) >= 2) {
+ return "https://github.com/{$parts[0]}/{$parts[1]}/pkgs/container/{$parts[1]}";
+ }
+ }
+
+ // Quay.io
+ if (str_starts_with($repository, 'quay.io/')) {
+ $repo = str_replace('quay.io/', '', $repository);
+
+ return "https://quay.io/repository/{$repo}?tab=tags";
+ }
+
+ // Codeberg
+ if (str_starts_with($repository, 'codeberg.org/')) {
+ $parts = explode('/', str_replace('codeberg.org/', '', $repository));
+ if (count($parts) >= 2) {
+ return "https://codeberg.org/{$parts[0]}/-/packages/container/{$parts[1]}";
+ }
+ }
+
+ // Docker Hub
+ $cleanRepo = str_replace(['index.docker.io/', 'docker.io/', 'lscr.io/'], '', $repository);
+ if (! str_contains($cleanRepo, '/')) {
+ // Official image
+ return "https://hub.docker.com/_/{$cleanRepo}/tags";
+ } else {
+ // User/org image
+ return "https://hub.docker.com/r/{$cleanRepo}/tags";
+ }
+ }
+
+ protected function parseImage(string $image): array
+ {
+ if (str_contains($image, ':')) {
+ [$repo, $tag] = explode(':', $image, 2);
+ } else {
+ $repo = $image;
+ $tag = 'latest';
+ }
+
+ // Handle variables in tags
+ if (str_contains($tag, '$')) {
+ $tag = 'latest'; // Default to latest for variable tags
+ }
+
+ return [$repo, $tag];
+ }
+
+ protected function getDockerHubLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ // Remove various registry prefixes
+ $cleanRepo = $repository;
+ $cleanRepo = str_replace('index.docker.io/', '', $cleanRepo);
+ $cleanRepo = str_replace('docker.io/', '', $cleanRepo);
+ $cleanRepo = str_replace('lscr.io/', '', $cleanRepo);
+
+ // For official images (no /) add library prefix
+ if (! str_contains($cleanRepo, '/')) {
+ $cleanRepo = "library/{$cleanRepo}";
+ }
+
+ $url = "https://hub.docker.com/v2/repositories/{$cleanRepo}/tags";
+
+ $response = Http::timeout(10)->get($url, [
+ 'page_size' => 100,
+ 'ordering' => 'last_updated',
+ ]);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = $data['results'] ?? [];
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ // Find the best matching tag
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" DockerHub API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function findLatestTagDigest(array $tags, string $targetTag = 'latest'): ?string
+ {
+ // Find the digest/sha for the target tag (usually 'latest')
+ foreach ($tags as $tag) {
+ if ($tag['name'] === $targetTag) {
+ return $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+ protected function findVersionTagsForDigest(array $tags, string $digest): array
+ {
+ // Find all semantic version tags that share the same digest
+ $versionTags = [];
+
+ foreach ($tags as $tag) {
+ $tagDigest = $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
+
+ if ($tagDigest === $digest) {
+ $tagName = $tag['name'];
+ // Only include semantic version tags
+ if (preg_match('/^\d+\.\d+(\.\d+)?$/', $tagName)) {
+ $versionTags[] = $tagName;
+ }
+ }
+ }
+
+ return $versionTags;
+ }
+
+ protected function getGhcrLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // GHCR doesn't have a public API for listing tags without auth
+ // We'll try to fetch the package metadata via GitHub API
+ $parts = explode('/', str_replace('ghcr.io/', '', $repository));
+
+ if (count($parts) < 2) {
+ return null;
+ }
+
+ $owner = $parts[0];
+ $package = $parts[1];
+
+ // Try GitHub Container Registry API
+ $url = "https://api.github.com/users/{$owner}/packages/container/{$package}/versions";
+
+ $response = Http::timeout(10)
+ ->withHeaders([
+ 'Accept' => 'application/vnd.github.v3+json',
+ ])
+ ->get($url, ['per_page' => 100]);
+
+ if (! $response->successful()) {
+ // Most GHCR packages require authentication
+ if ($currentTag === 'latest') {
+ $this->warn(' ⚠ GHCR requires authentication - manual review needed');
+ }
+
+ return null;
+ }
+
+ $versions = $response->json();
+ $tags = [];
+
+ // Build tags array with digest information
+ foreach ($versions as $version) {
+ $digest = $version['name'] ?? null; // This is the SHA digest
+
+ if (isset($version['metadata']['container']['tags'])) {
+ foreach ($version['metadata']['container']['tags'] as $tag) {
+ $tags[] = [
+ 'name' => $tag,
+ 'digest' => $digest,
+ ];
+ }
+ }
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" GHCR API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function getQuayLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ $cleanRepo = str_replace('quay.io/', '', $repository);
+
+ $url = "https://quay.io/api/v1/repository/{$cleanRepo}/tag/";
+
+ $response = Http::timeout(10)->get($url, ['limit' => 100]);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = array_map(fn ($tag) => ['name' => $tag['name']], $data['tags'] ?? []);
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" Quay API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function getCodebergLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ // Codeberg uses Forgejo/Gitea, which has a container registry API
+ $cleanRepo = str_replace('codeberg.org/', '', $repository);
+ $parts = explode('/', $cleanRepo);
+
+ if (count($parts) < 2) {
+ return null;
+ }
+
+ $owner = $parts[0];
+ $package = $parts[1];
+
+ // Codeberg API endpoint for packages
+ $url = "https://codeberg.org/api/packages/{$owner}/container/{$package}";
+
+ $response = Http::timeout(10)->get($url);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = [];
+
+ if (isset($data['versions'])) {
+ foreach ($data['versions'] as $version) {
+ if (isset($version['name'])) {
+ $tags[] = ['name' => $version['name']];
+ }
+ }
+ }
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" Codeberg API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function findBestTag(array $tags, string $currentTag, string $repository): ?string
+ {
+ if (empty($tags)) {
+ return null;
+ }
+
+ // If current tag is 'latest', find what version it actually points to
+ if ($currentTag === 'latest') {
+ // First, try to find the digest for 'latest' tag
+ $latestDigest = $this->findLatestTagDigest($tags, 'latest');
+
+ if ($latestDigest) {
+ // Find all semantic version tags that share the same digest
+ $versionTags = $this->findVersionTagsForDigest($tags, $latestDigest);
+
+ if (! empty($versionTags)) {
+ // Prefer shorter version tags (1.8 over 1.8.1)
+ $bestVersion = $this->preferShorterVersion($versionTags);
+ $this->info(" ✓ Found 'latest' points to: {$bestVersion}");
+
+ return $repository.':'.$bestVersion;
+ }
+ }
+
+ // Fallback: get the latest semantic version available (prefer shorter)
+ $semverTags = $this->filterSemanticVersionTags($tags);
+ if (! empty($semverTags)) {
+ $bestVersion = $this->preferShorterVersion($semverTags);
+
+ return $repository.':'.$bestVersion;
+ }
+
+ // If no semantic versions found, keep 'latest'
+ return null;
+ }
+
+ // Check for major version updates for reporting
+ $this->checkForMajorVersionUpdate($tags, $currentTag, $repository);
+
+ // If current tag is a major version (e.g., "8", "5", "16")
+ if (preg_match('/^\d+$/', $currentTag)) {
+ $majorVersion = (int) $currentTag;
+ $matchingTags = array_filter($tags, function ($tag) use ($majorVersion) {
+ $name = $tag['name'];
+
+ // Match tags that start with the major version
+ return preg_match("/^{$majorVersion}(\.\d+)?(\.\d+)?$/", $name);
+ });
+
+ if (! empty($matchingTags)) {
+ $versions = array_column($matchingTags, 'name');
+ $bestVersion = $this->preferShorterVersion($versions);
+ if ($bestVersion !== $currentTag) {
+ return $repository.':'.$bestVersion;
+ }
+ }
+ }
+
+ // If current tag is date-based version (e.g., "2025.06.02-sha-xxx")
+ if (preg_match('/^\d{4}\.\d{2}\.\d{2}/', $currentTag)) {
+ // Get all date-based tags
+ $dateTags = array_filter($tags, function ($tag) {
+ return preg_match('/^\d{4}\.\d{2}\.\d{2}/', $tag['name']);
+ });
+
+ if (! empty($dateTags)) {
+ $versions = array_column($dateTags, 'name');
+ $sorted = $this->sortSemanticVersions($versions);
+ $latestDate = $sorted[0];
+
+ // Compare dates
+ if ($latestDate !== $currentTag) {
+ return $repository.':'.$latestDate;
+ }
+ }
+
+ return null;
+ }
+
+ // If current tag is semantic version (e.g., "1.7.4", "8.0")
+ if (preg_match('/^\d+\.\d+(\.\d+)?$/', $currentTag)) {
+ $parts = explode('.', $currentTag);
+ $majorMinor = $parts[0].'.'.$parts[1];
+
+ $matchingTags = array_filter($tags, function ($tag) use ($majorMinor) {
+ $name = $tag['name'];
+
+ return str_starts_with($name, $majorMinor);
+ });
+
+ if (! empty($matchingTags)) {
+ $versions = array_column($matchingTags, 'name');
+ $bestVersion = $this->preferShorterVersion($versions);
+ if (version_compare($bestVersion, $currentTag, '>') || version_compare($bestVersion, $currentTag, '=')) {
+ // Only update if it's newer or if we can simplify (1.8.1 -> 1.8)
+ if ($bestVersion !== $currentTag) {
+ return $repository.':'.$bestVersion;
+ }
+ }
+ }
+ }
+
+ // If current tag is a named version (e.g., "stable")
+ if (in_array($currentTag, ['stable', 'lts', 'edge'])) {
+ // Check if the same tag exists in the list (it's up to date)
+ $exists = array_filter($tags, fn ($tag) => $tag['name'] === $currentTag);
+ if (! empty($exists)) {
+ return null; // Tag exists and is current
+ }
+ }
+
+ return null;
+ }
+
+ protected function filterSemanticVersionTags(array $tags): array
+ {
+ $semverTags = array_filter($tags, function ($tag) {
+ $name = $tag['name'];
+
+ // Accept semantic versions (1.2.3, v1.2.3)
+ if (preg_match('/^v?\d+\.\d+(\.\d+)?(\.\d+)?$/', $name)) {
+ // Exclude versions with suffixes like -rc, -beta, -alpha
+ if (preg_match('/-(rc|beta|alpha|dev|test|pre|snapshot)/i', $name)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Accept date-based versions (2025.06.02, 2025.10.0, 2025.06.02-sha-xxx, RELEASE.2025-10-15T17-29-55Z)
+ if (preg_match('/^\d{4}\.\d{2}\.(\d{2}|\d)/', $name) || preg_match('/^RELEASE\.\d{4}-\d{2}-\d{2}/', $name)) {
+ return true;
+ }
+
+ return false;
+ });
+
+ return $this->sortSemanticVersions(array_column($semverTags, 'name'));
+ }
+
+ protected function sortSemanticVersions(array $versions): array
+ {
+ usort($versions, function ($a, $b) {
+ // Check if these are date-based versions (YYYY.MM.DD or YYYY.MM.D format)
+ $isDateA = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $a, $matchesA);
+ $isDateB = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $b, $matchesB);
+
+ if ($isDateA && $isDateB) {
+ // Both are date-based (YYYY.MM.DD), compare as dates
+ $dateA = $matchesA[1].$matchesA[2].str_pad($matchesA[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
+ $dateB = $matchesB[1].$matchesB[2].str_pad($matchesB[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
+
+ return strcmp($dateB, $dateA); // Descending order (newest first)
+ }
+
+ // Check if these are RELEASE date versions (RELEASE.YYYY-MM-DDTHH-MM-SSZ)
+ $isReleaseA = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $a, $matchesA);
+ $isReleaseB = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $b, $matchesB);
+
+ if ($isReleaseA && $isReleaseB) {
+ // Both are RELEASE format, compare as datetime
+ $dateTimeA = $matchesA[1].$matchesA[2].$matchesA[3].$matchesA[4].$matchesA[5].$matchesA[6]; // YYYYMMDDHHMMSS
+ $dateTimeB = $matchesB[1].$matchesB[2].$matchesB[3].$matchesB[4].$matchesB[5].$matchesB[6]; // YYYYMMDDHHMMSS
+
+ return strcmp($dateTimeB, $dateTimeA); // Descending order (newest first)
+ }
+
+ // Strip 'v' prefix for version comparison
+ $cleanA = ltrim($a, 'v');
+ $cleanB = ltrim($b, 'v');
+
+ // Fall back to semantic version comparison
+ return version_compare($cleanB, $cleanA); // Descending order
+ });
+
+ return $versions;
+ }
+
+ protected function preferShorterVersion(array $versions): string
+ {
+ if (empty($versions)) {
+ return '';
+ }
+
+ // Sort by version (highest first)
+ $sorted = $this->sortSemanticVersions($versions);
+ $highest = $sorted[0];
+
+ // Parse the highest version
+ $parts = explode('.', $highest);
+
+ // Look for shorter versions that match
+ // Priority: major (8) > major.minor (8.0) > major.minor.patch (8.0.39)
+
+ // Try to find just major.minor (e.g., 1.8 instead of 1.8.1)
+ if (count($parts) === 3) {
+ $majorMinor = $parts[0].'.'.$parts[1];
+ if (in_array($majorMinor, $versions)) {
+ return $majorMinor;
+ }
+ }
+
+ // Try to find just major (e.g., 8 instead of 8.0.39)
+ if (count($parts) >= 2) {
+ $major = $parts[0];
+ if (in_array($major, $versions)) {
+ return $major;
+ }
+ }
+
+ // Return the highest version we found
+ return $highest;
+ }
+
+ protected function updateYamlFile(string $filePath, string $originalContent, array $updatedYaml): void
+ {
+ // Preserve comments and formatting by updating the YAML content
+ $lines = explode("\n", $originalContent);
+ $updatedLines = [];
+ $inServices = false;
+ $currentService = null;
+
+ foreach ($lines as $line) {
+ // Detect if we're in the services section
+ if (preg_match('/^services:/', $line)) {
+ $inServices = true;
+ $updatedLines[] = $line;
+
+ continue;
+ }
+
+ // Detect service name (allow hyphens and underscores)
+ if ($inServices && preg_match('/^ ([\w-]+):/', $line, $matches)) {
+ $currentService = $matches[1];
+ $updatedLines[] = $line;
+
+ continue;
+ }
+
+ // Update image line
+ if ($currentService && preg_match('/^(\s+)image:\s*(.+)$/', $line, $matches)) {
+ $indent = $matches[1];
+ $newImage = $updatedYaml['services'][$currentService]['image'] ?? $matches[2];
+ $updatedLines[] = "{$indent}image: {$newImage}";
+
+ continue;
+ }
+
+ // If we hit a non-indented line, we're out of services
+ if ($inServices && preg_match('/^\S/', $line) && ! preg_match('/^services:/', $line)) {
+ $inServices = false;
+ $currentService = null;
+ }
+
+ $updatedLines[] = $line;
+ }
+
+ file_put_contents($filePath, implode("\n", $updatedLines));
+ }
+
+ protected function checkForMajorVersionUpdate(array $tags, string $currentTag, string $repository): void
+ {
+ // Only check semantic versions
+ if (! preg_match('/^v?(\d+)\./', $currentTag, $currentMatches)) {
+ return;
+ }
+
+ $currentMajor = (int) $currentMatches[1];
+
+ // Get all semantic version tags
+ $semverTags = $this->filterSemanticVersionTags($tags);
+
+ // Find the highest major version available
+ $highestMajor = $currentMajor;
+ foreach ($semverTags as $version) {
+ if (preg_match('/^v?(\d+)\./', $version, $matches)) {
+ $major = (int) $matches[1];
+ if ($major > $highestMajor) {
+ $highestMajor = $major;
+ }
+ }
+ }
+
+ // If there's a higher major version available, record it
+ if ($highestMajor > $currentMajor) {
+ $this->majorVersionUpdates[] = [
+ 'repository' => $repository,
+ 'current' => $currentTag,
+ 'current_major' => $currentMajor,
+ 'available_major' => $highestMajor,
+ 'registry_url' => $this->getRegistryUrl($repository.':'.$currentTag),
+ ];
+ }
+ }
+
+ protected function displayStats(): void
+ {
+ $this->info('Summary:');
+ $this->table(
+ ['Metric', 'Count'],
+ [
+ ['Total Templates', $this->stats['total']],
+ ['Updated', $this->stats['updated']],
+ ['Skipped (up to date)', $this->stats['skipped']],
+ ['Failed', $this->stats['failed']],
+ ]
+ );
+
+ // Display major version updates if any
+ if (! empty($this->majorVersionUpdates)) {
+ $this->newLine();
+ $this->warn('⚠ Services with available MAJOR version updates:');
+ $this->newLine();
+
+ $tableData = [];
+ foreach ($this->majorVersionUpdates as $update) {
+ $tableData[] = [
+ $update['repository'],
+ "v{$update['current_major']}.x",
+ "v{$update['available_major']}.x",
+ $update['registry_url'],
+ ];
+ }
+
+ $this->table(
+ ['Repository', 'Current', 'Available', 'Registry URL'],
+ $tableData
+ );
+
+ $this->newLine();
+ $this->comment('💡 Major version updates may include breaking changes. Review before upgrading.');
+ }
+ }
+}
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index f05b3c0ca..cfef30772 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -30,6 +30,60 @@ public function loadTokens()
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
+ public function validateToken(int $tokenId)
+ {
+ try {
+ $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
+ $this->authorize('view', $token);
+
+ if ($token->provider === 'hetzner') {
+ $isValid = $this->validateHetznerToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'Hetzner token is valid.');
+ } else {
+ $this->dispatch('error', 'Hetzner token validation failed. Please check the token.');
+ }
+ } elseif ($token->provider === 'digitalocean') {
+ $isValid = $this->validateDigitalOceanToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'DigitalOcean token is valid.');
+ } else {
+ $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
+ }
+ } else {
+ $this->dispatch('error', 'Unknown provider.');
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ private function validateHetznerToken(string $token): bool
+ {
+ try {
+ $response = \Illuminate\Support\Facades\Http::withToken($token)
+ ->timeout(10)
+ ->get('https://api.hetzner.cloud/v1/servers?per_page=1');
+
+ return $response->successful();
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ private function validateDigitalOceanToken(string $token): bool
+ {
+ try {
+ $response = \Illuminate\Support\Facades\Http::withToken($token)
+ ->timeout(10)
+ ->get('https://api.digitalocean.com/v2/account');
+
+ return $response->successful();
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
public function deleteToken(int $tokenId)
{
try {
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 7a9b58b70..f7d12dbc1 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -561,7 +561,12 @@ public function submit()
$server->save();
if ($this->from_onboarding) {
- // When in onboarding, use wire:navigate for proper modal handling
+ // Complete the boarding when server is successfully created via Hetzner
+ currentTeam()->update([
+ 'show_boarding' => false,
+ ]);
+ refreshSession();
+
return $this->redirect(route('server.show', $server->uuid));
}
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index 0d643306c..ab82c9a9c 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -28,7 +28,20 @@ protected static function booted(): void
if ($applications_count > 0) {
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
}
- $github_app->privateKey()->delete();
+
+ $privateKey = $github_app->privateKey;
+ if ($privateKey) {
+ // Check if key is used by anything EXCEPT this GitHub app
+ $isUsedElsewhere = $privateKey->servers()->exists()
+ || $privateKey->applications()->exists()
+ || $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists()
+ || $privateKey->gitlabApps()->exists();
+
+ if (! $isUsedElsewhere) {
+ $privateKey->delete();
+ } else {
+ }
+ }
});
}
diff --git a/config/constants.php b/config/constants.php
index 813594e61..503fe3808 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.438',
+ 'version' => '4.0.0-beta.439',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php
index 94a187ad8..bb5dcfc4d 100644
--- a/resources/views/livewire/project/database/backup-edit.blade.php
+++ b/resources/views/livewire/project/database/backup-edit.blade.php
@@ -106,7 +106,7 @@
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />