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/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/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 8976203..40970c5 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 @@ -70,20 +72,28 @@ public static function table(Table $table): Table 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' : '')), ]) ->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 +175,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 +216,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 +249,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)) @@ -249,7 +276,7 @@ public static function getRenderedSafeDataForRecord(PolydockAppInstance $record) } if ($value === null) { - $value = ''; + $value = 'N/A'; } $renderKey = 'webhook_data_'.$key; @@ -271,6 +298,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 [ @@ -281,7 +378,7 @@ public static function getRelations(): array #[\Override] public static function canCreate(): bool { - return false; + return true; } #[\Override] @@ -289,6 +386,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..7386486 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), @@ -67,12 +85,29 @@ public static function form(Form $form): Form 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(), + 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 +182,7 @@ public static function form(Form $form): Form ]) ->columnSpanFull(), ]) + ->collapsible() ->columnSpanFull(), ]); } @@ -241,7 +277,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([ @@ -280,6 +317,14 @@ public static function infolist(Infolist $infolist): Infolist ]) ->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..1c8ff27 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource/Pages/CreatePolydockStoreApp.php @@ -3,9 +3,47 @@ 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]); + } + } + } + + // 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..ec9106e 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,57 @@ 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; + } + + 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]); + } + } + } + + // 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 fbe8107..8e73a19 100644 --- a/app/Filament/Admin/Resources/PolydockStoreResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreResource.php @@ -79,6 +79,16 @@ 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() @@ -142,6 +152,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() @@ -248,6 +261,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/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/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php b/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php index 81120fc..bf1b233 100644 --- a/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php +++ b/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php @@ -108,7 +108,7 @@ public static function getRenderedSafeDataForRecord(UserRemoteRegistration $reco } if ($value === null) { - $value = ''; + $value = 'N/A'; } $renderKey = 'request_data_'.$key; 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..161b4ae 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 @@ -384,11 +389,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/PolydockStoreApp.php b/app/Models/PolydockStoreApp.php index 32be63f..67e0f8b 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', @@ -162,7 +164,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; } diff --git a/app/Models/User.php b/app/Models/User.php index 93c4247..983956b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -82,7 +82,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 1ffda20..b72b735 100644 --- a/app/PolydockEngine/Engine.php +++ b/app/PolydockEngine/Engine.php @@ -7,6 +7,7 @@ use FreedomtechHosting\PolydockApp\Exceptions\PolydockEngineProcessPolydockAppInstanceStatusException; use FreedomtechHosting\PolydockApp\PolydockAppInstanceInterface; use FreedomtechHosting\PolydockApp\PolydockAppInstanceStatusFlowException; +use FreedomtechHosting\PolydockApp\PolydockAppInterface; use FreedomtechHosting\PolydockApp\PolydockAppLoggerInterface; use FreedomtechHosting\PolydockApp\PolydockEngineBase; use FreedomtechHosting\PolydockApp\PolydockEngineInterface; @@ -133,6 +134,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, 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 8be96c9..cfc78f7 100644 --- a/composer.json +++ b/composer.json @@ -7,13 +7,15 @@ "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.11", "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.11", + "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.6", + "freedomtech-hosting/polydock-app": "^0.0.30", + "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.79", "laravel/framework": "^11.31", "laravel/horizon": "^5.30", "laravel/sanctum": "^4.0", @@ -23,6 +25,7 @@ }, "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 ddbfe4f..6818511 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,74 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "322d3905648cbba84d21c3f294546c88", + "content-hash": "e310386e38a65e83d5071a758d2bc500", "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.11", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-amazeeio-privategpt.git", - "reference": "be5c3864e0262a94af8363ca3e4627f59e4a4d31" + "reference": "f19eb47855515e104bf64d3cb78e538be84af641" }, "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/f19eb47855515e104bf64d3cb78e538be84af641", + "reference": "f19eb47855515e104bf64d3cb78e538be84af641", "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": "*", + "freedomtech-hosting/ft-lagoon-php": "^0.0.11", + "freedomtech-hosting/polydock-app": "^0.0.30", + "freedomtech-hosting/polydock-app-amazeeio-generic": "^0.0.79", "guzzlehttp/guzzle": "^7.0" }, "require-dev": { @@ -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.11" }, - "time": "2025-11-25T19:59:39+00:00" + "time": "2026-02-18T20:41:03+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", @@ -770,29 +817,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 +859,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 +1230,16 @@ }, { "name": "filament/actions", - "version": "v3.3.47", + "version": "v3.3.48", "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 +1279,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.48", "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 +1344,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.48", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58" + "reference": "45f6e9337d60154f2d0ad3b2c4e47f7e16912971" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/f708ce490cff3770071d18e9ea678eb4b7c65c58", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/45f6e9337d60154f2d0ad3b2c4e47f7e16912971", + "reference": "45f6e9337d60154f2d0ad3b2c4e47f7e16912971", "shasum": "" }, "require": { @@ -1353,20 +1400,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-07T21:51:43+00:00" }, { "name": "filament/infolists", - "version": "v3.3.47", + "version": "v3.3.48", "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 +1451,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.48", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -1460,16 +1507,16 @@ }, { "name": "filament/support", - "version": "v3.3.47", + "version": "v3.3.48", "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 +1562,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.48", "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 +1614,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.48", "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 +1658,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.11", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-lagoon-php-lib.git", - "reference": "e8669ab79091eade0444a1fe7bcf96ed243b8ea4" + "reference": "f9c12b2be5b204c6ba37a64c86ace8af4745da23" }, "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/f9c12b2be5b204c6ba37a64c86ace8af4745da23", + "reference": "f9c12b2be5b204c6ba37a64c86ace8af4745da23", "shasum": "" }, "require": { @@ -1651,25 +1698,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.11" }, - "time": "2025-04-22T03:56:15+00:00" + "time": "2026-02-17T11:31:32+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 +1738,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.30", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-lib.git", - "reference": "aa6cfc7601b6eeedb6e04e01aaa61b1e84b6b00c" + "reference": "b779e9dac1b8fea63e9f617931991c840674436c" }, "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/b779e9dac1b8fea63e9f617931991c840674436c", + "reference": "b779e9dac1b8fea63e9f617931991c840674436c", "shasum": "" }, "require": { @@ -1731,28 +1779,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.30" }, - "time": "2025-04-11T19:57:05+00:00" + "time": "2026-02-13T02:15:27+00:00" }, { "name": "freedomtech-hosting/polydock-app-amazeeio-generic", - "version": "v0.0.73", + "version": "v0.0.79", "source": { "type": "git", "url": "https://github.com/amazeeio/polydock-app-amazeeio-generic.git", - "reference": "54df7bed96e00824d6e765ae5fa06e9823f71830" + "reference": "ab18815f8f26d605e9af32dc5e551ce3fb2fc81d" }, "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/ab18815f8f26d605e9af32dc5e551ce3fb2fc81d", + "reference": "ab18815f8f26d605e9af32dc5e551ce3fb2fc81d", "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.11", + "freedomtech-hosting/polydock-amazeeai-backend-client-php": "^0.0.6", + "freedomtech-hosting/polydock-app": "^0.0.30" }, "type": "library", "autoload": { @@ -1773,9 +1821,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.79" }, - "time": "2025-06-25T20:14:15+00:00" + "time": "2026-02-18T20:37:21+00:00" }, { "name": "fruitcake/php-cors", @@ -2601,36 +2649,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 +2722,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 +2781,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 +2846,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 +2907,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 +2971,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 +3692,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 +3756,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 +3764,7 @@ "type": "github" } ], - "time": "2026-02-03T02:57:56+00:00" + "time": "2026-02-09T22:49:33+00:00" }, { "name": "masterminds/html5", @@ -3995,16 +4043,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 +4060,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 +4102,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 +4129,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 +4193,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 +4257,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 +4313,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 +4324,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 +4340,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "openspout/openspout", @@ -5152,16 +5202,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": { @@ -5225,9 +5275,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", @@ -5507,16 +5557,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": { @@ -5524,7 +5574,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", @@ -5556,9 +5606,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", @@ -5787,25 +5837,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": { @@ -5836,7 +5886,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": [ { @@ -5844,7 +5894,7 @@ "type": "github" } ], - "time": "2025-03-17T19:50:19+00:00" + "time": "2026-02-09T15:00:49+00:00" }, { "name": "spatie/ssh", @@ -8979,39 +9029,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": { @@ -9056,20 +9238,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": { @@ -9080,13 +9262,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" @@ -9123,32 +9305,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": [ @@ -9186,7 +9368,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", @@ -9333,39 +9515,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": { @@ -9428,7 +9607,7 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "phar-io/manifest", @@ -9550,11 +9729,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": { @@ -9599,7 +9778,7 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-11T14:48:56+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9950,16 +10129,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": { @@ -9974,7 +10153,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", @@ -9986,6 +10165,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" @@ -10031,7 +10211,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": [ { @@ -10055,25 +10235,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": "*", @@ -10107,7 +10287,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": [ { @@ -10115,7 +10295,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-02-19T14:44:16+00:00" }, { "name": "sebastian/cli-parser", @@ -11441,7 +11621,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_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/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/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/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); + } + } + } +}