diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 92829edd6..de79fcd24 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -101,6 +101,7 @@ class StackForm extends Component $rules = data_get($field, 'rules', 'nullable'); $isPassword = data_get($field, 'isPassword', false); $customHelper = data_get($field, 'customHelper', false); + $sortOrder = data_get($field, 'sortOrder'); $this->fields->put($key, [ 'serviceName' => $serviceName, 'key' => $key, @@ -109,6 +110,7 @@ class StackForm extends Component 'isPassword' => $isPassword, 'rules' => $rules, 'customHelper' => $customHelper, + 'sortOrder' => $sortOrder, ]); $this->validationAttributes["fields.$key.value"] = $fieldKey; @@ -116,7 +118,7 @@ class StackForm extends Component } $this->fields = $this->fields->groupBy('serviceName')->map(function ($group) { return $group->sortBy(function ($field) { - return data_get($field, 'isPassword') ? 1 : 0; + return data_get($field, 'sortOrder') ?? (data_get($field, 'isPassword') ? 1 : 0); })->mapWithKeys(function ($field) { return [$field['key'] => $field]; }); diff --git a/app/Models/Service.php b/app/Models/Service.php index 224507c4e..0da97b301 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1180,6 +1180,27 @@ class Service extends BaseModel } $fields->put('Openclaw', $data->toArray()); break; + case $image->contains('coollabsio/jean-server'): + $data = collect([]); + $settings = [ + 'Token' => ['key' => 'SERVICE_PASSWORD_64_JEAN', 'rules' => 'required', 'isPassword' => true, 'sortOrder' => 1, 'customHelper' => 'Token required to access Jean Server. Variable name: SERVICE_PASSWORD_64_JEAN'], + 'Allowed Origins' => ['key' => 'JEAN_ALLOWED_ORIGINS', 'rules' => 'nullable|string', 'sortOrder' => 2, 'customHelper' => 'Comma-separated additional browser origins. Same-origin access is always allowed. Variable name: JEAN_ALLOWED_ORIGINS'], + ]; + + foreach ($settings as $label => $setting) { + $variable = $this->environment_variables()->where('key', $setting['key'])->first(); + if (! $variable) { + continue; + } + + $data->put($label, [ + ...$setting, + 'value' => data_get($variable, 'value'), + ]); + } + + $fields->put('', $data->toArray()); + break; default: $data = collect([]); $admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index 89dfa0e5c..d6c55ecfe 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -66,7 +66,12 @@ @foreach ($fields as $serviceName => $field)
- {{ data_get($field, 'serviceName') }} · {{ data_get($field, 'name') }} + + @if (filled(data_get($field, 'serviceName'))) + {{ data_get($field, 'serviceName') }} · + @endif + {{ data_get($field, 'name') }} + @if (data_get($field, 'customHelper')) @else diff --git a/svgs/jean.png b/svgs/jean.png new file mode 100644 index 000000000..b5f15fdc2 Binary files /dev/null and b/svgs/jean.png differ diff --git a/templates/compose/jean.yaml b/templates/compose/jean.yaml new file mode 100644 index 000000000..4b24ee02d --- /dev/null +++ b/templates/compose/jean.yaml @@ -0,0 +1,31 @@ +# documentation: https://github.com/coollabsio/jean/blob/main/docs/headless-server.md +# slogan: Open-source desktop and server client for orchestrating AI coding agents. +# category: development +# tags: jean,ai,coding,agents,development,git,worktrees +# logo: svgs/jean.png +# port: 3456 + +services: + jean: + image: 'ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}' + environment: + - SERVICE_URL_JEAN_3456 + - JEAN_HEADLESS=${JEAN_HEADLESS:-1} + - JEAN_HOST=${JEAN_HOST:-0.0.0.0} + - JEAN_PORT=${JEAN_PORT:-3456} + - JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN} + - JEAN_NO_TOKEN=${JEAN_NO_TOKEN:-0} + - JEAN_ALLOW_UNSAFE_NO_TOKEN=${JEAN_ALLOW_UNSAFE_NO_TOKEN:-0} + - JEAN_ALLOW_NATIVE_OPEN=${JEAN_ALLOW_NATIVE_OPEN:-0} + - JEAN_ALLOWED_ORIGINS=${JEAN_ALLOWED_ORIGINS:-} + - JEAN_DATA_DIR=/home/jean/.local/share/com.jean.desktop + volumes: + - jean-data:/home/jean/.local/share/com.jean.desktop + healthcheck: + test: + - CMD-SHELL + - 'curl -fsS http://127.0.0.1:3456/readyz >/dev/null || exit 1' + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index cda94584b..f90fa3790 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2453,6 +2453,25 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "jean": { + "documentation": "https://github.com/coollabsio/jean/blob/main/docs/headless-server.md?utm_source=coolify.io", + "slogan": "Open-source desktop and server client for orchestrating AI coding agents.", + "compose": "c2VydmljZXM6CiAgamVhbjoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL2plYW4tc2VydmVyOiR7SkVBTl9WRVJTSU9OOi1sYXRlc3R9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSkVBTl8zNDU2CiAgICAgIC0gJ0pFQU5fSEVBRExFU1M9JHtKRUFOX0hFQURMRVNTOi0xfScKICAgICAgLSAnSkVBTl9IT1NUPSR7SkVBTl9IT1NUOi0wLjAuMC4wfScKICAgICAgLSAnSkVBTl9QT1JUPSR7SkVBTl9QT1JUOi0zNDU2fScKICAgICAgLSAnSkVBTl9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfSkVBTn0nCiAgICAgIC0gJ0pFQU5fTk9fVE9LRU49JHtKRUFOX05PX1RPS0VOOi0wfScKICAgICAgLSAnSkVBTl9BTExPV19VTlNBRkVfTk9fVE9LRU49JHtKRUFOX0FMTE9XX1VOU0FGRV9OT19UT0tFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dfTkFUSVZFX09QRU49JHtKRUFOX0FMTE9XX05BVElWRV9PUEVOOi0wfScKICAgICAgLSAnSkVBTl9BTExPV0VEX09SSUdJTlM9JHtKRUFOX0FMTE9XRURfT1JJR0lOUzotfScKICAgICAgLSBKRUFOX0RBVEFfRElSPS9ob21lL2plYW4vLmxvY2FsL3NoYXJlL2NvbS5qZWFuLmRlc2t0b3AKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2plYW4tZGF0YTovaG9tZS9qZWFuLy5sb2NhbC9zaGFyZS9jb20uamVhbi5kZXNrdG9wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTozNDU2L3JlYWR5eiA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", + "tags": [ + "jean", + "ai", + "coding", + "agents", + "development", + "git", + "worktrees" + ], + "category": "development", + "logo": "svgs/jean.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "3456" + }, "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 50f647315..5a4ad35be 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2453,6 +2453,25 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "jean": { + "documentation": "https://github.com/coollabsio/jean/blob/main/docs/headless-server.md?utm_source=coolify.io", + "slogan": "Open-source desktop and server client for orchestrating AI coding agents.", + "compose": "c2VydmljZXM6CiAgamVhbjoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL2plYW4tc2VydmVyOiR7SkVBTl9WRVJTSU9OOi1sYXRlc3R9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0pFQU5fMzQ1NgogICAgICAtICdKRUFOX0hFQURMRVNTPSR7SkVBTl9IRUFETEVTUzotMX0nCiAgICAgIC0gJ0pFQU5fSE9TVD0ke0pFQU5fSE9TVDotMC4wLjAuMH0nCiAgICAgIC0gJ0pFQU5fUE9SVD0ke0pFQU5fUE9SVDotMzQ1Nn0nCiAgICAgIC0gJ0pFQU5fVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0pFQU59JwogICAgICAtICdKRUFOX05PX1RPS0VOPSR7SkVBTl9OT19UT0tFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dfVU5TQUZFX05PX1RPS0VOPSR7SkVBTl9BTExPV19VTlNBRkVfTk9fVE9LRU46LTB9JwogICAgICAtICdKRUFOX0FMTE9XX05BVElWRV9PUEVOPSR7SkVBTl9BTExPV19OQVRJVkVfT1BFTjotMH0nCiAgICAgIC0gJ0pFQU5fQUxMT1dFRF9PUklHSU5TPSR7SkVBTl9BTExPV0VEX09SSUdJTlM6LX0nCiAgICAgIC0gSkVBTl9EQVRBX0RJUj0vaG9tZS9qZWFuLy5sb2NhbC9zaGFyZS9jb20uamVhbi5kZXNrdG9wCiAgICB2b2x1bWVzOgogICAgICAtICdqZWFuLWRhdGE6L2hvbWUvamVhbi8ubG9jYWwvc2hhcmUvY29tLmplYW4uZGVza3RvcCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZnNTIGh0dHA6Ly8xMjcuMC4wLjE6MzQ1Ni9yZWFkeXogPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMK", + "tags": [ + "jean", + "ai", + "coding", + "agents", + "development", + "git", + "worktrees" + ], + "category": "development", + "logo": "svgs/jean.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "3456" + }, "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", diff --git a/tests/Feature/ServiceExtraFieldsTest.php b/tests/Feature/ServiceExtraFieldsTest.php index 2b6ed0c0b..2a521f97e 100644 --- a/tests/Feature/ServiceExtraFieldsTest.php +++ b/tests/Feature/ServiceExtraFieldsTest.php @@ -45,3 +45,30 @@ it('only adds Grafana extra fields for Grafana server images', function (string 'promtail' => ['grafana/promtail:latest', false], 'tempo' => ['grafana/tempo:latest', false], ]); + +it('exposes Jean Server authentication and access settings', function () { + $service = serviceExtraFieldsTestServiceWithApplicationImage('ghcr.io/coollabsio/jean-server:latest'); + + $service->environment_variables()->createMany([ + ['key' => 'SERVICE_PASSWORD_64_JEAN', 'value' => 'secret-token', 'is_preview' => false], + ['key' => 'JEAN_ALLOWED_ORIGINS', 'value' => 'https://jean.example.com', 'is_preview' => false], + ]); + + $fields = $service->extraFields(); + + expect($fields)->toHaveKey('') + ->and($fields[''])->toMatchArray([ + 'Token' => [ + 'key' => 'SERVICE_PASSWORD_64_JEAN', + 'value' => 'secret-token', + 'rules' => 'required', + 'isPassword' => true, + 'sortOrder' => 1, + ], + 'Allowed Origins' => [ + 'key' => 'JEAN_ALLOWED_ORIGINS', + 'value' => 'https://jean.example.com', + 'sortOrder' => 2, + ], + ]); +}); diff --git a/tests/Unit/JeanServiceTemplateTest.php b/tests/Unit/JeanServiceTemplateTest.php new file mode 100644 index 000000000..32de62ed3 --- /dev/null +++ b/tests/Unit/JeanServiceTemplateTest.php @@ -0,0 +1,39 @@ +toContain('ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}') + ->toContain('SERVICE_URL_JEAN_3456') + ->toContain('JEAN_HEADLESS=${JEAN_HEADLESS:-1}') + ->toContain('JEAN_HOST=${JEAN_HOST:-0.0.0.0}') + ->toContain('JEAN_PORT=${JEAN_PORT:-3456}') + ->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}') + ->toContain('JEAN_NO_TOKEN=${JEAN_NO_TOKEN:-0}') + ->toContain('JEAN_ALLOW_UNSAFE_NO_TOKEN=${JEAN_ALLOW_UNSAFE_NO_TOKEN:-0}') + ->toContain('JEAN_ALLOW_NATIVE_OPEN=${JEAN_ALLOW_NATIVE_OPEN:-0}') + ->toContain('JEAN_ALLOWED_ORIGINS=${JEAN_ALLOWED_ORIGINS:-}') + ->toContain('JEAN_DATA_DIR=/home/jean/.local/share/com.jean.desktop') + ->toContain('jean-data:/home/jean/.local/share/com.jean.desktop') + ->toContain('http://127.0.0.1:3456/readyz'); + + foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) { + $templates = json_decode( + file_get_contents(__DIR__."/../../templates/{$templateFile}"), + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + expect($templates)->toHaveKey('jean'); + expect($templates['jean']['port'] ?? null)->toBe('3456'); + expect($templates['jean']['logo'] ?? null)->toBe('svgs/jean.png'); + expect($templates['jean']['category'] ?? null)->toBe('development'); + + $generatedCompose = base64_decode($templates['jean']['compose'], strict: true); + + expect($generatedCompose) + ->toContain('ghcr.io/coollabsio/jean-server:${JEAN_VERSION:-latest}') + ->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}'); + } +});