feat: add comment field to environment variables

- Add comment field to EnvironmentVariable model and database
- Update parseEnvFormatToArray to extract inline comments from env files
- Update Livewire components to handle comment field
- Add UI for displaying and editing comments
- Add tests for comment parsing functionality
This commit is contained in:
Andras Bacsai 2025-11-18 10:10:29 +01:00
parent 083d745d70
commit e33558488e
12 changed files with 623 additions and 103 deletions

View file

@ -63,10 +63,15 @@ public function submit()
]); ]);
$variables = parseEnvFormatToArray($this->envFile); $variables = parseEnvFormatToArray($this->envFile);
foreach ($variables as $key => $variable) { foreach ($variables as $key => $data) {
// Extract value and comment from parsed data
$value = $data['value'] ?? $data;
$comment = $data['comment'] ?? null;
EnvironmentVariable::create([ EnvironmentVariable::create([
'key' => $key, 'key' => $key,
'value' => $variable, 'value' => $value,
'comment' => $comment,
'is_preview' => false, 'is_preview' => false,
'resourceable_id' => $service->id, 'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(), 'resourceable_type' => $service->getMorphClass(),

View file

@ -270,18 +270,36 @@ private function deleteRemovedVariables($isPreview, $variables)
private function updateOrCreateVariables($isPreview, $variables) private function updateOrCreateVariables($isPreview, $variables)
{ {
$count = 0; $count = 0;
foreach ($variables as $key => $value) { foreach ($variables as $key => $data) {
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
continue; continue;
} }
// Extract value and comment from parsed data
$value = $data['value'] ?? $data;
$comment = $data['comment'] ?? null;
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
$found = $this->resource->$method()->where('key', $key)->first(); $found = $this->resource->$method()->where('key', $key)->first();
if ($found) { if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) { if (! $found->is_shown_once && ! $found->is_multiline) {
// Only count as a change if the value actually changed $changed = false;
// Update value if it changed
if ($found->value !== $value) { if ($found->value !== $value) {
$found->value = $value; $found->value = $value;
$changed = true;
}
// Always update comment from inline comment (overwrites existing)
// Set to comment if provided, otherwise set to null if no comment
if ($found->comment !== $comment) {
$found->comment = $comment;
$changed = true;
}
if ($changed) {
$found->save(); $found->save();
$count++; $count++;
} }
@ -290,6 +308,7 @@ private function updateOrCreateVariables($isPreview, $variables)
$environment = new EnvironmentVariable; $environment = new EnvironmentVariable;
$environment->key = $key; $environment->key = $key;
$environment->value = $value; $environment->value = $value;
$environment->comment = $comment; // Set comment from inline comment
$environment->is_multiline = false; $environment->is_multiline = false;
$environment->is_preview = $isPreview; $environment->is_preview = $isPreview;
$environment->resourceable_id = $this->resource->id; $environment->resourceable_id = $this->resource->id;

View file

@ -34,6 +34,8 @@ class Show extends Component
public ?string $real_value = null; public ?string $real_value = null;
public ?string $comment = null;
public bool $is_shared = false; public bool $is_shared = false;
public bool $is_multiline = false; public bool $is_multiline = false;
@ -63,6 +65,7 @@ class Show extends Component
protected $rules = [ protected $rules = [
'key' => 'required|string', 'key' => 'required|string',
'value' => 'nullable', 'value' => 'nullable',
'comment' => 'nullable|string|max:1000',
'is_multiline' => 'required|boolean', 'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean', 'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean', 'is_shown_once' => 'required|boolean',
@ -104,6 +107,7 @@ public function syncData(bool $toModel = false)
$this->validate([ $this->validate([
'key' => 'required|string', 'key' => 'required|string',
'value' => 'nullable', 'value' => 'nullable',
'comment' => 'nullable|string|max:1000',
'is_multiline' => 'required|boolean', 'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean', 'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean', 'is_shown_once' => 'required|boolean',
@ -118,6 +122,7 @@ public function syncData(bool $toModel = false)
} }
$this->env->key = $this->key; $this->env->key = $this->key;
$this->env->value = $this->value; $this->env->value = $this->value;
$this->env->comment = $this->comment;
$this->env->is_multiline = $this->is_multiline; $this->env->is_multiline = $this->is_multiline;
$this->env->is_literal = $this->is_literal; $this->env->is_literal = $this->is_literal;
$this->env->is_shown_once = $this->is_shown_once; $this->env->is_shown_once = $this->is_shown_once;
@ -125,6 +130,7 @@ public function syncData(bool $toModel = false)
} else { } else {
$this->key = $this->env->key; $this->key = $this->env->key;
$this->value = $this->env->value; $this->value = $this->env->value;
$this->comment = $this->env->comment;
$this->is_multiline = $this->env->is_multiline; $this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal; $this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once; $this->is_shown_once = $this->env->is_shown_once;

View file

@ -24,6 +24,7 @@
'key' => ['type' => 'string'], 'key' => ['type' => 'string'],
'value' => ['type' => 'string'], 'value' => ['type' => 'string'],
'real_value' => ['type' => 'string'], 'real_value' => ['type' => 'string'],
'comment' => ['type' => 'string', 'nullable' => true],
'version' => ['type' => 'string'], 'version' => ['type' => 'string'],
'created_at' => ['type' => 'string'], 'created_at' => ['type' => 'string'],
'updated_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'],
@ -67,6 +68,7 @@ protected static function booted()
'is_literal' => $environment_variable->is_literal ?? false, 'is_literal' => $environment_variable->is_literal ?? false,
'is_runtime' => $environment_variable->is_runtime ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false,
'is_buildtime' => $environment_variable->is_buildtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false,
'comment' => $environment_variable->comment,
'resourceable_type' => Application::class, 'resourceable_type' => Application::class,
'resourceable_id' => $environment_variable->resourceable_id, 'resourceable_id' => $environment_variable->resourceable_id,
'is_preview' => true, 'is_preview' => true,

View file

@ -441,13 +441,71 @@ function parseEnvFormatToArray($env_file_contents)
$equals_pos = strpos($line, '='); $equals_pos = strpos($line, '=');
if ($equals_pos !== false) { if ($equals_pos !== false) {
$key = substr($line, 0, $equals_pos); $key = substr($line, 0, $equals_pos);
$value = substr($line, $equals_pos + 1); $value_and_comment = substr($line, $equals_pos + 1);
if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') { $comment = null;
$value = substr($value, 1, -1); $remainder = '';
} elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") {
$value = substr($value, 1, -1); // Check if value starts with quotes
$firstChar = isset($value_and_comment[0]) ? $value_and_comment[0] : '';
$isDoubleQuoted = $firstChar === '"';
$isSingleQuoted = $firstChar === "'";
if ($isDoubleQuoted) {
// Find the closing double quote
$closingPos = strpos($value_and_comment, '"', 1);
if ($closingPos !== false) {
// Extract quoted value and remove quotes
$value = substr($value_and_comment, 1, $closingPos - 1);
// Everything after closing quote (including comments)
$remainder = substr($value_and_comment, $closingPos + 1);
} else {
// No closing quote - treat as unquoted
$value = substr($value_and_comment, 1);
}
} elseif ($isSingleQuoted) {
// Find the closing single quote
$closingPos = strpos($value_and_comment, "'", 1);
if ($closingPos !== false) {
// Extract quoted value and remove quotes
$value = substr($value_and_comment, 1, $closingPos - 1);
// Everything after closing quote (including comments)
$remainder = substr($value_and_comment, $closingPos + 1);
} else {
// No closing quote - treat as unquoted
$value = substr($value_and_comment, 1);
}
} else {
// Unquoted value - strip inline comments
// Only treat # as comment if preceded by whitespace
if (preg_match('/\s+#/', $value_and_comment, $matches, PREG_OFFSET_CAPTURE)) {
// Found whitespace followed by #, extract comment
$remainder = substr($value_and_comment, $matches[0][1]);
$value = substr($value_and_comment, 0, $matches[0][1]);
$value = rtrim($value);
} else {
$value = $value_and_comment;
}
} }
$env_array[$key] = $value;
// Extract comment from remainder (if any)
if ($remainder !== '') {
// Look for # in remainder
$hashPos = strpos($remainder, '#');
if ($hashPos !== false) {
// Extract everything after the # and trim
$comment = substr($remainder, $hashPos + 1);
$comment = trim($comment);
// Set to null if empty after trimming
if ($comment === '') {
$comment = null;
}
}
}
$env_array[$key] = [
'value' => $value,
'comment' => $comment,
];
} }
} }

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
$table->text('comment')->nullable();
});
Schema::table('shared_environment_variables', function (Blueprint $table) {
$table->text('comment')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
$table->dropColumn('comment');
});
Schema::table('shared_environment_variables', function (Blueprint $table) {
$table->dropColumn('comment');
});
}
};

View file

@ -10540,6 +10540,10 @@
"real_value": { "real_value": {
"type": "string" "type": "string"
}, },
"comment": {
"type": "string",
"nullable": true
},
"version": { "version": {
"type": "string" "type": "string"
}, },

View file

@ -6687,6 +6687,9 @@ components:
type: string type: string
real_value: real_value:
type: string type: string
comment:
type: string
nullable: true
version: version:
type: string type: string
created_at: created_at:

View file

@ -79,6 +79,13 @@
@else @else
<form wire:submit.prevent='submit' class="flex flex-col gap-2"> <form wire:submit.prevent='submit' class="flex flex-col gap-2">
@can('manageEnvironment', $resource) @can('manageEnvironment', $resource)
<div class="flex items-center gap-2 p-3 mb-2 text-sm border rounded bg-warning/10 border-warning/50 dark:text-warning text-coollabs">
<svg class="w-5 h-5" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m-4 48a12 12 0 1 1-12 12a12 12 0 0 1 12-12m12 112a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 0 16"/>
</svg>
<span><strong>Note:</strong> Inline comments with space before # (e.g., <code>KEY=value #comment</code>) are stripped. Values like <code>PASSWORD=pass#word</code> are preserved. Use the Comment field in Normal view to document variables.</span>
</div>
<x-forms.textarea rows="10" class="whitespace-pre-wrap" id="variables" wire:model="variables" <x-forms.textarea rows="10" class="whitespace-pre-wrap" id="variables" wire:model="variables"
label="Production Environment Variables"></x-forms.textarea> label="Production Environment Variables"></x-forms.textarea>

View file

@ -37,23 +37,29 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" /> label="Is Literal?" />
@else @else
@if ($isSharedVariable) @if ($is_shared)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else @else
@if (!$env->is_nixpacks) @if ($isSharedVariable)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false) @else
<x-forms.checkbox instantSave id="is_literal" @if (!$env->is_nixpacks)
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." <x-forms.checkbox instantSave id="is_buildtime"
label="Is Literal?" /> helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif @endif
@endif @endif
@endif @endif
@ -77,22 +83,26 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" /> label="Is Literal?" />
@else @else
@if ($isSharedVariable) @if ($is_shared)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else @else
@if (!$env->is_nixpacks) @if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
<x-forms.checkbox disabled id="is_buildtime" <x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" /> label="Available at Buildtime" />
@endif <x-forms.checkbox disabled id="is_runtime"
<x-forms.checkbox disabled id="is_runtime" helper="Make this variable available in the running container at runtime."
helper="Make this variable available in the running container at runtime." label="Available at Runtime" />
label="Available at Runtime" /> <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> @if ($is_multiline === false)
@if ($is_multiline === false) <x-forms.checkbox disabled id="is_literal"
<x-forms.checkbox disabled id="is_literal" helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" />
label="Is Literal?" /> @endif
@endif @endif
@endif @endif
@endif @endif
@ -103,51 +113,43 @@
@else @else
@can('update', $this->env) @can('update', $this->env)
@if ($isDisabled) @if ($isDisabled)
<div class="flex flex-col w-full gap-2">
<div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" />
<x-forms.input disabled type="password" id="value" />
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" />
@endif
</div>
</div>
@else
<div class="flex flex-col w-full gap-2">
<div class="flex flex-col w-full gap-2 lg:flex-row">
@if ($is_multiline)
<x-forms.input :required="$is_redis_credential" isMultiline="{{ $is_multiline }}" id="key" />
<x-forms.textarea :required="$is_redis_credential" type="password" id="value" />
@else
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" id="key" />
<x-forms.input :required="$is_redis_credential" type="password" id="value" />
@endif
@if ($is_shared)
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" disabled type="password" id="real_value" />
@endif
</div>
<x-forms.input instantSave id="comment" label="Comment (Optional)" helper="Add a note to document what this environment variable is used for." />
</div>
@endif
@else
<div class="flex flex-col w-full gap-2">
<div class="flex flex-col w-full gap-2 lg:flex-row"> <div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" /> <x-forms.input disabled id="key" />
<x-forms.env-var-input <x-forms.input disabled type="password" id="value" />
disabled
type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@if ($is_shared) @if ($is_shared)
<x-forms.input disabled type="password" id="real_value" /> <x-forms.input disabled type="password" id="real_value" />
@endif @endif
</div> </div>
@else @if (!$isDisabled)
<div class="flex flex-col w-full gap-2 lg:flex-row"> <x-forms.input disabled id="comment" label="Comment (Optional)" helper="Add a note to document what this environment variable is used for." />
@if ($is_multiline)
<x-forms.input :required="$is_redis_credential" isMultiline="{{ $is_multiline }}" id="key" />
<x-forms.textarea :required="$is_redis_credential" type="password" id="value" />
@else
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" id="key" />
<x-forms.env-var-input
:required="$is_redis_credential"
type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@endif
@if ($is_shared)
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" disabled type="password" id="real_value" />
@endif
</div>
@endif
@else
<div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" />
<x-forms.env-var-input
disabled
type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" />
@endif @endif
</div> </div>
@endcan @endcan
@ -167,23 +169,29 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" /> label="Is Literal?" />
@else @else
@if ($isSharedVariable) @if ($is_shared)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else @else
@if (!$env->is_nixpacks) @if ($isSharedVariable)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false) @else
<x-forms.checkbox instantSave id="is_literal" @if (!$env->is_nixpacks)
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." <x-forms.checkbox instantSave id="is_buildtime"
label="Is Literal?" /> helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif @endif
@endif @endif
@endif @endif
@ -229,22 +237,26 @@
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" /> label="Is Literal?" />
@else @else
@if ($isSharedVariable) @if ($is_shared)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else @else
@if (!$env->is_nixpacks) @if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
<x-forms.checkbox disabled id="is_buildtime" <x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" /> label="Available at Buildtime" />
@endif <x-forms.checkbox disabled id="is_runtime"
<x-forms.checkbox disabled id="is_runtime" helper="Make this variable available in the running container at runtime."
helper="Make this variable available in the running container at runtime." label="Available at Runtime" />
label="Available at Runtime" /> <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> @if ($is_multiline === false)
@if ($is_multiline === false) <x-forms.checkbox disabled id="is_literal"
<x-forms.checkbox disabled id="is_literal" helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" />
label="Is Literal?" /> @endif
@endif @endif
@endif @endif
@endif @endif

View file

@ -0,0 +1,120 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Team;
use App\Models\User;
beforeEach(function () {
$this->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 booted() method should create 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');
});

View file

@ -0,0 +1,248 @@
<?php
test('parseEnvFormatToArray parses simple KEY=VALUE pairs', function () {
$input = "KEY1=value1\nKEY2=value2";
$result = parseEnvFormatToArray($input);
expect($result)->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();
});