This commit is contained in:
Aditya Tripathi 2026-03-11 03:30:35 +08:00 committed by GitHub
commit 3c337cfd06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1102 additions and 25 deletions

View file

@ -0,0 +1,55 @@
<?php
namespace App\Data;
use App\Enums\BuildPackTypes;
use Spatie\LaravelData\Data;
class RepositoryDetectionResult extends Data
{
/**
* @param array<int, string> $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile']
* @param array<int, string> $dockerComposeFiles e.g. ['docker-compose.yml']
* @param array<string, string|null> $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null]
* @param array<string, int|null> $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;
}
}

View file

@ -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 {
@ -188,7 +224,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(),
@ -202,22 +238,51 @@ 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) {
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 = 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,

View file

@ -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;
@ -135,6 +142,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();
@ -188,7 +220,18 @@ 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) {
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;
}
@ -201,6 +244,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,

View file

@ -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,8 +200,13 @@ class PublicGitRepository extends Component
try {
$this->git_branch = 'master';
$this->getBranch();
if ($this->branchFound) {
$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);
@ -197,6 +214,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,7 +397,18 @@ 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) {
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;
}
@ -362,11 +419,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', [

View file

@ -0,0 +1,131 @@
<?php
namespace App\Services;
use App\Data\RepositoryDetectionResult;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Visus\Cuid2\Cuid2;
class RepositoryDetector
{
/**
* Env file patterns considered safe to import (template/example files, not real secrets).
*/
private const ENV_FILE_PATTERN = '^\\.env\\.(example|sample|template|dist|local\\.example|development|production|staging|testing|test)$';
public function __construct(
private string $repositoryUrl,
private string $branch,
private string $baseDirectory,
private int $serverId,
private int $teamId,
) {}
public function detect(): RepositoryDetectionResult
{
$server = Server::query()
->where('id', $this->serverId)
->where('team_id', $this->teamId)
->first();
if (! $server) {
Log::debug('Repository detection skipped: server not found', [
'serverId' => $this->serverId,
'teamId' => $this->teamId,
]);
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;
$escapedTempDir = escapeshellarg($tempDir);
$commands = collect([
'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 -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=\'{}\'',
'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\'',
]);
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_numeric($port) ? (int) $port : null;
}
return new RepositoryDetectionResult(
dockerfiles: $data['dockerfiles'] ?? [],
dockerComposeFiles: $data['dockerComposeFiles'] ?? [],
envFiles: $data['envFiles'] ?? [],
dockerfilePorts: $dockerfilePorts,
);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace App\Traits;
use App\Data\RepositoryDetectionResult;
trait HasRepositoryDetection
{
public bool $detectionRan = false;
public array $detectedDockerfiles = [];
public array $detectedDockerComposeFiles = [];
public ?string $selectedDockerfile = null;
public ?string $selectedDockerComposeFile = null;
public ?int $detectedPort = null;
public array $dockerfilePorts = [];
public array $detectedEnvFiles = [];
public ?string $selectedEnvFile = null;
public array $parsedEnvFiles = [];
public array $envExampleVars = [];
public bool $envImported = false;
protected function applyDetectionResult(RepositoryDetectionResult $result): void
{
$this->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()) {
$this->selectedDockerComposeFile = $result->dockerComposeFiles[0];
$this->docker_compose_location = '/'.$this->selectedDockerComposeFile;
}
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 && ! 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;
}
}
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] ?? [];
}
}

View file

