@endif
- @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
-
-
- Hardcoded variables are not shown here.
-
- {{-- If you would like to add a variable, you must add it to
- your compose file.
--}}
- @endif
@if ($view === 'normal')
@@ -61,31 +48,48 @@
Environment (secrets) variables for Production.
@forelse ($this->environmentVariables as $env)
-
+
@empty
No environment variables found.
@endforelse
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariables as $index => $env)
+
+ @endforeach
+ @endif
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
Preview Deployments Environment Variables
Environment (secrets) variables for Preview Deployments.
@foreach ($this->environmentVariablesPreview as $env)
-
+
@endforeach
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
+
+ @endforeach
+ @endif
@endif
@else
@endif
-
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
new file mode 100644
index 000000000..9158d127e
--- /dev/null
+++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
@@ -0,0 +1,31 @@
+
+
+
+
+ Hardcoded env
+
+ @if($serviceName)
+
+ Service: {{ $serviceName }}
+
+ @endif
+
+
+
+
+ @if($value !== null && $value !== '')
+
+ @else
+
+ @endif
+
+ @if($comment)
+
+ @endif
+
+
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index 68e1d7e7d..86faeeeb4 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -15,13 +15,21 @@
@can('delete', $this->env)
@endcan
@can('update', $this->env)
+
@if (!$is_redis_credential)
@@ -32,28 +40,40 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
-
- @endif
-
- @if (!$env->is_nixpacks)
-
- @if ($is_multiline === false)
-
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if (!$env->is_nixpacks)
+
+ @endif
+
+ @if (!$isMagicVariable)
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@@ -72,82 +92,95 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
- @endif
-
-
- @if ($is_multiline === false)
-
+
+ @if (!$isMagicVariable)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@endif
+
+
+
@endcan
@else
@can('update', $this->env)
@if ($isDisabled)
+
+
+
+
+ @if ($is_shared)
+
+ @endif
+
+ @if (!$isMagicVariable)
+
+ @endif
+
+ @else
+
+
+ @if ($is_multiline)
+
+
+ @else
+
+
+ @endif
+ @if ($is_shared)
+
+ @endif
+
+
+
+ @endif
+ @else
+
-
+
@if ($is_shared)
@endif
- @else
-
- @if ($is_multiline)
-
-
- @else
-
-
- @endif
- @if ($is_shared)
-
- @endif
-
- @endif
- @else
-
-
-
- @if ($is_shared)
-
+ @if (!$isMagicVariable)
+
@endif
@endcan
@@ -162,28 +195,40 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
-
- @endif
-
- @if (!$env->is_nixpacks)
-
- @if ($is_multiline === false)
-
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if (!$env->is_nixpacks)
+
+ @endif
+
+ @if (!$isMagicVariable)
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@@ -191,12 +236,13 @@
@endif
-
- @if ($isDisabled)
+ @if (!$isMagicVariable)
+
+ @if ($isDisabled)
Update
Lock
- Update
Lock
-
- @endif
-
+ @endif
+
+ @endif
@else
@@ -224,27 +271,37 @@
-
-
- @else
- @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
+ @if ($is_shared)
+
@else
- @if (!$env->is_nixpacks)
+ @if ($isSharedVariable)
+ @if (!$isMagicVariable)
+
+ @endif
+ @else
- @endif
-
-
- @if ($is_multiline === false)
-
+
+ @if (!$isMagicVariable)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@@ -255,4 +312,4 @@
@endif
-
+
\ No newline at end of file
diff --git a/svgs/cells.svg b/svgs/cells.svg
new file mode 100644
index 000000000..f82e53ec7
--- /dev/null
+++ b/svgs/cells.svg
@@ -0,0 +1,38 @@
+
+
+
+
diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml
index 89f1692b1..584d50872 100644
--- a/templates/compose/grist.yaml
+++ b/templates/compose/grist.yaml
@@ -3,16 +3,16 @@
# category: productivity
# tags: lowcode, nocode, spreadsheet, database, relational
# logo: svgs/grist.svg
-# port: 443
+# port: 8484
services:
grist:
image: gristlabs/grist:latest
environment:
- - SERVICE_URL_GRIST_443
+ - SERVICE_URL_GRIST_8484
- APP_HOME_URL=${SERVICE_URL_GRIST}
- APP_DOC_URL=${SERVICE_URL_GRIST}
- - GRIST_DOMAIN=${SERVICE_URL_GRIST}
+ - GRIST_DOMAIN=${SERVICE_FQDN_GRIST}
- TZ=${TZ:-UTC}
- GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false}
- GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true}
@@ -20,7 +20,7 @@ services:
- GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix}
- GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials}
- GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist}
- - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com}
+ - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?}
- GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true}
- GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST}
- GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email}
@@ -37,7 +37,7 @@ services:
- TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db}
- TYPEORM_USERNAME=${SERVICE_USER_POSTGRES}
- TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- - TYPEORM_HOST=${TYPEORM_HOST}
+ - TYPEORM_HOST=${TYPEORM_HOST:-postgres}
- TYPEORM_PORT=${TYPEORM_PORT:-5432}
- TYPEORM_LOGGING=${TYPEORM_LOGGING:-false}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml
index 1143235e5..49a393624 100644
--- a/templates/compose/minio-community-edition.yaml
+++ b/templates/compose/minio-community-edition.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images
# slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs.
# category: storage
diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml
new file mode 100644
index 000000000..77a24a533
--- /dev/null
+++ b/templates/compose/pydio-cells.yml
@@ -0,0 +1,33 @@
+# documentation: https://docs.pydio.com/
+# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.
+# tags: storage
+# logo: svgs/cells.svg
+# port: 8080
+
+services:
+ cells:
+ image: pydio/cells:4.4
+ environment:
+ - SERVICE_URL_CELLS_8080
+ - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS}
+ - CELLS_SITE_NO_TLS=1
+ volumes:
+ - cells_data:/var/cells
+ mariadb:
+ image: 'mariadb:11'
+ volumes:
+ - mysql_data:/var/lib/mysql
+ environment:
+ - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT}
+ - MYSQL_DATABASE=${MYSQL_DATABASE:-cells}
+ - MYSQL_USER=${SERVICE_USER_MYSQL}
+ - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL}
+ healthcheck:
+ test:
+ - CMD
+ - healthcheck.sh
+ - '--connect'
+ - '--innodb_initialized'
+ interval: 10s
+ timeout: 20s
+ retries: 5
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index a9f653460..e343e6293 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -1891,7 +1891,7 @@
"grist": {
"documentation": "https://support.getgrist.com/?utm_source=coolify.io",
"slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.",
- "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
+ "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
"tags": [
"lowcode",
"nocode",
@@ -1902,7 +1902,7 @@
"category": "productivity",
"logo": "svgs/grist.svg",
"minversion": "0.0.0",
- "port": "443"
+ "port": "8484"
},
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
@@ -2830,21 +2830,6 @@
"minversion": "0.0.0",
"port": "8080"
},
- "minio-community-edition": {
- "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io",
- "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.",
- "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "object",
- "storage",
- "server",
- "s3",
- "api"
- ],
- "category": "storage",
- "logo": "svgs/minio.svg",
- "minversion": "0.0.0"
- },
"mixpost": {
"documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io",
"slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.",
@@ -5246,5 +5231,17 @@
"logo": "svgs/marimo.svg",
"minversion": "0.0.0",
"port": "8080"
+ },
+ "pydio-cells": {
+ "documentation": "https://docs.pydio.com/?utm_source=coolify.io",
+ "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.",
+ "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=",
+ "tags": [
+ "storage"
+ ],
+ "category": null,
+ "logo": "svgs/cells.svg",
+ "minversion": "0.0.0",
+ "port": "8080"
}
}
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 580834a21..2a08e7b4b 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -1891,7 +1891,7 @@
"grist": {
"documentation": "https://support.getgrist.com/?utm_source=coolify.io",
"slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.",
- "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK",
+ "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=",
"tags": [
"lowcode",
"nocode",
@@ -1902,7 +1902,7 @@
"category": "productivity",
"logo": "svgs/grist.svg",
"minversion": "0.0.0",
- "port": "443"
+ "port": "8484"
},
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
@@ -2830,21 +2830,6 @@
"minversion": "0.0.0",
"port": "8080"
},
- "minio-community-edition": {
- "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io",
- "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.",
- "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "object",
- "storage",
- "server",
- "s3",
- "api"
- ],
- "category": "storage",
- "logo": "svgs/minio.svg",
- "minversion": "0.0.0"
- },
"mixpost": {
"documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io",
"slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.",
@@ -5246,5 +5231,17 @@
"logo": "svgs/marimo.svg",
"minversion": "0.0.0",
"port": "8080"
+ },
+ "pydio-cells": {
+ "documentation": "https://docs.pydio.com/?utm_source=coolify.io",
+ "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.",
+ "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==",
+ "tags": [
+ "storage"
+ ],
+ "category": null,
+ "logo": "svgs/cells.svg",
+ "minversion": "0.0.0",
+ "port": "8080"
}
}
diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php
new file mode 100644
index 000000000..f62ad6650
--- /dev/null
+++ b/tests/Feature/ApplicationRollbackTest.php
@@ -0,0 +1,88 @@
+application = new Application;
+ $this->application->forceFill([
+ 'uuid' => 'test-app-uuid',
+ 'git_commit_sha' => 'HEAD',
+ ]);
+
+ $settings = new ApplicationSetting;
+ $settings->is_git_shallow_clone_enabled = false;
+ $settings->is_git_submodules_enabled = false;
+ $settings->is_git_lfs_enabled = false;
+ $this->application->setRelation('settings', $settings);
+ });
+
+ test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () {
+ $rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $rollbackCommit
+ );
+
+ expect($result)->toContain($rollbackCommit);
+ });
+
+ test('setGitImportSettings with shallow clone fetches specific commit', function () {
+ $this->application->settings->is_git_shallow_clone_enabled = true;
+
+ $rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $rollbackCommit
+ );
+
+ expect($result)
+ ->toContain('git fetch --depth=1 origin')
+ ->toContain($rollbackCommit);
+ });
+
+ test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () {
+ $this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ );
+
+ expect($result)->toContain('def789abc012def789abc012def789abc012def7');
+ });
+
+ test('setGitImportSettings escapes shell metacharacters in commit parameter', function () {
+ $maliciousCommit = 'abc123; rm -rf /';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $maliciousCommit
+ );
+
+ // escapeshellarg wraps the value in single quotes, neutralizing metacharacters
+ expect($result)
+ ->toContain("checkout 'abc123; rm -rf /'")
+ ->not->toContain('checkout abc123; rm -rf /');
+ });
+
+ test('setGitImportSettings does not append checkout when commit is HEAD', function () {
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ );
+
+ expect($result)->not->toContain('advice.detachedHead=false checkout');
+ });
+});
diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php
index 5d9dcd174..74bff2043 100644
--- a/tests/Feature/DockerCustomCommandsTest.php
+++ b/tests/Feature/DockerCustomCommandsTest.php
@@ -198,3 +198,20 @@
'entrypoint' => 'python -c "print(\"hi\")"',
]);
});
+
+test('ConvertIp6', function () {
+ $input = '--ip6 2001:db8::1';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'ip6' => ['2001:db8::1'],
+ ]);
+});
+
+test('ConvertIpAndIp6Together', function () {
+ $input = '--ip 172.20.0.5 --ip6 2001:db8::1';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'ip' => ['172.20.0.5'],
+ 'ip6' => ['2001:db8::1'],
+ ]);
+});
diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php
new file mode 100644
index 000000000..e7f9a07fb
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableCommentTest.php
@@ -0,0 +1,283 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->actingAs($this->user);
+});
+
+test('environment variable can be created with comment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'This is a test environment variable',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('This is a test environment variable');
+ expect($env->key)->toBe('TEST_VAR');
+ expect($env->value)->toBe('test_value');
+});
+
+test('environment variable comment is optional', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+ expect($env->key)->toBe('TEST_VAR');
+});
+
+test('environment variable comment can be updated', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->comment = 'Updated comment';
+ $env->save();
+
+ $env->refresh();
+ expect($env->comment)->toBe('Updated comment');
+});
+
+test('environment variable comment is preserved when updating value', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Important variable for testing',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->value = 'new_value';
+ $env->save();
+
+ $env->refresh();
+ expect($env->value)->toBe('new_value');
+ expect($env->comment)->toBe('Important variable for testing');
+});
+
+test('environment variable comment is copied to preview environment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The model's created() event listener automatically creates a preview version
+ $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
+ ->where('resourceable_id', $this->application->id)
+ ->where('is_preview', true)
+ ->first();
+
+ expect($previewEnv)->not->toBeNull();
+ expect($previewEnv->comment)->toBe('Test comment');
+});
+
+test('parseEnvFormatToArray preserves values without inline comments', function () {
+ $input = "KEY1=value1\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('developer view format does not break with comment-like values', function () {
+ // Values that contain # but shouldn't be treated as comments when quoted
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'HASH_VAR',
+ 'value' => 'value_with_#_in_it',
+ 'comment' => 'Contains hash symbol',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env1->value)->toBe('value_with_#_in_it');
+ expect($env1->comment)->toBe('Contains hash symbol');
+});
+
+test('environment variable comment can store up to 256 characters', function () {
+ $comment = str_repeat('a', 256);
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(256);
+});
+
+test('environment variable comment cannot exceed 256 characters via Livewire', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $longComment = str_repeat('a', 257);
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
+ ->set('comment', $longComment)
+ ->call('submit')
+ ->assertHasErrors(['comment' => 'max']);
+});
+
+test('bulk update preserves existing comments when no inline comment provided', function () {
+ // Create existing variable with a manually-entered comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://old-host',
+ 'comment' => 'Production database',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User switches to Developer view and pastes new value without inline comment
+ $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('postgres://new-host');
+
+ // The manually-entered comment should be PRESERVED
+ expect($env->comment)->toBe('Production database');
+});
+
+test('bulk update overwrites existing comments when inline comment provided', function () {
+ // Create existing variable with a comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'old-key',
+ 'comment' => 'Old comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User pastes new value WITH inline comment
+ $bulkContent = 'API_KEY=new-key #Updated production key';
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('new-key');
+
+ // The comment should be OVERWRITTEN with the inline comment
+ expect($env->comment)->toBe('Updated production key');
+});
+
+test('bulk update handles mixed inline and stored comments correctly', function () {
+ // Create two variables with comments
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITH_COMMENT',
+ 'value' => 'value1',
+ 'comment' => 'Existing comment 1',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env2 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITHOUT_COMMENT',
+ 'value' => 'value2',
+ 'comment' => 'Existing comment 2',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Bulk paste: one with inline comment, one without
+ $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh both variables
+ $env1->refresh();
+ $env2->refresh();
+
+ // First variable: comment should be overwritten with inline comment
+ expect($env1->value)->toBe('new_value1');
+ expect($env1->comment)->toBe('New inline comment');
+
+ // Second variable: comment should be preserved
+ expect($env2->value)->toBe('new_value2');
+ expect($env2->comment)->toBe('Existing comment 2');
+});
+
+test('bulk update creates new variables with inline comments', function () {
+ // Bulk paste creates new variables, some with inline comments
+ $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Check that variables were created with correct comments
+ $var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+
+ expect($var1->value)->toBe('value1');
+ expect($var1->comment)->toBe('Comment for var1');
+
+ expect($var2->value)->toBe('value2');
+ expect($var2->comment)->toBeNull();
+
+ expect($var3->value)->toBe('value3');
+ expect($var3->comment)->toBe('Comment for var3');
+});
diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
new file mode 100644
index 000000000..f2650fdc7
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
@@ -0,0 +1,217 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create();
+
+ $this->actingAs($this->user);
+});
+
+test('all fillable fields can be mass assigned', function () {
+ $data = [
+ 'key' => 'TEST_KEY',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_literal' => true,
+ 'is_multiline' => true,
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'is_shown_once' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ];
+
+ $env = EnvironmentVariable::create($data);
+
+ expect($env->key)->toBe('TEST_KEY');
+ expect($env->value)->toBe('test_value');
+ expect($env->comment)->toBe('Test comment');
+ expect($env->is_literal)->toBeTrue();
+ expect($env->is_multiline)->toBeTrue();
+ expect($env->is_preview)->toBeFalse();
+ expect($env->is_runtime)->toBeTrue();
+ expect($env->is_buildtime)->toBeFalse();
+ expect($env->is_shown_once)->toBeFalse();
+ expect($env->resourceable_type)->toBe(Application::class);
+ expect($env->resourceable_id)->toBe($this->application->id);
+});
+
+test('comment field can be mass assigned with null', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => null,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+});
+
+test('comment field can be mass assigned with empty string', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => '',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('');
+});
+
+test('comment field can be mass assigned with long text', function () {
+ $comment = str_repeat('This is a long comment. ', 10);
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(strlen($comment));
+});
+
+test('all boolean fields default correctly when not provided', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Boolean fields can be null or false depending on database defaults
+ expect($env->is_multiline)->toBeIn([false, null]);
+ expect($env->is_preview)->toBeIn([false, null]);
+ expect($env->is_runtime)->toBeIn([false, null]);
+ expect($env->is_buildtime)->toBeIn([false, null]);
+ expect($env->is_shown_once)->toBeIn([false, null]);
+});
+
+test('value field is properly encrypted when mass assigned', function () {
+ $plainValue = 'secret_value_123';
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'SECRET_KEY',
+ 'value' => $plainValue,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Value should be decrypted when accessed via model
+ expect($env->value)->toBe($plainValue);
+
+ // Verify it's actually encrypted in the database
+ $rawValue = \DB::table('environment_variables')
+ ->where('id', $env->id)
+ ->value('value');
+
+ expect($rawValue)->not->toBe($plainValue);
+ expect($rawValue)->not->toBeNull();
+});
+
+test('key field is trimmed and spaces replaced with underscores', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => ' TEST KEY WITH SPACES ',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
+});
+
+test('version field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'version' => '1.2.3',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The booted() method sets version automatically, so it will be the current version
+ expect($env->version)->not->toBeNull();
+});
+
+test('mass assignment works with update method', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->update([
+ 'value' => 'updated_value',
+ 'comment' => 'Updated comment',
+ 'is_literal' => true,
+ ]);
+
+ $env->refresh();
+
+ expect($env->value)->toBe('updated_value');
+ expect($env->comment)->toBe('Updated comment');
+ expect($env->is_literal)->toBeTrue();
+});
+
+test('protected attributes cannot be mass assigned', function () {
+ $customDate = '2020-01-01 00:00:00';
+
+ $env = EnvironmentVariable::create([
+ 'id' => 999999,
+ 'uuid' => 'custom-uuid',
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ 'created_at' => $customDate,
+ 'updated_at' => $customDate,
+ ]);
+
+ // id should be auto-generated, not 999999
+ expect($env->id)->not->toBe(999999);
+
+ // uuid should be auto-generated, not 'custom-uuid'
+ expect($env->uuid)->not->toBe('custom-uuid');
+
+ // Timestamps should be current, not 2020
+ expect($env->created_at->year)->toBe(now()->year);
+});
+
+test('order field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'order' => 5,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->order)->toBe(5);
+});
+
+test('is_shared field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'is_shared' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Note: is_shared is also computed via accessor, but can be mass assigned
+ expect($env->is_shared)->not->toBeNull();
+});
diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php
index fbd6e383e..741082cff 100644
--- a/tests/Feature/ScheduledTaskApiTest.php
+++ b/tests/Feature/ScheduledTaskApiTest.php
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
+use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
@@ -15,6 +16,9 @@
uses(RefreshDatabase::class);
beforeEach(function () {
+ // ApiAllowed middleware requires InstanceSettings with id=0
+ InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
+
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -25,12 +29,14 @@
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
- $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ // Server::booted() auto-creates a StandaloneDocker, reuse it
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ // Project::booted() auto-creates a 'production' Environment, reuse it
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
- $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->environment = $this->project->environments()->first();
});
-function authHeaders($bearerToken): array
+function scheduledTaskAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
@@ -46,7 +52,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -66,7 +72,7 @@ function authHeaders($bearerToken): array
'name' => 'Test Task',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -75,7 +81,7 @@ function authHeaders($bearerToken): array
});
test('returns 404 for unknown application uuid', function () {
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
$response->assertStatus(404);
@@ -90,7 +96,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Backup',
'command' => 'php artisan backup',
@@ -116,7 +122,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'command' => 'echo test',
'frequency' => '* * * * *',
@@ -132,7 +138,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -150,7 +156,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -168,7 +174,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -199,7 +205,7 @@ function authHeaders($bearerToken): array
'name' => 'Old Name',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
'name' => 'New Name',
]);
@@ -215,7 +221,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
'name' => 'Test',
]);
@@ -237,7 +243,7 @@ function authHeaders($bearerToken): array
'team_id' => $this->team->id,
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
@@ -253,7 +259,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
$response->assertStatus(404);
@@ -279,7 +285,7 @@ function authHeaders($bearerToken): array
'message' => 'OK',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
$response->assertStatus(200);
@@ -294,7 +300,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
$response->assertStatus(404);
@@ -316,7 +322,7 @@ function authHeaders($bearerToken): array
'name' => 'Service Task',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -332,7 +338,7 @@ function authHeaders($bearerToken): array
'environment_id' => $this->environment->id,
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
'name' => 'Service Backup',
'command' => 'pg_dump',
@@ -356,7 +362,7 @@ function authHeaders($bearerToken): array
'team_id' => $this->team->id,
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
diff --git a/tests/Unit/ApplicationComposeEditorLoadTest.php b/tests/Unit/ApplicationComposeEditorLoadTest.php
index c0c8660e1..305bc72a2 100644
--- a/tests/Unit/ApplicationComposeEditorLoadTest.php
+++ b/tests/Unit/ApplicationComposeEditorLoadTest.php
@@ -3,7 +3,6 @@
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
-use Mockery;
/**
* Unit test to verify docker_compose_raw is properly synced to the Livewire component
diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php
index 1babdcf49..241364a93 100644
--- a/tests/Unit/ApplicationPortDetectionTest.php
+++ b/tests/Unit/ApplicationPortDetectionTest.php
@@ -11,7 +11,6 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
-use Mockery;
beforeEach(function () {
// Clean up Mockery after each test
diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php
index b38a6aa8e..0b33db470 100644
--- a/tests/Unit/ContainerHealthStatusTest.php
+++ b/tests/Unit/ContainerHealthStatusTest.php
@@ -1,7 +1,6 @@
getFillable();
+
+ // Core identification
+ expect($fillable)->toContain('key')
+ ->toContain('value')
+ ->toContain('comment');
+
+ // Polymorphic relationship
+ expect($fillable)->toContain('resourceable_type')
+ ->toContain('resourceable_id');
+
+ // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
+ expect($fillable)->toContain('is_preview')
+ ->toContain('is_multiline')
+ ->toContain('is_literal')
+ ->toContain('is_runtime')
+ ->toContain('is_buildtime')
+ ->toContain('is_shown_once')
+ ->toContain('is_shared')
+ ->toContain('is_required');
+
+ // Metadata
+ expect($fillable)->toContain('version')
+ ->toContain('order');
+});
+
+test('is_required can be mass assigned', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['is_required' => true]);
+
+ expect($model->is_required)->toBeTrue();
+});
+
+test('all boolean flags can be mass assigned', function () {
+ $booleanFlags = [
+ 'is_preview',
+ 'is_multiline',
+ 'is_literal',
+ 'is_runtime',
+ 'is_buildtime',
+ 'is_shown_once',
+ 'is_required',
+ ];
+
+ $model = new EnvironmentVariable;
+ $model->fill(array_fill_keys($booleanFlags, true));
+
+ foreach ($booleanFlags as $flag) {
+ expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
+ }
+
+ // is_shared has a computed getter derived from the value field,
+ // so verify it's fillable via the underlying attributes instead
+ $model2 = new EnvironmentVariable;
+ $model2->fill(['is_shared' => true]);
+ expect($model2->getAttributes())->toHaveKey('is_shared');
+});
+
+test('non-fillable fields are rejected by mass assignment', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
+
+ expect($model->id)->toBeNull()
+ ->and($model->uuid)->toBeNull()
+ ->and($model->created_at)->toBeNull();
+});
diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php
new file mode 100644
index 000000000..ae85ba45f
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php
@@ -0,0 +1,141 @@
+shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_NAME variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_NAME');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('regular variables are not magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('DATABASE_URL');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isDisabled)->toBeFalse();
+});
+
+test('locked variables are not magic variables unless they start with SERVICE_', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SECRET_KEY');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(true);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isLocked)->toBeTrue();
+});
+
+test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB_5432');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API_8080');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
new file mode 100644
index 000000000..a52d7dba5
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
@@ -0,0 +1,351 @@
+toBe('');
+});
+
+test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
+ $split = splitOnOperatorOutsideNested(':-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent handles double opening brace', function () {
+ $result = extractBalancedBraceContent('${{VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('{VAR}');
+});
+
+test('extractBalancedBraceContent returns null for empty string', function () {
+ $result = extractBalancedBraceContent('', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just dollar sign', function () {
+ $result = extractBalancedBraceContent('$', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just opening brace', function () {
+ $result = extractBalancedBraceContent('{', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just closing brace', function () {
+ $result = extractBalancedBraceContent('}', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles extra closing brace', function () {
+ $result = extractBalancedBraceContent('${VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+test('extractBalancedBraceContent returns null for unclosed with no content', function () {
+ $result = extractBalancedBraceContent('${', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
+
+ assertNull($result);
+});
+
+test('replaceVariables handles empty braces gracefully', function () {
+ $result = replaceVariables('${}');
+
+ expect($result->value())->toBe('');
+});
+
+test('replaceVariables handles double braces gracefully', function () {
+ $result = replaceVariables('${{VAR}}');
+
+ expect($result->value())->toBe('{VAR}');
+});
+
+// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
+
+test('extractBalancedBraceContent finds consecutive variables', function () {
+ $str = '${A}${B}';
+
+ $first = extractBalancedBraceContent($str, 0);
+ assertNotNull($first);
+ expect($first['content'])->toBe('A');
+
+ $second = extractBalancedBraceContent($str, $first['end'] + 1);
+ assertNotNull($second);
+ expect($second['content'])->toBe('B');
+});
+
+test('splitOnOperatorOutsideNested handles URL with port in default', function () {
+ $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('http://host:8080/path');
+});
+
+test('splitOnOperatorOutsideNested handles equals sign in default', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('key=value&foo=bar');
+});
+
+test('splitOnOperatorOutsideNested handles dashes in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-value-with-dashes');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('value-with-dashes');
+});
+
+test('splitOnOperatorOutsideNested handles question mark in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-what?');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('what?');
+});
+
+test('extractBalancedBraceContent handles variable with digits', function () {
+ $result = extractBalancedBraceContent('${VAR123}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR123');
+});
+
+test('extractBalancedBraceContent handles long variable name', function () {
+ $longName = str_repeat('A', 200);
+ $result = extractBalancedBraceContent('${'.$longName.'}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe($longName);
+});
+
+test('splitOnOperatorOutsideNested returns null for empty string', function () {
+ $split = splitOnOperatorOutsideNested('');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
+ $split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('_MY_VAR_')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent with startPos beyond string length', function () {
+ $result = extractBalancedBraceContent('${VAR}', 100);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles brace in middle of text', function () {
+ $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
+
+test('extractBalancedBraceContent handles four levels of nesting', function () {
+ $input = '${A:-${B:-${C:-${D}}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
+});
+
+test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
+ $content = 'A:-${B:-${C:-${D}}}';
+ $split = splitOnOperatorOutsideNested($content);
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-${C:-${D}}}');
+
+ // Verify second level
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ assertNotNull($nested);
+ $split2 = splitOnOperatorOutsideNested($nested['content']);
+ assertNotNull($split2);
+ expect($split2['variable'])->toBe('B')
+ ->and($split2['default'])->toBe('${C:-${D}}');
+});
+
+test('multiple variables at same depth in default', function () {
+ $input = '${A:-${B}/${C}/${D}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['default'])->toBe('${B}/${C}/${D}');
+
+ // Verify all three nested variables can be found
+ $default = $split['default'];
+ $vars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $vars[] = $nested['content'];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($vars)->toBe(['B', 'C', 'D']);
+});
+
+test('nested with mixed operators', function () {
+ $input = '${A:-${B:?required}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:?required}');
+
+ // Inner variable uses :? operator
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ expect($innerSplit['variable'])->toBe('B')
+ ->and($innerSplit['operator'])->toBe(':?')
+ ->and($innerSplit['default'])->toBe('required');
+});
+
+test('nested variable without default as default', function () {
+ $input = '${A:-${B}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${B}');
+
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ assertNull($innerSplit);
+ expect($nested['content'])->toBe('B');
+});
+
+// ─── Backwards Compatibility ───────────────────────────────────────────────────
+
+test('replaceVariables with brace format without dollar sign', function () {
+ $result = replaceVariables('{MY_VAR}');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with truncated brace format', function () {
+ $result = replaceVariables('{MY_VAR');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with plain string returns unchanged', function () {
+ $result = replaceVariables('plain_value');
+
+ expect($result->value())->toBe('plain_value');
+});
+
+test('replaceVariables preserves full content for variable with default', function () {
+ $result = replaceVariables('${DB_HOST:-localhost}');
+
+ expect($result->value())->toBe('DB_HOST:-localhost');
+});
+
+test('replaceVariables preserves nested content for variable with nested default', function () {
+ $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
+
+ expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
+});
+
+test('replaceVariables with brace format containing default falls back gracefully', function () {
+ $result = replaceVariables('{VAR:-default}');
+
+ expect($result->value())->toBe('VAR:-default');
+});
+
+test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-val-ue');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':-')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('val-ue');
+});
+
+test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error?');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':?')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('error?');
+});
+
+test('full round trip: extract, split, and resolve nested variables', function () {
+ $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
+
+ // Step 1: Extract outer content
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+ expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 2: Split on outer operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['variable'])->toBe('APP_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 3: Find all nested variables in default
+ $default = $split['default'];
+ $nestedVars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+ $nestedVars[] = [
+ 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
+ 'default' => $innerSplit !== null ? $innerSplit['default'] : null,
+ ];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($nestedVars)->toHaveCount(2)
+ ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
+ ->and($nestedVars[0]['default'])->toBeNull()
+ ->and($nestedVars[1]['name'])->toBe('API_VERSION')
+ ->and($nestedVars[1]['default'])->toBe('2');
+});
diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
new file mode 100644
index 000000000..8d8caacaf
--- /dev/null
+++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
@@ -0,0 +1,147 @@
+toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe('3000')
+ ->and($result[1]['service_name'])->toBe('app');
+});
+
+test('extracts environment variables with inline comments', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production # Production environment
+ - DEBUG=false # Disable debug mode
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['comment'])->toBe('Production environment')
+ ->and($result[1]['comment'])->toBe('Disable debug mode');
+});
+
+test('handles multiple services', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - APP_ENV=prod
+ db:
+ environment:
+ - POSTGRES_DB=mydb
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('APP_ENV')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('POSTGRES_DB')
+ ->and($result[1]['service_name'])->toBe('db');
+});
+
+test('handles associative array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
+});
+
+test('handles environment variables without values', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - API_KEY
+ - DEBUG=false
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('API_KEY')
+ ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
+ ->and($result[1]['key'])->toBe('DEBUG')
+ ->and($result[1]['value'])->toBe('false');
+});
+
+test('returns empty collection for malformed YAML', function () {
+ $yaml = 'invalid: yaml: content::: [[[';
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection for empty compose file', function () {
+ $result = extractHardcodedEnvironmentVariables('');
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when no services defined', function () {
+ $yaml = <<<'YAML'
+version: '3.8'
+networks:
+ default:
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when service has no environment section', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ image: nginx
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('handles mixed associative and array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ // Mixed format is invalid YAML and returns empty collection
+ expect($result)->toBeEmpty();
+});
diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
new file mode 100644
index 000000000..4300b3abf
--- /dev/null
+++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
@@ -0,0 +1,334 @@
+toBe([]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # This is a comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - FOO=bar # This is a comment
+ - BAZ=qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ COLOR: "#FF0000" # hex color code
+ DB_URL: "postgres://user:pass#123@localhost" # database URL
+ PLAIN: value # no quotes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'COLOR' => 'hex color code',
+ 'DB_URL' => 'database URL',
+ 'PLAIN' => 'no quotes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ PASSWORD: 'secret#123' # my password
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'PASSWORD' => 'my password',
+ ]);
+});
+
+test('extractYamlEnvironmentComments skips full-line comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ # This is a full line comment
+ FOO: bar # This is an inline comment
+ # Another full line comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is an inline comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles multiple services', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ WEB_PORT: 8080 # web server port
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: admin # database admin user
+ POSTGRES_PASSWORD: secret
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'WEB_PORT' => 'web server port',
+ 'POSTGRES_USER' => 'database admin user',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables without values', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DEBUG # enable debug mode
+ - VERBOSE
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DEBUG' => 'enable debug mode',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles array format with colons', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DATABASE_URL: postgres://localhost # connection string
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => 'connection string',
+ ]);
+});
+
+test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ API_KEY: abc#def
+ OTHER: xyz # this is a comment
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // abc#def has no space before #, so it's not treated as a comment
+ expect($result)->toBe([
+ 'OTHER' => 'this is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles empty environment section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ ports:
+ - "80:80"
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
+ // Inline format like environment: { FOO: bar } is not supported for comment extraction
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment: { FOO: bar }
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // No comments extracted from inline format
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+
+services:
+ app:
+ image: myapp:latest
+ environment:
+ NODE_ENV: production # Set to development for local
+ DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
+ REDIS_URL: "redis://cache:6379"
+ API_SECRET: "${API_SECRET}" # From .env file
+ LOG_LEVEL: debug # Options: debug, info, warn, error
+ ports:
+ - "3000:3000"
+
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: user # Database admin username
+ POSTGRES_PASSWORD: "${DB_PASSWORD}"
+ POSTGRES_DB: mydb
+
+ cache:
+ image: redis:7
+ environment:
+ - REDIS_MAXMEMORY=256mb # Memory limit for cache
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'NODE_ENV' => 'Set to development for local',
+ 'DATABASE_URL' => 'Main database',
+ 'API_SECRET' => 'From .env file',
+ 'LOG_LEVEL' => 'Options: debug, info, warn, error',
+ 'POSTGRES_USER' => 'Database admin username',
+ 'REDIS_MAXMEMORY' => 'Memory limit for cache',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar # comment # with # hashes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'comment # with # hashes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables with empty comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar #
+ BAZ: qux #
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Empty comments should not be included
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # env comment
+ ports:
+ - "80:80" # port comment should not be captured
+ volumes:
+ - ./data:/data # volume comment should not be captured
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Only environment variables should have comments extracted
+ expect($result)->toBe([
+ 'FOO' => 'env comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ SERVICE_FQDN_WEB: /api # Path for the web service
+ SERVICE_URL_WEB: # URL will be generated
+ NORMAL_VAR: value # Regular variable
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'SERVICE_FQDN_WEB' => 'Path for the web service',
+ 'SERVICE_URL_WEB' => 'URL will be generated',
+ 'NORMAL_VAR' => 'Regular variable',
+ ]);
+});
diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php
index 9bfc046b0..534be700a 100644
--- a/tests/Unit/HealthCheckCommandInjectionTest.php
+++ b/tests/Unit/HealthCheckCommandInjectionTest.php
@@ -5,7 +5,6 @@
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use Illuminate\Support\Facades\Validator;
-use Mockery;
beforeEach(function () {
Mockery::close();
diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php
index 6cb9f0bb3..22d5e80db 100644
--- a/tests/Unit/HetznerDeletionFailedNotificationTest.php
+++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php
@@ -1,7 +1,6 @@
['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBe('This is a comment');
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles plain string format gracefully', function () {
+ // Simulate a scenario where parseEnvFormatToArray might return plain strings
+ // (for backward compatibility or edge cases)
+ $variables = [
+ 'KEY1' => 'value1',
+ 'KEY2' => 'value2',
+ 'KEY3' => 'value3',
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($comment)->toBeNull();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+ }
+});
+
+test('DockerCompose handles mixed array and string formats', function () {
+ // Simulate a mixed scenario (unlikely but possible)
+ $variables = [
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'KEY2' => 'value2', // Plain string
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ 'KEY4' => 'value4', // Plain string
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBe('comment1');
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY4') {
+ expect($value)->toBe('value4');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles empty array values gracefully', function () {
+ // Simulate edge case with incomplete array structure
+ $variables = [
+ 'KEY1' => ['value' => 'value1'], // Missing 'comment' key
+ 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
+ 'KEY3' => [], // Empty array (edge case)
+ ];
+
+ // Test the extraction logic with improved fallback
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction doesn't crash
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ // If 'value' is missing, fallback to empty string (not the whole array)
+ expect($value)->toBe('');
+ expect($comment)->toBe('comment2');
+ } elseif ($key === 'KEY3') {
+ // If both are missing, fallback to empty string (not empty array)
+ expect($value)->toBe('');
+ expect($comment)->toBeNull();
+ }
+ }
+});
diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php
new file mode 100644
index 000000000..65e8738cc
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php
@@ -0,0 +1,220 @@
+not->toBeNull()
+ ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split)->not->toBeNull()
+ ->and($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables correctly extracts nested variable content', function () {
+ // Before the fix, this would incorrectly extract only up to the first closing brace
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
+});
+
+test('nested defaults with path concatenation work', function () {
+ $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('REDIS_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
+});
+
+test('deeply nested variables are handled', function () {
+ // Three levels of nesting
+ $input = '${A:-${B:-${C}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['default'])->toBe('${B:-${C}}');
+});
+
+test('multiple nested variables in default value', function () {
+ // Default value contains multiple variable references
+ $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API')
+ ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
+});
+
+test('nested variables with different operators', function () {
+ // Nested variable uses different operator
+ $input = '${API_URL:-${SERVICE_URL?error message}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
+});
+
+test('backward compatibility with simple variables', function () {
+ // Simple variable without nesting should still work
+ $input = '${VAR}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('backward compatibility with single-level defaults', function () {
+ // Single-level default without nesting
+ $input = '${VAR:-default_value}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR:-default_value');
+
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('backward compatibility with dash operator', function () {
+ $input = '${VAR-default}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('-');
+});
+
+test('backward compatibility with colon question operator', function () {
+ $input = '${VAR:?error message}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('backward compatibility with question operator', function () {
+ $input = '${VAR?error}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('SERVICE_URL magic variables in nested defaults', function () {
+ // Real-world scenario: SERVICE_URL_* magic variable used in nested default
+ $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('DATABASE_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
+
+ // Extract the nested SERVICE_URL variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
+});
+
+test('SERVICE_FQDN magic variables in nested defaults', function () {
+ $input = '${API_HOST:-${SERVICE_FQDN_API}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_FQDN_API}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
+});
+
+test('complex real-world example', function () {
+ // Complex real-world scenario from the bug report
+ $input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
+
+ // Step 1: Extract outer variable content
+ $result = extractBalancedBraceContent($input, 0);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ // Step 2: Split on operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ expect($split['variable'])->toBe('API_URL');
+ expect($split['operator'])->toBe(':-');
+ expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+
+ // Step 3: Extract nested variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
+
+ // This verifies that:
+ // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
+ // 2. SERVICE_URL_YOLO should be recognized and created as magic variable
+});
+
+test('empty nested default values', function () {
+ $input = '${VAR:-${NESTED:-}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${NESTED:-}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
+
+ expect($nestedSplit['default'])->toBe('');
+});
+
+test('nested variables with complex paths', function () {
+ $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
+});
+
+test('operator precedence with nesting', function () {
+ // The first :- at depth 0 should be used, not the one inside nested braces
+ $input = '${A:-${B:-default}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ // Should split on first :- (at depth 0)
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}'); // Not split here
+});
diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php
new file mode 100644
index 000000000..81b440927
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableTest.php
@@ -0,0 +1,207 @@
+toBe('VAR')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(5);
+});
+
+test('extractBalancedBraceContent handles nested braces', function () {
+ $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(34); // Position of closing }
+});
+
+test('extractBalancedBraceContent handles triple nesting', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+});
+
+test('extractBalancedBraceContent returns null for unbalanced braces', function () {
+ $result = extractBalancedBraceContent('${VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null when no braces', function () {
+ $result = extractBalancedBraceContent('VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles startPos parameter', function () {
+ $result = extractBalancedBraceContent('foo ${VAR} bar', 4);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR')
+ ->and($result['start'])->toBe(5)
+ ->and($result['end'])->toBe(9);
+});
+
+test('splitOnOperatorOutsideNested splits on :- operator', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-default_value');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('splitOnOperatorOutsideNested handles nested defaults', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('splitOnOperatorOutsideNested handles dash operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('splitOnOperatorOutsideNested handles colon question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error message');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('splitOnOperatorOutsideNested handles question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR?error');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('splitOnOperatorOutsideNested returns null for simple variable', function () {
+ $split = splitOnOperatorOutsideNested('SIMPLE_VAR');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
+ $split = splitOnOperatorOutsideNested('A:-${B:-default}');
+
+ assertNotNull($split);
+ // Should split on first :- (outside nested braces), not the one inside ${B:-default}
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}');
+});
+
+test('replaceVariables handles simple variable', function () {
+ $result = replaceVariables('${VAR}');
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('replaceVariables handles nested expressions', function () {
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables handles variable with default', function () {
+ $result = replaceVariables('${API_URL:-http://localhost}');
+
+ expect($result->value())->toBe('API_URL:-http://localhost');
+});
+
+test('replaceVariables returns unchanged for non-variable string', function () {
+ $result = replaceVariables('not_a_variable');
+
+ expect($result->value())->toBe('not_a_variable');
+});
+
+test('replaceVariables handles triple nesting', function () {
+ $result = replaceVariables('${A:-${B:-${C}}}');
+
+ expect($result->value())->toBe('A:-${B:-${C}}');
+});
+
+test('replaceVariables fallback works for malformed input', function () {
+ // When braces are unbalanced, it falls back to old behavior
+ $result = replaceVariables('${VAR');
+
+ // Old behavior would extract everything before first }
+ // But since there's no }, it will extract 'VAR' (removing ${)
+ expect($result->value())->toContain('VAR');
+});
+
+test('extractBalancedBraceContent handles complex nested expression', function () {
+ $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('splitOnOperatorOutsideNested handles complex nested expression', function () {
+ $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('extractBalancedBraceContent finds second variable in string', function () {
+ $str = '${VAR1} and ${VAR2}';
+
+ // First variable
+ $result1 = extractBalancedBraceContent($str, 0);
+ assertNotNull($result1);
+ expect($result1['content'])->toBe('VAR1');
+
+ // Second variable
+ $result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
+ assertNotNull($result2);
+ expect($result2['content'])->toBe('VAR2');
+});
+
+test('replaceVariables handles empty default value', function () {
+ $result = replaceVariables('${VAR:-}');
+
+ expect($result->value())->toBe('VAR:-');
+});
+
+test('splitOnOperatorOutsideNested handles empty default value', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('');
+});
+
+test('replaceVariables handles brace format without dollar sign', function () {
+ // This format is used by the regex capture group in magic variable detection
+ $result = replaceVariables('{SERVICE_URL_YOLO}');
+ expect($result->value())->toBe('SERVICE_URL_YOLO');
+});
+
+test('replaceVariables handles truncated brace format', function () {
+ // When regex captures {VAR from a larger expression, no closing brace
+ $result = replaceVariables('{API_URL');
+ expect($result->value())->toBe('API_URL');
+});
diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php
new file mode 100644
index 000000000..303ff007d
--- /dev/null
+++ b/tests/Unit/ParseEnvFormatToArrayTest.php
@@ -0,0 +1,248 @@
+toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
+ $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
+ ]);
+});
+
+test('parseEnvFormatToArray preserves # in quoted values', function () {
+ $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with # hash', 'comment' => null],
+ 'KEY2' => ['value' => 'another # hash', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values correctly', function () {
+ $input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'quoted value', 'comment' => null],
+ 'KEY2' => ['value' => 'single quoted', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips comment lines', function () {
+ $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips empty lines', function () {
+ $input = "KEY1=value1\n\nKEY2=value2\n\n";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles values with equals signs', function () {
+ $input = 'KEY1=value=with=equals';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty values', function () {
+ $input = "KEY1=\nKEY2=value";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles complex real-world example', function () {
+ $input = <<<'ENV'
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432 #default postgres port
+DB_NAME="my_database"
+DB_PASSWORD='p@ssw0rd#123'
+
+# API Keys
+API_KEY=abc123 # Production key
+SECRET_KEY=xyz789
+ENV;
+
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DB_HOST' => ['value' => 'localhost', 'comment' => null],
+ 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
+ 'DB_NAME' => ['value' => 'my_database', 'comment' => null],
+ 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
+ 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the original bug scenario', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ // The value should be "22", not "22 #needed for now"
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
+ // And the comment should be extracted
+ expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
+});
+
+test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
+ $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'another value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
+ $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
+ $result = parseEnvFormatToArray($input);
+
+ // KEY1: no space before #, so entire value is kept
+ // KEY2: space before first #, so everything from first space+# is stripped
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
+ $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '#starts with hash', 'comment' => null],
+ 'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
+ 'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray trims whitespace before comments', function () {
+ $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
+ ]);
+ // Values should not have trailing spaces
+ expect($result['KEY1']['value'])->not->toEndWith(' ');
+ expect($result['KEY2']['value'])->not->toEndWith("\t");
+});
+
+test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
+ $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips comments with space before hash', function () {
+ $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
+ $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
+ 'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty comments', function () {
+ $input = "KEY1=value #\nKEY2=value # ";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts multi-word comments', function () {
+ $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
+ $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
+ 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
+ $input = 'ASD=asd #asdfgg';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
+ ]);
+
+ // Specifically verify the comment is extracted
+ expect($result['ASD']['value'])->toBe('asd');
+ expect($result['ASD']['comment'])->toBe('asdfgg');
+ expect($result['ASD']['comment'])->not->toBeNull();
+});
diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php
index d8449adc3..dc28d18fe 100644
--- a/tests/Unit/ServerManagerJobSentinelCheckTest.php
+++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php
@@ -7,7 +7,6 @@
use App\Models\Server;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
-use Mockery;
beforeEach(function () {
Queue::fake();
diff --git a/tests/Unit/ServerQueryScopeTest.php b/tests/Unit/ServerQueryScopeTest.php
index 8ab0b8b10..0d1e7729c 100644
--- a/tests/Unit/ServerQueryScopeTest.php
+++ b/tests/Unit/ServerQueryScopeTest.php
@@ -3,7 +3,6 @@
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Database\Eloquent\Builder;
-use Mockery;
it('filters servers by proxy type using whereProxyType scope', function () {
// Mock the Builder
diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php
index 2ad345c44..6ef5bf030 100644
--- a/tests/Unit/ServiceRequiredPortTest.php
+++ b/tests/Unit/ServiceRequiredPortTest.php
@@ -2,7 +2,6 @@
use App\Models\Service;
use App\Models\ServiceApplication;
-use Mockery;
it('returns required port from service template', function () {
// Mock get_service_templates() function