diff --git a/.gitignore b/.gitignore index 4f31722..2d7c11b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ yarn-error.log /.vscode /.zed amazeeai-trial.pass +/*.sql diff --git a/.lagoon.env b/.lagoon.env index f478270..25abbf2 100644 --- a/.lagoon.env +++ b/.lagoon.env @@ -37,18 +37,16 @@ QUEUE_CONNECTION=redis # No environment vars # Freedom Tech PHP Lagoon Client Settings -FTLAGOON_PRIVATE_KEY_FILE=storage/ftlagoon/.ssh/id_rsa -FTLAGOON_TOKEN_CACHE_DIR=storage/ftlagoon/.tokencache/ +FTLAGOON_PRIVATE_KEY_FILE=/app/storage/ftlagoon/.ssh/polydock +FTLAGOON_TOKEN_CACHE_DIR=/app/storage/ftlagoon/.tokencache/ -FTLAGOON_ENDPOINT=https://api.main.lagoon-core.test6.amazee.io/graphql -FTLAGOON_SSH_USER=lagoon -FTLAGOON_SSH_PORT=22 -FTLAGOON_SSH_SERVER=ssh.main.lagoon-core.test6.amazee.io -FTLAGOON_SSH_PRIVATE_KEY_FILE=/home/bryan/.ssh/id_rsa +# FTLAGOON_ENDPOINT=https://api.main.lagoon-core.test6.amazee.io/graphql +# FTLAGOON_SSH_USER=lagoon +# FTLAGOON_SSH_PORT=22 +# FTLAGOON_SSH_SERVER=ssh.main.lagoon-core.test6.amazee.io +# FTLAGOON_PRIVATE_KEY_FILE=storage/ftlagoon/.ssh/polydock # FTLAGOON_ENDPOINT=https://api.lagoon.amazeeio.cloud/graphql # FTLAGOON_SSH_USER=lagoon # FTLAGOON_SSH_PORT=32222 # FTLAGOON_SSH_SERVER=ssh.lagoon.amazeeio.cloud -# FTLAGOON_SSH_PRIVATE_KEY_FILE=/home/bryan/.ssh/id_rsa - diff --git a/app/Console/Commands/CreateStore.php b/app/Console/Commands/CreateStore.php index 59f9c35..92c2a0b 100644 --- a/app/Console/Commands/CreateStore.php +++ b/app/Console/Commands/CreateStore.php @@ -108,11 +108,12 @@ public function handle() 'lagoon_deploy_region_id_ext' => $regionId, 'lagoon_deploy_project_prefix' => $prefix, 'lagoon_deploy_organization_id_ext' => $orgId, - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => $aiRegionId, 'lagoon_deploy_group_name' => $groupName, ]); + $store->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); + $this->info("✅ Store '{$store->name}' created successfully with ID: {$store->id}"); return 0; diff --git a/app/Console/Commands/CreateStoreApp.php b/app/Console/Commands/CreateStoreApp.php index 53d43a2..aae545b 100644 --- a/app/Console/Commands/CreateStoreApp.php +++ b/app/Console/Commands/CreateStoreApp.php @@ -5,6 +5,7 @@ use App\Enums\PolydockStoreAppStatusEnum; use App\Models\PolydockStore; use App\Models\PolydockStoreApp; +use App\Services\PolydockAppClassDiscovery; use Illuminate\Console\Command; class CreateStoreApp extends Command @@ -72,7 +73,23 @@ public function handle(): int // Gather app information $name = $this->option('name') ?? $this->ask('App name'); - $appClass = $this->option('app-class') ?? $this->ask('Polydock app class'); + $discovery = app(PolydockAppClassDiscovery::class); + $availableClasses = $discovery->getAvailableAppClasses(); + $appClass = $this->option('app-class'); + if ($appClass) { + if (! $discovery->isValidAppClass($appClass)) { + $this->error("Invalid app class: {$appClass}. Must be a concrete PolydockAppInterface implementation."); + + return 1; + } + } else { + if (empty($availableClasses)) { + $this->error('No Polydock app classes found. Ensure packages are installed correctly.'); + + return 1; + } + $appClass = $this->choice('Select Polydock app class', array_keys($availableClasses)); + } $description = $this->option('description') ?? $this->ask('App description'); $author = $this->option('author') ?? $this->ask('Author'); $website = $this->option('website') ?? $this->ask('Author website'); diff --git a/app/Filament/Admin/Resources/ApiTokenResource.php b/app/Filament/Admin/Resources/ApiTokenResource.php new file mode 100644 index 0000000..99e9af5 --- /dev/null +++ b/app/Filament/Admin/Resources/ApiTokenResource.php @@ -0,0 +1,84 @@ +where('tokenable_type', User::class); + } + + #[\Override] + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('tokenable.email') + ->label('Owner') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('abilities') + ->formatStateUsing( + static fn (mixed $state): string => is_array($state) ? implode(', ', $state) : (string) $state + ) + ->label('Abilities') + ->wrap(), + Tables\Columns\TextColumn::make('last_used_at') + ->dateTime() + ->sortable() + ->placeholder('Never'), + Tables\Columns\TextColumn::make('expires_at') + ->dateTime() + ->sortable() + ->placeholder('Never'), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable(), + ]) + ->actions([ + Tables\Actions\DeleteAction::make() + ->label('Revoke'), + ]) + ->bulkActions([]); + } + + #[\Override] + public static function canCreate(): bool + { + return false; + } + + #[\Override] + public static function getPages(): array + { + return [ + 'index' => Pages\ListApiTokens::route('/'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/ApiTokenResource/Pages/ListApiTokens.php b/app/Filament/Admin/Resources/ApiTokenResource/Pages/ListApiTokens.php new file mode 100644 index 0000000..29eeb14 --- /dev/null +++ b/app/Filament/Admin/Resources/ApiTokenResource/Pages/ListApiTokens.php @@ -0,0 +1,73 @@ +label('Create Token') + ->icon('heroicon-o-plus') + ->modalHeading('Create API token') + ->modalDescription('The token will only be shown once after creation.') + ->form([ + Forms\Components\Select::make('user_id') + ->label('Owner user') + ->required() + ->searchable() + ->preload() + ->options(User::query()->orderBy('email')->pluck('email', 'id')), + Forms\Components\TextInput::make('token_name') + ->label('Token name') + ->required() + ->maxLength(255), + Forms\Components\CheckboxList::make('abilities') + ->label('Abilities') + ->options([ + 'instances.read' => 'instances.read', + 'instances.write' => 'instances.write', + '*' => '* (full access)', + ]) + ->default(['instances.read']) + ->required() + ->minItems(1) + ->columns(1), + Forms\Components\DateTimePicker::make('expires_at') + ->label('Expires at') + ->seconds(false), + ]) + ->action(function (array $data): void { + /** @var User $user */ + $user = User::query()->findOrFail($data['user_id']); + $expiresAt = ! empty($data['expires_at']) ? Carbon::parse((string) $data['expires_at']) : null; + $token = $user->createToken( + name: (string) $data['token_name'], + abilities: array_values($data['abilities']), + expiresAt: $expiresAt, + ); + + Notification::make() + ->title('API token created') + ->body("Copy this token now. It will not be shown again:\n{$token->plainTextToken}") + ->success() + ->persistent() + ->send(); + }), + ]; + } +} diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 59e5fef..6934d89 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -8,6 +8,7 @@ use App\Models\PolydockAppInstance; use App\PolydockEngine\Helpers\AmazeeAiBackendHelper; use App\PolydockEngine\Helpers\LagoonHelper; +use App\Services\PolydockAppClassDiscovery; use Filament\Forms; use Filament\Forms\Form; use Filament\Infolists\Infolist; @@ -17,6 +18,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; class PolydockAppInstanceResource extends Resource @@ -48,10 +50,12 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->searchable() ->columns([ TextColumn::make('name') ->description(fn ($record) => $record->storeApp->store->name.' - '.$record->storeApp->name) - ->searchable(), + ->searchable() + ->sortable(), TextColumn::make('userGroup.name') ->label('User Group') ->searchable(), @@ -59,31 +63,49 @@ public static function table(Table $table): Table ->badge() ->color(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getColor()) ->icon(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getIcon()) - ->formatStateUsing(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getLabel()), + ->formatStateUsing(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getLabel()) + ->sortable(), TextColumn::make('is_trial') ->state(fn ($record) => $record->is_trial ? 'Yes' : 'No') ->label('Trial'), TextColumn::make('trial_ends_at') ->label('Trial Ends At') ->description(fn ($record) => $record->trial_completed ? 'Trial completed' : 'Trial active') - ->dateTime(), + ->dateTime() + ->sortable(), TextColumn::make('send_midtrial_email_at') ->label('Midtrial Email') ->description(fn ($record) => $record->midtrial_email_sent ? 'Sent' : 'Pending') - ->state(fn ($record) => $record->send_midtrial_email_at ? $record->send_midtrial_email_at->format('Y-m-d H:i:s') : ''), + ->state(fn ($record) => $record->send_midtrial_email_at + ? $record->send_midtrial_email_at->format('Y-m-d H:i:s') + : ''), TextColumn::make('send_one_day_left_email_at') ->label('1D Left Email') ->description(fn ($record) => $record->one_day_left_email_sent ? 'Sent' : 'Pending') - ->state(fn ($record) => $record->send_one_day_left_email_at ? $record->send_one_day_left_email_at->format('Y-m-d H:i:s') : ''), + ->state(fn ($record) => $record->send_one_day_left_email_at + ? $record->send_one_day_left_email_at->format('Y-m-d H:i:s') + : ''), TextColumn::make('trial_complete_email_sent') ->label('Trial Complete Email') - ->state(fn ($record) => ($record->is_trial && $record->trial_complete_email_sent) ? 'Sent' : ($record->is_trial ? 'Pending' : '')), + ->state(fn ($record) => $record->is_trial && $record->trial_complete_email_sent + ? 'Sent' + : ($record->is_trial ? 'Pending' : '')), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('status') - ->options(collect(PolydockAppInstanceStatus::cases()) - ->mapWithKeys(fn ($status) => [$status->value => $status->getLabel()]) - ->toArray()) + ->options( + collect(PolydockAppInstanceStatus::cases()) + ->mapWithKeys(fn ($status) => [$status->value => $status->getLabel()]) + ->toArray(), + ) ->multiple() ->label('Instance Status') ->indicator('Status'), @@ -165,7 +187,8 @@ public static function table(Table $table): Table ->actions([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), - ])->headerActions([ + ]) + ->headerActions([ ExportAction::make() ->label('Export registrations') ->exporter(UserRemoteRegistrationExporter::class), @@ -205,10 +228,19 @@ public static function infolist(Infolist $infolist): Infolist ->schema([ \Filament\Infolists\Components\TextEntry::make('storeApp.lagoon_deploy_region_id_ext') ->label('Deploy Region') - ->formatStateUsing(fn ($state) => LagoonHelper::getLagoonCodeDataValueForRegion($state, 'name')), - \Filament\Infolists\Components\TextEntry::make('storeApp.amazee_ai_backend_region_id_ext') + ->formatStateUsing( + fn ($state) => LagoonHelper::getLagoonCodeDataValueForRegion($state, 'name'), + ), + \Filament\Infolists\Components\TextEntry::make( + 'storeApp.amazee_ai_backend_region_id_ext', + ) ->label('AI Backend Region') - ->formatStateUsing(fn ($state) => AmazeeAiBackendHelper::getAmazeeAiBackendCodeDataValueForRegion($state, 'name')), + ->formatStateUsing( + fn ($state) => AmazeeAiBackendHelper::getAmazeeAiBackendCodeDataValueForRegion( + $state, + 'name', + ), + ), ]), ]) ->columnSpan(2), @@ -229,6 +261,13 @@ public static function infolist(Infolist $infolist): Infolist ]) ->columnSpan(1), + \Filament\Infolists\Components\Section::make('Instance Configuration') + ->description('Instance-specific settings configured at creation.') + ->schema(fn ($record) => self::getRenderedInstanceConfigForRecord($record)) + ->visible(fn ($record) => self::hasInstanceConfigFields($record)) + ->columnSpan(3) + ->collapsible(), + \Filament\Infolists\Components\Section::make('Instance Data') ->description('Safe data that can be shared with webhooks') ->schema(fn ($record) => self::getRenderedSafeDataForRecord($record)) @@ -241,12 +280,17 @@ public static function infolist(Infolist $infolist): Infolist public static function getRenderedSafeDataForRecord(PolydockAppInstance $record): array { $safeData = $record->data; + $sensitiveKeys = $record->getSensitiveDataKeys(); $renderedArray = []; foreach ($safeData as $key => $value) { - if ($record->shouldFilterKey($key, $record->getSensitiveDataKeys())) { + if ($record->shouldFilterKey($key, $sensitiveKeys)) { $value = 'REDACTED'; } + if ($value === null) { + $value = 'N/A'; + } + $renderKey = 'webhook_data_'.$key; $renderedItem = \Filament\Infolists\Components\TextEntry::make($renderKey) ->label($key) @@ -266,6 +310,76 @@ public static function getRenderedSafeDataForRecord(PolydockAppInstance $record) return $renderedArray; } + /** + * Check if the record's app class defines instance configuration fields. + */ + public static function hasInstanceConfigFields(PolydockAppInstance $record): bool + { + $storeApp = $record->storeApp; + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return false; + } + + $discovery = app(PolydockAppClassDiscovery::class); + + return ! empty($discovery->getAppInstanceInfolistSchema($storeApp->polydock_app_class)); + } + + /** + * Get rendered infolist components for instance configuration fields. + * + * Values are loaded from PolydockVariables associated with the app instance. + */ + public static function getRenderedInstanceConfigForRecord(PolydockAppInstance $record): array + { + $storeApp = $record->storeApp; + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return []; + } + + $discovery = app(PolydockAppClassDiscovery::class); + $fieldNames = $discovery->getAppInstanceFormFieldNames($storeApp->polydock_app_class); + + if (empty($fieldNames)) { + return []; + } + + // Build a simple display of instance config values from PolydockVariables + $renderedArray = []; + $instanceConfigPrefix = PolydockAppInstanceFields::FIELD_PREFIX; + + foreach ($fieldNames as $fieldName) { + $value = $record->getPolydockVariableValue($fieldName); + + // Create a human-readable label from the field name + // e.g., "instance_config_ai_model_override" -> "Ai Model Override" + $labelName = str_replace($instanceConfigPrefix, '', $fieldName); + $labelName = str_replace('_', ' ', $labelName); + $labelName = ucwords($labelName); + + // Check if value should be masked (for encrypted fields) + $isEncrypted = $record->isPolydockVariableEncrypted($fieldName); + + $renderedItem = \Filament\Infolists\Components\TextEntry::make('instance_config_display_'.$fieldName) + ->label($labelName); + + if ($isEncrypted && $value !== null && $value !== '') { + // Mask encrypted values + $renderedItem->state('********'); + } elseif ($value === null || $value === '') { + $renderedItem->state('Not configured') + ->color('gray'); + } else { + $renderedItem->state($value); + } + + $renderedArray[] = $renderedItem; + } + + return $renderedArray; + } + + #[\Override] public static function getRelations(): array { return [ @@ -276,7 +390,7 @@ public static function getRelations(): array #[\Override] public static function canCreate(): bool { - return false; + return true; } #[\Override] @@ -284,6 +398,7 @@ public static function getPages(): array { return [ 'index' => Pages\ListPolydockAppInstances::route('/'), + 'create' => Pages\CreatePolydockAppInstance::route('/create'), 'view' => Pages\ViewPolydockAppInstance::route('/{record}'), 'edit' => Pages\EditPolydockAppInstance::route('/{record}/edit'), ]; diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php index a4d0055..dc918c8 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/CreatePolydockAppInstance.php @@ -2,10 +2,251 @@ namespace App\Filament\Admin\Resources\PolydockAppInstanceResource\Pages; +use App\Enums\PolydockStoreAppStatusEnum; use App\Filament\Admin\Resources\PolydockAppInstanceResource; -use Filament\Resources\Pages\CreateRecord; +use App\Jobs\ProcessUserRemoteRegistration; +use App\Models\PolydockStoreApp; +use App\Models\UserRemoteRegistration; +use App\Services\PolydockAppClassDiscovery; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Form; +use Filament\Forms\Get; +use Filament\Notifications\Notification; +use Filament\Resources\Pages\Page; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; +use Illuminate\Support\Facades\Log; -class CreatePolydockAppInstance extends CreateRecord +class CreatePolydockAppInstance extends Page { protected static string $resource = PolydockAppInstanceResource::class; + + protected static string $view = 'filament.admin.pages.create-polydock-app-instance'; + + protected static ?string $title = 'Create App Instance'; + + protected static ?string $navigationIcon = 'heroicon-o-plus-circle'; + + public ?array $data = []; + + public function mount(): void + { + $this->form->fill([ + 'aup_and_privacy_acceptance' => true, + 'opt_in_to_product_updates' => true, + 'is_trial' => true, + 'custom_fields' => [], + ]); + } + + #[\Override] + public function form(Form $form): Form + { + return $form + ->schema([ + Section::make('User Information') + ->description('Enter the user details for the instance owner') + ->schema([ + TextInput::make('email') + ->email() + ->required() + ->maxLength(255) + ->placeholder('user@example.com') + ->columnSpan(2), + TextInput::make('first_name') + ->required() + ->maxLength(255) + ->placeholder('John'), + TextInput::make('last_name') + ->required() + ->maxLength(255) + ->placeholder('Doe'), + TextInput::make('organization') + ->maxLength(255) + ->placeholder('Acme Inc.'), + TextInput::make('job_title') + ->maxLength(255) + ->placeholder('Developer'), + ]) + ->columns(2), + + Section::make('App Configuration') + ->description('Select the app and configure instance settings') + ->schema([ + Select::make('trial_app') + ->label('Store App') + ->options( + PolydockStoreApp::query() + ->where('available_for_trials', true) + ->where('status', PolydockStoreAppStatusEnum::AVAILABLE) + ->get() + ->mapWithKeys(fn ($app) => [$app->uuid => $app->name.' ('.$app->store->name.')']), + ) + ->required() + ->searchable() + ->live() + ->placeholder('Select an app'), + Toggle::make('is_trial') + ->label('Is Trial Instance') + ->default(true) + ->helperText( + 'If enabled, trial duration and emails will be configured based on the store app settings', + ), + ]), + + Section::make('Consent & Preferences') + ->description('These are auto-accepted for admin-created instances') + ->schema([ + Toggle::make('aup_and_privacy_acceptance') + ->label('AUP and Privacy Acceptance') + ->default(true) + ->disabled() + ->dehydrated(), + Toggle::make('opt_in_to_product_updates') + ->label('Opt-in to Product Updates') + ->default(true), + ]) + ->columns(2), + + Section::make('Instance Configuration') + ->description('Configure instance-specific settings defined by the app class.') + ->schema(function (Get $get): array { + $storeAppUuid = $get('trial_app'); + if (empty($storeAppUuid)) { + return []; + } + + $storeApp = PolydockStoreApp::where('uuid', $storeAppUuid)->first(); + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return []; + } + + return app(PolydockAppClassDiscovery::class) + ->getAppInstanceFormSchema($storeApp->polydock_app_class); + }) + ->visible(function (Get $get): bool { + $storeAppUuid = $get('trial_app'); + if (empty($storeAppUuid)) { + return false; + } + + $storeApp = PolydockStoreApp::where('uuid', $storeAppUuid)->first(); + if (! $storeApp || empty($storeApp->polydock_app_class)) { + return false; + } + + return ! empty(app(PolydockAppClassDiscovery::class) + ->getAppInstanceFormSchema($storeApp->polydock_app_class)); + }) + ->collapsible() + ->columnSpanFull(), + + Grid::make(2)->schema([ + Section::make('Custom Fields') + ->description( + 'Add additional key-value data to pass through to the Polydock webhooks (e.g. to be consumed by n8n)', + ) + ->schema([ + KeyValue::make('custom_fields') + ->label('') + ->keyLabel('Field Name') + ->valueLabel('Value') + ->addActionLabel('Add Custom Field') + ->reorderable() + ->columnSpanFull(), + ]) + ->columnSpan(1), + ]), + ]) + ->statePath('data'); + } + + public function create(): void + { + $data = $this->form->getState(); + + $safeLogData = [ + 'email' => $data['email'] ?? null, + 'trial_app' => $data['trial_app'] ?? null, + 'is_trial' => $data['is_trial'] ?? null, + ]; + + Log::info('Admin creating app instance', $safeLogData); + try { + // Build the request data array + $requestData = [ + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'organization' => $data['organization'] ?? '', + 'job_title' => $data['job_title'] ?? '', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $data['trial_app'], + 'aup_and_privacy_acceptance' => $data['aup_and_privacy_acceptance'] ? 1 : 0, + 'opt_in_to_product_updates' => $data['opt_in_to_product_updates'] ? 1 : 0, + 'admin_created' => true, + 'is_trial' => $data['is_trial'], + ]; + + // Merge custom fields into request data + if (! empty($data['custom_fields'])) { + $requestData = array_merge($requestData, $data['custom_fields']); + } + + // Extract and merge instance config fields (prefixed with 'instance_config_') + $instanceConfigPrefix = PolydockAppInstanceFields::FIELD_PREFIX; + foreach ($data as $key => $value) { + if (str_starts_with((string) $key, $instanceConfigPrefix) && $value !== null && $value !== '') { + $requestData[$key] = $value; + } + } + + // Create the UserRemoteRegistration record + $registration = UserRemoteRegistration::create([ + 'email' => $data['email'], + 'request_data' => $requestData, + ]); + + Log::info('Created registration for admin-initiated instance', [ + 'registration_id' => $registration->id, + 'registration_uuid' => $registration->uuid, + ]); + + // Dispatch the job to process the registration + ProcessUserRemoteRegistration::dispatch($registration); + + Notification::make() + ->title('Instance creation started') + ->body('The app instance is being created. You can track its progress in the App Instances list.') + ->success() + ->send(); + + // Redirect to the registrations list so they can track progress + $this->redirect(PolydockAppInstanceResource::getUrl('index')); + } catch (\Exception $e) { + Log::error('Failed to create admin-initiated instance', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + Notification::make() + ->title('Failed to create instance') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getFormActions(): array + { + return [ + \Filament\Actions\Action::make('create') + ->label('Create Instance') + ->submit('create'), + ]; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource.php b/app/Filament/Admin/Resources/PolydockStoreAppResource.php index 0193ca4..a198a76 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource.php @@ -6,10 +6,13 @@ use App\Filament\Admin\Resources\PolydockStoreAppResource\Pages; use App\Models\PolydockStore; use App\Models\PolydockStoreApp; +use App\Services\PolydockAppClassDiscovery; use Filament\Forms; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Section; use Filament\Forms\Form; +use Filament\Forms\Get; +use Filament\Forms\Set; use Filament\Infolists\Infolist; use Filament\Resources\Resource; use Filament\Tables; @@ -38,9 +41,24 @@ public static function form(Form $form): Form ->required() ->disabled(fn (?PolydockStoreApp $record) => $record && $record->instances()->exists()) ->dehydrated(fn (?PolydockStoreApp $record) => ! $record || ! $record->instances()->exists()), - Forms\Components\TextInput::make('polydock_app_class') + Forms\Components\Select::make('polydock_app_class') + ->label('Polydock App Class') + ->options(fn () => app(PolydockAppClassDiscovery::class)->getAvailableAppClasses()) ->required() - ->maxLength(255), + ->searchable() + ->live(onBlur: false) + ->afterStateUpdated(function (Set $set, ?string $old) { + if ($old) { + $fieldNames = app(PolydockAppClassDiscovery::class) + ->getStoreAppFormFieldNames($old); + foreach ($fieldNames as $fieldName) { + $set($fieldName, null); + } + } + }) + ->helperText('The application class that controls deployment and lifecycle behaviour.') + ->disabled(fn (?PolydockStoreApp $record) => $record && $record->instances()->exists()) + ->dehydrated(fn (?PolydockStoreApp $record) => ! $record || ! $record->instances()->exists()), Forms\Components\TextInput::make('name') ->required() ->maxLength(255), @@ -64,15 +82,155 @@ public static function form(Form $form): Form ->required() ->maxLength(255) ->default('main'), + Section::make('Lagoon Script Configuration') + ->description('Optional scripts run during app lifecycle stages.') + ->schema([ + Section::make('Post Deploy') + ->schema([ + Forms\Components\Textarea::make('lagoon_post_deploy_script') + ->label('Script') + ->rows(3), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_post_deploy_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_post_deploy_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + Section::make('Pre Upgrade') + ->schema([ + Forms\Components\Textarea::make('lagoon_pre_upgrade_script') + ->label('Script') + ->rows(3), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_pre_upgrade_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_pre_upgrade_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + Section::make('Upgrade') + ->schema([ + Forms\Components\Textarea::make('lagoon_upgrade_script') + ->label('Script') + ->rows(3), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_upgrade_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_upgrade_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + Section::make('Post Upgrade') + ->schema([ + Forms\Components\Textarea::make('lagoon_post_upgrade_script') + ->label('Script') + ->rows(3), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_post_upgrade_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_post_upgrade_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + Section::make('Claim') + ->schema([ + Forms\Components\Textarea::make('lagoon_claim_script') + ->label('Script') + ->rows(3) + ->helperText('When set, command output must be a valid URL and becomes app URL.'), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_claim_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_claim_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + Section::make('Pre Remove') + ->schema([ + Forms\Components\Textarea::make('lagoon_pre_remove_script') + ->label('Script') + ->rows(3), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_pre_remove_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_pre_remove_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + Section::make('Remove') + ->schema([ + Forms\Components\Textarea::make('lagoon_remove_script') + ->label('Script') + ->rows(3), + Grid::make(2)->schema([ + Forms\Components\TextInput::make('lagoon_remove_service') + ->maxLength(255) + ->placeholder('cli'), + Forms\Components\TextInput::make('lagoon_remove_container') + ->maxLength(255) + ->placeholder('cli'), + ]), + ]), + ]) + ->collapsible() + ->collapsed(), Forms\Components\Select::make('status') ->options(PolydockStoreAppStatusEnum::class) ->required(), - Forms\Components\Toggle::make('available_for_trials') - ->required(), Forms\Components\TextInput::make('target_unallocated_app_instances') ->required() ->numeric() ->default(0), + Forms\Components\Toggle::make('available_for_trials') + ->label('Available for Trials') + ->required() + ->columnSpanFull(), + Section::make('Lagoon Runtime Settings') + ->description('Configuration used by app instance creation for Lagoon runtime behavior.') + ->schema([ + Forms\Components\TextInput::make('lagoon_auto_idle') + ->label('Lagoon Auto Idle') + ->numeric() + ->minValue(0) + ->default(0) + ->helperText('Minutes before idle actions apply. Use 0 to disable auto-idle logic.'), + Forms\Components\TextInput::make('lagoon_production_environment') + ->label('Lagoon Production Environment') + ->default('main') + ->required() + ->maxLength(255) + ->helperText('Lagoon environment name considered production (for example: main).'), + ]) + ->columns(2) + ->collapsible(), + Forms\Components\Section::make('App-Specific Configuration') + ->description('These fields are defined by the selected App Class and will be configurable for this Store App.') + ->schema(fn (Get $get): array => app(PolydockAppClassDiscovery::class) + ->getStoreAppFormSchema($get('polydock_app_class') ?? '')) + ->visible(fn (Get $get): bool => ! empty(app(PolydockAppClassDiscovery::class) + ->getStoreAppFormSchema($get('polydock_app_class') ?? ''))) + ->collapsible() + ->collapsed(false) + ->columnSpanFull(), + Forms\Components\Placeholder::make('no_app_specific_fields') + ->label('') + ->content('The selected App Class does not define any app-specific configuration fields.') + ->visible(fn (Get $get): bool => ! empty($get('polydock_app_class')) && + empty(app(PolydockAppClassDiscovery::class)->getStoreAppFormSchema($get('polydock_app_class') ?? ''))) + ->columnSpanFull(), Forms\Components\Section::make('Instance Ready Email Configuration') ->schema([ Forms\Components\TextInput::make('email_subject_line') @@ -147,6 +305,7 @@ public static function form(Form $form): Form ]) ->columnSpanFull(), ]) + ->collapsible() ->columnSpanFull(), ]); } @@ -157,10 +316,12 @@ public static function table(Table $table): Table return $table ->columns([ Tables\Columns\TextColumn::make('name') - ->searchable(), + ->searchable() + ->sortable(), Tables\Columns\TextColumn::make('store.name') ->label('Store') - ->searchable(), + ->searchable() + ->sortable(), Tables\Columns\TextColumn::make('status'), Tables\Columns\IconColumn::make('available_for_trials') ->label('Trials') @@ -241,7 +402,8 @@ public static function infolist(Infolist $infolist): Infolist ]), \Filament\Infolists\Components\TextEntry::make('description') ->markdown() - ->columnSpanFull(), + ->columnSpanFull() + ->hidden(fn ($record) => blank($record->description)), \Filament\Infolists\Components\Grid::make(3) ->schema([ @@ -276,10 +438,26 @@ public static function infolist(Infolist $infolist): Infolist ->state(fn ($record) => $record->allocatedInstances()->count()) ->icon('heroicon-m-check-circle') ->iconColor('success'), + \Filament\Infolists\Components\TextEntry::make('lagoon_production_environment') + ->label('Lagoon Production Environment') + ->icon('heroicon-m-flag') + ->iconColor('primary'), + \Filament\Infolists\Components\TextEntry::make('lagoon_auto_idle') + ->label('Lagoon Auto Idle') + ->icon('heroicon-m-clock') + ->iconColor('gray'), ]), ]) ->columnSpan(1), + \Filament\Infolists\Components\Section::make('App-Specific Configuration') + ->schema(fn ($record): array => app(PolydockAppClassDiscovery::class) + ->getStoreAppInfolistSchema($record->polydock_app_class ?? '')) + ->visible(fn ($record): bool => ! empty(app(PolydockAppClassDiscovery::class) + ->getStoreAppInfolistSchema($record->polydock_app_class ?? ''))) + ->collapsible() + ->columnSpan(3), + \Filament\Infolists\Components\Section::make('Support Information') ->schema([ \Filament\Infolists\Components\Grid::make(2) diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php index 11d3ed3..c6b82dd 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php @@ -3,9 +3,52 @@ namespace App\Filament\Admin\Resources\PolydockStoreAppResource\Pages; use App\Filament\Admin\Resources\PolydockStoreAppResource; +use App\Services\PolydockAppClassDiscovery; use Filament\Resources\Pages\CreateRecord; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppStoreFields; class CreatePolydockStoreApp extends CreateRecord { protected static string $resource = PolydockStoreAppResource::class; + + #[\Override] + protected function mutateFormDataBeforeCreate(array $data): array + { + $discovery = app(PolydockAppClassDiscovery::class); + $className = $data['polydock_app_class'] ?? null; + + $appConfig = []; + + if ($className) { + // Get the prefixed field names from the schema + $prefixedFieldNames = $discovery->getStoreAppFormFieldNames($className); + $prefix = PolydockAppStoreFields::FIELD_PREFIX; + + // Build a list of original (unprefixed) field names + $originalFieldNames = []; + foreach ($prefixedFieldNames as $prefixedName) { + if (str_starts_with($prefixedName, $prefix)) { + $originalFieldNames[] = substr($prefixedName, strlen($prefix)); + } + } + + // Extract fields that match the original field names from form data + foreach ($originalFieldNames as $fieldName) { + if (array_key_exists($fieldName, $data)) { + $appConfig[$fieldName] = $data[$fieldName]; + unset($data[$fieldName]); + } + } + } + + // Always persist these runtime settings in app_config for app-instance defaults. + $appConfig['lagoon_auto_idle'] = isset($data['lagoon_auto_idle']) ? (int) $data['lagoon_auto_idle'] : 0; + $appConfig['lagoon_production_environment'] = (string) ($data['lagoon_production_environment'] ?? 'main'); + unset($data['lagoon_auto_idle'], $data['lagoon_production_environment']); + + // Store the app config as JSON + $data['app_config'] = ! empty($appConfig) ? $appConfig : null; + + return $data; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php index 9bab069..12904bf 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/EditPolydockStoreApp.php @@ -3,13 +3,16 @@ namespace App\Filament\Admin\Resources\PolydockStoreAppResource\Pages; use App\Filament\Admin\Resources\PolydockStoreAppResource; +use App\Services\PolydockAppClassDiscovery; use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppStoreFields; class EditPolydockStoreApp extends EditRecord { protected static string $resource = PolydockStoreAppResource::class; + #[\Override] protected function getHeaderActions(): array { return [ @@ -22,4 +25,71 @@ protected function getRedirectUrl(): string { return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]); } + + #[\Override] + protected function mutateFormDataBeforeFill(array $data): array + { + // Load custom field values from app_config JSON column + // Fields are stored without prefix, so we load them directly with their original names + $appConfig = $this->record->app_config ?? []; + + foreach ($appConfig as $key => $value) { + $data[$key] = $value; + } + $data['lagoon_auto_idle'] = $appConfig['lagoon_auto_idle'] ?? 0; + $data['lagoon_production_environment'] = $appConfig['lagoon_production_environment'] ?? 'main'; + + return $data; + } + + #[\Override] + protected function mutateFormDataBeforeSave(array $data): array + { + $discovery = app(PolydockAppClassDiscovery::class); + + // Use the record's polydock_app_class since it may not be in $data + // (field is disabled/not dehydrated when instances exist) + $className = $data['polydock_app_class'] ?? $this->record->polydock_app_class; + + $appConfig = []; + + if ($className) { + // Get the prefixed field names from the schema + $prefixedFieldNames = $discovery->getStoreAppFormFieldNames($className); + $prefix = PolydockAppStoreFields::FIELD_PREFIX; + + // Build a list of original (unprefixed) field names + $originalFieldNames = []; + foreach ($prefixedFieldNames as $prefixedName) { + if (str_starts_with($prefixedName, $prefix)) { + $originalFieldNames[] = substr($prefixedName, strlen($prefix)); + } + } + + // Extract fields that match the original field names from form data + foreach ($originalFieldNames as $fieldName) { + if (array_key_exists($fieldName, $data)) { + $appConfig[$fieldName] = $data[$fieldName]; + unset($data[$fieldName]); + } + } + } + + // Always persist these runtime settings in app_config for app-instance defaults. + $existingAppConfig = $this->record->app_config ?? []; + $appConfig['lagoon_auto_idle'] = isset($data['lagoon_auto_idle']) + ? (int) $data['lagoon_auto_idle'] + : (int) ($existingAppConfig['lagoon_auto_idle'] ?? 0); + $appConfig['lagoon_production_environment'] = (string) ( + $data['lagoon_production_environment'] + ?? $existingAppConfig['lagoon_production_environment'] + ?? 'main' + ); + unset($data['lagoon_auto_idle'], $data['lagoon_production_environment']); + + // Store the app config as JSON + $data['app_config'] = ! empty($appConfig) ? $appConfig : null; + + return $data; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php index cdad848..e4be5f3 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/ViewPolydockStoreApp.php @@ -1,5 +1,7 @@ record->app_config ?? []; + + foreach ($appConfig as $key => $value) { + $data[$key] = $value; + } + + return $data; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreResource.php b/app/Filament/Admin/Resources/PolydockStoreResource.php index 2513a64..47a2f4f 100644 --- a/app/Filament/Admin/Resources/PolydockStoreResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreResource.php @@ -38,8 +38,10 @@ public static function form(Form $form): Form ->options(PolydockStoreStatusEnum::class) ->required(), Forms\Components\Toggle::make('listed_in_marketplace') + ->label('Listed in Marketplace') ->required(), Forms\Components\TextInput::make('lagoon_deploy_region_id_ext') + ->label('Lagoon Deploy Region ID') ->required() ->maxLength(255) ->dehydrated( @@ -49,6 +51,7 @@ public static function form(Form $form): Form fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), ), Forms\Components\TextInput::make('lagoon_deploy_project_prefix') + ->label('Lagoon Deploy Project Prefix') ->required() ->maxLength(255) ->dehydrated( @@ -58,6 +61,7 @@ public static function form(Form $form): Form fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), ), Forms\Components\TextInput::make('lagoon_deploy_organization_id_ext') + ->label('Lagoon Deploy Organization ID') ->required() ->maxLength(255) ->dehydrated( @@ -67,6 +71,7 @@ public static function form(Form $form): Form fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), ), Forms\Components\TextInput::make('amazee_ai_backend_region_id_ext') + ->label('amazee.ai Backend Region ID') ->numeric() ->dehydrated( fn (?PolydockStore $record) => ! $record || ! $record->apps()->whereHas('instances')->exists(), @@ -74,9 +79,49 @@ public static function form(Form $form): Form ->disabled( fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), ), + Forms\Components\TextInput::make('lagoon_deploy_group_name') + ->label('Lagoon Deploy Group Name') + ->required() + ->maxLength(255) + ->dehydrated( + fn (?PolydockStore $record) => ! $record || ! $record->apps()->whereHas('instances')->exists(), + ) + ->disabled( + fn (?PolydockStore $record) => $record && $record->apps()->whereHas('instances')->exists(), + ), Forms\Components\Textarea::make('lagoon_deploy_private_key') + ->label('Lagoon Deploy Private Key') ->columnSpanFull() - ->rows(10), + ->rows(3) + ->formatStateUsing(fn ($state) => null) + ->dehydrated(fn ($state) => filled($state)) + ->placeholder(fn ($record) => filled($record?->lagoon_deploy_private_key) + ? 'Current key is set. Leave empty to keep it, or enter a new one to replace it.' + : 'No key is currently set. Enter a new key here.' + ) + ->live(onBlur: true) + ->afterStateUpdated(function (Forms\Set $set, ?string $state, ?PolydockStore $record) { + $keyToUse = filled($state) ? $state : $record?->lagoon_deploy_private_key; + $set('derived_public_key', $keyToUse ? LagoonHelper::getPublicKeyFromPrivateKey($keyToUse) : null); + }) + ->rules([ + fn () => function (string $attribute, $value, \Closure $fail) { + if (filled($value) && ! LagoonHelper::getPublicKeyFromPrivateKey($value)) { + $fail('The private key is invalid.'); + } + }, + ]), + Forms\Components\Textarea::make('derived_public_key') + ->label('Lagoon Deploy Public Key') + ->helperText('This is the public key derived from the stored private key (or the one you just entered).') + ->disabled() + ->dehydrated(false) + ->columnSpanFull() + ->rows(5) + ->formatStateUsing(fn (?PolydockStore $record) => $record?->lagoon_deploy_private_key + ? LagoonHelper::getPublicKeyFromPrivateKey($record->lagoon_deploy_private_key) + : null + ), ]); } @@ -84,9 +129,11 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->searchable() ->columns([ Tables\Columns\TextColumn::make('name') - ->searchable(), + ->searchable() + ->sortable(), Tables\Columns\TextColumn::make('status'), Tables\Columns\IconColumn::make('listed_in_marketplace') ->label('Listed') @@ -107,6 +154,9 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('lagoon_deploy_organization_id_ext') ->label('Deploy Org') ->searchable(), + Tables\Columns\TextColumn::make('lagoon_deploy_group_name') + ->label('Deploy Group') + ->searchable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() @@ -213,6 +263,13 @@ public static function infolist(Infolist $infolist): Infolist ->icon('heroicon-m-building-office') ->iconColor('warning'), ]), + \Filament\Infolists\Components\Grid::make(2) + ->schema([ + \Filament\Infolists\Components\TextEntry::make('lagoon_deploy_group_name') + ->label('Deploy Group Name') + ->icon('heroicon-m-users') + ->iconColor('primary'), + ]), ]) ->columnSpan(1), ]) diff --git a/app/Filament/Admin/Resources/PolydockStoreResource/Pages/CreatePolydockStore.php b/app/Filament/Admin/Resources/PolydockStoreResource/Pages/CreatePolydockStore.php index 6e0b89f..d228d78 100644 --- a/app/Filament/Admin/Resources/PolydockStoreResource/Pages/CreatePolydockStore.php +++ b/app/Filament/Admin/Resources/PolydockStoreResource/Pages/CreatePolydockStore.php @@ -6,8 +6,23 @@ use App\Filament\Admin\Resources\PolydockStoreResource; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Database\Eloquent\Model; class CreatePolydockStore extends CreateRecord { protected static string $resource = PolydockStoreResource::class; + + protected function handleRecordCreation(array $data): Model + { + $key = $data['lagoon_deploy_private_key'] ?? null; + unset($data['lagoon_deploy_private_key']); + + $record = static::getModel()::create($data); + + if ($key) { + $record->setPolydockVariableValue('lagoon_deploy_private_key', $key, true); + } + + return $record; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreResource/Pages/EditPolydockStore.php b/app/Filament/Admin/Resources/PolydockStoreResource/Pages/EditPolydockStore.php index f39a750..9619cee 100644 --- a/app/Filament/Admin/Resources/PolydockStoreResource/Pages/EditPolydockStore.php +++ b/app/Filament/Admin/Resources/PolydockStoreResource/Pages/EditPolydockStore.php @@ -5,6 +5,7 @@ use App\Filament\Admin\Resources\PolydockStoreResource; use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; class EditPolydockStore extends EditRecord { @@ -28,4 +29,18 @@ protected function getRedirectUrl(): string { return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]); } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $key = $data['lagoon_deploy_private_key'] ?? null; + unset($data['lagoon_deploy_private_key']); + + $record->update($data); + + if ($key) { + $record->setPolydockVariableValue('lagoon_deploy_private_key', $key, true); + } + + return $record; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php b/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php index 3274fe2..82cd258 100644 --- a/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreWebhookResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Admin\Resources; use App\Filament\Admin\Resources\PolydockStoreWebhookResource\Pages; +use App\Models\PolydockStore; use App\Models\PolydockStoreWebhook; use Filament\Forms; use Filament\Forms\Form; @@ -29,6 +30,10 @@ public static function form(Form $form): Form { return $form ->schema([ + Forms\Components\Select::make('polydock_store_id') + ->label('Store') + ->options(PolydockStore::all()->pluck('name', 'id')) + ->required(), Forms\Components\TextInput::make('url') ->required() ->maxLength(255) diff --git a/app/Filament/Admin/Resources/UserGroupResource.php b/app/Filament/Admin/Resources/UserGroupResource.php index bdba816..06a23e8 100644 --- a/app/Filament/Admin/Resources/UserGroupResource.php +++ b/app/Filament/Admin/Resources/UserGroupResource.php @@ -37,14 +37,17 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->searchable() ->columns([ - TextColumn::make('name'), + TextColumn::make('name') + ->searchable() + ->sortable(), TextColumn::make('users_count') ->counts('users') - ->label('Users'), - ]) - ->filters([ - // + ->label('Users') + ->sortable(), + TextColumn::make('created_at')->dateTime() + ->sortable(), ]) ->actions([ Tables\Actions\ViewAction::make(), diff --git a/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php b/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php index aaaada9..9e91b3d 100644 --- a/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php +++ b/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php @@ -3,7 +3,10 @@ namespace App\Filament\Admin\Resources; use App\Filament\Admin\Resources\UserRemoteRegistrationResource\Pages; +use App\Models\PolydockStore; +use App\Models\PolydockStoreApp; use App\Models\UserRemoteRegistration; +use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; @@ -36,17 +39,30 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->searchable() ->columns([ TextColumn::make('type') ->badge() ->color(fn ($state): string => $state ? $state->getColor() : 'gray') ->icon(fn ($state): string => $state ? $state->getIcon() : '') ->sortable(), - TextColumn::make('email'), - TextColumn::make('user.name'), - TextColumn::make('userGroup.name'), - TextColumn::make('storeApp.store.name'), - TextColumn::make('storeApp.name'), + TextColumn::make('email') + ->searchable() + ->sortable(), + TextColumn::make('user.name') + ->searchable() + ->sortable(), + TextColumn::make('userGroup.name') + ->searchable() + ->sortable(), + TextColumn::make('storeApp.store.name') + ->label('Store') + ->searchable() + ->sortable(), + TextColumn::make('storeApp.name') + ->label('Store App') + ->searchable() + ->sortable(), TextColumn::make('status') ->badge() ->color(fn ($state): string => match ($state->value) { @@ -55,10 +71,25 @@ public static function table(Table $table): Table 'success' => 'success', 'failed' => 'danger', default => 'gray', + }) + ->sortable(), + TextColumn::make('created_at')->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('store_id') + ->label('Store') + ->options(fn () => PolydockStore::pluck('name', 'id')) + ->query(function ($query, array $data) { + return $query->when($data['value'], fn ($query) => $query->whereHas('storeApp', fn ($q) => $q->where('polydock_store_id', $data['value']))); + }), + Tables\Filters\SelectFilter::make('store_app_id') + ->label('Store App') + ->options(fn () => PolydockStoreApp::pluck('name', 'id')) + ->query(function ($query, array $data) { + return $query->when($data['value'], fn ($query) => $query->where('polydock_store_app_id', $data['value'])); }), - TextColumn::make('created_at')->dateTime(), ]) - ->filters([]) ->actions([ Tables\Actions\ViewAction::make(), ]) diff --git a/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php b/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php index 367f889..bf1b233 100644 --- a/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php +++ b/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php @@ -107,6 +107,10 @@ public static function getRenderedSafeDataForRecord(UserRemoteRegistration $reco $value = 'REDACTED'; } + if ($value === null) { + $value = 'N/A'; + } + $renderKey = 'request_data_'.$key; $renderedItem = \Filament\Infolists\Components\TextEntry::make($renderKey) ->label($key) diff --git a/app/Filament/Admin/Resources/UserResource.php b/app/Filament/Admin/Resources/UserResource.php index 630fe67..85d478c 100644 --- a/app/Filament/Admin/Resources/UserResource.php +++ b/app/Filament/Admin/Resources/UserResource.php @@ -57,17 +57,35 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->searchable() ->columns([ - TextColumn::make('first_name'), - TextColumn::make('last_name'), - TextColumn::make('email'), + TextColumn::make('first_name') + ->searchable() + ->sortable(), + TextColumn::make('last_name') + ->searchable() + ->sortable(), + TextColumn::make('email') + ->searchable() + ->sortable(), TextColumn::make('groups_count') ->counts('groups') - ->label('Groups'), - TextColumn::make('created_at')->dateTime(), + ->label('Groups') + ->sortable(), + TextColumn::make('created_at')->dateTime() + ->sortable(), ]) ->filters([ - // + Tables\Filters\Filter::make('created_from') + ->form([ + Forms\Components\DatePicker::make('created_from'), + ]) + ->query(function ($query, array $data) { + return $query->when( + $data['created_from'], + fn ($query) => $query->where('created_at', '>=', $data['created_from']), + ); + }), ]) ->actions([ Tables\Actions\ViewAction::make(), diff --git a/app/Http/Controllers/Api/RegisterController.php b/app/Http/Controllers/Api/RegisterController.php index 4370841..00b7587 100644 --- a/app/Http/Controllers/Api/RegisterController.php +++ b/app/Http/Controllers/Api/RegisterController.php @@ -52,6 +52,7 @@ public function showRegister(string $uuid): JsonResponse try { $registration = UserRemoteRegistration::where('uuid', $uuid)->firstOrFail(); Log::info('Showing user remote registration', ['registration' => $registration->toArray()]); + $responseResultData = $registration->result_data ?? []; if ($registration->appInstance) { $appInstance = $registration->appInstance; @@ -65,12 +66,24 @@ public function showRegister(string $uuid): JsonResponse $registration->setResultValue('result_type', 'registration_failed'); $registration->save(); } + + if ($appInstance->getKeyValue('lagoon-project-id')) { + $responseResultData['lagoon_project_id'] = $appInstance->getKeyValue('lagoon-project-id'); + } + + if ($appInstance->getKeyValue('lagoon-deploy-branch')) { + $responseResultData['lagoon_deploy_branch'] = $appInstance->getKeyValue('lagoon-deploy-branch'); + } + + if ($appInstance->getKeyValue('lagoon-project-name')) { + $responseResultData['lagoon_project_name'] = $appInstance->getKeyValue('lagoon-project-name'); + } } return response()->json([ 'status' => $registration->status->value, 'email' => $registration->email, - 'result_data' => $registration->result_data, + 'result_data' => $responseResultData, 'created_at' => $registration->created_at, 'updated_at' => $registration->updated_at, ]); diff --git a/app/Http/Middleware/EnsureInstancesReadAbility.php b/app/Http/Middleware/EnsureInstancesReadAbility.php new file mode 100644 index 0000000..22e9455 --- /dev/null +++ b/app/Http/Middleware/EnsureInstancesReadAbility.php @@ -0,0 +1,35 @@ +user(); + $token = $user?->currentAccessToken(); + + if (! $user || ! $token) { + return response()->json([ + 'message' => 'API token is required.', + ], Response::HTTP_UNAUTHORIZED); + } + + if (! $token->can('instances.read') && ! $token->can('*')) { + return response()->json([ + 'message' => 'Token does not have the required instances.read ability.', + ], Response::HTTP_FORBIDDEN); + } + + return $next($request); + } +} diff --git a/app/Jobs/ProcessUserRemoteRegistration.php b/app/Jobs/ProcessUserRemoteRegistration.php index c0c016e..f7c31b9 100644 --- a/app/Jobs/ProcessUserRemoteRegistration.php +++ b/app/Jobs/ProcessUserRemoteRegistration.php @@ -6,10 +6,13 @@ use App\Enums\UserGroupRoleEnum; use App\Enums\UserRemoteRegistrationStatusEnum; use App\Enums\UserRemoteRegistrationType; +use App\Models\PolydockAppInstance; use App\Models\PolydockStoreApp; use App\Models\User; use App\Models\UserGroup; use App\Models\UserRemoteRegistration; +use App\Services\PolydockAppClassDiscovery; +use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -304,6 +307,9 @@ private function handleRequestTrial(): void } $allocatedInstance->save(); + + // Store instance config fields as PolydockVariables + $this->storeInstanceConfigFields($allocatedInstance, $trialApp); } } catch (\Exception $e) { Log::error('Failed to process trial registration', [ @@ -340,4 +346,42 @@ private function handleUnknownType(): void $this->registration->status = UserRemoteRegistrationStatusEnum::FAILED; $this->registration->setResultValue('message', 'Unknown registration type'); } + + /** + * Store instance config fields as PolydockVariables on the app instance. + * + * Extracts fields prefixed with 'instance_config_' from the registration request data + * and stores them as PolydockVariables, respecting encryption settings. + * + * @param \App\Models\PolydockAppInstance $appInstance The app instance to store variables on + * @param PolydockStoreApp $storeApp The store app to get field schema from + */ + private function storeInstanceConfigFields(PolydockAppInstance $appInstance, PolydockStoreApp $storeApp): void + { + $instanceConfigPrefix = PolydockAppInstanceFields::FIELD_PREFIX; + $requestData = $this->registration->request_data ?? []; + + // Get field encryption map from the app class schema + $discovery = app(PolydockAppClassDiscovery::class); + $schema = $discovery->getAppInstanceFormSchema($storeApp->polydock_app_class ?? ''); + $encryptionMap = $discovery->getFieldEncryptionMap($schema); + + $storedFields = []; + + foreach ($requestData as $key => $value) { + if (str_starts_with((string) $key, $instanceConfigPrefix) && $value !== null && $value !== '') { + $encrypted = $encryptionMap[$key] ?? false; + $appInstance->setPolydockVariableValue($key, (string) $value, $encrypted); + $storedFields[] = $key; + } + } + + if (! empty($storedFields)) { + Log::info('Stored instance config fields as PolydockVariables', [ + 'registration_id' => $this->registration->id, + 'app_instance_id' => $appInstance->id, + 'fields' => $storedFields, + ]); + } + } } diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 4b70fe5..ab01784 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -84,6 +84,11 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface */ private PolydockAppLoggerInterface $logger; + /** + * The name of the app instance + */ + private string $name; + // Add default sensitive keys specific to app instances protected array $sensitiveDataKeys = [ // Exact matches @@ -268,6 +273,8 @@ protected static function boot() 'lagoon-project-name' => $model->name, 'amazee-ai-backend-region-id' => $storeApp->amazee_ai_backend_region_id_ext, 'available-for-trials' => $storeApp->available_for_trials, + 'lagoon-auto-idle' => $storeApp->lagoon_auto_idle, + 'lagoon-production-environment' => $storeApp->lagoon_production_environment, 'lagoon-generate-app-admin-username' => $model->generateUniqueUsername(), 'lagoon-generate-app-admin-password' => $model->generateUniquePassword(), 'polydock-app-instance-health-webhook-url' => str_replace(':status:', '', route('api.instance.health', [ @@ -384,11 +391,31 @@ public function getApp(): PolydockAppInterface return $this->app; } + /** + * Set name of app instance + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * Get the name of the app instance + */ + public function getName(): string + { + return $this->name; + } + /** * Set the type of the app instance * * @param string $appType The type of the app instance * @return self Returns the instance for method chaining + * + * @throws PolydockEngineAppNotFoundException */ public function setAppType(string $appType): self { diff --git a/app/Models/PolydockStore.php b/app/Models/PolydockStore.php index 7ba9793..8fa40a8 100644 --- a/app/Models/PolydockStore.php +++ b/app/Models/PolydockStore.php @@ -20,7 +20,6 @@ class PolydockStore extends Model 'listed_in_marketplace', 'lagoon_deploy_region_id_ext', 'lagoon_deploy_project_prefix', - 'lagoon_deploy_private_key', 'lagoon_deploy_organization_id_ext', 'amazee_ai_backend_region_id_ext', 'lagoon_deploy_group_name', @@ -31,6 +30,11 @@ class PolydockStore extends Model 'listed_in_marketplace' => 'boolean', ]; + public function getLagoonDeployPrivateKeyAttribute(): ?string + { + return $this->getPolydockVariableValue('lagoon_deploy_private_key'); + } + public function apps(): HasMany { return $this->hasMany(PolydockStoreApp::class); diff --git a/app/Models/PolydockStoreApp.php b/app/Models/PolydockStoreApp.php index 32be63f..be37012 100644 --- a/app/Models/PolydockStoreApp.php +++ b/app/Models/PolydockStoreApp.php @@ -19,6 +19,7 @@ class PolydockStoreApp extends Model protected $fillable = [ 'polydock_store_id', 'polydock_app_class', + 'app_config', 'name', 'description', 'author', @@ -67,6 +68,7 @@ class PolydockStoreApp extends Model protected $casts = [ 'status' => PolydockStoreAppStatusEnum::class, + 'app_config' => 'array', 'available_for_trials' => 'boolean', 'target_unallocated_app_instances' => 'integer', 'send_midtrial_email' => 'boolean', @@ -88,6 +90,8 @@ class PolydockStoreApp extends Model 'unallocated_instances_count', 'needs_more_unallocated_instances', 'lagoon_deploy_group_name', + 'lagoon_auto_idle', + 'lagoon_production_environment', ]; /** @@ -162,7 +166,7 @@ public function getLagoonDeployOrganizationIdExtAttribute(): string /** * Get the Amazee AI backend region ID attribute */ - public function getAmazeeAiBackendRegionIdExtAttribute(): string + public function getAmazeeAiBackendRegionIdExtAttribute(): ?string { return $this->store->amazee_ai_backend_region_id_ext; } @@ -216,4 +220,20 @@ public function getLagoonDeployGroupNameAttribute(): ?string { return $this->store->lagoon_deploy_group_name; } + + /** + * Get the Lagoon autoIdle setting from app_config + */ + public function getLagoonAutoIdleAttribute(): ?int + { + return $this->app_config['lagoon_auto_idle'] ?? 0; + } + + /** + * Get the Lagoon production environment from app_config + */ + public function getLagoonProductionEnvironmentAttribute(): ?string + { + return $this->app_config['lagoon_production_environment'] ?? 'main'; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 93c4247..093c40f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,10 +14,13 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser, HasTenants { /** @use HasFactory<\Database\Factories\UserFactory> */ + use HasApiTokens; + use HasFactory; use Notifiable; @@ -82,7 +85,9 @@ public function getNameAttribute(): string */ public function groups() { - return $this->belongsToMany(UserGroup::class); + return $this->belongsToMany(UserGroup::class, 'user_user_group', 'user_id', 'user_group_id') + ->withPivot('role') + ->withTimestamps(); } /** diff --git a/app/Models/UserGroup.php b/app/Models/UserGroup.php index 82616c2..b60d46b 100644 --- a/app/Models/UserGroup.php +++ b/app/Models/UserGroup.php @@ -25,7 +25,9 @@ class UserGroup extends Model */ public function users() { - return $this->belongsToMany(User::class); + return $this->belongsToMany(User::class, 'user_user_group', 'user_group_id', 'user_id') + ->withPivot('role') + ->withTimestamps(); } /** diff --git a/app/PolydockEngine/Engine.php b/app/PolydockEngine/Engine.php index 387a716..045fde6 100644 --- a/app/PolydockEngine/Engine.php +++ b/app/PolydockEngine/Engine.php @@ -5,8 +5,10 @@ use App\PolydockEngine\Traits\PolydockEngineFunctionCallerTrait; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; use FreedomtechHosting\PolydockApp\Exceptions\PolydockEngineProcessPolydockAppInstanceStatusException; +use FreedomtechHosting\PolydockApp\Exceptions\PolydockEngineValidationException; use FreedomtechHosting\PolydockApp\PolydockAppInstanceInterface; use FreedomtechHosting\PolydockApp\PolydockAppInstanceStatusFlowException; +use FreedomtechHosting\PolydockApp\PolydockAppInterface; use FreedomtechHosting\PolydockApp\PolydockAppLoggerInterface; use FreedomtechHosting\PolydockApp\PolydockEngineBase; use FreedomtechHosting\PolydockApp\PolydockEngineInterface; @@ -70,6 +72,50 @@ public function getLogger(): PolydockAppLoggerInterface return $this->logger; } + /** + * Validate that an app instance has all required variables. + * + * The upstream base implementation uses a truthy check, which treats "0" + * as missing. We only treat truly missing values (empty string) as missing. + * + * @throws PolydockEngineValidationException + */ + #[\Override] + public function validateAppInstanceHasAllRequiredVariables(PolydockAppInstanceInterface $appInstance): bool + { + foreach ($appInstance->getApp()->getVariableDefinitions() as $variableDefinition) { + if ($appInstance->getKeyValue($variableDefinition->getName()) === '') { + throw new PolydockEngineValidationException( + sprintf( + 'App instance %s is missing required variable %s', + $appInstance->getAppType(), + $variableDefinition->getName() + ) + ); + } + } + + return true; + } + + /** + * Backfill Lagoon runtime defaults on legacy app instances. + */ + protected function hydrateLagoonRuntimeDefaultsOnInstance(PolydockAppInstanceInterface $appInstance): void + { + $runtimeDefaults = [ + 'lagoon-auto-idle' => (string) ($appInstance->storeApp->lagoon_auto_idle ?? 0), + 'lagoon-production-environment' => (string) ($appInstance->storeApp->lagoon_production_environment ?? 'main'), + ]; + + foreach ($runtimeDefaults as $key => $value) { + if ($appInstance->getKeyValue($key) === '') { + $appInstance->storeKeyValue($key, $value); + $this->info('Backfilled missing app instance runtime variable', ['key' => $key, 'value' => $value]); + } + } + } + /** * Initialize the polydock service providers * @@ -123,7 +169,7 @@ public function getPolydockServiceProviderSingletonInstance(string $polydockServ * @param PolydockAppInstanceInterface $appInstance The app instance to process * @return PolydockAppInstanceInterface The app instance */ - public function processPolydockAppInstance(PolydockAppInstanceInterface $appInstance) + public function processPolydockAppInstance(PolydockAppInstanceInterface $appInstance): PolydockAppInstanceInterface { $appInstance->setLogger($this->logger); $appInstance->setEngine($this); @@ -133,6 +179,12 @@ public function processPolydockAppInstance(PolydockAppInstanceInterface $appInst throw new PolydockEngineAppNotFoundException('Class '.$polydockAppClass.' not found'); } + if (! is_subclass_of($polydockAppClass, PolydockAppInterface::class)) { + throw new PolydockEngineAppNotFoundException( + 'Class '.$polydockAppClass.' does not implement PolydockAppInterface' + ); + } + $app = new $polydockAppClass( $appInstance->storeApp->name, $appInstance->storeApp->description, @@ -149,6 +201,7 @@ public function processPolydockAppInstance(PolydockAppInstanceInterface $appInst $this->info('App Website: '.$app->getAppWebsite()); $this->info('App Support Email: '.$app->getAppSupportEmail()); $appInstance->setApp($app); + $this->hydrateLagoonRuntimeDefaultsOnInstance($appInstance); $this->info('Validating app instance has all required variables'); diff --git a/app/PolydockEngine/Helpers/LagoonHelper.php b/app/PolydockEngine/Helpers/LagoonHelper.php index 9bce069..ad2d1ea 100644 --- a/app/PolydockEngine/Helpers/LagoonHelper.php +++ b/app/PolydockEngine/Helpers/LagoonHelper.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use phpseclib3\Crypt\PublicKeyLoader; class LagoonHelper { @@ -23,14 +24,14 @@ public static function getLagoonCoreDataForRegion(string $regionId): ?array $lagoonCoreData = $allLagoonCoresData[$FTLAGOON_ENDPOINT] ?? null; if (! $lagoonCoreData) { - Log::error('No lagoon core data found for endpoint '.$FTLAGOON_ENDPOINT); + Log::error("No lagoon core data found for endpoint {$FTLAGOON_ENDPOINT}"); return null; } $lagoonCoreDataForRegion = $lagoonCoreData['lagoon_deploy_regions'][$regionId] ?? null; if (! $lagoonCoreDataForRegion) { - Log::error('No lagoon core data found for region '.$regionId.' and endpoint '.$FTLAGOON_ENDPOINT); + Log::error("No lagoon core data found for region {$regionId} and endpoint {$FTLAGOON_ENDPOINT}"); return null; } @@ -40,10 +41,27 @@ public static function getLagoonCoreDataForRegion(string $regionId): ?array return $lagoonCoreDataForRegion; } - public static function getLagoonCodeDataValueForRegion(string $regionId, string $key): string + public static function getLagoonCodeDataValueForRegion(string $regionId, string $key): ?string { $lagoonCoreDataForRegion = self::getLagoonCoreDataForRegion($regionId); return $lagoonCoreDataForRegion[$key] ?? null; } + + public static function getPublicKeyFromPrivateKey(string $privateKey): ?string + { + if (empty($privateKey)) { + return null; + } + + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->toString('OpenSSH'); + } catch (\Throwable) { + // Parsing failures are expected when validating user-provided keys. + + return null; + } + } } diff --git a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php index 7bff081..0a6562c 100644 --- a/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php +++ b/app/PolydockEngine/Traits/PolydockEngineFunctionCallerTrait.php @@ -58,10 +58,13 @@ protected function processPolydockAppUsingFunction( return false; } } catch (Exception $e) { - $this->error( - $appFunctionName.' failed - unknown initialisation exception', - $outputContext + ['exception' => $e], - ); + $message = $appFunctionName.' failed - unknown initialisation exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); return false; } @@ -84,7 +87,11 @@ protected function processPolydockAppUsingFunction( return true; } catch (PolydockAppInstanceStatusFlowException $e) { $message = $appFunctionName.' failed - status flow exception'; - $context = $outputContext + ['exception' => $e->getMessage()]; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + ]; + $this->error($message, $context); $polydockApp->error($message, $context); if ($appInstance->getStatus() !== $failedStatus) { $polydockApp->info('Forcing status to '.$failedStatus->value, $outputContext); @@ -94,15 +101,27 @@ protected function processPolydockAppUsingFunction( return false; } catch (PolydockEngineProcessPolydockAppInstanceException $e) { $message = $appFunctionName.' failed - process exception'; - $context = $outputContext + ['exception' => $e]; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); $polydockApp->error($message, $context); if ($appInstance->getStatus() !== $failedStatus) { $polydockApp->info('Forcing status to '.$failedStatus->value, $outputContext); $appInstance->logLine('error', $message, $context)->setStatus($failedStatus)->save(); } + + return false; } catch (Exception $e) { $message = $appFunctionName.' failed - unknown exception'; - $context = $outputContext + ['exception' => $e]; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); $polydockApp->error($message, $context); $appInstance->logLine('error', $message, $context)->setStatus($failedStatus)->save(); } @@ -139,10 +158,13 @@ protected function processPolydockAppPollUpdateUsingFunction( return false; } } catch (Exception $e) { - $this->error( - $appFunctionName.' failed - unknown initialisation exception', - $outputContext + ['exception' => $e], - ); + $message = $appFunctionName.' failed - unknown initialisation exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); return false; } @@ -171,18 +193,35 @@ protected function processPolydockAppPollUpdateUsingFunction( return true; } catch (PolydockAppInstanceStatusFlowException $e) { - $polydockApp->error( - $appFunctionName.' failed - status flow exception', - $outputContext + ['exception' => $e], - ); + $message = $appFunctionName.' failed - status flow exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + ]; + $this->error($message, $context); + $polydockApp->error($message, $context); return false; } catch (PolydockEngineProcessPolydockAppInstanceException $e) { - $polydockApp->error($appFunctionName.' failed - process exception', $outputContext + ['exception' => $e]); + $message = $appFunctionName.' failed - process exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); + $polydockApp->error($message, $context); return false; } catch (Exception $e) { - $polydockApp->error($appFunctionName.' failed - unknown exception', $outputContext + ['exception' => $e]); + $message = $appFunctionName.' failed - unknown exception'; + $context = $outputContext + [ + 'exception_message' => $e->getMessage(), + 'exception_class' => $e::class, + 'exception_trace' => $e->getTraceAsString(), + ]; + $this->error($message, $context); + $polydockApp->error($message, $context); return false; } diff --git a/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php b/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php index 75b1d5b..aa61dcb 100644 --- a/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php +++ b/app/PolydockServiceProviders/PolydockServiceProviderAmazeeAiBackend.php @@ -16,6 +16,11 @@ class PolydockServiceProviderAmazeeAiBackend implements PolydockServiceProviderI protected Client $AmazeeAiBackendClient; + /** + * Construct a new service provider amazee.ai backend. + * + * @throws PolydockEngineServiceProviderInitializationException + */ public function __construct(array $config, PolydockAppLoggerInterface $logger) { $this->setLogger($logger); @@ -86,26 +91,41 @@ protected function initAmazeeAiBackendClient(array $config) } } + /** + * Get the amazee.ai backend client. + */ public function getAmazeeAiBackendClient(): Client { return $this->AmazeeAiBackendClient; } + /** + * Fixed name for this provider. + */ public function getName(): string { return 'Polydock-Amazee-AI-Backend-Client-Provider'; } + /** + * Fixed description for this provider. + */ public function getDescription(): string { return 'An implementation of the Polydock Amazee AI Backend Client from polydock-amazeeai-backend-client-php'; } + /** + * Get the logger instance. + */ public function getLogger(): PolydockAppLoggerInterface { return $this->logger; } + /** + * Set the logger instance. Return self for chaining. + */ public function setLogger(PolydockAppLoggerInterface $logger): self { $this->logger = $logger; @@ -113,6 +133,9 @@ public function setLogger(PolydockAppLoggerInterface $logger): self return $this; } + /** + * Send a message marked as info level to the logger. + */ public function info(string $message, array $context = []): self { $this->logger->info($message, $context); @@ -120,6 +143,9 @@ public function info(string $message, array $context = []): self return $this; } + /** + * Send a message marked as error level to the logger. + */ public function error(string $message, array $context = []): self { $this->logger->error($message, $context); @@ -127,6 +153,9 @@ public function error(string $message, array $context = []): self return $this; } + /** + * Send a message marked as warning level to the logger. + */ public function warning(string $message, array $context = []): self { $this->logger->warning($message, $context); @@ -134,6 +163,9 @@ public function warning(string $message, array $context = []): self return $this; } + /** + * Send a message marked as debug level to the logger. + */ public function debug(string $message, array $context = []): self { $this->logger->debug($message, $context); diff --git a/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php b/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php index 4c82006..5ef20c9 100644 --- a/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php +++ b/app/PolydockServiceProviders/PolydockServiceProviderFTLagoon.php @@ -16,8 +16,12 @@ class PolydockServiceProviderFTLagoon implements PolydockServiceProviderInterfac protected Client $LagoonClient; - /** @var int Maximum age in minutes before a token is considered expired */ - const MAX_TOKEN_AGE_MINUTES = 2; + /** + * Maximum age in minutes before a token is considered expired. + * + * @var int + */ + private const int MAX_TOKEN_AGE_MINUTES = 2; public function __construct(array $config, PolydockAppLoggerInterface $logger) { @@ -106,31 +110,49 @@ protected function initLagoonClient(array $config) } } + /** + * Return the lagoon client. + */ public function getLagoonClient(): Client { return $this->LagoonClient; } + /** + * Return the max token age in minutes. + */ public function getMaxTokenAgeMinutes(): int { return self::MAX_TOKEN_AGE_MINUTES; } + /** + * Fixed name for this provider. + */ public function getName(): string { return 'FT-Lagoon-Client-Provider'; } + /** + * Fixed description of this provider. + */ public function getDescription(): string { return 'An implementation of the FT Lagoon Client from ft-lagoon-php'; } + /** + * Get the logger instance. + */ public function getLogger(): PolydockAppLoggerInterface { return $this->logger; } + /** + * Set the logger instance. Return self for chaining. + */ public function setLogger(PolydockAppLoggerInterface $logger): self { $this->logger = $logger; @@ -138,6 +160,9 @@ public function setLogger(PolydockAppLoggerInterface $logger): self return $this; } + /** + * Send a message marked as info level to the logger. + */ public function info(string $message, array $context = []): self { $this->logger->info($message, $context); @@ -145,6 +170,9 @@ public function info(string $message, array $context = []): self return $this; } + /** + * Send a message marked as error level to the logger. + */ public function error(string $message, array $context = []): self { $this->logger->error($message, $context); @@ -152,6 +180,9 @@ public function error(string $message, array $context = []): self return $this; } + /** + * Send a message marked as warning level to the logger. + */ public function warning(string $message, array $context = []): self { $this->logger->warning($message, $context); @@ -159,6 +190,9 @@ public function warning(string $message, array $context = []): self return $this; } + /** + * Send a message marked as debug level to the logger. + */ public function debug(string $message, array $context = []): self { $this->logger->debug($message, $context); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 91a42ea..ff22869 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; +use App\Services\PolydockAppClassDiscovery; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -14,7 +15,7 @@ class AppServiceProvider extends ServiceProvider #[\Override] public function register(): void { - // + $this->app->singleton(PolydockAppClassDiscovery::class); } /** diff --git a/app/Services/PolydockAppClassDiscovery.php b/app/Services/PolydockAppClassDiscovery.php new file mode 100644 index 0000000..6c0a3f0 --- /dev/null +++ b/app/Services/PolydockAppClassDiscovery.php @@ -0,0 +1,612 @@ +|null + */ + private ?array $cache = null; + + /** + * Discover all concrete classes that implement PolydockAppInterface. + * + * @return array FQCN => human-readable label + */ + public function getAvailableAppClasses(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + $classes = []; + + foreach (array_keys($this->getClassMap()) as $className) { + if (! $this->matchesNamespaceFilter($className)) { + continue; + } + + try { + if (! class_exists($className, true)) { + continue; + } + + $reflection = new ReflectionClass($className); + } catch (\Throwable) { + continue; + } + + if ($reflection->isAbstract() || $reflection->isInterface()) { + continue; + } + + if (! $reflection->implementsInterface(PolydockAppInterface::class)) { + continue; + } + + $label = $this->buildLabel($reflection); + $classes[$className] = $label; + } + + ksort($classes); + $this->cache = $classes; + + return $classes; + } + + /** + * Build a human-readable label for the given class. + * + * If the class has a #[PolydockAppTitle] attribute, use its title. + * Otherwise, fall back to "ShortName (Namespace)" format. + * + * @param ReflectionClass $reflection + */ + private function buildLabel(ReflectionClass $reflection): string + { + $attributes = $reflection->getAttributes(PolydockAppTitle::class); + + if (! empty($attributes)) { + /** @var PolydockAppTitle $titleAttr */ + $titleAttr = $attributes[0]->newInstance(); + + return $titleAttr->title; + } + + // Fallback: ShortName (Namespace) + $shortName = $reflection->getShortName(); + $namespace = $reflection->getNamespaceName(); + + return "{$shortName} ({$namespace})"; + } + + /** + * Check whether a given class name is a valid, concrete PolydockAppInterface implementation. + */ + public function isValidAppClass(string $className): bool + { + return isset($this->getAvailableAppClasses()[$className]); + } + + /** + * Clear the cached discovery results. + */ + public function clearCache(): void + { + $this->cache = null; + } + + /** + * Get the custom store app form schema for a given class. + * Field names are automatically prefixed with 'app_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Forms\Components\Component> Array of Filament form components + */ + public function getStoreAppFormSchema(string $className): array + { + if (empty($className)) { + \Log::debug('getStoreAppFormSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + \Log::debug('getStoreAppFormSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppStoreFields::class); + + if (empty($attributes)) { + \Log::debug('getStoreAppFormSchema: No PolydockAppStoreFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppStoreFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->formMethod; + + if (! method_exists($className, $methodName)) { + \Log::debug('getStoreAppFormSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + \Log::debug('getStoreAppFormSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + \Log::debug('getStoreAppFormSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'app_config_' + return $this->prefixSchemaFieldNames($schema); + } catch (\Throwable $e) { + \Log::error('getStoreAppFormSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get the custom store app infolist schema for a given class. + * Field names are automatically prefixed with 'app_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Infolists\Components\Component> Array of Filament infolist components + */ + public function getStoreAppInfolistSchema(string $className): array + { + if (empty($className)) { + Log::debug('getStoreAppInfolistSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + Log::debug('getStoreAppInfolistSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppStoreFields::class); + + if (empty($attributes)) { + Log::debug('getStoreAppInfolistSchema: No PolydockAppStoreFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppStoreFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->infolistMethod; + + if (! method_exists($className, $methodName)) { + Log::debug('getStoreAppInfolistSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + Log::debug('getStoreAppInfolistSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + Log::debug('getStoreAppInfolistSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'app_config_' + return $this->prefixSchemaFieldNames($schema); + } catch (\Throwable $e) { + Log::error('getStoreAppInfolistSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get field names from a store app form schema (with prefix). + * + * @param string $className The fully qualified class name + * @return array Array of prefixed field names + */ + public function getStoreAppFormFieldNames(string $className): array + { + $schema = $this->getStoreAppFormSchema($className); + + return $this->extractFieldNamesFromSchema($schema); + } + + /** + * Get the custom app instance form schema for a given class. + * Field names are automatically prefixed with 'instance_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Forms\Components\Component> Array of Filament form components + */ + public function getAppInstanceFormSchema(string $className): array + { + if (empty($className)) { + Log::debug('getAppInstanceFormSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + Log::debug('getAppInstanceFormSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppInstanceFields::class); + + if (empty($attributes)) { + Log::debug('getAppInstanceFormSchema: No PolydockAppInstanceFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppInstanceFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->formMethod; + + if (! method_exists($className, $methodName)) { + Log::debug('getAppInstanceFormSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + Log::debug('getAppInstanceFormSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + Log::debug('getAppInstanceFormSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'instance_config_' + return $this->prefixAppInstanceSchemaFieldNames($schema); + } catch (\Throwable $e) { + Log::error('getAppInstanceFormSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get the custom app instance infolist schema for a given class. + * Field names are automatically prefixed with 'instance_config_'. + * + * @param string $className The fully qualified class name + * @return array<\Filament\Infolists\Components\Component> Array of Filament infolist components + */ + public function getAppInstanceInfolistSchema(string $className): array + { + if (empty($className)) { + Log::debug('getAppInstanceInfolistSchema: Empty class name provided'); + + return []; + } + + if (! $this->isValidAppClass($className)) { + Log::debug('getAppInstanceInfolistSchema: Invalid app class', ['className' => $className]); + + return []; + } + + try { + $reflection = new ReflectionClass($className); + $attributes = $reflection->getAttributes(PolydockAppInstanceFields::class); + + if (empty($attributes)) { + Log::debug('getAppInstanceInfolistSchema: No PolydockAppInstanceFields attribute found', ['className' => $className]); + + return []; + } + + /** @var PolydockAppInstanceFields $attr */ + $attr = $attributes[0]->newInstance(); + $methodName = $attr->infolistMethod; + + if (! method_exists($className, $methodName)) { + Log::debug('getAppInstanceInfolistSchema: Method does not exist', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + return []; + } + + Log::debug('getAppInstanceInfolistSchema: Calling schema method', [ + 'className' => $className, + 'methodName' => $methodName, + ]); + + $schema = $className::$methodName(); + + Log::debug('getAppInstanceInfolistSchema: Schema retrieved', [ + 'className' => $className, + 'schemaCount' => count($schema), + ]); + + // Prefix all field names with 'instance_config_' + return $this->prefixAppInstanceSchemaFieldNames($schema); + } catch (\Throwable $e) { + Log::error('getAppInstanceInfolistSchema: Exception thrown', [ + 'className' => $className, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + return []; + } + } + + /** + * Get field names from an app instance form schema (with prefix). + * + * @param string $className The fully qualified class name + * @return array Array of prefixed field names + */ + public function getAppInstanceFormFieldNames(string $className): array + { + $schema = $this->getAppInstanceFormSchema($className); + + return $this->extractFieldNamesFromSchema($schema); + } + + /** + * Recursively prefix field names in Filament schema components for App Instance. + * + * @return array + */ + private function prefixAppInstanceSchemaFieldNames(array $components): array + { + $prefix = PolydockAppInstanceFields::FIELD_PREFIX; + + foreach ($components as $component) { + // Prefix the field name if this component has one + if (method_exists($component, 'getName') && method_exists($component, 'name')) { + $name = $component->getName(); + if ($name !== null && ! str_starts_with($name, $prefix)) { + $component->name($prefix.$name); + } + } + + // Recursively process child schema (for Sections, Grids, etc.) + if (method_exists($component, 'getChildComponents') && method_exists($component, 'schema')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $component->schema($this->prefixAppInstanceSchemaFieldNames($children)); + } + } + } + + return $components; + } + + /** + * Recursively prefix field names in Filament schema components. + * + * @return array + */ + private function prefixSchemaFieldNames(array $components): array + { + $prefix = PolydockAppStoreFields::FIELD_PREFIX; + + foreach ($components as $component) { + // Prefix the field name if this component has one + if (method_exists($component, 'getName') && method_exists($component, 'name')) { + $name = $component->getName(); + if ($name !== null && ! str_starts_with($name, $prefix)) { + $component->name($prefix.$name); + } + } + + // Recursively process child schema (for Sections, Grids, etc.) + if (method_exists($component, 'getChildComponents') && method_exists($component, 'schema')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $component->schema($this->prefixSchemaFieldNames($children)); + } + } + } + + return $components; + } + + /** + * Recursively extract field names from Filament schema components. + * + * @return array + */ + private function extractFieldNamesFromSchema(array $components): array + { + $names = []; + + foreach ($components as $component) { + // Get the field name if this component has one + if (method_exists($component, 'getName')) { + $name = $component->getName(); + if ($name !== null) { + $names[] = $name; + } + } + + // Recursively check child schema (for Sections, Grids, etc.) + if (method_exists($component, 'getChildComponents')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $names = array_merge($names, $this->extractFieldNamesFromSchema($children)); + } + } + } + + return $names; + } + + /** + * Check if a field should be stored encrypted based on extraAttributes. + * + * @param mixed $component + */ + public function isFieldEncrypted($component): bool + { + if (! method_exists($component, 'getExtraAttributes')) { + return false; + } + + $extraAttributes = $component->getExtraAttributes(); + + return isset($extraAttributes['encrypted']) && $extraAttributes['encrypted'] === true; + } + + /** + * Get encryption map for all fields in a schema. + * + * @return array fieldName => isEncrypted + */ + public function getFieldEncryptionMap(array $components): array + { + $map = []; + + foreach ($components as $component) { + if (method_exists($component, 'getName')) { + $name = $component->getName(); + if ($name !== null) { + $map[$name] = $this->isFieldEncrypted($component); + } + } + + if (method_exists($component, 'getChildComponents')) { + $children = $component->getChildComponents(); + if (! empty($children)) { + $map = array_merge($map, $this->getFieldEncryptionMap($children)); + } + } + } + + return $map; + } + + /** + * Check if a class name matches any of the namespace filters. + */ + private function matchesNamespaceFilter(string $className): bool + { + foreach (self::NAMESPACE_FILTERS as $filter) { + if (stripos($className, $filter) !== false) { + return true; + } + } + + return false; + } + + /** + * Get Composer's merged class map from all registered loaders. + * + * @return array className => filePath + */ + protected function getClassMap(): array + { + /** @var ClassLoader[] $loaders */ + $loaders = ClassLoader::getRegisteredLoaders(); + + $classMap = []; + foreach ($loaders as $loader) { + $classMap = array_merge($classMap, $loader->getClassMap()); + } + + return $classMap; + } +} diff --git a/composer.json b/composer.json index edf4ed2..9f6a482 100644 --- a/composer.json +++ b/composer.json @@ -1,27 +1,34 @@ { "$schema": "https://getcomposer.org/schema.json", - "name": "laravel/laravel", + "name": "amazeeio/polydock-engine", "type": "project", "description": "The skeleton application for the Laravel framework.", - "keywords": ["laravel", "framework"], + "keywords": [ + "laravel", + "framework" + ], "license": "MIT", "require": { "php": "^8.3", - "amazeeio/polydock-app-amazeeio-privategpt": "v0.0.4", + "ext-curl": "*", + "amazeeio/lagoon-logs": "^0.0.5", + "amazeeio/polydock-app-amazeeio-privategpt": "^0.0.13", "evanschleret/lara-mjml": "^0.3.0", "filament/filament": "^3.2", - "freedomtech-hosting/ft-lagoon-php": "^0.0.5", - "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.5", - "freedomtech-hosting/polydock-app": "^0.0.26", - "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.73", + "freedomtech-hosting/ft-lagoon-php": "^0.0.12", + "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.6", + "freedomtech-hosting/polydock-app": "^0.0.31", + "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.80", "laravel/framework": "^11.31", "laravel/horizon": "^5.30", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.9", + "phpseclib/phpseclib": "^3.0", "spatie/mjml-php": "^1.2" }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.9", "laravel/pail": "^1.1", "laravel/pint": "^1.13", "laravel/sail": "^1.26", diff --git a/composer.lock b/composer.lock index c441753..619a6b2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,32 +4,79 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b1ccaec07db21e5fe894bb4394229cb4", + "content-hash": "2b6342ce3c11cc91087fd3c28bfc7d71", "packages": [ + { + "name": "amazeeio/lagoon-logs", + "version": "v0.0.5", + "source": { + "type": "git", + "url": "https://github.com/amazeeio/laravel_lagoon_logs.git", + "reference": "2d25153b36f1de3c4c8ae45c167319c5164f3d83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amazeeio/laravel_lagoon_logs/zipball/2d25153b36f1de3c4c8ae45c167319c5164f3d83", + "reference": "2d25153b36f1de3c4c8ae45c167319c5164f3d83", + "shasum": "" + }, + "require": { + "monolog/monolog": "^2.0|^3.0" + }, + "type": "package", + "extra": { + "laravel": { + "providers": [ + "amazeeio\\LagoonLogs\\LagoonLogsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "amazeeio\\LagoonLogs\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Blaize Kaye", + "email": "blaize.kaye@amazee.com" + } + ], + "description": "Zero config logging module for Laravel and Lagoon", + "support": { + "issues": "https://github.com/amazeeio/laravel_lagoon_logs/issues", + "source": "https://github.com/amazeeio/laravel_lagoon_logs/tree/v0.0.5" + }, + "time": "2023-04-19T05:21:35+00:00" + }, { "name": "amazeeio/polydock-app-amazeeio-privategpt", - "version": "v0.0.4", + "version": "v0.0.13", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt.git", - "reference": "be5c3864e0262a94af8363ca3e4627f59e4a4d31" + "reference": "fe64d58fc223e6b7e2754c154c1f253b39296078" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-privategpt/zipball/be5c3864e0262a94af8363ca3e4627f59e4a4d31", - "reference": "be5c3864e0262a94af8363ca3e4627f59e4a4d31", + "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-privategpt/zipball/fe64d58fc223e6b7e2754c154c1f253b39296078", + "reference": "fe64d58fc223e6b7e2754c154c1f253b39296078", "shasum": "" }, "require": { - "cuyz/valinor": "^1.0", - "freedomtech-hosting/ft-lagoon-php": "^0.0.5", - "freedomtech-hosting/polydock-app": "^0.0.26", - "freedomtech-hosting/polydock-app-amazeeio-generic": "*", - "guzzlehttp/guzzle": "^7.0" + "cuyz/valinor": "^2.3.2", + "freedomtech-hosting/ft-lagoon-php": "^0.0.12", + "freedomtech-hosting/polydock-app": "^0.0.31", + "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.80", + "guzzlehttp/guzzle": "^7.10.0" }, "require-dev": { - "laravel/pint": "^1.22", - "mockery/mockery": "*", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", "orchestra/testbench": "*", "phpstan/extension-installer": "*", "phpstan/phpstan": "^1.10", @@ -55,9 +102,9 @@ "description": "Polydock App - amazee.io PrivateGPT with Direct API Integration", "support": { "issues": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt/issues", - "source": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt/tree/v0.0.4" + "source": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt/tree/v0.0.13" }, - "time": "2025-11-25T19:59:39+00:00" + "time": "2026-02-20T19:01:43+00:00" }, { "name": "anourvalar/eloquent-serialize", @@ -277,16 +324,16 @@ }, { "name": "brick/math", - "version": "0.14.5", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/618a8077b3c326045e10d5788ed713b341fcfe40", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -325,7 +372,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.5" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -333,7 +380,7 @@ "type": "github" } ], - "time": "2026-02-03T18:06:51+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -406,30 +453,28 @@ }, { "name": "cuyz/valinor", - "version": "1.17.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/CuyZ/Valinor.git", - "reference": "9eb2802797e36f3af1fd6e13ff23e4e3d0058022" + "reference": "3b9f3f54901371d589776502aab3da3a046801a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CuyZ/Valinor/zipball/9eb2802797e36f3af1fd6e13ff23e4e3d0058022", - "reference": "9eb2802797e36f3af1fd6e13ff23e4e3d0058022", + "url": "https://api.github.com/repos/CuyZ/Valinor/zipball/3b9f3f54901371d589776502aab3da3a046801a7", + "reference": "3b9f3f54901371d589776502aab3da3a046801a7", "shasum": "" }, "require": { - "composer-runtime-api": "^2.0", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "conflict": { "phpstan/phpstan": "<1.0 || >= 3.0", "vimeo/psalm": "<5.0 || >=7.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.4", - "infection/infection": "^0.29", + "friendsofphp/php-cs-fixer": "^3.91", + "infection/infection": "^0.31", "marcocesarato/php-conventional-changelog": "^1.12", "mikey179/vfsstream": "^1.6.10", "phpbench/phpbench": "^1.3", @@ -457,7 +502,7 @@ "homepage": "https://github.com/romm" } ], - "description": "Library that helps to map any input into a strongly-typed value object structure.", + "description": "Dependency free PHP library that helps to map any input into a strongly-typed structure.", "homepage": "https://github.com/CuyZ/Valinor", "keywords": [ "array", @@ -472,7 +517,7 @@ ], "support": { "issues": "https://github.com/CuyZ/Valinor/issues", - "source": "https://github.com/CuyZ/Valinor/tree/1.17.0" + "source": "https://github.com/CuyZ/Valinor/tree/2.3.2" }, "funding": [ { @@ -480,7 +525,7 @@ "type": "github" } ], - "time": "2025-06-20T06:40:38+00:00" + "time": "2026-01-23T15:26:34+00:00" }, { "name": "danharrin/date-format-converter", @@ -770,29 +815,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -812,9 +857,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -1183,16 +1228,16 @@ }, { "name": "filament/actions", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1" + "reference": "3cb3e1f9094ed3b4bc102616966c365138c908bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", - "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/3cb3e1f9094ed3b4bc102616966c365138c908bc", + "reference": "3cb3e1f9094ed3b4bc102616966c365138c908bc", "shasum": "" }, "require": { @@ -1232,20 +1277,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:27+00:00" + "time": "2026-02-07T21:52:11+00:00" }, { "name": "filament/filament", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "790e3c163e93f5746beea88b93d38673424984b6" + "reference": "6098e568b4257dc438ff68aced0a260f06ba6d52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/790e3c163e93f5746beea88b93d38673424984b6", - "reference": "790e3c163e93f5746beea88b93d38673424984b6", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/6098e568b4257dc438ff68aced0a260f06ba6d52", + "reference": "6098e568b4257dc438ff68aced0a260f06ba6d52", "shasum": "" }, "require": { @@ -1297,20 +1342,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:34+00:00" + "time": "2026-02-07T21:52:21+00:00" }, { "name": "filament/forms", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58" + "reference": "c64bf142f808d292b0c6c21fdd3c75cbef9e9d30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/f708ce490cff3770071d18e9ea678eb4b7c65c58", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/c64bf142f808d292b0c6c21fdd3c75cbef9e9d30", + "reference": "c64bf142f808d292b0c6c21fdd3c75cbef9e9d30", "shasum": "" }, "require": { @@ -1353,20 +1398,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:33+00:00" + "time": "2026-02-19T23:07:33+00:00" }, { "name": "filament/infolists", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "ac7fc1c8acc651c6c793696f0772747791c91155" + "reference": "9cef7bf9f46756a8adf762ced62952e8c239b840" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/ac7fc1c8acc651c6c793696f0772747791c91155", - "reference": "ac7fc1c8acc651c6c793696f0772747791c91155", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/9cef7bf9f46756a8adf762ced62952e8c239b840", + "reference": "9cef7bf9f46756a8adf762ced62952e8c239b840", "shasum": "" }, "require": { @@ -1404,11 +1449,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:28:31+00:00" + "time": "2026-02-07T21:51:54+00:00" }, { "name": "filament/notifications", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -1460,16 +1505,16 @@ }, { "name": "filament/support", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "c37f4b9045a7c514974e12562b5a41813860b505" + "reference": "493bd79d4f4ae7b9256c317af742354318a6f4e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/c37f4b9045a7c514974e12562b5a41813860b505", - "reference": "c37f4b9045a7c514974e12562b5a41813860b505", + "url": "https://api.github.com/repos/filamentphp/support/zipball/493bd79d4f4ae7b9256c317af742354318a6f4e0", + "reference": "493bd79d4f4ae7b9256c317af742354318a6f4e0", "shasum": "" }, "require": { @@ -1515,20 +1560,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-09T09:01:14+00:00" + "time": "2026-02-07T21:51:58+00:00" }, { "name": "filament/tables", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "c88d17248827b3fbca09db53d563498d29c6b180" + "reference": "fb0ab986950dc8129725f676bdb310851b18403f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/c88d17248827b3fbca09db53d563498d29c6b180", - "reference": "c88d17248827b3fbca09db53d563498d29c6b180", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/fb0ab986950dc8129725f676bdb310851b18403f", + "reference": "fb0ab986950dc8129725f676bdb310851b18403f", "shasum": "" }, "require": { @@ -1567,20 +1612,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:37+00:00" + "time": "2026-02-07T21:52:06+00:00" }, { "name": "filament/widgets", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e" + "reference": "f58ff26e81ca2557205e3111e1d9d05c258cc206" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/2bf59fd94007b69c22c161f7a4749ea19560e03e", - "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/f58ff26e81ca2557205e3111e1d9d05c258cc206", + "reference": "f58ff26e81ca2557205e3111e1d9d05c258cc206", "shasum": "" }, "require": { @@ -1611,20 +1656,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:32+00:00" + "time": "2026-02-07T21:51:58+00:00" }, { "name": "freedomtech-hosting/ft-lagoon-php", - "version": "v0.0.5", + "version": "v0.0.12", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-lagoon-php-lib.git", - "reference": "e8669ab79091eade0444a1fe7bcf96ed243b8ea4" + "reference": "2543ae3aea1f3d5e176e6c918c8e8c6b4cc27cb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-lagoon-php-lib/zipball/e8669ab79091eade0444a1fe7bcf96ed243b8ea4", - "reference": "e8669ab79091eade0444a1fe7bcf96ed243b8ea4", + "url": "https://api.github.com/repos/amazeeio/polydock-lagoon-php-lib/zipball/2543ae3aea1f3d5e176e6c918c8e8c6b4cc27cb7", + "reference": "2543ae3aea1f3d5e176e6c918c8e8c6b4cc27cb7", "shasum": "" }, "require": { @@ -1651,25 +1696,26 @@ "description": "The Freedom Tech Lagoon PHP library", "support": { "issues": "https://github.com/amazeeio/polydock-lagoon-php-lib/issues", - "source": "https://github.com/amazeeio/polydock-lagoon-php-lib/tree/v0.0.5" + "source": "https://github.com/amazeeio/polydock-lagoon-php-lib/tree/v0.0.12" }, - "time": "2025-04-22T03:56:15+00:00" + "time": "2026-02-20T12:39:54+00:00" }, { "name": "freedomtech-hosting/polydock-amazeeai-backend-client-php", - "version": "v0.0.5", + "version": "v0.0.6", "source": { "type": "git", - "url": "https://github.com/Freedomtech-Hosting/polydock-amazeeai-backend-client-php.git", - "reference": "5b0c8ac37cc4db7fb69d34a5d09875f4a342760b" + "url": "https://github.com/amazeeio/polydock-amazeeai-backend-php-lib.git", + "reference": "f4841e97eabe7c78bda35e146dfd9b9d13dddb4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Freedomtech-Hosting/polydock-amazeeai-backend-client-php/zipball/5b0c8ac37cc4db7fb69d34a5d09875f4a342760b", - "reference": "5b0c8ac37cc4db7fb69d34a5d09875f4a342760b", + "url": "https://api.github.com/repos/amazeeio/polydock-amazeeai-backend-php-lib/zipball/f4841e97eabe7c78bda35e146dfd9b9d13dddb4f", + "reference": "f4841e97eabe7c78bda35e146dfd9b9d13dddb4f", "shasum": "" }, "require": { + "ext-curl": "*", "symfony/http-client": "^7.2" }, "type": "library", @@ -1690,23 +1736,23 @@ ], "description": "The Freedom Tech amazee.ai Private AI PHP library", "support": { - "issues": "https://github.com/Freedomtech-Hosting/polydock-amazeeai-backend-client-php/issues", - "source": "https://github.com/Freedomtech-Hosting/polydock-amazeeai-backend-client-php/tree/v0.0.5" + "issues": "https://github.com/amazeeio/polydock-amazeeai-backend-php-lib/issues", + "source": "https://github.com/amazeeio/polydock-amazeeai-backend-php-lib/tree/v0.0.6" }, - "time": "2025-03-24T02:16:39+00:00" + "time": "2026-02-13T00:30:17+00:00" }, { "name": "freedomtech-hosting/polydock-app", - "version": "v0.0.26", + "version": "v0.0.31", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-lib.git", - "reference": "aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c" + "reference": "cf234a3d6722cd01f0296e0fc01c34e3553c9340" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-app-lib/zipball/aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c", - "reference": "aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c", + "url": "https://api.github.com/repos/amazeeio/polydock-app-lib/zipball/cf234a3d6722cd01f0296e0fc01c34e3553c9340", + "reference": "cf234a3d6722cd01f0296e0fc01c34e3553c9340", "shasum": "" }, "require": { @@ -1731,28 +1777,28 @@ "description": "Library for Polydock Apps", "support": { "issues": "https://github.com/amazeeio/polydock-app-lib/issues", - "source": "https://github.com/amazeeio/polydock-app-lib/tree/v0.0.26" + "source": "https://github.com/amazeeio/polydock-app-lib/tree/v0.0.31" }, - "time": "2025-04-11T19:57:05+00:00" + "time": "2026-02-20T18:31:16+00:00" }, { "name": "freedomtech-hosting/polydock-app-amazeeio-generic", - "version": "v0.0.73", + "version": "v0.0.80", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-amazeeio-generic.git", - "reference": "54df7bed96e00824d6e765ae5fa06e9823f71830" + "reference": "459b0e91e04bf489a36279bf80a94cea361db6ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-generic/zipball/54df7bed96e00824d6e765ae5fa06e9823f71830", - "reference": "54df7bed96e00824d6e765ae5fa06e9823f71830", + "url": "https://api.github.com/repos/amazeeio/polydock-app-amazeeio-generic/zipball/459b0e91e04bf489a36279bf80a94cea361db6ef", + "reference": "459b0e91e04bf489a36279bf80a94cea361db6ef", "shasum": "" }, "require": { - "freedomtech-hosting/ft-lagoon-php": "^0.0.5", - "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.5", - "freedomtech-hosting/polydock-app": "^0.0.26" + "freedomtech-hosting/ft-lagoon-php": "^0.0.12", + "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.6", + "freedomtech-hosting/polydock-app": "^0.0.31" }, "type": "library", "autoload": { @@ -1773,9 +1819,9 @@ "description": "Polydock App - amazee.io Generic", "support": { "issues": "https://github.com/amazeeio/polydock-app-amazeeio-generic/issues", - "source": "https://github.com/amazeeio/polydock-app-amazeeio-generic/tree/v0.0.73" + "source": "https://github.com/amazeeio/polydock-app-amazeeio-generic/tree/v0.0.80" }, - "time": "2025-06-25T20:14:15+00:00" + "time": "2026-02-20T18:33:49+00:00" }, { "name": "fruitcake/php-cors", @@ -2601,36 +2647,36 @@ }, { "name": "laravel/horizon", - "version": "v5.43.0", + "version": "v5.44.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de" + "reference": "00c21e4e768112cce3f4fe576d75956dfc423de2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2a04285ba83915511afbe987cbfedafdc27fd2de", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de", + "url": "https://api.github.com/repos/laravel/horizon/zipball/00c21e4e768112cce3f4fe576d75956dfc423de2", + "reference": "00c21e4e768112cce3f4fe576d75956dfc423de2", "shasum": "" }, "require": { "ext-json": "*", "ext-pcntl": "*", "ext-posix": "*", - "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", - "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.17|^3.0", "php": "^8.0", "ramsey/uuid": "^4.0", - "symfony/console": "^6.0|^7.0", - "symfony/error-handler": "^6.0|^7.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", "symfony/polyfill-php83": "^1.28", - "symfony/process": "^6.0|^7.0" + "symfony/process": "^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10|^2.0", "predis/predis": "^1.1|^2.0|^3.0" }, @@ -2674,36 +2720,36 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.43.0" + "source": "https://github.com/laravel/horizon/tree/v5.44.0" }, - "time": "2026-01-15T15:10:56+00:00" + "time": "2026-02-10T18:18:08+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.11", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -2733,36 +2779,36 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.11" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2026-01-27T02:55:06+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -2798,31 +2844,31 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-22T22:27:01+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.9", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -2859,20 +2905,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-03T06:55:34+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -2923,9 +2969,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "league/commonmark", @@ -3644,16 +3690,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.8", + "version": "v3.7.10", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607" + "reference": "0dc679eb4c8b4470cb12522b5927ef08ca2358bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/06ec7e8cd61bb01739b8f26396db6fe73b7e0607", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607", + "url": "https://api.github.com/repos/livewire/livewire/zipball/0dc679eb4c8b4470cb12522b5927ef08ca2358bb", + "reference": "0dc679eb4c8b4470cb12522b5927ef08ca2358bb", "shasum": "" }, "require": { @@ -3708,7 +3754,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.8" + "source": "https://github.com/livewire/livewire/tree/v3.7.10" }, "funding": [ { @@ -3716,7 +3762,7 @@ "type": "github" } ], - "time": "2026-02-03T02:57:56+00:00" + "time": "2026-02-09T22:49:33+00:00" }, { "name": "masterminds/html5", @@ -3995,16 +4041,16 @@ }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", "shasum": "" }, "require": { @@ -4012,8 +4058,8 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -4054,22 +4100,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.4" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-08T02:54:00+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -4081,8 +4127,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -4143,9 +4191,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -4207,31 +4255,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -4263,7 +4311,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -4274,7 +4322,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -4290,7 +4338,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "openspout/openspout", @@ -4385,6 +4433,125 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -4460,6 +4627,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.49", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:17:28+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -4923,16 +5200,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -4996,9 +5273,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "ralouphie/getallheaders", @@ -5278,16 +5555,16 @@ }, { "name": "softonic/graphql-client", - "version": "3.1.1", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/softonic/graphql-client.git", - "reference": "72ae042d3f22ad5eb57cd7672954d8f5dc9687ef" + "reference": "71efe9e93609a98f7878c7d323732ccfb817653d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/softonic/graphql-client/zipball/72ae042d3f22ad5eb57cd7672954d8f5dc9687ef", - "reference": "72ae042d3f22ad5eb57cd7672954d8f5dc9687ef", + "url": "https://api.github.com/repos/softonic/graphql-client/zipball/71efe9e93609a98f7878c7d323732ccfb817653d", + "reference": "71efe9e93609a98f7878c7d323732ccfb817653d", "shasum": "" }, "require": { @@ -5295,7 +5572,7 @@ "guzzlehttp/guzzle": "^6.3 || ^7.0", "php": "^8.0", "softonic/guzzle-oauth2-middleware": "^2.1", - "symfony/console": "^6.0 || ^7.0" + "symfony/console": "^6.0 || ^7.0 || ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.9", @@ -5327,9 +5604,9 @@ ], "support": { "issues": "https://github.com/softonic/graphql-client/issues", - "source": "https://github.com/softonic/graphql-client/tree/3.1.1" + "source": "https://github.com/softonic/graphql-client/tree/3.1.2" }, - "time": "2025-03-26T14:37:06+00:00" + "time": "2026-02-18T10:31:17+00:00" }, { "name": "softonic/guzzle-oauth2-middleware", @@ -5558,25 +5835,25 @@ }, { "name": "spatie/mjml-php", - "version": "1.2.5", + "version": "1.2.6", "source": { "type": "git", "url": "https://github.com/spatie/mjml-php.git", - "reference": "0af0b3944617889df7bed69faca3819a0399feff" + "reference": "8bf9e5966beb9521510a0af29d94bfffaa1f3dbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/mjml-php/zipball/0af0b3944617889df7bed69faca3819a0399feff", - "reference": "0af0b3944617889df7bed69faca3819a0399feff", + "url": "https://api.github.com/repos/spatie/mjml-php/zipball/8bf9e5966beb9521510a0af29d94bfffaa1f3dbd", + "reference": "8bf9e5966beb9521510a0af29d94bfffaa1f3dbd", "shasum": "" }, "require": { - "php": "^8.1", - "symfony/process": "^6.3.2|^7.0" + "php": "^8.2", + "symfony/process": "^6.3.2|^7.0|^8.0" }, "require-dev": { "laravel/pint": "^1.11", - "pestphp/pest": "^2.16", + "pestphp/pest": "^2.16|^3.0", "spatie/ray": "^1.37.2" }, "suggest": { @@ -5607,7 +5884,7 @@ ], "support": { "issues": "https://github.com/spatie/mjml-php/issues", - "source": "https://github.com/spatie/mjml-php/tree/1.2.5" + "source": "https://github.com/spatie/mjml-php/tree/1.2.6" }, "funding": [ { @@ -5615,7 +5892,7 @@ "type": "github" } ], - "time": "2025-03-17T19:50:19+00:00" + "time": "2026-02-09T15:00:49+00:00" }, { "name": "spatie/ssh", @@ -8750,39 +9027,171 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.9.2", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.32" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.9.2" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-01-30T15:16:32+00:00" + }, { "name": "laravel/pail", - "version": "v1.2.4", + "version": "v1.2.6", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -8827,20 +9236,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -8851,13 +9260,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -8894,32 +9303,32 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { "name": "laravel/sail", - "version": "v1.52.0", + "version": "v1.53.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0", - "symfony/yaml": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^2.0" }, "bin": [ @@ -8957,7 +9366,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-01-01T02:46:03+00:00" + "time": "2026-02-06T12:16:02+00:00" }, { "name": "mockery/mockery", @@ -9104,39 +9513,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -9199,7 +9605,7 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "phar-io/manifest", @@ -9321,11 +9727,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.39", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", "shasum": "" }, "require": { @@ -9370,7 +9776,7 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-11T14:48:56+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9721,16 +10127,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.50", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", - "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -9745,7 +10151,7 @@ "phar-io/version": "^3.2.1", "php": ">=8.2", "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", @@ -9757,6 +10163,7 @@ "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" @@ -9802,7 +10209,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -9826,25 +10233,25 @@ "type": "tidelift" } ], - "time": "2026-01-27T05:59:18+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "rector/rector", - "version": "2.3.5", + "version": "2.3.7", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" + "reference": "9c46ad17f57963932c9788fd1b0f1d07ff450370" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9c46ad17f57963932c9788fd1b0f1d07ff450370", + "reference": "9c46ad17f57963932c9788fd1b0f1d07ff450370", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.36" + "phpstan/phpstan": "^2.1.38" }, "conflict": { "rector/rector-doctrine": "*", @@ -9878,7 +10285,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.5" + "source": "https://github.com/rectorphp/rector/tree/2.3.7" }, "funding": [ { @@ -9886,7 +10293,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-02-19T14:44:16+00:00" }, { "name": "sebastian/cli-parser", @@ -11212,7 +11619,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.3", + "ext-curl": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/database/factories/PolydockStoreAppFactory.php b/database/factories/PolydockStoreAppFactory.php index 248390d..435fda5 100644 --- a/database/factories/PolydockStoreAppFactory.php +++ b/database/factories/PolydockStoreAppFactory.php @@ -13,7 +13,7 @@ class PolydockStoreAppFactory extends Factory public function definition(): array { return [ - 'polydock_app_class' => 'FreedomtechHosting\\PolydockApp'.fake()->word().'\\PolydockApp', + 'polydock_app_class' => \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class, 'name' => fake()->words(3, true), 'description' => fake()->paragraph(), 'author' => fake()->name(), diff --git a/database/migrations/2026_02_05_133703_move_lagoon_deploy_private_key_to_variables.php b/database/migrations/2026_02_05_133703_move_lagoon_deploy_private_key_to_variables.php new file mode 100644 index 0000000..98fe54c --- /dev/null +++ b/database/migrations/2026_02_05_133703_move_lagoon_deploy_private_key_to_variables.php @@ -0,0 +1,87 @@ +get(); + + foreach ($stores as $store) { + if (! empty($store->lagoon_deploy_private_key)) { + DB::table('polydock_variables')->updateOrInsert( + [ + 'variabled_type' => \App\Models\PolydockStore::class, + 'variabled_id' => $store->id, + 'name' => 'lagoon_deploy_private_key', + ], + [ + 'value' => Crypt::encryptString($store->lagoon_deploy_private_key), + 'is_encrypted' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + } + + Schema::table('polydock_stores', function (Blueprint $table) { + $table->dropColumn('lagoon_deploy_private_key'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('polydock_stores', function (Blueprint $table) { + $table->text('lagoon_deploy_private_key')->nullable()->after('lagoon_deploy_project_prefix'); + }); + + // Restore keys from variables + $variables = DB::table('polydock_variables') + ->where('variabled_type', \App\Models\PolydockStore::class) + ->where('name', 'lagoon_deploy_private_key') + ->get(); + + $restoredIds = []; + + foreach ($variables as $variable) { + try { + $value = $variable->is_encrypted + ? Crypt::decryptString($variable->value) + : $variable->value; + + DB::table('polydock_stores') + ->where('id', $variable->variabled_id) + ->update(['lagoon_deploy_private_key' => $value]); + + $restoredIds[] = $variable->id; + } catch (\Exception $e) { + Log::warning('Migration rollback: failed to decrypt lagoon_deploy_private_key, skipping delete to preserve data', [ + 'variable_id' => $variable->id, + 'store_id' => $variable->variabled_id, + 'error' => $e->getMessage(), + ]); + } + } + + // Only delete variables that were successfully restored + if (! empty($restoredIds)) { + DB::table('polydock_variables') + ->whereIn('id', $restoredIds) + ->delete(); + } + } +}; diff --git a/database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php b/database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php new file mode 100644 index 0000000..9c541b2 --- /dev/null +++ b/database/migrations/2026_02_15_015738_add_app_config_to_polydock_store_apps_table.php @@ -0,0 +1,28 @@ +json('app_config')->nullable()->after('polydock_app_class'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('polydock_store_apps', function (Blueprint $table) { + $table->dropColumn('app_config'); + }); + } +}; diff --git a/database/seeders/AmazeeTrialSeeder.php b/database/seeders/AmazeeTrialSeeder.php index 2ae523d..a058254 100644 --- a/database/seeders/AmazeeTrialSeeder.php +++ b/database/seeders/AmazeeTrialSeeder.php @@ -46,9 +46,9 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '126', 'lagoon_deploy_project_prefix' => 'ait-us', 'lagoon_deploy_organization_id_ext' => '549', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 68, ]); + $usStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); $chStore = \App\Models\PolydockStore::create([ 'name' => 'Switzerland Store', @@ -57,9 +57,9 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '131', 'lagoon_deploy_project_prefix' => 'ait-ch', 'lagoon_deploy_organization_id_ext' => '549', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 34, ]); + $chStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); $auStore = \App\Models\PolydockStore::create([ 'name' => 'Australia Store', @@ -68,9 +68,9 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '132', 'lagoon_deploy_project_prefix' => 'ait-au', 'lagoon_deploy_organization_id_ext' => '549', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 69, ]); + $auStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); $deStore = \App\Models\PolydockStore::create([ 'name' => 'Europe Store', @@ -79,9 +79,9 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '115', 'lagoon_deploy_project_prefix' => 'ait-de', 'lagoon_deploy_organization_id_ext' => '549', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 67, ]); + $deStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); // Add webhook to both stores $webhookUrl = 'https://webhook.site/bbe9c2ef-bb18-4c13-8d40-14fb428c7b64'; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9d535f1..4d7a9b5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -76,10 +76,10 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '1', 'lagoon_deploy_project_prefix' => 'ft-us', 'lagoon_deploy_organization_id_ext' => '271', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 34, 'lagoon_deploy_group_name' => 'polydock-demo-apps', ]); + $usaStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); $switzerlandStore = \App\Models\PolydockStore::create([ 'name' => 'Switzerland Store', @@ -88,10 +88,10 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '1', 'lagoon_deploy_project_prefix' => 'ft-ch', 'lagoon_deploy_organization_id_ext' => '271', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 34, 'lagoon_deploy_group_name' => 'polydock-demo-apps', ]); + $switzerlandStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); // Add webhook to both stores $webhookUrl = 'https://webhook.site/bbe9c2ef-bb18-4c13-8d40-14fb428c7b64'; diff --git a/database/seeders/LocalstackSeeder.php b/database/seeders/LocalstackSeeder.php index d7f900b..6ea1c3e 100644 --- a/database/seeders/LocalstackSeeder.php +++ b/database/seeders/LocalstackSeeder.php @@ -41,10 +41,10 @@ public function run(): void 'lagoon_deploy_region_id_ext' => '2001', 'lagoon_deploy_project_prefix' => 'localstack', 'lagoon_deploy_organization_id_ext' => '1', - 'lagoon_deploy_private_key' => $deployKey, 'amazee_ai_backend_region_id_ext' => 666, 'lagoon_deploy_group_name' => 'polydock-demo-apps', ]); + $store->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); // Add webhook to both stores $webhookUrl = 'https://webhook.site/bbe9c2ef-bb18-4c13-8d40-14fb428c7b64'; diff --git a/lagoon-docker-compose.yml b/lagoon-docker-compose.yml index ceb9101..425ea9e 100644 --- a/lagoon-docker-compose.yml +++ b/lagoon-docker-compose.yml @@ -50,7 +50,6 @@ services: lagoon.name: worker lagoon.persistent.name: nginx lagoon.persistent: /app/storage/ - mariadb: image: uselagoon/mysql-8.0 diff --git a/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php b/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php new file mode 100644 index 0000000..d32534e --- /dev/null +++ b/resources/views/filament/admin/pages/create-polydock-app-instance.blade.php @@ -0,0 +1,12 @@ + +
+ @csrf + {{ $this->form }} + +
+ + Create Instance + +
+
+
diff --git a/routes/api.php b/routes/api.php index ed5618b..80c18fc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\RegionsController; use App\Http\Controllers\Api\RegisterController; +use App\Http\Middleware\EnsureInstancesReadAbility; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -14,6 +15,12 @@ Route::get('/regions', [RegionsController::class, 'index'])->name('regions.index'); +Route::prefix('/v1') + ->middleware(['auth:sanctum', EnsureInstancesReadAbility::class]) + ->group(function (): void { + Route::get('/instances/{uuid}', [RegisterController::class, 'showRegister'])->name('instances.show'); + }); + Route::match(['get', 'post'], '/instance/{uuid}/health/{status}', [ \App\Http\Controllers\Api\PolydockInstanceHealthController::class, '__invoke', diff --git a/tests/Feature/Api/InstancesApiAuthTest.php b/tests/Feature/Api/InstancesApiAuthTest.php new file mode 100644 index 0000000..5100086 --- /dev/null +++ b/tests/Feature/Api/InstancesApiAuthTest.php @@ -0,0 +1,90 @@ +seedRegistration(); + + $this->getJson("/api/register/{$uuid}") + ->assertOk() + ->assertJsonPath('status', UserRemoteRegistrationStatusEnum::PENDING->value); + } + + public function test_legacy_register_create_endpoint_stays_public(): void + { + $this->postJson('/api/register', [ + 'email' => 'legacy-create@example.com', + 'first_name' => 'Legacy', + 'last_name' => 'Flow', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => 'app-uuid', + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => true, + ]) + ->assertStatus(202) + ->assertJsonPath('status', UserRemoteRegistrationStatusEnum::PENDING->value); + } + + public function test_v1_instances_status_requires_token(): void + { + $uuid = $this->seedRegistration(); + + $this->getJson("/api/v1/instances/{$uuid}") + ->assertUnauthorized(); + } + + public function test_v1_instances_status_rejects_token_without_instances_read(): void + { + $uuid = $this->seedRegistration(); + $user = User::factory()->create(); + $token = $user->createToken('no-read', ['instances.write']); + + $this->withHeader('Authorization', "Bearer {$token->plainTextToken}") + ->getJson("/api/v1/instances/{$uuid}") + ->assertForbidden(); + } + + public function test_v1_instances_status_accepts_instances_read_token(): void + { + $uuid = $this->seedRegistration(); + $user = User::factory()->create(); + $token = $user->createToken('instances-read', ['instances.read']); + + $this->withHeader('Authorization', "Bearer {$token->plainTextToken}") + ->getJson("/api/v1/instances/{$uuid}") + ->assertOk() + ->assertJsonPath('status', UserRemoteRegistrationStatusEnum::PENDING->value) + ->assertJsonPath('email', 'instance-test@example.com'); + } + + private function seedRegistration(): string + { + $uuid = (string) Str::uuid(); + + DB::table('user_remote_registrations')->insert([ + 'uuid' => $uuid, + 'email' => 'instance-test@example.com', + 'request_data' => json_encode(['register_type' => 'REQUEST_TRIAL']), + 'result_data' => null, + 'status' => UserRemoteRegistrationStatusEnum::PENDING->value, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $uuid; + } +} diff --git a/tests/Feature/Filament/CreatePolydockAppInstanceTest.php b/tests/Feature/Filament/CreatePolydockAppInstanceTest.php new file mode 100644 index 0000000..d267ff4 --- /dev/null +++ b/tests/Feature/Filament/CreatePolydockAppInstanceTest.php @@ -0,0 +1,180 @@ +admin = User::factory()->create([ + 'email' => 'admin@example.com', + ]); + + // Create a store and store app available for trials + $store = PolydockStore::factory()->create(); + $this->storeApp = PolydockStoreApp::factory() + ->availableForTrials() + ->create([ + 'polydock_store_id' => $store->id, + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + ]); + + // Set the admin panel as current for Filament + Filament::setCurrentPanel( + Filament::getPanel('admin'), + ); + } + + public function test_admin_can_create_app_instance(): void + { + Queue::fake(); + + $this->actingAs($this->admin); + + Livewire::test(CreatePolydockAppInstance::class) + ->fillForm([ + 'email' => 'newuser@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'organization' => 'Test Org', + 'trial_app' => $this->storeApp->uuid, + 'is_trial' => true, + 'aup_and_privacy_acceptance' => true, + 'opt_in_to_product_updates' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + // Verify registration was created + $this->assertDatabaseHas('user_remote_registrations', [ + 'email' => 'newuser@example.com', + ]); + + // Verify job was dispatched + Queue::assertPushed(ProcessUserRemoteRegistration::class); + } + + public function test_create_form_validates_required_fields(): void + { + $this->actingAs($this->admin); + + Livewire::test(CreatePolydockAppInstance::class) + ->fillForm([ + 'email' => '', + 'first_name' => '', + 'last_name' => '', + 'trial_app' => null, + ]) + ->call('create') + ->assertHasFormErrors([ + 'email' => 'required', + 'first_name' => 'required', + 'last_name' => 'required', + 'trial_app' => 'required', + ]); + } + + public function test_registration_includes_admin_created_flag(): void + { + Queue::fake(); + + $this->actingAs($this->admin); + + $registration = UserRemoteRegistration::create([ + 'email' => 'flagtest@example.com', + 'request_data' => [ + 'email' => 'flagtest@example.com', + 'first_name' => 'Admin', + 'last_name' => 'Created', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => 1, + 'admin_created' => true, + 'is_trial' => true, + ], + ]); + + $this->assertNotNull($registration); + $this->assertTrue($registration->request_data['admin_created']); + $this->assertEquals('REQUEST_TRIAL', $registration->request_data['register_type']); + } + + public function test_store_app_factory_creates_available_trial_app(): void + { + $this->assertTrue($this->storeApp->available_for_trials); + $this->assertEquals(PolydockStoreAppStatusEnum::AVAILABLE, $this->storeApp->status); + $this->assertNotNull($this->storeApp->uuid); + } + + public function test_user_remote_registration_stores_request_data(): void + { + $registration = UserRemoteRegistration::create([ + 'email' => 'datatest@example.com', + 'request_data' => [ + 'email' => 'datatest@example.com', + 'first_name' => 'Data', + 'last_name' => 'Test', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'custom_field' => 'custom_value', + ], + ]); + + $this->assertEquals('Data', $registration->getRequestValue('first_name')); + $this->assertEquals('Test', $registration->getRequestValue('last_name')); + $this->assertEquals('custom_value', $registration->getRequestValue('custom_field')); + } + + public function test_process_user_remote_registration_job_can_be_dispatched(): void + { + Queue::fake(); + + $registration = UserRemoteRegistration::create([ + 'email' => 'jobtest@example.com', + 'request_data' => [ + 'email' => 'jobtest@example.com', + 'first_name' => 'Job', + 'last_name' => 'Test', + 'register_type' => 'REQUEST_TRIAL', + 'trial_app' => $this->storeApp->uuid, + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => 1, + ], + ]); + + ProcessUserRemoteRegistration::dispatch($registration); + + Queue::assertPushed(ProcessUserRemoteRegistration::class, function ($job) use ($registration) { + // Use reflection to access the private registration property + $reflection = new \ReflectionClass($job); + $property = $reflection->getProperty('registration'); + $jobRegistration = $property->getValue($job); + + return $jobRegistration->id === $registration->id && $jobRegistration->email === 'jobtest@example.com'; + }); + } +} diff --git a/tests/Unit/Models/PolydockStoreTest.php b/tests/Unit/Models/PolydockStoreTest.php new file mode 100644 index 0000000..fb5463d --- /dev/null +++ b/tests/Unit/Models/PolydockStoreTest.php @@ -0,0 +1,49 @@ +create(); + $privateKey = 'test-private-key'; + + $store->setPolydockVariableValue('lagoon_deploy_private_key', $privateKey, true); + + $this->assertEquals($privateKey, $store->lagoon_deploy_private_key); + } + + public function test_lagoon_deploy_private_key_is_encrypted_in_database() + { + $store = PolydockStore::factory()->create(); + $privateKey = 'test-private-key'; + + $store->setPolydockVariableValue('lagoon_deploy_private_key', $privateKey, true); + + $variable = PolydockVariable::where('variabled_type', PolydockStore::class) + ->where('variabled_id', $store->id) + ->where('name', 'lagoon_deploy_private_key') + ->first(); + + $this->assertNotNull($variable); + $this->assertTrue($variable->is_encrypted); + $this->assertNotEquals($privateKey, $variable->value); + $this->assertEquals($privateKey, Crypt::decryptString($variable->value)); + } + + public function test_lagoon_deploy_private_key_returns_null_when_not_set() + { + $store = PolydockStore::factory()->create(); + + $this->assertNull($store->lagoon_deploy_private_key); + } +} diff --git a/tests/Unit/PolydockEngine/Helpers/LagoonHelperTest.php b/tests/Unit/PolydockEngine/Helpers/LagoonHelperTest.php new file mode 100644 index 0000000..64e72f8 --- /dev/null +++ b/tests/Unit/PolydockEngine/Helpers/LagoonHelperTest.php @@ -0,0 +1,48 @@ +privateKey); + + $this->assertStringContainsString($this->publicKey, $derivedPublicKey); + } + + public function test_get_public_key_from_private_key_returns_null_for_invalid_key() + { + $invalidKey = 'invalid-key-content'; + $derivedPublicKey = LagoonHelper::getPublicKeyFromPrivateKey($invalidKey); + + $this->assertNull($derivedPublicKey); + } + + public function test_get_public_key_from_private_key_returns_null_for_empty_key() + { + $derivedPublicKey = LagoonHelper::getPublicKeyFromPrivateKey(''); + + $this->assertNull($derivedPublicKey); + } +} diff --git a/tests/Unit/PolydockEngine/PolydockEngineTest.php b/tests/Unit/PolydockEngine/PolydockEngineTest.php index 5483ef9..48ba00e 100644 --- a/tests/Unit/PolydockEngine/PolydockEngineTest.php +++ b/tests/Unit/PolydockEngine/PolydockEngineTest.php @@ -3,7 +3,11 @@ namespace Tests\Unit\PolydockEngine; use App\PolydockEngine\Engine; +use FreedomtechHosting\PolydockApp\Exceptions\PolydockEngineValidationException; +use FreedomtechHosting\PolydockApp\PolydockAppInstanceInterface; +use FreedomtechHosting\PolydockApp\PolydockAppInterface; use FreedomtechHosting\PolydockApp\PolydockAppLoggerInterface; +use FreedomtechHosting\PolydockApp\PolydockAppVariableDefinitionBase; use Mockery; use PHPUnit\Framework\Attributes\Test; use Tests\Doubles\AlphaTestPolydockServiceProvider; @@ -121,6 +125,40 @@ public function it_returns_different_service_provider_instances_for_different_ke $this->assertInstanceOf(BetaTestPolydockServiceProvider::class, $secondInstance); } + #[Test] + public function it_accepts_zero_string_for_required_variable_validation(): void + { + $app = Mockery::mock(PolydockAppInterface::class); + $app->shouldReceive('getVariableDefinitions') + ->once() + ->andReturn([new PolydockAppVariableDefinitionBase('lagoon-auto-idle')]); + + $appInstance = Mockery::mock(PolydockAppInstanceInterface::class); + $appInstance->shouldReceive('getApp')->once()->andReturn($app); + $appInstance->shouldReceive('getKeyValue')->with('lagoon-auto-idle')->once()->andReturn('0'); + + $this->assertTrue($this->engine->validateAppInstanceHasAllRequiredVariables($appInstance)); + } + + #[Test] + public function it_throws_when_required_variable_is_empty_string(): void + { + $app = Mockery::mock(PolydockAppInterface::class); + $app->shouldReceive('getVariableDefinitions') + ->once() + ->andReturn([new PolydockAppVariableDefinitionBase('lagoon-auto-idle')]); + + $appInstance = Mockery::mock(PolydockAppInstanceInterface::class); + $appInstance->shouldReceive('getApp')->once()->andReturn($app); + $appInstance->shouldReceive('getKeyValue')->with('lagoon-auto-idle')->once()->andReturn(''); + $appInstance->shouldReceive('getAppType')->once()->andReturn('Test_App'); + + $this->expectException(PolydockEngineValidationException::class); + $this->expectExceptionMessage('missing required variable lagoon-auto-idle'); + + $this->engine->validateAppInstanceHasAllRequiredVariables($appInstance); + } + #[\Override] protected function tearDown(): void { diff --git a/tests/Unit/Services/PolydockAppClassDiscoveryTest.php b/tests/Unit/Services/PolydockAppClassDiscoveryTest.php new file mode 100644 index 0000000..606ec91 --- /dev/null +++ b/tests/Unit/Services/PolydockAppClassDiscoveryTest.php @@ -0,0 +1,357 @@ +discovery = app(PolydockAppClassDiscovery::class); + $this->discovery->clearCache(); + } + + // ────────────────────────────────────────────────────────────── + // Discovery: happy-path + // ────────────────────────────────────────────────────────────── + + public function test_returns_non_empty_array(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + $this->assertNotEmpty($classes, 'Should discover at least one app class'); + $this->assertIsArray($classes); + } + + public function test_all_returned_classes_implement_interface(): void + { + foreach (array_keys($this->discovery->getAvailableAppClasses()) as $className) { + $this->assertTrue( + class_exists($className), + "Class {$className} should exist" + ); + + $reflection = new \ReflectionClass($className); + $this->assertTrue( + $reflection->implementsInterface(PolydockAppInterface::class), + "Class {$className} should implement PolydockAppInterface" + ); + } + } + + public function test_all_returned_classes_are_concrete(): void + { + foreach (array_keys($this->discovery->getAvailableAppClasses()) as $className) { + $reflection = new \ReflectionClass($className); + $this->assertFalse( + $reflection->isAbstract(), + "Class {$className} should not be abstract" + ); + $this->assertFalse( + $reflection->isInterface(), + "Class {$className} should not be an interface" + ); + } + } + + public function test_includes_known_app_classes(): void + { + $classNames = array_keys($this->discovery->getAvailableAppClasses()); + + $this->assertContains( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class, + $classNames, + 'Should include PolydockAiApp' + ); + + $this->assertContains( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockApp::class, + $classNames, + 'Should include PolydockApp' + ); + + $this->assertContains( + \Amazeeio\PolydockAppAmazeeioPrivateGpt\PolydockPrivateGptApp::class, + $classNames, + 'Should include PolydockPrivateGptApp' + ); + } + + // ────────────────────────────────────────────────────────────── + // Discovery: exclusion rules + // ────────────────────────────────────────────────────────────── + + public function test_excludes_interface(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + $this->assertArrayNotHasKey( + PolydockAppInterface::class, + $classes, + 'Should not include the interface itself' + ); + } + + public function test_excludes_abstract_base_class(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + $this->assertArrayNotHasKey( + PolydockAppBase::class, + $classes, + 'Should not include the abstract base class' + ); + } + + // ────────────────────────────────────────────────────────────── + // Labels + // ────────────────────────────────────────────────────────────── + + public function test_labels_use_attribute_title_when_available(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + + // Check PolydockAiApp - it may have an attribute (after package update) or fallback format + $aiAppLabel = $classes[\FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class] ?? null; + $this->assertNotNull($aiAppLabel, 'PolydockAiApp should have a label'); + + // The label should either be the attribute title OR the fallback format + $hasAttributeTitle = $aiAppLabel === 'Generic Lagoon AI App'; + $hasFallbackFormat = str_contains($aiAppLabel, 'PolydockAiApp') && str_contains($aiAppLabel, 'FreedomtechHosting'); + + $this->assertTrue( + $hasAttributeTitle || $hasFallbackFormat, + "Label should be either 'Generic Lagoon AI App' (attribute) or contain class/namespace (fallback). Got: {$aiAppLabel}" + ); + } + + public function test_build_label_uses_attribute_when_present(): void + { + // Create a mock class with the PolydockAppTitle attribute for testing + // This tests the buildLabel logic directly using reflection on a known attributed class + $reflection = new \ReflectionClass(\FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class); + $attributes = $reflection->getAttributes(\FreedomtechHosting\PolydockApp\Attributes\PolydockAppTitle::class); + + if (! empty($attributes)) { + // If the attribute exists (after package update), verify it's read correctly + $titleAttr = $attributes[0]->newInstance(); + $this->assertEquals('Generic Lagoon AI App', $titleAttr->title); + } else { + // If attribute doesn't exist yet (before package update), just verify the class exists + $this->assertTrue(class_exists(\FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class)); + } + } + + public function test_keys_are_sorted_alphabetically(): void + { + $classes = $this->discovery->getAvailableAppClasses(); + $keys = array_keys($classes); + $sorted = $keys; + sort($sorted); + + $this->assertSame($sorted, $keys, 'Class keys should be sorted alphabetically'); + } + + // ────────────────────────────────────────────────────────────── + // Caching + // ────────────────────────────────────────────────────────────── + + public function test_caching_returns_identical_result(): void + { + $first = $this->discovery->getAvailableAppClasses(); + $second = $this->discovery->getAvailableAppClasses(); + + $this->assertSame($first, $second, 'Cached results should be identical'); + } + + public function test_clear_cache_allows_rediscovery(): void + { + $first = $this->discovery->getAvailableAppClasses(); + $this->discovery->clearCache(); + $second = $this->discovery->getAvailableAppClasses(); + + $this->assertEquals($first, $second, 'Rediscovered results should match'); + } + + // ────────────────────────────────────────────────────────────── + // isValidAppClass() — positive cases + // ────────────────────────────────────────────────────────────── + + public function test_is_valid_app_class_returns_true_for_known_class(): void + { + $this->assertTrue( + $this->discovery->isValidAppClass( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ) + ); + } + + public function test_is_valid_app_class_returns_true_for_all_discovered_classes(): void + { + foreach (array_keys($this->discovery->getAvailableAppClasses()) as $className) { + $this->assertTrue( + $this->discovery->isValidAppClass($className), + "{$className} should be valid" + ); + } + } + + // ────────────────────────────────────────────────────────────── + // isValidAppClass() — negative / edge cases + // ────────────────────────────────────────────────────────────── + + public function test_is_valid_app_class_returns_false_for_nonexistent_class(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass('Acme\\Nonexistent\\FakePolydockApp') + ); + } + + public function test_is_valid_app_class_returns_false_for_interface(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass(PolydockAppInterface::class) + ); + } + + public function test_is_valid_app_class_returns_false_for_abstract_class(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass(PolydockAppBase::class) + ); + } + + public function test_is_valid_app_class_returns_false_for_empty_string(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass('') + ); + } + + public function test_is_valid_app_class_returns_false_for_unrelated_class(): void + { + $this->assertFalse( + $this->discovery->isValidAppClass(\stdClass::class) + ); + } + + // ────────────────────────────────────────────────────────────── + // Container binding + // ────────────────────────────────────────────────────────────── + + public function test_service_is_registered_as_singleton(): void + { + $first = app(PolydockAppClassDiscovery::class); + $second = app(PolydockAppClassDiscovery::class); + + $this->assertSame($first, $second, 'Service should be the same singleton instance'); + } + + // ────────────────────────────────────────────────────────────── + // Store App Form Schema + // ────────────────────────────────────────────────────────────── + + public function test_get_store_app_form_schema_returns_empty_for_class_without_attribute(): void + { + $schema = $this->discovery->getStoreAppFormSchema( + \Amazeeio\PolydockAppAmazeeioPrivateGpt\PolydockPrivateGptApp::class + ); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_form_schema_returns_empty_for_invalid_class(): void + { + $schema = $this->discovery->getStoreAppFormSchema('NonExistent\\Class'); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_form_schema_returns_empty_for_empty_string(): void + { + $schema = $this->discovery->getStoreAppFormSchema(''); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_infolist_schema_returns_empty_for_invalid_class(): void + { + $schema = $this->discovery->getStoreAppInfolistSchema('NonExistent\\Class'); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_infolist_schema_returns_empty_for_empty_string(): void + { + $schema = $this->discovery->getStoreAppInfolistSchema(''); + + $this->assertIsArray($schema); + $this->assertEmpty($schema); + } + + public function test_get_store_app_form_field_names_returns_array(): void + { + $fieldNames = $this->discovery->getStoreAppFormFieldNames( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + $this->assertIsArray($fieldNames); + } + + public function test_get_field_encryption_map_returns_array(): void + { + $schema = $this->discovery->getStoreAppFormSchema( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + $map = $this->discovery->getFieldEncryptionMap($schema); + + $this->assertIsArray($map); + } + + public function test_field_names_are_prefixed_with_app_config(): void + { + $fieldNames = $this->discovery->getStoreAppFormFieldNames( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + $this->assertIsArray($fieldNames); + + // If the class has fields (after package update), they should all be prefixed + foreach ($fieldNames as $fieldName) { + $this->assertStringStartsWith( + 'app_config_', + $fieldName, + "Field '{$fieldName}' should be prefixed with 'app_config_'" + ); + } + } + + public function test_store_app_form_schema_returns_components_when_attribute_present(): void + { + $schema = $this->discovery->getStoreAppFormSchema( + \FreedomtechHosting\PolydockAppAmazeeioGeneric\PolydockAiApp::class + ); + + // The schema may be empty if the package hasn't been updated yet + // But if it has components, they should be Filament components + $this->assertIsArray($schema); + + if (! empty($schema)) { + foreach ($schema as $component) { + $this->assertIsObject($component); + } + } + } +}