From 2e7c6fc0eb18912365615ffa39b8879d68b4cd55 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Sat, 21 Feb 2026 01:12:19 +0000 Subject: [PATCH 1/2] feat: add smart repository detection for new application creation --- app/Data/RepositoryDetectionResult.php | 55 +++++ .../Project/New/GithubPrivateRepository.php | 73 ++++++- .../New/GithubPrivateRepositoryDeployKey.php | 50 +++++ .../Project/New/PublicGitRepository.php | 66 +++++- app/Services/RepositoryDetector.php | 124 +++++++++++ app/Traits/HasRepositoryDetection.php | 98 +++++++++ ...ub-private-repository-deploy-key.blade.php | 61 +++++- .../new/github-private-repository.blade.php | 55 ++++- .../partials/env-detection-badges.blade.php | 20 ++ .../new/partials/env-import-modal.blade.php | 67 ++++++ .../new/public-git-repository.blade.php | 56 ++++- tests/Unit/RepositoryDetectionResultTest.php | 104 ++++++++++ tests/Unit/RepositoryDetectorTest.php | 192 ++++++++++++++++++ 13 files changed, 997 insertions(+), 24 deletions(-) create mode 100644 app/Data/RepositoryDetectionResult.php create mode 100644 app/Services/RepositoryDetector.php create mode 100644 app/Traits/HasRepositoryDetection.php create mode 100644 resources/views/livewire/project/new/partials/env-detection-badges.blade.php create mode 100644 resources/views/livewire/project/new/partials/env-import-modal.blade.php create mode 100644 tests/Unit/RepositoryDetectionResultTest.php create mode 100644 tests/Unit/RepositoryDetectorTest.php diff --git a/app/Data/RepositoryDetectionResult.php b/app/Data/RepositoryDetectionResult.php new file mode 100644 index 000000000..f77ad8a15 --- /dev/null +++ b/app/Data/RepositoryDetectionResult.php @@ -0,0 +1,55 @@ + $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile'] + * @param array $dockerComposeFiles e.g. ['docker-compose.yml'] + * @param array $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null] + * @param array $dockerfilePorts e.g. ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080] + */ + public function __construct( + public array $dockerfiles = [], + public array $dockerComposeFiles = [], + public array $envFiles = [], + public array $dockerfilePorts = [], + ) {} + + public static function none(): self + { + return new self; + } + + public function getSuggestedBuildPack(): BuildPackTypes + { + if (count($this->dockerComposeFiles) > 0) { + return BuildPackTypes::DOCKERCOMPOSE; + } + + if (count($this->dockerfiles) > 0) { + return BuildPackTypes::DOCKERFILE; + } + + return BuildPackTypes::NIXPACKS; + } + + public function hasDockerfile(): bool + { + return count($this->dockerfiles) > 0; + } + + public function hasDockerCompose(): bool + { + return count($this->dockerComposeFiles) > 0; + } + + public function hasEnvFiles(): bool + { + return count($this->envFiles) > 0; + } +} diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 6acb17f82..964cdbe06 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -3,17 +3,24 @@ namespace App\Livewire\Project\New; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; +use App\Services\RepositoryDetector; +use App\Traits\HasRepositoryDetection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Livewire\Component; class GithubPrivateRepository extends Component { + use HasRepositoryDetection; + public $current_step = 'github_apps'; public $github_apps; @@ -135,6 +142,8 @@ class GithubPrivateRepository extends Component } $this->branches = sortBranchesByPriority($this->branches); $this->selected_branch_name = data_get($this->branches, '0.name', 'main'); + + $this->detectRepository(); } protected function loadBranchByPage() @@ -155,6 +164,33 @@ class GithubPrivateRepository extends Component $this->branches = $this->branches->concat(collect($json)); } + public function detectRepository(): void + { + $this->detectionRan = false; + $this->envImported = false; + + try { + $serverId = data_get($this->query, 'server_id'); + $teamId = currentTeam()->id; + + $repoUrl = "https://github.com/{$this->selected_repository_owner}/{$this->selected_repository_repo}"; + + $detector = new RepositoryDetector( + repositoryUrl: $repoUrl, + branch: $this->selected_branch_name, + baseDirectory: $this->base_directory ?? '/', + serverId: (int) $serverId, + teamId: $teamId, + ); + + $this->applyDetectionResult($detector->detect()); + } catch (\Throwable $e) { + Log::debug('Repository detection failed in component', ['error' => $e->getMessage()]); + } + + $this->detectionRan = true; + } + public function submit() { try { @@ -186,7 +222,7 @@ class GithubPrivateRepository extends Component $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); - $application = Application::create([ + $application_init = [ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), @@ -200,22 +236,43 @@ class GithubPrivateRepository extends Component 'destination_type' => $destination_class, 'source_id' => $this->github_app->id, 'source_type' => $this->github_app->getMorphClass(), - ]); + ]; + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application_init['health_check_enabled'] = false; + } + if ($this->build_pack === 'dockerfile' && $this->selectedDockerfile) { + $application_init['dockerfile_location'] = $this->selectedDockerfile; + } + if ($this->build_pack === 'dockercompose') { + $application_init['docker_compose_location'] = $this->docker_compose_location; + } + + $application = Application::create($application_init); $application->settings->is_static = $this->is_static; $application->settings->save(); - if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { - $application->health_check_enabled = false; - } - if ($this->build_pack === 'dockercompose') { - $application['docker_compose_location'] = $this->docker_compose_location; - } $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); $application->save(); + // Import environment variables from .env.example + if ($this->envImported && count($this->envExampleVars) > 0) { + DB::transaction(function () use ($application): void { + foreach ($this->envExampleVars as $key => $value) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + }); + } + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 77b106200..d5656e27c 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\New; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; @@ -11,12 +12,18 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Services\RepositoryDetector; +use App\Traits\HasRepositoryDetection; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Livewire\Component; use Spatie\Url\Url; class GithubPrivateRepositoryDeployKey extends Component { + use HasRepositoryDetection; + public $current_step = 'private_keys'; public $parameters; @@ -133,6 +140,31 @@ class GithubPrivateRepositoryDeployKey extends Component $this->current_step = 'repository'; } + public function detectRepository(): void + { + $this->detectionRan = false; + $this->envImported = false; + + try { + $serverId = data_get($this->query, 'server_id'); + $teamId = currentTeam()->id; + + $detector = new RepositoryDetector( + repositoryUrl: $this->repository_url, + branch: $this->branch ?? 'main', + baseDirectory: $this->base_directory ?? '/', + serverId: (int) $serverId, + teamId: $teamId, + ); + + $this->applyDetectionResult($detector->detect()); + } catch (\Throwable $e) { + Log::debug('Repository detection failed in component', ['error' => $e->getMessage()]); + } + + $this->detectionRan = true; + } + public function submit() { $this->validate(); @@ -186,6 +218,9 @@ class GithubPrivateRepositoryDeployKey extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $this->selectedDockerfile) { + $application_init['dockerfile_location'] = $this->selectedDockerfile; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -199,6 +234,21 @@ class GithubPrivateRepositoryDeployKey extends Component $application->name = generate_random_name($application->uuid); $application->save(); + // Import environment variables from .env.example + if ($this->envImported && count($this->envExampleVars) > 0) { + DB::transaction(function () use ($application): void { + foreach ($this->envExampleVars as $key => $value) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + }); + } + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 2fffff6b9..4cad3ebf3 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\New; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\Project; @@ -11,12 +12,18 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Services\RepositoryDetector; +use App\Traits\HasRepositoryDetection; use Carbon\Carbon; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Livewire\Component; use Spatie\Url\Url; class PublicGitRepository extends Component { + use HasRepositoryDetection; + public string $repository_url; public int $port = 3000; @@ -177,10 +184,15 @@ class PublicGitRepository extends Component $this->git_branch = 'master'; } $this->selectedBranch = $this->git_branch; + + if ($this->branchFound) { + $this->detectRepository(); + } } catch (\Throwable $e) { if ($this->rate_limit_remaining == 0) { $this->selectedBranch = $this->git_branch; $this->branchFound = true; + $this->detectRepository(); return; } @@ -188,6 +200,9 @@ class PublicGitRepository extends Component try { $this->git_branch = 'master'; $this->getBranch(); + if ($this->branchFound) { + $this->detectRepository(); + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -197,6 +212,35 @@ class PublicGitRepository extends Component } } + public function detectRepository(): void + { + $this->detectionRan = false; + $this->envImported = false; + + try { + $serverId = data_get($this->query, 'server_id'); + $teamId = currentTeam()->id; + + $repoUrl = $this->git_source === 'other' + ? $this->git_repository + : "https://github.com/{$this->git_repository}"; + + $detector = new RepositoryDetector( + repositoryUrl: $repoUrl, + branch: $this->git_branch, + baseDirectory: $this->base_directory, + serverId: (int) $serverId, + teamId: $teamId, + ); + + $this->applyDetectionResult($detector->detect()); + } catch (\Throwable $e) { + Log::debug('Repository detection failed in component', ['error' => $e->getMessage()]); + } + + $this->detectionRan = true; + } + private function getGitSource() { $this->git_branch = 'main'; @@ -351,6 +395,9 @@ class PublicGitRepository extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $this->selectedDockerfile) { + $application_init['dockerfile_location'] = $this->selectedDockerfile; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -362,11 +409,20 @@ class PublicGitRepository extends Component $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); - if ($this->checkCoolifyConfig) { - // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id); - // if ($config) { - // $application->setConfig($config); - // } + + // Import environment variables from .env.example + if ($this->envImported && count($this->envExampleVars) > 0) { + DB::transaction(function () use ($application): void { + foreach ($this->envExampleVars as $key => $value) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + }); } return redirect()->route('project.application.configuration', [ diff --git a/app/Services/RepositoryDetector.php b/app/Services/RepositoryDetector.php new file mode 100644 index 000000000..fdb96411d --- /dev/null +++ b/app/Services/RepositoryDetector.php @@ -0,0 +1,124 @@ +where('id', $this->serverId) + ->where('team_id', $this->teamId) + ->first(); + + if (! $server) { + return RepositoryDetectionResult::none(); + } + + $uuid = (string) new Cuid2; + if (strlen($uuid) < 10 || ! preg_match('/^[a-z0-9]+$/', $uuid)) { + return RepositoryDetectionResult::none(); + } + + $tempDir = "/tmp/coolify-detect-{$uuid}"; + $baseDir = rtrim($this->baseDirectory, '/'); + if ($baseDir === '') { + $baseDir = '/'; + } + $cdBase = $baseDir === '/' ? '' : $baseDir; + + $workDir = escapeshellarg("{$tempDir}{$cdBase}"); + $envPattern = self::ENV_FILE_PATTERN; + + $commands = collect([ + 'rm -rf -- '.escapeshellarg($tempDir), + 'git clone --depth 1 -b '.escapeshellarg($this->branch).' '.escapeshellarg($this->repositoryUrl).' '.escapeshellarg($tempDir).' >/dev/null 2>&1', + "cd {$workDir}", + // Collect file lists into shell variables + 'df_list=$(git ls-files | grep -i \'dockerfile\' || true)', + 'compose_list=$(git ls-files | grep -iE \'^(docker-compose\.(yml|yaml)|compose\.(yml|yaml))$\' || true)', + 'env_list=$(git ls-files | grep -iE \''.$envPattern.'\' || true)', + // Build env file contents as a JSON object (uses jq to safely encode file content) + 'env_json=\'{}\'', + 'for f in $env_list; do', + ' file_json=$(jq -Rs \'.\' "$f")', + ' env_json=$(echo "$env_json" | jq --arg k "$f" --argjson v "$file_json" \'. + {($k): $v}\')', + 'done', + // Build dockerfile ports as a JSON object + 'port_json=\'{}\'', + 'for f in $df_list; do', + ' port=$(grep -m1 \'^EXPOSE\' "$f" 2>/dev/null | awk \'{print $2}\' || true)', + ' if [ -n "$port" ] && echo "$port" | grep -qE \'^[0-9]+$\'; then', + ' port_json=$(echo "$port_json" | jq --arg k "$f" --argjson v "$port" \'. + {($k): $v}\')', + ' else', + ' port_json=$(echo "$port_json" | jq --arg k "$f" \'. + {($k): null}\')', + ' fi', + 'done', + // Output structured JSON + 'jq -n \\', + ' --argjson dockerfiles "$(echo "$df_list" | jq -R -s \'split("\\n") | map(select(. != ""))\')" \\', + ' --argjson dockerComposeFiles "$(echo "$compose_list" | jq -R -s \'split("\\n") | map(select(. != ""))\')" \\', + ' --argjson envFiles "$env_json" \\', + ' --argjson dockerfilePorts "$port_json" \\', + ' \'$ARGS.named\'', + 'rm -rf -- '.escapeshellarg($tempDir), + ]); + + try { + $output = instant_remote_process($commands, $server, throwError: false, timeout: 60); + + if (! $output) { + return RepositoryDetectionResult::none(); + } + + return $this->parseOutput($output); + } catch (\Throwable $e) { + Log::debug('Repository detection failed', [ + 'error' => $e->getMessage(), + 'repositoryUrl' => $this->repositoryUrl, + 'branch' => $this->branch, + ]); + + return RepositoryDetectionResult::none(); + } + } + + protected function parseOutput(string $output): RepositoryDetectionResult + { + $data = json_decode(trim($output), true); + + if (! is_array($data)) { + return RepositoryDetectionResult::none(); + } + + $dockerfilePorts = []; + foreach ($data['dockerfilePorts'] ?? [] as $file => $port) { + $dockerfilePorts[$file] = is_int($port) ? $port : null; + } + + return new RepositoryDetectionResult( + dockerfiles: $data['dockerfiles'] ?? [], + dockerComposeFiles: $data['dockerComposeFiles'] ?? [], + envFiles: $data['envFiles'] ?? [], + dockerfilePorts: $dockerfilePorts, + ); + } +} diff --git a/app/Traits/HasRepositoryDetection.php b/app/Traits/HasRepositoryDetection.php new file mode 100644 index 000000000..d4d8775e6 --- /dev/null +++ b/app/Traits/HasRepositoryDetection.php @@ -0,0 +1,98 @@ +detectedDockerfiles = $result->dockerfiles; + $this->detectedDockerComposeFiles = $result->dockerComposeFiles; + $this->dockerfilePorts = $result->dockerfilePorts; + + $suggestedBuildPack = $result->getSuggestedBuildPack()->value; + if ($suggestedBuildPack !== $this->build_pack) { + $this->build_pack = $suggestedBuildPack; + $this->updatedBuildPack(); + } + + if ($result->hasDockerfile()) { + $this->selectedDockerfile = $result->dockerfiles[0]; + $port = $result->dockerfilePorts[$this->selectedDockerfile] ?? null; + if ($port) { + $this->port = $port; + $this->detectedPort = $port; + } + } + + if ($result->hasDockerCompose() && count($result->dockerComposeFiles) === 1) { + $this->docker_compose_location = '/'.$result->dockerComposeFiles[0]; + } + + if ($result->hasEnvFiles()) { + $this->detectedEnvFiles = array_keys($result->envFiles); + $this->parsedEnvFiles = []; + foreach ($result->envFiles as $filename => $content) { + $this->parsedEnvFiles[$filename] = $content !== null + ? parseEnvFormatToArray($content) + : []; + } + $this->selectedEnvFile = $this->detectedEnvFiles[0]; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } + } + + public function updatedSelectedDockerfile(): void + { + if ($this->selectedDockerfile && isset($this->dockerfilePorts[$this->selectedDockerfile])) { + $port = $this->dockerfilePorts[$this->selectedDockerfile]; + if ($port) { + $this->port = $port; + $this->detectedPort = $port; + } + } + } + + public function updatedSelectedEnvFile(): void + { + if ($this->selectedEnvFile && isset($this->parsedEnvFiles[$this->selectedEnvFile])) { + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile]; + $this->envImported = false; + } + } + + public function confirmEnvImport(): void + { + $this->envImported = true; + } + + public function clearEnvVars(): void + { + $this->envImported = false; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } +} diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 9eb9baea8..cf32f17b8 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -1,4 +1,4 @@ -
+

Create a new Application

Deploy any public or private Git repositories through a Deploy Key.
@@ -60,6 +60,60 @@ @endif
+ + {{-- Repository Detection --}} +
+

Smart Scan

+

Scan for Dockerfiles, Docker Compose files, and environment configuration.

+ +
+ + Detect Repository + + Scanning... + + +
+ + @if ($detectionRan) +
+
+ @if (count($detectedDockerfiles)) + + + Dockerfile{{ count($detectedDockerfiles) > 1 ? 's' : '' }} + ({{ count($detectedDockerfiles) }}) + + @endif + @if (count($detectedDockerComposeFiles)) + + + Docker Compose + ({{ count($detectedDockerComposeFiles) }}) + + @endif + @include('livewire.project.new.partials.env-detection-badges') + @if (!count($detectedDockerfiles) && !count($detectedDockerComposeFiles) && !count($detectedEnvFiles)) + No Dockerfile, Docker Compose, or env files detected. + @endif +
+
+ @endif +
+ + {{-- Configuration --}} +

Configuration

+ + {{-- Dockerfile selector when multiple detected --}} + @if ($build_pack === 'dockerfile' && count($detectedDockerfiles) > 1) + + @foreach ($detectedDockerfiles as $df) + + @endforeach + + @endif + @if ($build_pack === 'dockercompose')

Create a new Application

@@ -57,7 +57,42 @@
No repositories found. Check your GitHub App configuration.
@endif @if ($branches->count() > 0) -

Configuration

+ {{-- Repository Detection --}} +
+

Smart Scan

+

Detected configuration from your repository.

+ +
+ Scanning repository for Dockerfiles and configuration... +
+ + @if ($detectionRan) +
+
+ @if (count($detectedDockerfiles)) + + + Dockerfile{{ count($detectedDockerfiles) > 1 ? 's' : '' }} + ({{ count($detectedDockerfiles) }}) + + @endif + @if (count($detectedDockerComposeFiles)) + + + Docker Compose + ({{ count($detectedDockerComposeFiles) }}) + + @endif + @include('livewire.project.new.partials.env-detection-badges') + @if (!count($detectedDockerfiles) && !count($detectedDockerComposeFiles) && !count($detectedEnvFiles)) + No Dockerfile, Docker Compose, or env files detected. + @endif +
+
+ @endif +
+ +

Configuration

@@ -87,6 +122,17 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ + {{-- Dockerfile selector when multiple detected --}} + @if ($build_pack === 'dockerfile' && count($detectedDockerfiles) > 1) + + @foreach ($detectedDockerfiles as $df) + + @endforeach + + @endif + @if ($build_pack === 'dockercompose')
No GitHub Application found. Please create a new GitHub Application. diff --git a/resources/views/livewire/project/new/partials/env-detection-badges.blade.php b/resources/views/livewire/project/new/partials/env-detection-badges.blade.php new file mode 100644 index 000000000..9d9a8c99a --- /dev/null +++ b/resources/views/livewire/project/new/partials/env-detection-badges.blade.php @@ -0,0 +1,20 @@ +@if (count($detectedEnvFiles) > 0 && $envImported && count($envExampleVars) > 0) + +@elseif (count($detectedEnvFiles) > 0 && count($envExampleVars) > 0) + +@elseif (count($detectedEnvFiles) > 0) + + + {{ count($detectedEnvFiles) > 1 ? 'Env Files (' . count($detectedEnvFiles) . ')' : $detectedEnvFiles[0] }} + +@endif diff --git a/resources/views/livewire/project/new/partials/env-import-modal.blade.php b/resources/views/livewire/project/new/partials/env-import-modal.blade.php new file mode 100644 index 000000000..3e747ea3c --- /dev/null +++ b/resources/views/livewire/project/new/partials/env-import-modal.blade.php @@ -0,0 +1,67 @@ +@if (count($envExampleVars) > 0) + +@endif diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 02489719a..1a84241ab 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -1,4 +1,4 @@ -
+

Create a new Application

Deploy any public Git repositories.
@@ -29,8 +29,44 @@
@endif + {{-- Repository Detection --}} +
+

Smart Scan

+

Detected configuration from your repository.

+ +
+ Scanning repository for Dockerfiles and configuration... +
+ + @if ($detectionRan) +
+
+ @if (count($detectedDockerfiles)) + + + Dockerfile{{ count($detectedDockerfiles) > 1 ? 's' : '' }} + ({{ count($detectedDockerfiles) }}) + + @endif + @if (count($detectedDockerComposeFiles)) + + + Docker Compose + ({{ count($detectedDockerComposeFiles) }}) + + @endif + @include('livewire.project.new.partials.env-detection-badges') + @if (!count($detectedDockerfiles) && !count($detectedDockerComposeFiles) && !count($detectedEnvFiles)) + No Dockerfile, Docker Compose, or env files detected. + @endif +
+
+ @endif +
+ - +

Configuration

+
@if ($git_source === 'other') @@ -51,6 +87,17 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ + {{-- Dockerfile selector when multiple detected --}} + @if ($build_pack === 'dockerfile' && count($detectedDockerfiles) > 1) + + @foreach ($detectedDockerfiles as $df) + + @endforeach + + @endif + @if ($build_pack === 'dockercompose')
['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080], + ]); + + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile', 'apps/api/Dockerfile']) + ->and($result->dockerComposeFiles)->toBe(['docker-compose.yml']) + ->and($result->envFiles)->toHaveKey('.env.example') + ->and($result->envFiles['.env.example'])->toContain('APP_NAME=MyApp') + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('parseOutput handles empty repository', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, $output); + + expect($result->dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::NIXPACKS); +}); + +test('parseOutput handles dockerfile without EXPOSE', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile'], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => ['Dockerfile' => null], + ]); + + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile']) + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => null]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); +}); + +test('parseOutput handles only env files without dockerfiles', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => ['.env.example' => "SECRET_KEY=changeme\nDATABASE_URL=postgres://localhost/db"], + 'dockerfilePorts' => (object) [], + ]); + + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, $output); + + expect($result->dockerfiles)->toBe([]) + ->and($result->envFiles)->toHaveKey('.env.example') + ->and($result->envFiles['.env.example'])->toContain('SECRET_KEY=changeme') + ->and($result->envFiles['.env.example'])->toContain('DATABASE_URL=postgres://localhost/db') + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::NIXPACKS); +}); + +test('parseOutput handles multiple compose files', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => ['docker-compose.yml', 'compose.yaml'], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, $output); + + expect($result->dockerComposeFiles)->toBe(['docker-compose.yml', 'compose.yaml']) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('parseOutput handles multiple env files', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => [ + '.env.example' => "APP_KEY=base64:abc\nAPP_ENV=local", + '.env.sample' => "DB_HOST=127.0.0.1\nDB_PORT=5432", + '.env.dist' => 'REDIS_HOST=localhost', + ], + 'dockerfilePorts' => (object) [], + ]); + + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, $output); + + expect($result->envFiles)->toHaveCount(3) + ->and($result->envFiles['.env.example'])->toContain('APP_KEY=base64:abc') + ->and($result->envFiles['.env.sample'])->toContain('DB_HOST=127.0.0.1') + ->and($result->envFiles['.env.dist'])->toContain('REDIS_HOST=localhost') + ->and($result->hasEnvFiles())->toBeTrue(); +}); + +test('parseOutput returns none for invalid JSON', function () { + $detector = new RepositoryDetector( + repositoryUrl: 'https://github.com/test/repo', + branch: 'main', + baseDirectory: '/', + serverId: 1, + teamId: 1, + ); + + $reflection = new ReflectionClass($detector); + $method = $reflection->getMethod('parseOutput'); + + $result = $method->invoke($detector, 'not valid json'); + + expect($result->dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]); +}); From 6b87705b7779f5eaaad155f6b51e6376c03f3ab2 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Sat, 21 Feb 2026 20:27:28 +0000 Subject: [PATCH 2/2] fix: coderabbit changes + compose improvements --- .../Project/New/GithubPrivateRepository.php | 8 ++++++ .../New/GithubPrivateRepositoryDeployKey.php | 8 ++++++ .../Project/New/PublicGitRepository.php | 12 ++++++++- app/Services/RepositoryDetector.php | 19 +++++++++----- app/Traits/HasRepositoryDetection.php | 26 +++++++++++++++++-- ...ub-private-repository-deploy-key.blade.php | 8 ++++++ .../new/github-private-repository.blade.php | 8 ++++++ .../new/partials/env-import-modal.blade.php | 2 +- .../new/public-git-repository.blade.php | 8 ++++++ tests/Unit/RepositoryDetectorTest.php | 25 ++++++++++++++++++ 10 files changed, 114 insertions(+), 10 deletions(-) diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 964cdbe06..c85e52214 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -242,9 +242,17 @@ class GithubPrivateRepository extends Component $application_init['health_check_enabled'] = false; } if ($this->build_pack === 'dockerfile' && $this->selectedDockerfile) { + if (! empty($this->detectedDockerfiles) && ! in_array($this->selectedDockerfile, $this->detectedDockerfiles, true)) { + $this->selectedDockerfile = $this->detectedDockerfiles[0]; + } $application_init['dockerfile_location'] = $this->selectedDockerfile; } if ($this->build_pack === 'dockercompose') { + if (! empty($this->detectedDockerComposeFiles) && $this->selectedDockerComposeFile + && ! in_array($this->selectedDockerComposeFile, $this->detectedDockerComposeFiles, true)) { + $this->selectedDockerComposeFile = $this->detectedDockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } $application_init['docker_compose_location'] = $this->docker_compose_location; } diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index d5656e27c..e8f07538b 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -219,9 +219,17 @@ class GithubPrivateRepositoryDeployKey extends Component $application_init['health_check_enabled'] = false; } if ($this->build_pack === 'dockerfile' && $this->selectedDockerfile) { + if (! empty($this->detectedDockerfiles) && ! in_array($this->selectedDockerfile, $this->detectedDockerfiles, true)) { + $this->selectedDockerfile = $this->detectedDockerfiles[0]; + } $application_init['dockerfile_location'] = $this->selectedDockerfile; } if ($this->build_pack === 'dockercompose') { + if (! empty($this->detectedDockerComposeFiles) && $this->selectedDockerComposeFile + && ! in_array($this->selectedDockerComposeFile, $this->detectedDockerComposeFiles, true)) { + $this->selectedDockerComposeFile = $this->detectedDockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 4cad3ebf3..3c9410405 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -204,7 +204,9 @@ class PublicGitRepository extends Component $this->detectRepository(); } } catch (\Throwable $e) { - return handleError($e, $this); + // Both main and master failed — still run detection + // with clone fallback to the repo's default branch + $this->detectRepository(); } } else { return handleError($e, $this); @@ -396,9 +398,17 @@ class PublicGitRepository extends Component $application_init['health_check_enabled'] = false; } if ($this->build_pack === 'dockerfile' && $this->selectedDockerfile) { + if (! empty($this->detectedDockerfiles) && ! in_array($this->selectedDockerfile, $this->detectedDockerfiles, true)) { + $this->selectedDockerfile = $this->detectedDockerfiles[0]; + } $application_init['dockerfile_location'] = $this->selectedDockerfile; } if ($this->build_pack === 'dockercompose') { + if (! empty($this->detectedDockerComposeFiles) && $this->selectedDockerComposeFile + && ! in_array($this->selectedDockerComposeFile, $this->detectedDockerComposeFiles, true)) { + $this->selectedDockerComposeFile = $this->detectedDockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; } diff --git a/app/Services/RepositoryDetector.php b/app/Services/RepositoryDetector.php index fdb96411d..91b5de4f5 100644 --- a/app/Services/RepositoryDetector.php +++ b/app/Services/RepositoryDetector.php @@ -30,6 +30,11 @@ class RepositoryDetector ->first(); if (! $server) { + Log::debug('Repository detection skipped: server not found', [ + 'serverId' => $this->serverId, + 'teamId' => $this->teamId, + ]); + return RepositoryDetectionResult::none(); } @@ -48,13 +53,16 @@ class RepositoryDetector $workDir = escapeshellarg("{$tempDir}{$cdBase}"); $envPattern = self::ENV_FILE_PATTERN; + $escapedTempDir = escapeshellarg($tempDir); + $commands = collect([ - 'rm -rf -- '.escapeshellarg($tempDir), - 'git clone --depth 1 -b '.escapeshellarg($this->branch).' '.escapeshellarg($this->repositoryUrl).' '.escapeshellarg($tempDir).' >/dev/null 2>&1', + 'rm -rf -- '.$escapedTempDir, + "trap 'rm -rf -- {$escapedTempDir}' EXIT", + 'git clone --depth 1 -b '.escapeshellarg($this->branch).' '.escapeshellarg($this->repositoryUrl).' '.escapeshellarg($tempDir).' >/dev/null 2>&1 || git clone --depth 1 '.escapeshellarg($this->repositoryUrl).' '.escapeshellarg($tempDir).' >/dev/null 2>&1', "cd {$workDir}", // Collect file lists into shell variables - 'df_list=$(git ls-files | grep -i \'dockerfile\' || true)', - 'compose_list=$(git ls-files | grep -iE \'^(docker-compose\.(yml|yaml)|compose\.(yml|yaml))$\' || true)', + 'df_list=$(git ls-files | grep -iE \'(^|/)Dockerfile(\.[a-zA-Z0-9_-]+)?$\' || true)', + 'compose_list=$(git ls-files | grep -iE \'(^|/)(docker-compose\.(yml|yaml)|compose\.(yml|yaml))$\' || true)', 'env_list=$(git ls-files | grep -iE \''.$envPattern.'\' || true)', // Build env file contents as a JSON object (uses jq to safely encode file content) 'env_json=\'{}\'', @@ -79,7 +87,6 @@ class RepositoryDetector ' --argjson envFiles "$env_json" \\', ' --argjson dockerfilePorts "$port_json" \\', ' \'$ARGS.named\'', - 'rm -rf -- '.escapeshellarg($tempDir), ]); try { @@ -111,7 +118,7 @@ class RepositoryDetector $dockerfilePorts = []; foreach ($data['dockerfilePorts'] ?? [] as $file => $port) { - $dockerfilePorts[$file] = is_int($port) ? $port : null; + $dockerfilePorts[$file] = is_numeric($port) ? (int) $port : null; } return new RepositoryDetectionResult( diff --git a/app/Traits/HasRepositoryDetection.php b/app/Traits/HasRepositoryDetection.php index d4d8775e6..53078bd05 100644 --- a/app/Traits/HasRepositoryDetection.php +++ b/app/Traits/HasRepositoryDetection.php @@ -14,6 +14,8 @@ trait HasRepositoryDetection public ?string $selectedDockerfile = null; + public ?string $selectedDockerComposeFile = null; + public ?int $detectedPort = null; public array $dockerfilePorts = []; @@ -49,8 +51,9 @@ trait HasRepositoryDetection } } - if ($result->hasDockerCompose() && count($result->dockerComposeFiles) === 1) { - $this->docker_compose_location = '/'.$result->dockerComposeFiles[0]; + if ($result->hasDockerCompose()) { + $this->selectedDockerComposeFile = $result->dockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; } if ($result->hasEnvFiles()) { @@ -68,12 +71,31 @@ trait HasRepositoryDetection public function updatedSelectedDockerfile(): void { + if ($this->selectedDockerfile && ! in_array($this->selectedDockerfile, $this->detectedDockerfiles, true)) { + $this->selectedDockerfile = $this->detectedDockerfiles[0] ?? null; + } + if ($this->selectedDockerfile && isset($this->dockerfilePorts[$this->selectedDockerfile])) { $port = $this->dockerfilePorts[$this->selectedDockerfile]; if ($port) { $this->port = $port; $this->detectedPort = $port; + } else { + $this->detectedPort = null; } + } else { + $this->detectedPort = null; + } + } + + public function updatedSelectedDockerComposeFile(): void + { + if ($this->selectedDockerComposeFile && ! in_array($this->selectedDockerComposeFile, $this->detectedDockerComposeFiles, true)) { + $this->selectedDockerComposeFile = $this->detectedDockerComposeFiles[0] ?? null; + } + + if ($this->selectedDockerComposeFile) { + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; } } diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index cf32f17b8..04a3f8995 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -115,6 +115,14 @@ @endif @if ($build_pack === 'dockercompose') + @if (count($detectedDockerComposeFiles) > 1) + + @foreach ($detectedDockerComposeFiles as $cf) + + @endforeach + + @endif
+ @foreach ($detectedDockerComposeFiles as $cf) + + @endforeach + + @endif
+ wire:model="envExampleVars.{{ $key }}" />
@endforeach
diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 1a84241ab..5e4cccd1e 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -99,6 +99,14 @@ @endif @if ($build_pack === 'dockercompose') + @if (count($detectedDockerComposeFiles) > 1) + + @foreach ($detectedDockerComposeFiles as $cf) + + @endforeach + + @endif