@ -1,4 +1,4 @@
<div>
<div x-data="{ envModalOpen: false }">
<h1>Create a new Application</h1>
<div class="pb-4">Deploy any public or private Git repositories through a Deploy Key.</div>
<div class="flex flex-col ">
@ -60,16 +60,76 @@
<x-forms.input id="publish_directory" required label="Publish Directory" />
@endif
</div>
{{-- Repository Detection --}}
<div class="pt-6 mt-4 border-t border-neutral-200 dark:border-coolgray-300">
<h3 class="text-lg font-bold">Smart Scan</h3>
<p class="pt-1 pb-3 text-sm dark:text-neutral-400">Scan for Dockerfiles, Docker Compose files, and environment configuration.</p>
<div class="flex items-center gap-3">
<x-forms.button type="button" wire:click="detectRepository">
<span wire:loading.remove wire:target="detectRepository">Detect Repository</span>
<span wire:loading wire:target="detectRepository" class="inline-flex items-center gap-2">
<x-loading /> Scanning...
</span>
</x-forms.button>
</div>
@if ($detectionRan)
<div wire:loading.remove wire:target="detectRepository" class="pt-3">
<div class="flex items-center gap-3 flex-wrap text-sm">
@if (count($detectedDockerfiles))
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
Dockerfile{{ count($detectedDockerfiles) > 1 ? 's' : '' }}
<span class="dark:text-neutral-400">({{ count($detectedDockerfiles) }})</span>
</span>
@endif
@if (count($detectedDockerComposeFiles))
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
Docker Compose
<span class="dark:text-neutral-400">({{ count($detectedDockerComposeFiles) }})</span>
</span>
@endif
@include('livewire.project.new.partials.env-detection-badges')
@if (!count($detectedDockerfiles) && !count($detectedDockerComposeFiles) && !count($detectedEnvFiles))
<span class="dark:text-neutral-400">No Dockerfile, Docker Compose, or env files detected.</span>
@endif
</div>
</div>
@endif
</div>
{{-- Configuration --}}
<h3 class="pt-6 text-lg font-bold">Configuration</h3>
{{-- Dockerfile selector when multiple detected --}}
@if ($build_pack === 'dockerfile' && count($detectedDockerfiles) > 1)
<x-forms.select wire:model.live="selectedDockerfile" label="Dockerfile"
helper="Multiple Dockerfiles were detected in your repository. Select which one to use.">
@foreach ($detectedDockerfiles as $df)
<option value="{{ $df }}">{{ $df }}</option>
@endforeach
</x-forms.select>
@endif
@if ($build_pack === 'dockercompose')
@if (count($detectedDockerComposeFiles) > 1)
<x-forms.select wire:model.live="selectedDockerComposeFile" label="Docker Compose File"
helper="Multiple Docker Compose files were detected. Select which one to use.">
@foreach ($detectedDockerComposeFiles as $cf)
<option value="{{ $cf }}">{{ $cf }}</option>
@endforeach
</x-forms.select>
@endif
<div x-data="{
baseDir: '{{ $base_directory }}',
composeLocation: '{{ $docker_compose_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Ensure leading slash
if (!path.startsWith('/')) {
path = '/' + path;
}
@ -109,6 +169,9 @@
Continue
</x-forms.button>
</form>
{{-- Environment Variables Import Modal --}}
@include('livewire.project.new.partials.env-import-modal')
@endif
</div>
</div>

View file

