merge: merge next branch into feat-traefik-version-checker

Merged latest changes from the next branch to keep the feature branch
up to date. No conflicts were encountered during the merge.

Changes from next branch:
- Updated application deployment job error logging
- Updated server manager job and instance settings
- Removed PullHelperImageJob in favor of updated approach
- Database migration refinements
- Updated versions.json with latest component versions

All automatic merges were successful and no manual conflict resolution
was required.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2025-11-17 14:56:24 +01:00
11 changed files with 538 additions and 267 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 @@ class UpdateCoolify
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

@@ -976,7 +976,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} catch (Exception $e) {
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
if ($forceFail) {
throw new DeploymentException($e->getMessage(), 69420);
throw new DeploymentException(get_class($e).': '.$e->getMessage(), $e->getCode(), $e);
}
}
}
@@ -1610,6 +1610,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function rolling_update()
{
try {
$this->checkForCancellation();
if ($this->server->isSwarm()) {
$this->application_deployment_queue->addLogEntry('Rolling update started.');
@@ -1653,10 +1654,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
}
}
} catch (Exception $e) {
throw new DeploymentException('Rolling update failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
}
}
private function health_check()
{
try {
if ($this->server->isSwarm()) {
// Implement healthcheck for swarm
} else {
@@ -1728,6 +1733,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
}
} catch (Exception $e) {
throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
}
}
private function query_logs()
@@ -3034,6 +3042,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function stop_running_container(bool $force = false)
{
try {
$this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
@@ -3059,10 +3068,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->failDeployment();
$this->graceful_shutdown_container($this->container_name);
}
} catch (Exception $e) {
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
}
}
private function start_by_compose_file()
{
try {
// Ensure .env file exists before docker compose tries to load it (defensive programming)
$this->execute_remote_command(
["touch {$this->configuration_dir}/.env", 'hidden' => true],
@@ -3086,6 +3099,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
$this->application_deployment_queue->addLogEntry('New container started.');
} catch (Exception $e) {
throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e);
}
}
private function analyzeBuildTimeVariables($variables)
@@ -3829,7 +3845,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
* Fail the deployment.
* Sends failure notification and queues next deployment.
*/
private function failDeployment(): void
protected function failDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
}
@@ -3837,8 +3853,38 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
public function failed(Throwable $exception): void
{
$this->failDeployment();
// Log comprehensive error information
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
$errorCode = $exception->getCode();
$errorClass = get_class($exception);
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
$this->application_deployment_queue->addLogEntry("Error type: {$errorClass}", 'stderr', hidden: true);
$this->application_deployment_queue->addLogEntry("Error code: {$errorCode}", 'stderr', hidden: true);
// Log the exception file and line for debugging
$this->application_deployment_queue->addLogEntry("Location: {$exception->getFile()}:{$exception->getLine()}", 'stderr', hidden: true);
// Log previous exceptions if they exist (for chained exceptions)
$previous = $exception->getPrevious();
if ($previous) {
$this->application_deployment_queue->addLogEntry('Caused by:', 'stderr', hidden: true);
$previousMessage = $previous->getMessage() ?: 'No message';
$previousClass = get_class($previous);
$this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true);
$this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true);
}
// Log first few lines of stack trace for debugging
$trace = $exception->getTraceAsString();
$traceLines = explode("\n", $trace);
$this->application_deployment_queue->addLogEntry('Stack trace (first 5 lines):', 'stderr', hidden: true);
foreach (array_slice($traceLines, 0, 5) as $traceLine) {
$this->application_deployment_queue->addLogEntry(" {$traceLine}", 'stderr', hidden: true);
}
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
if ($this->application->build_pack !== 'dockercompose') {
$code = $exception->getCode();

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

@@ -87,7 +87,7 @@ class ServerManagerJob implements ShouldQueue
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
'error' => get_class($e).': '.$e->getMessage(),
]);
}
});
@@ -103,7 +103,7 @@ class ServerManagerJob implements ShouldQueue
Log::channel('scheduled-errors')->error('Error processing server tasks', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
'error' => get_class($e).': '.$e->getMessage(),
]);
}
}

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

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

