diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 5dced0599..297585562 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -41,6 +41,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
+ public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
+
+ private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
+
+ private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
+
public $tries = 1;
public $timeout = 3600;
@@ -652,11 +658,27 @@ private function deploy_docker_compose_buildpack()
$this->save_buildtime_environment_variables();
if ($this->docker_compose_custom_build_command) {
+ // Auto-inject -f (compose file) and --env-file flags using helper function
+ $build_command = injectDockerComposeFlags(
+ $this->docker_compose_custom_build_command,
+ "{$this->workdir}{$this->docker_compose_location}",
+ self::BUILD_TIME_ENV_PATH
+ );
+
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
- $build_command = $this->docker_compose_custom_build_command;
if ($this->dockerBuildkitSupported) {
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
}
+
+ // Append build arguments if not using build secrets (matching default behavior)
+ if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
+ $build_args_string = $this->build_args->implode(' ');
+ // Escape single quotes for bash -c context used by executeInDocker
+ $build_args_string = str_replace("'", "'\\''", $build_args_string);
+ $build_command .= " {$build_args_string}";
+ $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
+ }
+
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
@@ -667,7 +689,7 @@ private function deploy_docker_compose_buildpack()
$command = "DOCKER_BUILDKIT=1 {$command}";
}
// Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
- $command .= ' --env-file /artifacts/build-time.env';
+ $command .= ' --env-file '.self::BUILD_TIME_ENV_PATH;
if ($this->force_rebuild) {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else {
@@ -715,9 +737,16 @@ private function deploy_docker_compose_buildpack()
$server_workdir = $this->application->workdir();
if ($this->application->settings->is_raw_compose_deployment_enabled) {
if ($this->docker_compose_custom_start_command) {
+ // Auto-inject -f (compose file) and --env-file flags using helper function
+ $start_command = injectDockerComposeFlags(
+ $this->docker_compose_custom_start_command,
+ "{$server_workdir}{$this->docker_compose_location}",
+ "{$server_workdir}/.env"
+ );
+
$this->write_deployment_configurations();
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$this->docker_compose_custom_start_command}"), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
);
} else {
$this->write_deployment_configurations();
@@ -733,9 +762,18 @@ private function deploy_docker_compose_buildpack()
}
} else {
if ($this->docker_compose_custom_start_command) {
+ // Auto-inject -f (compose file) and --env-file flags using helper function
+ // Use $this->workdir for non-preserve-repository mode
+ $workdir_path = $this->preserveRepository ? $server_workdir : $this->workdir;
+ $start_command = injectDockerComposeFlags(
+ $this->docker_compose_custom_start_command,
+ "{$workdir_path}{$this->docker_compose_location}",
+ "{$workdir_path}/.env"
+ );
+
$this->write_deployment_configurations();
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_start_command}"), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
);
} else {
$command = "{$this->coolify_variables} docker compose";
@@ -1534,10 +1572,10 @@ private function save_buildtime_environment_variables()
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
'hidden' => true,
],
);
@@ -1548,7 +1586,7 @@ private function save_buildtime_environment_variables()
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'),
+ executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
]
);
}
@@ -2674,15 +2712,15 @@ private function build_static_image()
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
],
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
@@ -2690,7 +2728,7 @@ private function build_static_image()
}
/**
- * Wrap a docker build command with environment export from /artifacts/build-time.env
+ * Wrap a docker build command with environment export from build-time .env file
* This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
*
* @param string $build_command The docker build command to wrap
@@ -2698,7 +2736,7 @@ private function build_static_image()
*/
private function wrap_build_command_with_env_export(string $build_command): string
{
- return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}";
+ return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
}
private function build_image()
@@ -2737,10 +2775,10 @@ private function build_image()
}
if ($this->application->build_pack === 'nixpacks') {
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
- $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true]);
if ($this->force_rebuild) {
$this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
+ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
'hidden' => true,
], [
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
@@ -2760,7 +2798,7 @@ private function build_image()
}
} else {
$this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
+ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
'hidden' => true,
], [
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
@@ -2784,19 +2822,19 @@ private function build_image()
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
- $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
@@ -2828,15 +2866,15 @@ private function build_image()
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
@@ -2867,15 +2905,15 @@ private function build_image()
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
],
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
@@ -2902,25 +2940,25 @@ private function build_image()
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
} else {
if ($this->application->build_pack === 'nixpacks') {
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
- $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true]);
if ($this->force_rebuild) {
$this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
+ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
'hidden' => true,
], [
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
@@ -2941,7 +2979,7 @@ private function build_image()
}
} else {
$this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
+ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
'hidden' => true,
], [
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
@@ -2964,19 +3002,19 @@ private function build_image()
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
- $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
@@ -3009,15 +3047,15 @@ private function build_image()
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 7d3d64bee..71ca9720e 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -1005,4 +1005,41 @@ public function getDetectedPortInfoProperty(): ?array
'isEmpty' => $isEmpty,
];
}
+
+ public function getDockerComposeBuildCommandPreviewProperty(): string
+ {
+ if (! $this->dockerComposeCustomBuildCommand) {
+ return '';
+ }
+
+ // Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
+ $normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
+
+ // Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
+ // Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
+ // Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
+ return injectDockerComposeFlags(
+ $this->dockerComposeCustomBuildCommand,
+ ".{$normalizedBase}{$this->dockerComposeLocation}",
+ \App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
+ );
+ }
+
+ public function getDockerComposeStartCommandPreviewProperty(): string
+ {
+ if (! $this->dockerComposeCustomStartCommand) {
+ return '';
+ }
+
+ // Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
+ $normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
+
+ // Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
+ // Placeholder {workdir}/.env shows it's the workdir .env file (runtime env, not build-time)
+ return injectDockerComposeFlags(
+ $this->dockerComposeCustomStartCommand,
+ ".{$normalizedBase}{$this->dockerComposeLocation}",
+ '{workdir}/.env'
+ );
+ }
}
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index c62c2ad8e..256a2cb66 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -1272,3 +1272,36 @@ function generateDockerEnvFlags($variables): string
})
->implode(' ');
}
+
+/**
+ * Auto-inject -f and --env-file flags into a docker compose command if not already present
+ *
+ * @param string $command The docker compose command to modify
+ * @param string $composeFilePath The path to the compose file
+ * @param string $envFilePath The path to the .env file
+ * @return string The modified command with injected flags
+ *
+ * @example
+ * Input: "docker compose build"
+ * Output: "docker compose -f ./docker-compose.yml --env-file .env build"
+ */
+function injectDockerComposeFlags(string $command, string $composeFilePath, string $envFilePath): string
+{
+ $dockerComposeReplacement = 'docker compose';
+
+ // Add -f flag if not present (checks for both -f and --file with various formats)
+ // Detects: -f path, -f=path, -fpath (concatenated with path chars: . / ~), --file path, --file=path
+ // Note: Uses [.~/]|$ instead of \S to prevent false positives with flags like -foo, -from, -feature
+ if (! preg_match('/(?:^|\s)(?:-f(?:[=\s]|[.\/~]|$)|--file(?:=|\s))/', $command)) {
+ $dockerComposeReplacement .= " -f {$composeFilePath}";
+ }
+
+ // Add --env-file flag if not present (checks for --env-file with various formats)
+ // Detects: --env-file path, --env-file=path with any whitespace
+ if (! preg_match('/(?:^|\s)--env-file(?:=|\s)/', $command)) {
+ $dockerComposeReplacement .= " --env-file {$envFilePath}";
+ }
+
+ // Replace only first occurrence to avoid modifying comments/strings/chained commands
+ return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1);
+}
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index c95260efe..66c4cfc60 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -259,13 +259,31 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
+ @if ($this->dockerComposeCustomBuildCommand)
+
+
+
+ @endif
+ @if ($this->dockerComposeCustomStartCommand)
+
+
+
+ @endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
toBe('docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('does not duplicate --env-file flag when already present', function () {
+ $customCommand = 'docker compose --env-file /custom/.env -f ./docker-compose.yaml build';
+
+ // Simulate the injection logic from ApplicationDeploymentJob
+ if (! str_contains($customCommand, '--env-file')) {
+ $customCommand = str_replace(
+ 'docker compose',
+ 'docker compose --env-file /artifacts/build-time.env',
+ $customCommand
+ );
+ }
+
+ expect($customCommand)->toBe('docker compose --env-file /custom/.env -f ./docker-compose.yaml build');
+ expect(substr_count($customCommand, '--env-file'))->toBe(1);
+});
+
+it('preserves custom build command structure with env-file injection', function () {
+ $customCommand = 'docker compose -f ./custom/path/docker-compose.prod.yaml build --no-cache';
+
+ // Simulate the injection logic from ApplicationDeploymentJob
+ if (! str_contains($customCommand, '--env-file')) {
+ $customCommand = str_replace(
+ 'docker compose',
+ 'docker compose --env-file /artifacts/build-time.env',
+ $customCommand
+ );
+ }
+
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f ./custom/path/docker-compose.prod.yaml build --no-cache');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+ expect($customCommand)->toContain('-f ./custom/path/docker-compose.prod.yaml');
+ expect($customCommand)->toContain('build --no-cache');
+});
+
+it('handles multiple docker compose commands in custom build command', function () {
+ // Edge case: Only the first 'docker compose' should get the env-file flag
+ $customCommand = 'docker compose -f ./docker-compose.yaml build';
+
+ // Simulate the injection logic from ApplicationDeploymentJob
+ if (! str_contains($customCommand, '--env-file')) {
+ $customCommand = str_replace(
+ 'docker compose',
+ 'docker compose --env-file /artifacts/build-time.env',
+ $customCommand
+ );
+ }
+
+ // Note: str_replace replaces ALL occurrences, which is acceptable in this case
+ // since you typically only have one 'docker compose' command
+ expect($customCommand)->toContain('docker compose --env-file /artifacts/build-time.env');
+});
+
+it('verifies build args would be appended correctly', function () {
+ $customCommand = 'docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build';
+ $buildArgs = collect([
+ '--build-arg NODE_ENV=production',
+ '--build-arg API_URL=https://api.example.com',
+ ]);
+
+ // Simulate build args appending logic
+ $buildArgsString = $buildArgs->implode(' ');
+ $buildArgsString = str_replace("'", "'\\''", $buildArgsString);
+ $customCommand .= " {$buildArgsString}";
+
+ expect($customCommand)->toContain('--build-arg NODE_ENV=production');
+ expect($customCommand)->toContain('--build-arg API_URL=https://api.example.com');
+ expect($customCommand)->toBe(
+ 'docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build --build-arg NODE_ENV=production --build-arg API_URL=https://api.example.com'
+ );
+});
+
+it('properly escapes single quotes in build args', function () {
+ $buildArg = "--build-arg MESSAGE='Hello World'";
+
+ // Simulate the escaping logic from ApplicationDeploymentJob
+ $escapedBuildArg = str_replace("'", "'\\''", $buildArg);
+
+ expect($escapedBuildArg)->toBe("--build-arg MESSAGE='\\''Hello World'\\''");
+});
+
+it('handles DOCKER_BUILDKIT prefix with env-file injection', function () {
+ $customCommand = 'docker compose -f ./docker-compose.yaml build';
+
+ // Simulate the injection logic from ApplicationDeploymentJob
+ if (! str_contains($customCommand, '--env-file')) {
+ $customCommand = str_replace(
+ 'docker compose',
+ 'docker compose --env-file /artifacts/build-time.env',
+ $customCommand
+ );
+ }
+
+ // Simulate BuildKit support
+ $dockerBuildkitSupported = true;
+ if ($dockerBuildkitSupported) {
+ $customCommand = "DOCKER_BUILDKIT=1 {$customCommand}";
+ }
+
+ expect($customCommand)->toBe('DOCKER_BUILDKIT=1 docker compose --env-file /artifacts/build-time.env -f ./docker-compose.yaml build');
+ expect($customCommand)->toStartWith('DOCKER_BUILDKIT=1');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+// Tests for -f flag injection
+
+it('injects -f flag with compose file path into custom build command', function () {
+ $customCommand = 'docker compose build';
+ $composeFilePath = '/artifacts/deployment-uuid/backend/docker-compose.yaml';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, $composeFilePath, '/artifacts/build-time.env');
+
+ expect($customCommand)->toBe('docker compose -f /artifacts/deployment-uuid/backend/docker-compose.yaml --env-file /artifacts/build-time.env build');
+ expect($customCommand)->toContain('-f /artifacts/deployment-uuid/backend/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('does not duplicate -f flag when already present', function () {
+ $customCommand = 'docker compose -f ./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f ./custom/docker-compose.yaml build');
+ expect(substr_count($customCommand, ' -f '))->toBe(1);
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('does not duplicate --file flag when already present', function () {
+ $customCommand = 'docker compose --file ./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env --file ./custom/docker-compose.yaml build');
+ expect(substr_count($customCommand, '--file '))->toBe(1);
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('injects both -f and --env-file flags in single operation', function () {
+ $customCommand = 'docker compose build --no-cache';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/app/docker-compose.prod.yaml', '/artifacts/build-time.env');
+
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/app/docker-compose.prod.yaml --env-file /artifacts/build-time.env build --no-cache');
+ expect($customCommand)->toContain('-f /artifacts/uuid/app/docker-compose.prod.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+ expect($customCommand)->toContain('build --no-cache');
+});
+
+it('respects user-provided -f and --env-file flags', function () {
+ $customCommand = 'docker compose -f ./my-compose.yaml --env-file .env build';
+
+ // Use the helper function - should not inject anything since both flags are already present
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ expect($customCommand)->toBe('docker compose -f ./my-compose.yaml --env-file .env build');
+ expect(substr_count($customCommand, ' -f '))->toBe(1);
+ expect(substr_count($customCommand, '--env-file'))->toBe(1);
+});
+
+// Tests for custom start command -f and --env-file injection
+
+it('injects -f and --env-file flags into custom start command', function () {
+ $customCommand = 'docker compose up -d';
+ $serverWorkdir = '/var/lib/docker/volumes/coolify-data/_data/applications/app-uuid';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, "{$serverWorkdir}{$composeLocation}", "{$serverWorkdir}/.env");
+
+ expect($customCommand)->toBe('docker compose -f /var/lib/docker/volumes/coolify-data/_data/applications/app-uuid/docker-compose.yaml --env-file /var/lib/docker/volumes/coolify-data/_data/applications/app-uuid/.env up -d');
+ expect($customCommand)->toContain('-f /var/lib/docker/volumes/coolify-data/_data/applications/app-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /var/lib/docker/volumes/coolify-data/_data/applications/app-uuid/.env');
+});
+
+it('does not duplicate -f flag in start command when already present', function () {
+ $customCommand = 'docker compose -f ./custom-compose.yaml up -d';
+ $serverWorkdir = '/var/lib/docker/volumes/coolify-data/_data/applications/app-uuid';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, "{$serverWorkdir}{$composeLocation}", "{$serverWorkdir}/.env");
+
+ expect($customCommand)->toBe('docker compose --env-file /var/lib/docker/volumes/coolify-data/_data/applications/app-uuid/.env -f ./custom-compose.yaml up -d');
+ expect(substr_count($customCommand, ' -f '))->toBe(1);
+ expect($customCommand)->toContain('--env-file');
+});
+
+it('does not duplicate --env-file flag in start command when already present', function () {
+ $customCommand = 'docker compose --env-file ./my.env up -d';
+ $serverWorkdir = '/var/lib/docker/volumes/coolify-data/_data/applications/app-uuid';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, "{$serverWorkdir}{$composeLocation}", "{$serverWorkdir}/.env");
+
+ expect($customCommand)->toBe('docker compose -f /var/lib/docker/volumes/coolify-data/_data/applications/app-uuid/docker-compose.yaml --env-file ./my.env up -d');
+ expect(substr_count($customCommand, '--env-file'))->toBe(1);
+ expect($customCommand)->toContain('-f');
+});
+
+it('respects both user-provided flags in start command', function () {
+ $customCommand = 'docker compose -f ./my-compose.yaml --env-file ./.env up -d';
+ $serverWorkdir = '/var/lib/docker/volumes/coolify-data/_data/applications/app-uuid';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Use the helper function - should not inject anything since both flags are already present
+ $customCommand = injectDockerComposeFlags($customCommand, "{$serverWorkdir}{$composeLocation}", "{$serverWorkdir}/.env");
+
+ expect($customCommand)->toBe('docker compose -f ./my-compose.yaml --env-file ./.env up -d');
+ expect(substr_count($customCommand, ' -f '))->toBe(1);
+ expect(substr_count($customCommand, '--env-file'))->toBe(1);
+});
+
+it('injects both flags in start command with additional parameters', function () {
+ $customCommand = 'docker compose up -d --remove-orphans';
+ $serverWorkdir = '/workdir/app';
+ $composeLocation = '/backend/docker-compose.prod.yaml';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, "{$serverWorkdir}{$composeLocation}", "{$serverWorkdir}/.env");
+
+ expect($customCommand)->toBe('docker compose -f /workdir/app/backend/docker-compose.prod.yaml --env-file /workdir/app/.env up -d --remove-orphans');
+ expect($customCommand)->toContain('-f /workdir/app/backend/docker-compose.prod.yaml');
+ expect($customCommand)->toContain('--env-file /workdir/app/.env');
+ expect($customCommand)->toContain('--remove-orphans');
+});
+
+// Security tests: Prevent bypass vectors for flag detection
+
+it('detects -f flag with equals sign format (bypass vector)', function () {
+ $customCommand = 'docker compose -f=./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f= is already present
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f=./custom/docker-compose.yaml build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects --file flag with equals sign format (bypass vector)', function () {
+ $customCommand = 'docker compose --file=./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since --file= is already present
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env --file=./custom/docker-compose.yaml build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects --env-file flag with equals sign format (bypass vector)', function () {
+ $customCommand = 'docker compose --env-file=./custom/.env build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject --env-file flag since --env-file= is already present
+ expect($customCommand)->toBe('docker compose -f /artifacts/deployment-uuid/docker-compose.yaml --env-file=./custom/.env build');
+ expect($customCommand)->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->not->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects -f flag with tab character whitespace (bypass vector)', function () {
+ $customCommand = "docker compose\t-f\t./custom/docker-compose.yaml build";
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f with tab is already present
+ expect($customCommand)->toBe("docker compose --env-file /artifacts/build-time.env\t-f\t./custom/docker-compose.yaml build");
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects --env-file flag with tab character whitespace (bypass vector)', function () {
+ $customCommand = "docker compose\t--env-file\t./custom/.env build";
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject --env-file flag since --env-file with tab is already present
+ expect($customCommand)->toBe("docker compose -f /artifacts/deployment-uuid/docker-compose.yaml\t--env-file\t./custom/.env build");
+ expect($customCommand)->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->not->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects -f flag with multiple spaces (bypass vector)', function () {
+ $customCommand = 'docker compose -f ./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f with multiple spaces is already present
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f ./custom/docker-compose.yaml build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects --file flag with multiple spaces (bypass vector)', function () {
+ $customCommand = 'docker compose --file ./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since --file with multiple spaces is already present
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env --file ./custom/docker-compose.yaml build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects -f flag at start of command (edge case)', function () {
+ $customCommand = '-f ./custom/docker-compose.yaml docker compose build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f is at start of command
+ expect($customCommand)->toBe('-f ./custom/docker-compose.yaml docker compose --env-file /artifacts/build-time.env build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects --env-file flag at start of command (edge case)', function () {
+ $customCommand = '--env-file=./custom/.env docker compose build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject --env-file flag since --env-file is at start of command
+ expect($customCommand)->toBe('--env-file=./custom/.env docker compose -f /artifacts/deployment-uuid/docker-compose.yaml build');
+ expect($customCommand)->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->not->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('handles mixed whitespace correctly (comprehensive test)', function () {
+ $customCommand = "docker compose\t-f=./custom/docker-compose.yaml --env-file\t./custom/.env build";
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject any flags since both are already present with various whitespace
+ expect($customCommand)->toBe("docker compose\t-f=./custom/docker-compose.yaml --env-file\t./custom/.env build");
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->not->toContain('--env-file /artifacts/build-time.env');
+});
+
+// Tests for concatenated -f flag format (no space, no equals)
+
+it('detects -f flag in concatenated format -fvalue (bypass vector)', function () {
+ $customCommand = 'docker compose -f./custom/docker-compose.yaml build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f is concatenated with value
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f./custom/docker-compose.yaml build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+it('detects -f flag concatenated with path containing slash', function () {
+ $customCommand = 'docker compose -f/path/to/compose.yml up -d';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f is concatenated
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f/path/to/compose.yml up -d');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('-f/path/to/compose.yml');
+});
+
+it('detects -f flag concatenated at start of command', function () {
+ $customCommand = '-f./compose.yaml docker compose build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag since -f is already present (even at start)
+ expect($customCommand)->toBe('-f./compose.yaml docker compose --env-file /artifacts/build-time.env build');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+});
+
+it('detects concatenated -f flag with relative path', function () {
+ $customCommand = 'docker compose -f../docker-compose.prod.yaml build --no-cache';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Should NOT inject -f flag
+ expect($customCommand)->toBe('docker compose --env-file /artifacts/build-time.env -f../docker-compose.prod.yaml build --no-cache');
+ expect($customCommand)->not->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('-f../docker-compose.prod.yaml');
+});
+
+it('correctly injects when no -f flag is present (sanity check after concatenated fix)', function () {
+ $customCommand = 'docker compose build --no-cache';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/deployment-uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // SHOULD inject both flags
+ expect($customCommand)->toBe('docker compose -f /artifacts/deployment-uuid/docker-compose.yaml --env-file /artifacts/build-time.env build --no-cache');
+ expect($customCommand)->toContain('-f /artifacts/deployment-uuid/docker-compose.yaml');
+ expect($customCommand)->toContain('--env-file /artifacts/build-time.env');
+});
+
+// Edge case tests: First occurrence only replacement
+
+it('only replaces first docker compose occurrence in chained commands', function () {
+ $customCommand = 'docker compose pull && docker compose build';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Only the FIRST 'docker compose' should get the flags
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env pull && docker compose build');
+ expect($customCommand)->toContain('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env pull');
+ expect($customCommand)->toContain(' && docker compose build');
+ // Verify the second occurrence is NOT modified
+ expect(substr_count($customCommand, '-f /artifacts/uuid/docker-compose.yaml'))->toBe(1);
+ expect(substr_count($customCommand, '--env-file /artifacts/build-time.env'))->toBe(1);
+});
+
+it('does not modify docker compose string in echo statements', function () {
+ $customCommand = 'docker compose build && echo "docker compose finished successfully"';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Only the FIRST 'docker compose' (the command) should get flags, NOT the echo message
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build && echo "docker compose finished successfully"');
+ expect($customCommand)->toContain('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build');
+ expect($customCommand)->toContain('echo "docker compose finished successfully"');
+ // Verify echo message is NOT modified
+ expect(substr_count($customCommand, 'docker compose', 0))->toBe(2); // Two total occurrences
+ expect(substr_count($customCommand, '-f /artifacts/uuid/docker-compose.yaml'))->toBe(1); // Only first has flags
+});
+
+it('does not modify docker compose string in bash comments', function () {
+ $customCommand = 'docker compose build # This runs docker compose to build the image';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // Only the FIRST 'docker compose' (the command) should get flags, NOT the comment
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build # This runs docker compose to build the image');
+ expect($customCommand)->toContain('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build');
+ expect($customCommand)->toContain('# This runs docker compose to build the image');
+ // Verify comment is NOT modified
+ expect(substr_count($customCommand, 'docker compose', 0))->toBe(2); // Two total occurrences
+ expect(substr_count($customCommand, '-f /artifacts/uuid/docker-compose.yaml'))->toBe(1); // Only first has flags
+});
+
+// False positive prevention tests: Flags like -foo, -from, -feature should NOT be detected as -f
+
+it('injects -f flag when command contains -foo flag (not -f)', function () {
+ $customCommand = 'docker compose build --foo bar';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // SHOULD inject -f flag because -foo is NOT the -f flag
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build --foo bar');
+ expect($customCommand)->toContain('-f /artifacts/uuid/docker-compose.yaml');
+});
+
+it('injects -f flag when command contains --from flag (not -f)', function () {
+ $customCommand = 'docker compose build --from cache-image';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // SHOULD inject -f flag because --from is NOT the -f flag
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build --from cache-image');
+ expect($customCommand)->toContain('-f /artifacts/uuid/docker-compose.yaml');
+});
+
+it('injects -f flag when command contains -feature flag (not -f)', function () {
+ $customCommand = 'docker compose build -feature test';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // SHOULD inject -f flag because -feature is NOT the -f flag
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build -feature test');
+ expect($customCommand)->toContain('-f /artifacts/uuid/docker-compose.yaml');
+});
+
+it('injects -f flag when command contains -fast flag (not -f)', function () {
+ $customCommand = 'docker compose build -fast';
+
+ // Use the helper function
+ $customCommand = injectDockerComposeFlags($customCommand, '/artifacts/uuid/docker-compose.yaml', '/artifacts/build-time.env');
+
+ // SHOULD inject -f flag because -fast is NOT the -f flag
+ expect($customCommand)->toBe('docker compose -f /artifacts/uuid/docker-compose.yaml --env-file /artifacts/build-time.env build -fast');
+ expect($customCommand)->toContain('-f /artifacts/uuid/docker-compose.yaml');
+});
+
+// Path normalization tests for preview methods
+
+it('normalizes path when baseDirectory is root slash', function () {
+ $baseDirectory = '/';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Normalize baseDirectory to prevent double slashes
+ $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
+ $path = ".{$normalizedBase}{$composeLocation}";
+
+ expect($path)->toBe('./docker-compose.yaml');
+ expect($path)->not->toContain('//');
+});
+
+it('normalizes path when baseDirectory has trailing slash', function () {
+ $baseDirectory = '/backend/';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Normalize baseDirectory to prevent double slashes
+ $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
+ $path = ".{$normalizedBase}{$composeLocation}";
+
+ expect($path)->toBe('./backend/docker-compose.yaml');
+ expect($path)->not->toContain('//');
+});
+
+it('handles empty baseDirectory correctly', function () {
+ $baseDirectory = '';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Normalize baseDirectory to prevent double slashes
+ $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
+ $path = ".{$normalizedBase}{$composeLocation}";
+
+ expect($path)->toBe('./docker-compose.yaml');
+ expect($path)->not->toContain('//');
+});
+
+it('handles normal baseDirectory without trailing slash', function () {
+ $baseDirectory = '/backend';
+ $composeLocation = '/docker-compose.yaml';
+
+ // Normalize baseDirectory to prevent double slashes
+ $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
+ $path = ".{$normalizedBase}{$composeLocation}";
+
+ expect($path)->toBe('./backend/docker-compose.yaml');
+ expect($path)->not->toContain('//');
+});
+
+it('handles nested baseDirectory with trailing slash', function () {
+ $baseDirectory = '/app/backend/';
+ $composeLocation = '/docker-compose.prod.yaml';
+
+ // Normalize baseDirectory to prevent double slashes
+ $normalizedBase = $baseDirectory === '/' ? '' : rtrim($baseDirectory, '/');
+ $path = ".{$normalizedBase}{$composeLocation}";
+
+ expect($path)->toBe('./app/backend/docker-compose.prod.yaml');
+ expect($path)->not->toContain('//');
+});
+
+it('produces correct preview path with normalized baseDirectory', function () {
+ $testCases = [
+ ['baseDir' => '/', 'compose' => '/docker-compose.yaml', 'expected' => './docker-compose.yaml'],
+ ['baseDir' => '', 'compose' => '/docker-compose.yaml', 'expected' => './docker-compose.yaml'],
+ ['baseDir' => '/backend', 'compose' => '/docker-compose.yaml', 'expected' => './backend/docker-compose.yaml'],
+ ['baseDir' => '/backend/', 'compose' => '/docker-compose.yaml', 'expected' => './backend/docker-compose.yaml'],
+ ['baseDir' => '/app/src/', 'compose' => '/docker-compose.prod.yaml', 'expected' => './app/src/docker-compose.prod.yaml'],
+ ];
+
+ foreach ($testCases as $case) {
+ $normalizedBase = $case['baseDir'] === '/' ? '' : rtrim($case['baseDir'], '/');
+ $path = ".{$normalizedBase}{$case['compose']}";
+
+ expect($path)->toBe($case['expected'], "Failed for baseDir: {$case['baseDir']}");
+ expect($path)->not->toContain('//', "Double slash found for baseDir: {$case['baseDir']}");
+ }
+});
diff --git a/tests/Unit/Livewire/ApplicationGeneralPreviewTest.php b/tests/Unit/Livewire/ApplicationGeneralPreviewTest.php
new file mode 100644
index 000000000..cea05a998
--- /dev/null
+++ b/tests/Unit/Livewire/ApplicationGeneralPreviewTest.php
@@ -0,0 +1,156 @@
+makePartial();
+ $component->baseDirectory = '/';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomBuildCommand = 'docker compose build';
+
+ $preview = $component->getDockerComposeBuildCommandPreviewProperty();
+
+ // Should be ./docker-compose.yaml, NOT .//docker-compose.yaml
+ expect($preview)
+ ->toBeString()
+ ->toContain('./docker-compose.yaml')
+ ->not->toContain('.//');
+});
+
+it('correctly formats build command preview with nested baseDirectory', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/backend';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomBuildCommand = 'docker compose build';
+
+ $preview = $component->getDockerComposeBuildCommandPreviewProperty();
+
+ // Should be ./backend/docker-compose.yaml
+ expect($preview)
+ ->toBeString()
+ ->toContain('./backend/docker-compose.yaml');
+});
+
+it('correctly formats build command preview with deeply nested baseDirectory', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/apps/api/backend';
+ $component->dockerComposeLocation = '/docker-compose.prod.yaml';
+ $component->dockerComposeCustomBuildCommand = 'docker compose build';
+
+ $preview = $component->getDockerComposeBuildCommandPreviewProperty();
+
+ expect($preview)
+ ->toBeString()
+ ->toContain('./apps/api/backend/docker-compose.prod.yaml');
+});
+
+it('uses BUILD_TIME_ENV_PATH constant instead of hardcoded path in build command preview', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomBuildCommand = 'docker compose build';
+
+ $preview = $component->getDockerComposeBuildCommandPreviewProperty();
+
+ // Should contain the path from the constant
+ expect($preview)
+ ->toBeString()
+ ->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_PATH);
+});
+
+it('returns empty string for build command preview when no custom build command is set', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/backend';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomBuildCommand = null;
+
+ $preview = $component->getDockerComposeBuildCommandPreviewProperty();
+
+ expect($preview)->toBe('');
+});
+
+it('prevents double slashes in start command preview when baseDirectory is root', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomStartCommand = 'docker compose up -d';
+
+ $preview = $component->getDockerComposeStartCommandPreviewProperty();
+
+ // Should be ./docker-compose.yaml, NOT .//docker-compose.yaml
+ expect($preview)
+ ->toBeString()
+ ->toContain('./docker-compose.yaml')
+ ->not->toContain('.//');
+});
+
+it('correctly formats start command preview with nested baseDirectory', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/frontend';
+ $component->dockerComposeLocation = '/compose.yaml';
+ $component->dockerComposeCustomStartCommand = 'docker compose up -d';
+
+ $preview = $component->getDockerComposeStartCommandPreviewProperty();
+
+ expect($preview)
+ ->toBeString()
+ ->toContain('./frontend/compose.yaml');
+});
+
+it('uses workdir env placeholder in start command preview', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomStartCommand = 'docker compose up -d';
+
+ $preview = $component->getDockerComposeStartCommandPreviewProperty();
+
+ // Start command should use {workdir}/.env, not build-time env
+ expect($preview)
+ ->toBeString()
+ ->toContain('{workdir}/.env')
+ ->not->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_PATH);
+});
+
+it('returns empty string for start command preview when no custom start command is set', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/backend';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomStartCommand = null;
+
+ $preview = $component->getDockerComposeStartCommandPreviewProperty();
+
+ expect($preview)->toBe('');
+});
+
+it('handles baseDirectory with trailing slash correctly in build command', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/backend/';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomBuildCommand = 'docker compose build';
+
+ $preview = $component->getDockerComposeBuildCommandPreviewProperty();
+
+ // rtrim should remove trailing slash to prevent double slashes
+ expect($preview)
+ ->toBeString()
+ ->toContain('./backend/docker-compose.yaml')
+ ->not->toContain('backend//');
+});
+
+it('handles baseDirectory with trailing slash correctly in start command', function () {
+ $component = Mockery::mock(General::class)->makePartial();
+ $component->baseDirectory = '/backend/';
+ $component->dockerComposeLocation = '/docker-compose.yaml';
+ $component->dockerComposeCustomStartCommand = 'docker compose up -d';
+
+ $preview = $component->getDockerComposeStartCommandPreviewProperty();
+
+ // rtrim should remove trailing slash to prevent double slashes
+ expect($preview)
+ ->toBeString()
+ ->toContain('./backend/docker-compose.yaml')
+ ->not->toContain('backend//');
+});