@ -1,4 +1,4 @@
<div>
<div x-data="{ envModalOpen: false }">
<div class="flex items-end gap-2">
<h1>Create a new Application</h1>
<x-modal-input buttonTitle="+ Add GitHub App" title="New GitHub App" closeOutside="false">
@ -57,7 +57,42 @@
<div>No repositories found. Check your GitHub App configuration.</div>
@endif
@if ($branches->count() > 0)
<h2 class="text-lg font-bold">Configuration</h2>
{{-- Repository Detection --}}
<div class="pt-6 mt-2 border-t border-neutral-200 dark:border-coolgray-300">
<h3 class="text-lg font-bold">Smart Scan</h3>
<p class="pt-1 pb-3 text-sm dark:text-neutral-400">Detected configuration from your repository.</p>
<div wire:loading.flex wire:target="detectRepository" class="items-center gap-2 py-3 text-sm dark:text-neutral-400">
<x-loading /> Scanning repository for Dockerfiles and configuration...
</div>
@if ($detectionRan)
<div wire:loading.remove wire:target="detectRepository">
<div class="flex items-center gap-3 flex-wrap text-sm">
@if (count($detectedDockerfiles))
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
Dockerfile{{ count($detectedDockerfiles) > 1 ? 's' : '' }}
<span class="dark:text-neutral-400">({{ count($detectedDockerfiles) }})</span>
</span>
@endif
@if (count($detectedDockerComposeFiles))
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
Docker Compose
<span class="dark:text-neutral-400">({{ count($detectedDockerComposeFiles) }})</span>
</span>
@endif
@include('livewire.project.new.partials.env-detection-badges')
@if (!count($detectedDockerfiles) && !count($detectedDockerComposeFiles) && !count($detectedEnvFiles))
<span class="dark:text-neutral-400">No Dockerfile, Docker Compose, or env files detected.</span>
@endif
</div>
</div>
@endif
</div>
<h2 class="pt-6 text-lg font-bold">Configuration</h2>
<div class="flex flex-col gap-2 pb-6">
<form class="flex flex-col" wire:submit='submit'>
<div class="flex flex-col gap-2 pb-6">
@ -87,16 +122,33 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
{{-- Dockerfile selector when multiple detected --}}
@if ($build_pack === 'dockerfile' && count($detectedDockerfiles) > 1)
<x-forms.select wire:model.live="selectedDockerfile" label="Dockerfile"
helper="Multiple Dockerfiles were detected in your repository. Select which one to use.">
@foreach ($detectedDockerfiles as $df)
<option value="{{ $df }}">{{ $df }}</option>
@endforeach
</x-forms.select>
@endif
@if ($build_pack === 'dockercompose')
@if (count($detectedDockerComposeFiles) > 1)
<x-forms.select wire:model.live="selectedDockerComposeFile" label="Docker Compose File"
helper="Multiple Docker Compose files were detected. Select which one to use.">
@foreach ($detectedDockerComposeFiles as $cf)
<option value="{{ $cf }}">{{ $cf }}</option>
@endforeach
</x-forms.select>
@endif
<div x-data="{
baseDir: '{{ $base_directory }}',
composeLocation: '{{ $docker_compose_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Ensure leading slash
if (!path.startsWith('/')) {
path = '/' + path;
}
@ -143,6 +195,9 @@
@endif
@endif
</div>
{{-- Environment Variables Import Modal --}}
@include('livewire.project.new.partials.env-import-modal')
@else
<div class="hero">
No GitHub Application found. Please create a new GitHub Application.

View file

@ -0,0 +1,20 @@
@if (count($detectedEnvFiles) > 0 && $envImported && count($envExampleVars) > 0)
<button type="button" @click="envModalOpen = true"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-success/30 dark:border-success/30 hover:dark:border-success transition-colors cursor-pointer">
<span class="badge badge-success"></span>
{{ $selectedEnvFile }}
<span class="text-success text-xs">({{ count($envExampleVars) }} imported)</span>
</button>
@elseif (count($detectedEnvFiles) > 0 && count($envExampleVars) > 0)
<button type="button" @click="envModalOpen = true"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 hover:dark:border-warning transition-colors cursor-pointer">
<span class="badge badge-success"></span>
{{ count($detectedEnvFiles) > 1 ? 'Env Files (' . count($detectedEnvFiles) . ')' : $detectedEnvFiles[0] }}
<span class="dark:text-warning text-xs">(click to import)</span>
</button>
@elseif (count($detectedEnvFiles) > 0)
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
{{ count($detectedEnvFiles) > 1 ? 'Env Files (' . count($detectedEnvFiles) . ')' : $detectedEnvFiles[0] }}
</span>
@endif

View file

@ -0,0 +1,67 @@
@if (count($envExampleVars) > 0)
<template x-teleport="body">
<div x-show="envModalOpen" @keydown.window.escape="envModalOpen = false"
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4">
<div x-show="envModalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="envModalOpen = false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="envModalOpen" x-trap.inert.noscroll="envModalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full lg:w-auto lg:min-w-xl lg:max-w-2xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-lg font-bold">Import from {{ $selectedEnvFile }}</h3>
<button @click="envModalOpen = false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@if (count($detectedEnvFiles) > 1)
<div class="px-6 pb-3">
<x-forms.select wire:model.live="selectedEnvFile" label="Env File"
helper="Multiple env files were detected. Select which one to import.">
@foreach ($detectedEnvFiles as $envFile)
<option value="{{ $envFile }}">{{ $envFile }}</option>
@endforeach
</x-forms.select>
</div>
@endif
<div class="px-6 pb-3 text-sm dark:text-neutral-400">
Review and edit the values below. Imported variables will be added to your application when you continue.
</div>
<div class="flex flex-col gap-3 px-6 pb-2 max-h-80 overflow-y-auto scrollbar">
@foreach ($envExampleVars as $key => $value)
<div class="flex gap-3 items-center">
<label class="w-1/3 text-sm font-mono truncate dark:text-neutral-400" title="{{ $key }}">{{ $key }}</label>
<input type="text" class="w-2/3 input"
wire:model="envExampleVars.{{ $key }}" />
</div>
@endforeach
</div>
<div class="flex items-center justify-between gap-4 px-6 py-5">
<x-forms.button @click="envModalOpen = false">
Close
</x-forms.button>
<div class="flex items-center gap-2">
@if ($envImported)
<x-forms.button wire:click="clearEnvVars" @click="envModalOpen = false">
Remove Import
</x-forms.button>
@endif
<x-forms.button isHighlighted wire:click="confirmEnvImport" @click="envModalOpen = false">
{{ $envImported ? 'Update' : 'Import' }} {{ count($envExampleVars) }} Variable{{ count($envExampleVars) > 1 ? 's' : '' }}
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
@endif