View File

@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.444"
"version": "4.0.0-beta.445"
},
"nightly": {
"version": "4.0.0-beta.445"
"version": "4.0.0-beta.446"
},
"helper": {
"version": "1.0.12"

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,344 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
/**
* Test to verify that deployment errors are properly logged with comprehensive details.
*
* This test suite verifies the fix for issue #7113 where deployments fail without
* clear error messages. The fix ensures that all deployment failures log:
* - The exception message
* - The exception type/class
* - The exception code (if present)
* - The file and line where the error occurred
* - Previous exception details (if chained)
* - Stack trace (first 5 lines)
*/
it('logs comprehensive error details when failed() is called', function () {
// Create a mock exception with all properties
$innerException = new \RuntimeException('Connection refused', 111);
$exception = new DeploymentException(
'Failed to start container',
500,
$innerException
);
// Mock the application deployment queue
$mockQueue = Mockery::mock(ApplicationDeploymentQueue::class);
$logEntries = [];
// Capture all log entries
$mockQueue->shouldReceive('addLogEntry')
->withArgs(function ($message, $type = 'stdout', $hidden = false) use (&$logEntries) {
$logEntries[] = ['message' => $message, 'type' => $type, 'hidden' => $hidden];
return true;
})
->atLeast()->once();
$mockQueue->shouldReceive('update')->andReturn(true);
// Mock Application and its relationships
$mockApplication = Mockery::mock(Application::class);
$mockApplication->shouldReceive('getAttribute')
->with('build_pack')
->andReturn('dockerfile');
$mockApplication->shouldReceive('setAttribute')
->with('build_pack', 'dockerfile')
->andReturnSelf();
$mockApplication->build_pack = 'dockerfile';
$mockSettings = Mockery::mock();
$mockSettings->shouldReceive('getAttribute')
->with('is_consistent_container_name_enabled')
->andReturn(false);
$mockSettings->shouldReceive('getAttribute')
->with('custom_internal_name')
->andReturn('');
$mockSettings->shouldReceive('setAttribute')
->andReturnSelf();
$mockSettings->is_consistent_container_name_enabled = false;
$mockSettings->custom_internal_name = '';
$mockApplication->shouldReceive('getAttribute')
->with('settings')
->andReturn($mockSettings);
// Use reflection to set private properties and call the failed() method
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$reflection = new \ReflectionClass(ApplicationDeploymentJob::class);
$queueProperty = $reflection->getProperty('application_deployment_queue');
$queueProperty->setAccessible(true);
$queueProperty->setValue($job, $mockQueue);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $mockApplication);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$containerNameProperty = $reflection->getProperty('container_name');
$containerNameProperty->setAccessible(true);
$containerNameProperty->setValue($job, 'test-container');
// Mock the failDeployment method to prevent errors
$job->shouldReceive('failDeployment')->andReturn();
$job->shouldReceive('execute_remote_command')->andReturn();
// Call the failed method
$failedMethod = $reflection->getMethod('failed');
$failedMethod->setAccessible(true);
$failedMethod->invoke($job, $exception);
// Verify comprehensive error logging
$errorMessages = array_column($logEntries, 'message');
$errorMessageString = implode("\n", $errorMessages);
// Check that all critical information is logged
expect($errorMessageString)->toContain('Deployment failed: Failed to start container');
expect($errorMessageString)->toContain('Error type: App\Exceptions\DeploymentException');
expect($errorMessageString)->toContain('Error code: 500');
expect($errorMessageString)->toContain('Location:');
expect($errorMessageString)->toContain('Caused by:');
expect($errorMessageString)->toContain('RuntimeException: Connection refused');
expect($errorMessageString)->toContain('Stack trace');
// Verify stderr type is used for error logging
$stderrEntries = array_filter($logEntries, fn ($entry) => $entry['type'] === 'stderr');
expect(count($stderrEntries))->toBeGreaterThan(0);
// Verify that the main error message is NOT hidden
$mainErrorEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Deployment failed: Failed to start container'));
expect($mainErrorEntry['hidden'])->toBeFalse();
// Verify that technical details ARE hidden
$errorTypeEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Error type:'));
expect($errorTypeEntry['hidden'])->toBeTrue();
$errorCodeEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Error code:'));
expect($errorCodeEntry['hidden'])->toBeTrue();
$locationEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Location:'));
expect($locationEntry['hidden'])->toBeTrue();
$stackTraceEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Stack trace'));
expect($stackTraceEntry['hidden'])->toBeTrue();
$causedByEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Caused by:'));
expect($causedByEntry['hidden'])->toBeTrue();
});
it('handles exceptions with no message gracefully', function () {
$exception = new \Exception;
$mockQueue = Mockery::mock(ApplicationDeploymentQueue::class);
$logEntries = [];
$mockQueue->shouldReceive('addLogEntry')
->withArgs(function ($message, $type = 'stdout', $hidden = false) use (&$logEntries) {
$logEntries[] = ['message' => $message, 'type' => $type, 'hidden' => $hidden];
return true;
})
->atLeast()->once();
$mockQueue->shouldReceive('update')->andReturn(true);
$mockApplication = Mockery::mock(Application::class);
$mockApplication->shouldReceive('getAttribute')
->with('build_pack')
->andReturn('dockerfile');
$mockApplication->shouldReceive('setAttribute')
->with('build_pack', 'dockerfile')
->andReturnSelf();
$mockApplication->build_pack = 'dockerfile';
$mockSettings = Mockery::mock();
$mockSettings->shouldReceive('getAttribute')
->with('is_consistent_container_name_enabled')
->andReturn(false);
$mockSettings->shouldReceive('getAttribute')
->with('custom_internal_name')
->andReturn('');
$mockSettings->shouldReceive('setAttribute')
->andReturnSelf();
$mockSettings->is_consistent_container_name_enabled = false;
$mockSettings->custom_internal_name = '';
$mockApplication->shouldReceive('getAttribute')
->with('settings')
->andReturn($mockSettings);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$reflection = new \ReflectionClass(ApplicationDeploymentJob::class);
$queueProperty = $reflection->getProperty('application_deployment_queue');
$queueProperty->setAccessible(true);
$queueProperty->setValue($job, $mockQueue);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $mockApplication);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$containerNameProperty = $reflection->getProperty('container_name');
$containerNameProperty->setAccessible(true);
$containerNameProperty->setValue($job, 'test-container');
$job->shouldReceive('failDeployment')->andReturn();
$job->shouldReceive('execute_remote_command')->andReturn();
$failedMethod = $reflection->getMethod('failed');
$failedMethod->setAccessible(true);
$failedMethod->invoke($job, $exception);
$errorMessages = array_column($logEntries, 'message');
$errorMessageString = implode("\n", $errorMessages);
// Should log "Unknown error occurred" for empty messages
expect($errorMessageString)->toContain('Unknown error occurred');
expect($errorMessageString)->toContain('Error type:');
});
it('wraps exceptions in deployment methods with DeploymentException', function () {
// Verify that our deployment methods wrap exceptions properly
$originalException = new \RuntimeException('Container not found');
try {
throw new DeploymentException('Failed to start container', 0, $originalException);
} catch (DeploymentException $e) {
expect($e->getMessage())->toBe('Failed to start container');
expect($e->getPrevious())->toBe($originalException);
expect($e->getPrevious()->getMessage())->toBe('Container not found');
}
});
it('logs error code 0 correctly', function () {
// Verify that error code 0 is logged (previously skipped due to falsy check)
$exception = new \Exception('Test error', 0);
$mockQueue = Mockery::mock(ApplicationDeploymentQueue::class);
$logEntries = [];
$mockQueue->shouldReceive('addLogEntry')
->withArgs(function ($message, $type = 'stdout', $hidden = false) use (&$logEntries) {
$logEntries[] = ['message' => $message, 'type' => $type, 'hidden' => $hidden];
return true;
})
->atLeast()->once();
$mockQueue->shouldReceive('update')->andReturn(true);
$mockApplication = Mockery::mock(Application::class);
$mockApplication->shouldReceive('getAttribute')
->with('build_pack')
->andReturn('dockerfile');
$mockApplication->shouldReceive('setAttribute')
->with('build_pack', 'dockerfile')
->andReturnSelf();
$mockApplication->build_pack = 'dockerfile';
$mockSettings = Mockery::mock();
$mockSettings->shouldReceive('getAttribute')
->with('is_consistent_container_name_enabled')
->andReturn(false);
$mockSettings->shouldReceive('getAttribute')
->with('custom_internal_name')
->andReturn('');
$mockSettings->shouldReceive('setAttribute')
->andReturnSelf();
$mockSettings->is_consistent_container_name_enabled = false;
$mockSettings->custom_internal_name = '';
$mockApplication->shouldReceive('getAttribute')
->with('settings')
->andReturn($mockSettings);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$reflection = new \ReflectionClass(ApplicationDeploymentJob::class);
$queueProperty = $reflection->getProperty('application_deployment_queue');
$queueProperty->setAccessible(true);
$queueProperty->setValue($job, $mockQueue);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $mockApplication);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$containerNameProperty = $reflection->getProperty('container_name');
$containerNameProperty->setAccessible(true);
$containerNameProperty->setValue($job, 'test-container');
$job->shouldReceive('failDeployment')->andReturn();
$job->shouldReceive('execute_remote_command')->andReturn();
$failedMethod = $reflection->getMethod('failed');
$failedMethod->setAccessible(true);
$failedMethod->invoke($job, $exception);
$errorMessages = array_column($logEntries, 'message');
$errorMessageString = implode("\n", $errorMessages);
// Should log error code 0 (not skip it)
expect($errorMessageString)->toContain('Error code: 0');
});
it('preserves original exception type in wrapped DeploymentException messages', function () {
// Verify that when wrapping exceptions, the original exception type is included in the message
$originalException = new \RuntimeException('Connection timeout');
// Test rolling update scenario
$wrappedException = new DeploymentException(
'Rolling update failed ('.get_class($originalException).'): '.$originalException->getMessage(),
$originalException->getCode(),
$originalException
);
expect($wrappedException->getMessage())->toContain('RuntimeException');
expect($wrappedException->getMessage())->toContain('Connection timeout');
expect($wrappedException->getPrevious())->toBe($originalException);
// Test health check scenario
$healthCheckException = new \InvalidArgumentException('Invalid health check URL');
$wrappedHealthCheck = new DeploymentException(
'Health check failed ('.get_class($healthCheckException).'): '.$healthCheckException->getMessage(),
$healthCheckException->getCode(),
$healthCheckException
);
expect($wrappedHealthCheck->getMessage())->toContain('InvalidArgumentException');
expect($wrappedHealthCheck->getMessage())->toContain('Invalid health check URL');
expect($wrappedHealthCheck->getPrevious())->toBe($healthCheckException);
// Test docker registry push scenario
$registryException = new \RuntimeException('Failed to authenticate');
$wrappedRegistry = new DeploymentException(
get_class($registryException).': '.$registryException->getMessage(),
$registryException->getCode(),
$registryException
);
expect($wrappedRegistry->getMessage())->toContain('RuntimeException');
expect($wrappedRegistry->getMessage())->toContain('Failed to authenticate');
expect($wrappedRegistry->getPrevious())->toBe($registryException);
});

View File

@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.444"
"version": "4.0.0-beta.445"
},
"nightly": {
"version": "4.0.0-beta.445"
"version": "4.0.0-beta.446"
},
"helper": {
"version": "1.0.12"