Merge branch 'next' into add-opnform-template

This commit is contained in:
Julien Nahum 2025-11-14 11:26:21 +00:00 committed by GitHub
commit 0cb7881f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 411 additions and 163 deletions

View file

@ -2,7 +2,6 @@
namespace App\Actions\Server;
use App\Jobs\PullHelperImageJob;
use App\Models\Server;
use Illuminate\Support\Sleep;
use Lorisleiva\Actions\Concerns\AsAction;
@ -50,7 +49,9 @@ public function handle($manual_update = false)
private function update()
{
PullHelperImageJob::dispatch($this->server);
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);

View file

@ -1,30 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 1000;
public function __construct(public Server $server)
{
$this->onQueue('high');
}
public function handle(): void
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
}
}

View file

@ -2,7 +2,6 @@
namespace App\Models;
use App\Jobs\PullHelperImageJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Spatie\Url\Url;
@ -35,14 +34,6 @@ class InstanceSettings extends Model
protected static function booted(): void
{
static::updated(function ($settings) {
if ($settings->wasChanged('helper_version')) {
Server::chunkById(100, function ($servers) {
foreach ($servers as $server) {
PullHelperImageJob::dispatch($server);
}
});
}
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');

View file

@ -58,16 +58,35 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
$commands = $commands->map(function ($line) {
$line = str($line);
// Detect complex piped commands that should be wrapped in bash -c
$isComplexPipeCommand = (
$line->contains(' | sh') ||
$line->contains(' | bash') ||
($line->contains(' | ') && ($line->contains('||') || $line->contains('&&')))
);
// If it's a complex pipe command and starts with sudo, wrap it in bash -c
if ($isComplexPipeCommand && $line->startsWith('sudo ')) {
$commandWithoutSudo = $line->after('sudo ')->value();
// Escape single quotes for bash -c by replacing ' with '\''
$escapedCommand = str_replace("'", "'\\''", $commandWithoutSudo);
return "sudo bash -c '$escapedCommand'";
}
// For non-complex commands, apply the original logic
if (str($line)->contains('$(')) {
$line = $line->replace('$(', '$(sudo ');
}
if (str($line)->contains('||')) {
if (! $isComplexPipeCommand && str($line)->contains('||')) {
$line = $line->replace('||', '|| sudo');
}
if (str($line)->contains('&&')) {
if (! $isComplexPipeCommand && str($line)->contains('&&')) {
$line = $line->replace('&&', '&& sudo');
}
if (str($line)->contains(' | ')) {
// Don't insert sudo into pipes for complex commands
if (! $isComplexPipeCommand && str($line)->contains(' | ')) {
$line = $line->replace(' | ', ' | sudo ');
}

72
composer.lock generated
View file

@ -9514,16 +9514,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.3.2",
"version": "v7.3.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6"
"reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6",
"reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4",
"reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4",
"shasum": ""
},
"require": {
@ -9573,7 +9573,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.3.2"
"source": "https://github.com/symfony/http-foundation/tree/v7.3.7"
},
"funding": [
{
@ -9593,7 +9593,7 @@
"type": "tidelift"
}
],
"time": "2025-07-10T08:47:49+00:00"
"time": "2025-11-08T16:41:12+00:00"
},
{
"name": "symfony/http-kernel",
@ -9799,16 +9799,16 @@
},
{
"name": "symfony/mime",
"version": "v7.3.2",
"version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1"
"reference": "b1b828f69cbaf887fa835a091869e55df91d0e35"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1",
"reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1",
"url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35",
"reference": "b1b828f69cbaf887fa835a091869e55df91d0e35",
"shasum": ""
},
"require": {
@ -9863,7 +9863,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.3.2"
"source": "https://github.com/symfony/mime/tree/v7.3.4"
},
"funding": [
{
@ -9883,7 +9883,7 @@
"type": "tidelift"
}
],
"time": "2025-07-15T13:41:35+00:00"
"time": "2025-09-16T08:38:17+00:00"
},
{
"name": "symfony/options-resolver",
@ -10195,7 +10195,7 @@
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.32.0",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
@ -10258,7 +10258,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0"
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
@ -10269,6 +10269,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@ -10278,7 +10282,7 @@
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.32.0",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@ -10339,7 +10343,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
},
"funding": [
{
@ -10350,6 +10354,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@ -10359,7 +10367,7 @@
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.32.0",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@ -10420,7 +10428,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
},
"funding": [
{
@ -10431,6 +10439,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@ -10440,7 +10452,7 @@
},
{
"name": "symfony/polyfill-php80",
"version": "v1.32.0",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
@ -10500,7 +10512,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
"source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
},
"funding": [
{
@ -10511,6 +10523,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@ -10520,16 +10536,16 @@
},
{
"name": "symfony/polyfill-php83",
"version": "v1.32.0",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "2fb86d65e2d424369ad2905e83b236a8805ba491"
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491",
"reference": "2fb86d65e2d424369ad2905e83b236a8805ba491",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
"shasum": ""
},
"require": {
@ -10576,7 +10592,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0"
"source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
},
"funding": [
{
@ -10587,12 +10603,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
"time": "2025-07-08T02:45:35+00:00"
},
{
"name": "symfony/polyfill-uuid",

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.443',
'version' => '4.0.0-beta.444',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -1,13 +1,13 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.443"
},
"nightly": {
"version": "4.0.0-beta.444"
},
"nightly": {
"version": "4.0.0-beta.445"
},
"helper": {
"version": "1.0.11"
"version": "1.0.12"
},
"realtime": {
"version": "1.0.10"

View file

@ -8,8 +8,26 @@
'content' => null,
'closeOutside' => true,
'minWidth' => '36rem',
'maxWidth' => '48rem',
'isFullWidth' => false,
])
@php
$modalId = 'modal-' . uniqid();
@endphp
<style>
#{{ $modalId }} {
max-height: calc(100vh - 2rem);
}
@media (min-width: 1024px) {
#{{ $modalId }} {
min-width: {{ $minWidth }};
max-width: {{ $maxWidth }};
}
}
</style>
<div x-data="{ modalOpen: false }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
@ -38,14 +56,14 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@if ($closeOutside) @click="modalOpen=false" @endif
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full border rounded-sm drop-shadow-sm min-w-full lg:min-w-[{{ $minWidth }}] max-w-fit max-h-[calc(100vh-2rem)] bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
class="relative w-full border rounded-sm drop-shadow-sm min-w-full bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen=false"

View file

@ -66,7 +66,7 @@
@if ($application->fqdn)
<span class="flex gap-1 text-xs">{{ Str::limit($application->fqdn, 60) }}
@can('update', $service)
<x-modal-input title="Edit Domains" :closeOutside="false">
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem" maxWidth="40rem">
<x-slot:content>
<span class="cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg"

View file

@ -1,81 +0,0 @@
<?php
use App\Jobs\PullHelperImageJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('dispatches PullHelperImageJob when helper_version changes', function () {
Queue::fake();
// Create user and servers
$user = User::factory()->create();
$team = $user->teams()->first();
Server::factory()->count(3)->create(['team_id' => $team->id]);
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
// Change helper_version
$settings->helper_version = 'v1.2.3';
$settings->save();
// Verify PullHelperImageJob was dispatched for all servers
Queue::assertPushed(PullHelperImageJob::class, 3);
});
it('does not dispatch PullHelperImageJob when helper_version is unchanged', function () {
Queue::fake();
// Create user and servers
$user = User::factory()->create();
$team = $user->teams()->first();
Server::factory()->count(3)->create(['team_id' => $team->id]);
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
$currentVersion = $settings->helper_version;
// Set to same value
$settings->helper_version = $currentVersion;
$settings->save();
// Verify no jobs were dispatched
Queue::assertNotPushed(PullHelperImageJob::class);
});
it('does not dispatch PullHelperImageJob when other fields change', function () {
Queue::fake();
// Create user and servers
$user = User::factory()->create();
$team = $user->teams()->first();
Server::factory()->count(3)->create(['team_id' => $team->id]);
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
// Change different field
$settings->is_auto_update_enabled = ! $settings->is_auto_update_enabled;
$settings->save();
// Verify no jobs were dispatched
Queue::assertNotPushed(PullHelperImageJob::class);
});
it('detects helper_version changes with wasChanged', function () {
$changeDetected = false;
InstanceSettings::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('helper_version')) {
$changeDetected = true;
}
});
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
$settings->helper_version = 'v2.0.0';
$settings->save();
expect($changeDetected)->toBeTrue();
});

View file

@ -0,0 +1,310 @@
<?php
use App\Models\Server;
beforeEach(function () {
// Create a mock server with non-root user
$this->server = Mockery::mock(Server::class)->makePartial();
$this->server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$this->server->shouldReceive('setAttribute')->andReturnSelf();
$this->server->user = 'ubuntu';
});
afterEach(function () {
Mockery::close();
});
test('wraps complex Docker install command with pipes in bash -c', function () {
$commands = collect([
'curl https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe("sudo bash -c 'curl https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh'");
});
test('wraps complex Docker install command with multiple fallbacks', function () {
$commands = collect([
'curl --max-time 300 https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh -s -- --version 27.3',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe("sudo bash -c 'curl --max-time 300 https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh -s -- --version 27.3'");
});
test('wraps command with pipe to bash in bash -c', function () {
$commands = collect([
'curl https://example.com/script.sh | bash',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe("sudo bash -c 'curl https://example.com/script.sh | bash'");
});
test('wraps complex command with pipes and && operators', function () {
$commands = collect([
'curl https://example.com | sh && echo "done"',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh && echo \"done\"'");
});
test('escapes single quotes in complex piped commands', function () {
$commands = collect([
"curl https://example.com | sh -c 'echo \"test\"'",
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh -c '\\''echo \"test\"'\\'''");
});
test('handles simple command without pipes or operators', function () {
$commands = collect([
'apt-get update',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo apt-get update');
});
test('handles command with double ampersand operator but no pipes', function () {
$commands = collect([
'mkdir -p /foo && chown ubuntu /foo',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo mkdir -p /foo && sudo chown ubuntu /foo');
});
test('handles command with double pipe operator but no pipes', function () {
$commands = collect([
'command -v docker || echo "not found"',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
// 'command' is exempted from sudo, but echo gets sudo after ||
expect($result[0])->toBe('command -v docker || sudo echo "not found"');
});
test('handles command with simple pipe but no operators', function () {
$commands = collect([
'cat file | grep pattern',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo cat file | sudo grep pattern');
});
test('handles command with subshell $(...)', function () {
$commands = collect([
'echo $(whoami)',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
// 'echo' is exempted from sudo at the start
expect($result[0])->toBe('echo $(sudo whoami)');
});
test('skips sudo for cd commands', function () {
$commands = collect([
'cd /var/www',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('cd /var/www');
});
test('skips sudo for echo commands', function () {
$commands = collect([
'echo "test"',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('echo "test"');
});
test('skips sudo for command commands', function () {
$commands = collect([
'command -v docker',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('command -v docker');
});
test('skips sudo for true commands', function () {
$commands = collect([
'true',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('true');
});
test('handles if statements by adding sudo to condition', function () {
$commands = collect([
'if command -v docker',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('if sudo command -v docker');
});
test('skips sudo for fi statements', function () {
$commands = collect([
'fi',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('fi');
});
test('adds ownership changes for Coolify data paths', function () {
$commands = collect([
'mkdir -p /data/coolify/logs',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
// Note: The && operator adds another sudo, creating double sudo for chown/chmod
// This is existing behavior that may need refactoring but isn't part of this bug fix
expect($result[0])->toBe('sudo mkdir -p /data/coolify/logs && sudo sudo chown -R ubuntu:ubuntu /data/coolify/logs && sudo sudo chmod -R o-rwx /data/coolify/logs');
});
test('adds ownership changes for Coolify tmp paths', function () {
$commands = collect([
'mkdir -p /tmp/coolify/cache',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
// Note: The && operator adds another sudo, creating double sudo for chown/chmod
// This is existing behavior that may need refactoring but isn't part of this bug fix
expect($result[0])->toBe('sudo mkdir -p /tmp/coolify/cache && sudo sudo chown -R ubuntu:ubuntu /tmp/coolify/cache && sudo sudo chmod -R o-rwx /tmp/coolify/cache');
});
test('does not add ownership changes for system paths', function () {
$commands = collect([
'mkdir -p /var/log',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo mkdir -p /var/log');
});
test('handles multiple commands in sequence', function () {
$commands = collect([
'apt-get update',
'apt-get install -y docker',
'curl https://get.docker.com | sh',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result)->toHaveCount(3);
expect($result[0])->toBe('sudo apt-get update');
expect($result[1])->toBe('sudo apt-get install -y docker');
expect($result[2])->toBe("sudo bash -c 'curl https://get.docker.com | sh'");
});
test('handles empty command list', function () {
$commands = collect([]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result)->toBeArray();
expect($result)->toHaveCount(0);
});
test('handles real-world Docker installation command from InstallDocker action', function () {
$version = '27.3';
$commands = collect([
"curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$version}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$version}",
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toStartWith("sudo bash -c '");
expect($result[0])->toEndWith("'");
expect($result[0])->toContain('curl --max-time 300');
expect($result[0])->toContain('| sh');
expect($result[0])->toContain('||');
expect($result[0])->not->toContain('| sudo sh');
});
test('preserves command structure in wrapped bash -c', function () {
$commands = collect([
'curl https://example.com | sh || curl https://backup.com | sh',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
// The command should be wrapped without breaking the pipe and fallback structure
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh || curl https://backup.com | sh'");
// Verify it doesn't contain broken patterns like "| sudo sh"
expect($result[0])->not->toContain('| sudo sh');
expect($result[0])->not->toContain('|| sudo curl');
});
test('handles command with mixed operators and subshells', function () {
$commands = collect([
'docker ps || echo $(date)',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
// This should use the original logic since it's not a complex pipe command
expect($result[0])->toBe('sudo docker ps || sudo echo $(sudo date)');
});
test('handles whitespace-only commands gracefully', function () {
$commands = collect([
' ',
'',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result)->toHaveCount(2);
});
test('detects pipe to sh with additional arguments', function () {
$commands = collect([
'curl https://example.com | sh -s -- --arg1 --arg2',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh -s -- --arg1 --arg2'");
});
test('handles command chains with both && and || operators with pipes', function () {
$commands = collect([
'curl https://first.com | sh && echo "success" || curl https://backup.com | sh',
]);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toStartWith("sudo bash -c '");
expect($result[0])->toEndWith("'");
expect($result[0])->not->toContain('| sudo');
});

View file

@ -1,13 +1,13 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.443"
},
"nightly": {
"version": "4.0.0-beta.444"
},
"nightly": {
"version": "4.0.0-beta.445"
},
"helper": {
"version": "1.0.11"
"version": "1.0.12"
},
"realtime": {
"version": "1.0.10"