View file

@ -1,4 +1,4 @@
<div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
<div x-data="{ envModalOpen: false }" x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
<h1>Create a new Application</h1>
<div class="pb-8">Deploy any public Git repositories.</div>
@ -29,8 +29,44 @@
</div>
@endif
{{-- Repository Detection --}}
<div class="pt-6 mt-6 border-t border-neutral-200 dark:border-coolgray-300">
<h3 class="text-lg font-bold">Smart Scan</h3>
<p class="pt-1 pb-3 text-sm dark:text-neutral-400">Detected configuration from your repository.</p>
<div wire:loading.flex wire:target="detectRepository" class="items-center gap-2 py-3 text-sm dark:text-neutral-400">
<x-loading /> Scanning repository for Dockerfiles and configuration...
</div>
@if ($detectionRan)
<div wire:loading.remove wire:target="detectRepository">
<div class="flex items-center gap-3 flex-wrap text-sm">
@if (count($detectedDockerfiles))
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
Dockerfile{{ count($detectedDockerfiles) > 1 ? 's' : '' }}
<span class="dark:text-neutral-400">({{ count($detectedDockerfiles) }})</span>
</span>
@endif
@if (count($detectedDockerComposeFiles))
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300">
<span class="badge badge-success"></span>
Docker Compose
<span class="dark:text-neutral-400">({{ count($detectedDockerComposeFiles) }})</span>
</span>
@endif
@include('livewire.project.new.partials.env-detection-badges')
@if (!count($detectedDockerfiles) && !count($detectedDockerComposeFiles) && !count($detectedEnvFiles))
<span class="dark:text-neutral-400">No Dockerfile, Docker Compose, or env files detected.</span>
@endif
</div>
</div>
@endif
</div>
<!-- Application Configuration Form -->
<form class="flex flex-col gap-2 pt-4" wire:submit='submit'>
<h3 class="pt-6 text-lg font-bold">Configuration</h3>
<form class="flex flex-col gap-2 pt-2" wire:submit='submit'>
<div class="flex flex-col gap-2 pb-6">
<div class="flex gap-2">
@if ($git_source === 'other')
@ -51,16 +87,33 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
{{-- Dockerfile selector when multiple detected --}}
@if ($build_pack === 'dockerfile' && count($detectedDockerfiles) > 1)
<x-forms.select wire:model.live="selectedDockerfile" label="Dockerfile"
helper="Multiple Dockerfiles were detected in your repository. Select which one to use.">
@foreach ($detectedDockerfiles as $df)
<option value="{{ $df }}">{{ $df }}</option>
@endforeach
</x-forms.select>
@endif
@if ($build_pack === 'dockercompose')
@if (count($detectedDockerComposeFiles) > 1)
<x-forms.select wire:model.live="selectedDockerComposeFile" label="Docker Compose File"
helper="Multiple Docker Compose files were detected. Select which one to use.">
@foreach ($detectedDockerComposeFiles as $cf)
<option value="{{ $cf }}">{{ $cf }}</option>
@endforeach
</x-forms.select>
@endif
<div x-data="{
baseDir: '{{ $base_directory }}',
composeLocation: '{{ $docker_compose_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Ensure leading slash
if (!path.startsWith('/')) {
path = '/' + path;
}
@ -102,5 +155,8 @@
Continue
</x-forms.button>
</form>
{{-- Environment Variables Import Modal --}}
@include('livewire.project.new.partials.env-import-modal')
@endif
</div>

View file

@ -0,0 +1,104 @@
<?php
use App\Data\RepositoryDetectionResult;
use App\Enums\BuildPackTypes;
test('empty result returns correct defaults', function () {
$result = RepositoryDetectionResult::none();
expect($result->dockerfiles)->toBe([])
->and($result->dockerComposeFiles)->toBe([])
->and($result->envFiles)->toBe([])
->and($result->dockerfilePorts)->toBe([]);
});
test('getSuggestedBuildPack returns dockercompose when compose files found', function () {
$result = new RepositoryDetectionResult(
dockerfiles: ['Dockerfile'],
dockerComposeFiles: ['docker-compose.yml'],
);
expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE);
});
test('getSuggestedBuildPack returns dockerfile when only dockerfiles found', function () {
$result = new RepositoryDetectionResult(
dockerfiles: ['Dockerfile'],
);
expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE);
});
test('getSuggestedBuildPack returns nixpacks when nothing found', function () {
$result = RepositoryDetectionResult::none();
expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::NIXPACKS);
});
test('hasDockerfile returns true when dockerfiles present', function () {
$result = new RepositoryDetectionResult(
dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'],
);
expect($result->hasDockerfile())->toBeTrue();
});
test('hasDockerfile returns false when no dockerfiles', function () {
$result = RepositoryDetectionResult::none();
expect($result->hasDockerfile())->toBeFalse();
});
test('hasDockerCompose returns true when compose files present', function () {
$result = new RepositoryDetectionResult(
dockerComposeFiles: ['docker-compose.yml'],
);
expect($result->hasDockerCompose())->toBeTrue();
});
test('hasDockerCompose returns false when no compose files', function () {
$result = RepositoryDetectionResult::none();
expect($result->hasDockerCompose())->toBeFalse();
});
test('dockerfilePorts stores port mapping correctly', function () {
$result = new RepositoryDetectionResult(
dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'],
dockerfilePorts: ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080],
);
expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080])
->and($result->dockerfilePorts['Dockerfile'])->toBe(3000)
->and($result->dockerfilePorts['apps/api/Dockerfile'])->toBe(8080);
});
test('hasEnvFiles returns true when env files present', function () {
$result = new RepositoryDetectionResult(
envFiles: ['.env.example' => 'APP_KEY=secret'],
);
expect($result->hasEnvFiles())->toBeTrue();
});
test('hasEnvFiles returns false when no env files', function () {
$result = RepositoryDetectionResult::none();
expect($result->hasEnvFiles())->toBeFalse();
});
test('envFiles stores multiple files with content', function () {
$result = new RepositoryDetectionResult(
envFiles: [
'.env.example' => 'APP_KEY=secret',
'.env.sample' => 'DB_HOST=localhost',
'.env.dist' => null,
],
);
expect($result->envFiles)->toHaveCount(3)
->and($result->envFiles['.env.example'])->toBe('APP_KEY=secret')
->and($result->envFiles['.env.sample'])->toBe('DB_HOST=localhost')
->and($result->envFiles['.env.dist'])->toBeNull();
});

View file

@ -0,0 +1,217 @@
<?php
use App\Enums\BuildPackTypes;
use App\Services\RepositoryDetector;
test('parseOutput handles complete detection output', function () {
$output = json_encode([
'dockerfiles' => ['Dockerfile', 'apps/api/Dockerfile'],
'dockerComposeFiles' => ['docker-compose.yml'],
'envFiles' => ['.env.example' => "APP_NAME=MyApp\nAPP_ENV=production\nDB_HOST=localhost\nDB_PORT=5432"],
'dockerfilePorts' => ['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 handles string port values from JSON', function () {
$output = json_encode([
'dockerfiles' => ['Dockerfile'],
'dockerComposeFiles' => [],
'envFiles' => (object) [],
'dockerfilePorts' => ['Dockerfile' => '3000'],
]);
$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->dockerfilePorts)->toBe(['Dockerfile' => 3000])
->and($result->dockerfilePorts['Dockerfile'])->toBeInt();
});
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([]);
});