From a3a9cd182cc9ac51403dd433e6c19d5f0c313d6e Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Wed, 19 Aug 2026 00:56:06 +0200 Subject: [PATCH 1/7] Add shared installer health checks --- app/Checks/AdminUserCheck.php | 29 ++++ app/Checks/ApplicationKeyCheck.php | 32 ++++ app/Checks/DatabaseExtensionCheck.php | 38 +++++ app/Checks/InstallationFlagCheck.php | 20 +++ app/Checks/MigrationsCheck.php | 37 +++++ app/Checks/PhpExtensionsCheck.php | 63 ++++++++ app/Checks/PhpVersionCheck.php | 52 +++++++ app/Checks/WritablePathsCheck.php | 46 ++++++ .../Environment/AppSettingsCommand.php | 22 ++- .../Environment/DatabaseSettingsCommand.php | 13 +- .../Environment/EnvironmentHealthCommand.php | 33 +++++ .../EnvironmentPreflightCommand.php | 42 ++++++ app/Enums/DatabaseDriver.php | 52 +++++++ app/Livewire/Installer/Steps/DatabaseStep.php | 138 +++++++++--------- .../Installer/Steps/RequirementsStep.php | 91 +++--------- app/Providers/AppServiceProvider.php | 14 ++ .../Environment/InstallationHealthService.php | 99 +++++++++++++ app/Traits/Commands/DisplaysHealthResults.php | 40 +++++ lang/en/commands.php | 12 ++ lang/en/installer.php | 60 ++++++++ .../Installer/InstallationHealthTest.php | 114 +++++++++++++++ 21 files changed, 897 insertions(+), 150 deletions(-) create mode 100644 app/Checks/AdminUserCheck.php create mode 100644 app/Checks/ApplicationKeyCheck.php create mode 100644 app/Checks/DatabaseExtensionCheck.php create mode 100644 app/Checks/InstallationFlagCheck.php create mode 100644 app/Checks/MigrationsCheck.php create mode 100644 app/Checks/PhpExtensionsCheck.php create mode 100644 app/Checks/PhpVersionCheck.php create mode 100644 app/Checks/WritablePathsCheck.php create mode 100644 app/Console/Commands/Environment/EnvironmentHealthCommand.php create mode 100644 app/Console/Commands/Environment/EnvironmentPreflightCommand.php create mode 100644 app/Enums/DatabaseDriver.php create mode 100644 app/Services/Environment/InstallationHealthService.php create mode 100644 app/Traits/Commands/DisplaysHealthResults.php create mode 100644 tests/Feature/Installer/InstallationHealthTest.php diff --git a/app/Checks/AdminUserCheck.php b/app/Checks/AdminUserCheck.php new file mode 100644 index 0000000000..0adf93ace9 --- /dev/null +++ b/app/Checks/AdminUserCheck.php @@ -0,0 +1,29 @@ +meta([ + 'remediation' => trans('installer.health.admin.remediation'), + ]); + + try { + return User::role(Role::ROOT_ADMIN)->exists() + ? $result->ok(trans('installer.health.admin.passed')) + : $result->failed(trans('installer.health.admin.failed')); + } catch (Throwable $exception) { + return $result->failed(trans('installer.health.admin.exception', [ + 'error' => $exception->getMessage(), + ])); + } + } +} diff --git a/app/Checks/ApplicationKeyCheck.php b/app/Checks/ApplicationKeyCheck.php new file mode 100644 index 0000000000..774dd124b4 --- /dev/null +++ b/app/Checks/ApplicationKeyCheck.php @@ -0,0 +1,32 @@ +meta([ + 'remediation' => trans('installer.health.app_key.remediation'), + ]); + + return $passed + ? $result->ok(trans('installer.health.app_key.passed')) + : $result->failed(trans('installer.health.app_key.failed')); + } +} diff --git a/app/Checks/DatabaseExtensionCheck.php b/app/Checks/DatabaseExtensionCheck.php new file mode 100644 index 0000000000..0c9cd08084 --- /dev/null +++ b/app/Checks/DatabaseExtensionCheck.php @@ -0,0 +1,38 @@ +driver = is_string($driver) ? DatabaseDriver::from($driver) : $driver; + + return $this; + } + + public function run(): Result + { + $extension = $this->driver->requiredExtension(); + $passed = extension_loaded($extension); + + $result = Result::make()->meta([ + 'driver' => $this->driver->value, + 'extension' => $extension, + 'remediation' => trans('installer.health.extensions.remediation'), + ]); + + return $passed + ? $result->ok(trans('installer.health.database_extension.passed', ['extension' => $extension])) + : $result->failed(trans('installer.health.database_extension.failed', [ + 'driver' => $this->driver->getLabel(), + 'extension' => $extension, + ])); + } +} diff --git a/app/Checks/InstallationFlagCheck.php b/app/Checks/InstallationFlagCheck.php new file mode 100644 index 0000000000..27970c17e1 --- /dev/null +++ b/app/Checks/InstallationFlagCheck.php @@ -0,0 +1,20 @@ +meta([ + 'remediation' => trans('installer.health.installed.remediation'), + ]); + + return config('app.installed') + ? $result->ok(trans('installer.health.installed.passed')) + : $result->failed(trans('installer.health.installed.failed')); + } +} diff --git a/app/Checks/MigrationsCheck.php b/app/Checks/MigrationsCheck.php new file mode 100644 index 0000000000..1b081f449b --- /dev/null +++ b/app/Checks/MigrationsCheck.php @@ -0,0 +1,37 @@ +meta([ + 'remediation' => trans('installer.health.migrations.remediation'), + ]); + + try { + if (!$this->migrator->repositoryExists()) { + return $result->failed(trans('installer.health.migrations.repository_missing')); + } + + $files = $this->migrator->getMigrationFiles(database_path('migrations')); + $pending = array_diff(array_keys($files), $this->migrator->getRepository()->getRan()); + + return $pending === [] + ? $result->ok(trans('installer.health.migrations.passed')) + : $result->failed(trans('installer.health.migrations.failed', ['count' => count($pending)])); + } catch (Throwable $exception) { + return $result->failed(trans('installer.health.migrations.exception', [ + 'error' => $exception->getMessage(), + ])); + } + } +} diff --git a/app/Checks/PhpExtensionsCheck.php b/app/Checks/PhpExtensionsCheck.php new file mode 100644 index 0000000000..cb9fa8a896 --- /dev/null +++ b/app/Checks/PhpExtensionsCheck.php @@ -0,0 +1,63 @@ +requiredExtensions = $extensions; + + return $this; + } + + public function run(): Result + { + $missing = array_values(array_filter( + $this->requiredExtensions, + fn (string $extension) => !extension_loaded($extension), + )); + + $databaseExtensions = array_unique(array_map( + fn (DatabaseDriver $driver) => $driver->requiredExtension(), + DatabaseDriver::cases(), + )); + + $hasDatabaseExtension = array_filter($databaseExtensions, extension_loaded(...)) !== []; + + if (!$hasDatabaseExtension) { + $missing[] = implode(', ', $databaseExtensions); + } + + $result = Result::make()->meta([ + 'missing' => implode(', ', $missing), + 'remediation' => trans('installer.health.extensions.remediation'), + ]); + + return $missing === [] + ? $result->ok(trans('installer.health.extensions.passed')) + : $result->failed(trans('installer.health.extensions.failed', [ + 'extensions' => implode(', ', $missing), + ])); + } +} diff --git a/app/Checks/PhpVersionCheck.php b/app/Checks/PhpVersionCheck.php new file mode 100644 index 0000000000..e40a4ed063 --- /dev/null +++ b/app/Checks/PhpVersionCheck.php @@ -0,0 +1,52 @@ +minimumVersion = $version; + + return $this; + } + + public function currentVersion(string $version): self + { + $this->currentVersion = $version; + + return $this; + } + + public function run(): Result + { + $passed = version_compare($this->currentVersion, $this->minimumVersion, '>='); + + $result = Result::make() + ->meta([ + 'current' => $this->currentVersion, + 'minimum' => $this->minimumVersion, + 'remediation' => trans('installer.health.php.remediation', ['minimum' => $this->minimumVersion]), + ]) + ->shortSummary($this->currentVersion); + + return $passed + ? $result->ok(trans('installer.health.php.passed', [ + 'current' => $this->currentVersion, + 'minimum' => $this->minimumVersion, + ])) + : $result->failed(trans('installer.health.php.failed', [ + 'current' => $this->currentVersion, + 'minimum' => $this->minimumVersion, + ])); + } +} diff --git a/app/Checks/WritablePathsCheck.php b/app/Checks/WritablePathsCheck.php new file mode 100644 index 0000000000..d16641c8c8 --- /dev/null +++ b/app/Checks/WritablePathsCheck.php @@ -0,0 +1,46 @@ +paths = $paths; + + return $this; + } + + public function run(): Result + { + $paths = $this->paths ?? [ + storage_path(), + base_path('bootstrap/cache'), + file_exists(base_path('.env')) ? base_path('.env') : base_path(), + ]; + + $notWritable = array_values(array_filter( + $paths, + fn (string $path) => !is_writable($path), + )); + + $result = Result::make()->meta([ + 'paths' => implode(', ', $paths), + 'not_writable' => implode(', ', $notWritable), + 'remediation' => trans('installer.health.paths.remediation'), + ]); + + return $notWritable === [] + ? $result->ok(trans('installer.health.paths.passed')) + : $result->failed(trans('installer.health.paths.failed', [ + 'paths' => implode(', ', $notWritable), + ])); + } +} diff --git a/app/Console/Commands/Environment/AppSettingsCommand.php b/app/Console/Commands/Environment/AppSettingsCommand.php index 928c7c74e5..e75865653b 100644 --- a/app/Console/Commands/Environment/AppSettingsCommand.php +++ b/app/Console/Commands/Environment/AppSettingsCommand.php @@ -2,16 +2,32 @@ namespace App\Console\Commands\Environment; +use App\Services\Environment\InstallationHealthService; +use App\Traits\Commands\DisplaysHealthResults; use Illuminate\Console\Command; class AppSettingsCommand extends Command { + use DisplaysHealthResults; + protected $description = 'Configure basic environment settings for the Panel.'; - protected $signature = 'p:environment:setup'; + protected $signature = 'p:environment:setup + {--skip-preflight : Skip server requirement checks before setup.}'; - public function handle(): void + public function handle(InstallationHealthService $health): int { + if (!$this->option('skip-preflight')) { + $results = $health->systemRequirements(); + $this->displayHealthResults($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.preflight_failed')); + + return self::FAILURE; + } + } + $path = base_path('.env'); if (!file_exists($path)) { $this->comment('Copying example .env file'); @@ -28,5 +44,7 @@ public function handle(): void $this->comment('Caching components & icons'); $this->call('filament:optimize'); + + return self::SUCCESS; } } diff --git a/app/Console/Commands/Environment/DatabaseSettingsCommand.php b/app/Console/Commands/Environment/DatabaseSettingsCommand.php index 3953c5b6f4..f4e51aabf1 100644 --- a/app/Console/Commands/Environment/DatabaseSettingsCommand.php +++ b/app/Console/Commands/Environment/DatabaseSettingsCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands\Environment; +use App\Enums\DatabaseDriver; use App\Traits\EnvironmentWriterTrait; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; @@ -12,13 +13,6 @@ class DatabaseSettingsCommand extends Command { use EnvironmentWriterTrait; - public const DATABASE_DRIVERS = [ - 'sqlite' => 'SQLite (recommended)', - 'mariadb' => 'MariaDB', - 'mysql' => 'MySQL', - 'pgsql' => 'PostgreSQL', - ]; - protected $description = 'Configure database settings for the Panel.'; protected $signature = 'p:environment:database @@ -54,10 +48,11 @@ public function handle(): int } $selected = config('database.default', 'sqlite'); + $databaseDrivers = DatabaseDriver::options(recommendSQLite: true); $this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice( 'Database Driver', - self::DATABASE_DRIVERS, - array_key_exists($selected, self::DATABASE_DRIVERS) ? $selected : null + $databaseDrivers, + array_key_exists($selected, $databaseDrivers) ? $selected : null ); if ($this->variables['DB_CONNECTION'] === 'mysql') { diff --git a/app/Console/Commands/Environment/EnvironmentHealthCommand.php b/app/Console/Commands/Environment/EnvironmentHealthCommand.php new file mode 100644 index 0000000000..aa143fe435 --- /dev/null +++ b/app/Console/Commands/Environment/EnvironmentHealthCommand.php @@ -0,0 +1,33 @@ +completeInstallation(); + + $this->displayHealthResults($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.health_failed')); + + return self::FAILURE; + } + + $this->info(trans('commands.environment_check.health_passed')); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php new file mode 100644 index 0000000000..821daba19b --- /dev/null +++ b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php @@ -0,0 +1,42 @@ +systemRequirements(); + + if ($this->option('with-database')) { + $results->push($health->databaseDriverExtension((string) config('database.default'))); + $results->push($health->runCheck( + DatabaseCheck::new()->label(trans('installer.health.database.label')), + )); + } + + $this->displayHealthResults($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.preflight_failed')); + + return self::FAILURE; + } + + $this->info(trans('commands.environment_check.preflight_passed')); + + return self::SUCCESS; + } +} diff --git a/app/Enums/DatabaseDriver.php b/app/Enums/DatabaseDriver.php new file mode 100644 index 0000000000..ad1bac6312 --- /dev/null +++ b/app/Enums/DatabaseDriver.php @@ -0,0 +1,52 @@ + 'SQLite', + self::MariaDB => 'MariaDB', + self::MySQL => 'MySQL', + self::PostgreSQL => 'PostgreSQL', + }; + } + + public function requiredExtension(): string + { + return match ($this) { + self::SQLite => 'pdo_sqlite', + self::MariaDB, self::MySQL => 'pdo_mysql', + self::PostgreSQL => 'pdo_pgsql', + }; + } + + public function defaultPort(): ?int + { + return match ($this) { + self::SQLite => null, + self::MariaDB, self::MySQL => 3306, + self::PostgreSQL => 5432, + }; + } + + /** @return array */ + public static function options(bool $recommendSQLite = false): array + { + return collect(self::cases()) + ->mapWithKeys(fn (self $driver) => [ + $driver->value => $driver->getLabel() + . ($recommendSQLite && $driver === self::SQLite ? ' (recommended)' : ''), + ]) + ->all(); + } +} diff --git a/app/Livewire/Installer/Steps/DatabaseStep.php b/app/Livewire/Installer/Steps/DatabaseStep.php index eaeb16f07b..3d060fcda9 100644 --- a/app/Livewire/Installer/Steps/DatabaseStep.php +++ b/app/Livewire/Installer/Steps/DatabaseStep.php @@ -2,9 +2,11 @@ namespace App\Livewire\Installer\Steps; +use App\Checks\DatabaseCheck; +use App\Enums\DatabaseDriver; use App\Enums\TablerIcon; use App\Livewire\Installer\PanelInstaller; -use Exception; +use App\Services\Environment\InstallationHealthService; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; use Filament\Notifications\Notification; @@ -16,13 +18,6 @@ class DatabaseStep { - public const DATABASE_DRIVERS = [ - 'sqlite' => 'SQLite', - 'mariadb' => 'MariaDB', - 'mysql' => 'MySQL', - 'pgsql' => 'PostgreSQL', - ]; - public static function make(PanelInstaller $installer): Step { return Step::make('database') @@ -34,44 +29,38 @@ public static function make(PanelInstaller $installer): Step ->hintIcon(TablerIcon::QuestionMark, trans('installer.database.driver_help')) ->required() ->inline() - ->options(self::DATABASE_DRIVERS) + ->options(DatabaseDriver::options()) ->default(config('database.default')) ->live() ->afterStateUpdated(function ($state, Set $set, Get $get) { - $set('env_database.DB_DATABASE', $state === 'sqlite' ? 'database.sqlite' : 'panel'); + $driver = DatabaseDriver::from($state); + $set('env_database.DB_DATABASE', $driver === DatabaseDriver::SQLite ? 'database.sqlite' : 'panel'); + + if ($driver === DatabaseDriver::SQLite) { + $set('env_database.DB_HOST', null); + $set('env_database.DB_PORT', null); + $set('env_database.DB_USERNAME', null); + $set('env_database.DB_PASSWORD', null); - switch ($state) { - case 'sqlite': - $set('env_database.DB_HOST', null); - $set('env_database.DB_PORT', null); - $set('env_database.DB_USERNAME', null); - $set('env_database.DB_PASSWORD', null); - break; - case 'mariadb': - case 'mysql': - $set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1'); - $set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican'); - $set('env_database.DB_PORT', '3306'); - break; - case 'pgsql': - $set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1'); - $set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican'); - $set('env_database.DB_PORT', '5432'); - break; + return; } + + $set('env_database.DB_HOST', $get('env_database.DB_HOST') ?? '127.0.0.1'); + $set('env_database.DB_USERNAME', $get('env_database.DB_USERNAME') ?? 'pelican'); + $set('env_database.DB_PORT', (string) $driver->defaultPort()); }), TextInput::make('env_database.DB_DATABASE') - ->label(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite' ? trans('installer.database.fields.path') : trans('installer.database.fields.name')) - ->placeholder(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel') - ->hintIcon(TablerIcon::QuestionMark, fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite' ? trans('installer.database.fields.path_help') : trans('installer.database.fields.name_help')) + ->label(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value ? trans('installer.database.fields.path') : trans('installer.database.fields.name')) + ->placeholder(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value ? 'database.sqlite' : 'panel') + ->hintIcon(TablerIcon::QuestionMark, fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value ? trans('installer.database.fields.path_help') : trans('installer.database.fields.name_help')) ->required() ->default('database.sqlite'), TextInput::make('env_database.DB_HOST') ->label(trans('installer.database.fields.host')) ->placeholder('127.0.0.1') ->hintIcon(TablerIcon::QuestionMark, trans('installer.database.fields.host_help')) - ->required(fn (Get $get) => $get('env_database.DB_CONNECTION') !== 'sqlite') - ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite'), + ->required(fn (Get $get) => $get('env_database.DB_CONNECTION') !== DatabaseDriver::SQLite->value) + ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value), TextInput::make('env_database.DB_PORT') ->label(trans('installer.database.fields.port')) ->placeholder('3306') @@ -79,61 +68,68 @@ public static function make(PanelInstaller $installer): Step ->numeric() ->minValue(1) ->maxValue(65535) - ->required(fn (Get $get) => $get('env_database.DB_CONNECTION') !== 'sqlite') - ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite'), + ->required(fn (Get $get) => $get('env_database.DB_CONNECTION') !== DatabaseDriver::SQLite->value) + ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value), TextInput::make('env_database.DB_USERNAME') ->label(trans('installer.database.fields.username')) ->placeholder('pelican') ->hintIcon(TablerIcon::QuestionMark, trans('installer.database.fields.username_help')) - ->required(fn (Get $get) => $get('env_database.DB_CONNECTION') !== 'sqlite') - ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite'), + ->required(fn (Get $get) => $get('env_database.DB_CONNECTION') !== DatabaseDriver::SQLite->value) + ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value), TextInput::make('env_database.DB_PASSWORD') ->label(trans('installer.database.fields.password')) ->hintIcon(TablerIcon::QuestionMark, trans('installer.database.fields.password_help')) ->password() ->revealable() - ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite'), + ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value), ]) ->afterValidation(function (Get $get) use ($installer) { - $driver = $get('env_database.DB_CONNECTION'); + $health = app(InstallationHealthService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + $driver = DatabaseDriver::from($get('env_database.DB_CONNECTION')); + $extensionResult = $health->databaseDriverExtension($driver); - throw_unless(self::testConnection($driver, $get('env_database.DB_HOST'), $get('env_database.DB_PORT'), $get('env_database.DB_DATABASE'), $get('env_database.DB_USERNAME'), $get('env_database.DB_PASSWORD')), new Halt(trans('installer.database.exceptions.connection'))); - - $installer->writeToEnv('env_database'); - }); - } + if ($health->hasFailures([$extensionResult])) { + Notification::make() + ->title(trans('installer.database.exceptions.extension')) + ->body($extensionResult->getNotificationMessage()) + ->danger() + ->send(); - private static function testConnection(string $driver, ?string $host, null|string|int $port, ?string $database, ?string $username, ?string $password): bool - { - if ($driver === 'sqlite') { - return true; - } + throw new Halt($extensionResult->getNotificationMessage()); + } - try { - config()->set('database.connections._panel_install_test', [ - 'driver' => $driver, - 'host' => $host, - 'port' => $port, - 'database' => $database, - 'username' => $username, - 'password' => $password, - 'collation' => 'utf8mb4_unicode_ci', - 'strict' => true, - ]); + if ($driver !== DatabaseDriver::SQLite) { + config()->set('database.connections._panel_install_test', [ + 'driver' => $driver->value, + 'host' => $get('env_database.DB_HOST'), + 'port' => $get('env_database.DB_PORT'), + 'database' => $get('env_database.DB_DATABASE'), + 'username' => $get('env_database.DB_USERNAME'), + 'password' => $get('env_database.DB_PASSWORD'), + 'collation' => 'utf8mb4_unicode_ci', + 'strict' => true, + ]); - DB::connection('_panel_install_test')->getPdo(); - } catch (Exception $exception) { - DB::disconnect('_panel_install_test'); + DB::purge('_panel_install_test'); + $connectionResult = $health->runCheck( + DatabaseCheck::new() + ->connectionName('_panel_install_test') + ->label(trans('installer.health.database.label')), + ); + DB::disconnect('_panel_install_test'); - Notification::make() - ->title(trans('installer.database.exceptions.connection')) - ->body($exception->getMessage()) - ->danger() - ->send(); + if ($health->hasFailures([$connectionResult])) { + Notification::make() + ->title(trans('installer.database.exceptions.connection')) + ->body($connectionResult->getNotificationMessage()) + ->danger() + ->send(); - return false; - } + throw new Halt($connectionResult->getNotificationMessage()); + } + } - return true; + $installer->writeToEnv('env_database'); + }); } } diff --git a/app/Livewire/Installer/Steps/RequirementsStep.php b/app/Livewire/Installer/Steps/RequirementsStep.php index 9954db5cb2..5218ae6fd1 100644 --- a/app/Livewire/Installer/Steps/RequirementsStep.php +++ b/app/Livewire/Installer/Steps/RequirementsStep.php @@ -3,89 +3,44 @@ namespace App\Livewire\Installer\Steps; use App\Enums\TablerIcon; +use App\Services\Environment\InstallationHealthService; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Wizard\Step; use Filament\Support\Exceptions\Halt; +use Spatie\Health\Checks\Result; class RequirementsStep { - public const MIN_PHP_VERSION = '8.3'; - public static function make(): Step { - $compare = version_compare(phpversion(), self::MIN_PHP_VERSION); - $correctPhpVersion = $compare >= 0; - - $fields = [ - Section::make(trans('installer.requirements.sections.version.title')) - ->description(trans('installer.requirements.sections.version.or_newer', ['version' => self::MIN_PHP_VERSION])) - ->icon($correctPhpVersion ? TablerIcon::Check : TablerIcon::X) - ->iconColor($correctPhpVersion ? 'success' : 'danger') - ->schema([ - TextEntry::make('php_version') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.version.content', ['version' => PHP_VERSION])), - ]), - ]; - - $phpExtensions = [ - 'BCMath' => extension_loaded('bcmath'), - 'cURL' => extension_loaded('curl'), - 'GD' => extension_loaded('gd'), - 'intl' => extension_loaded('intl'), - 'mbstring' => extension_loaded('mbstring'), - 'MySQL' => extension_loaded('pdo_mysql'), - 'SQLite3' => extension_loaded('pdo_sqlite'), - 'XML' => extension_loaded('xml'), - 'Zip' => extension_loaded('zip'), - ]; - $allExtensionsInstalled = !in_array(false, $phpExtensions); - - $fields[] = Section::make(trans('installer.requirements.sections.extensions.title')) - ->description(implode(', ', array_keys($phpExtensions))) - ->icon($allExtensionsInstalled ? TablerIcon::Check : TablerIcon::X) - ->iconColor($allExtensionsInstalled ? 'success' : 'danger') - ->schema([ - TextEntry::make('all_extensions_installed') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.extensions.good')) - ->visible($allExtensionsInstalled), - TextEntry::make('extensions_missing') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.extensions.bad', ['extensions' => implode(', ', array_keys($phpExtensions, false))])) - ->visible(!$allExtensionsInstalled), - ]); - - $folderPermissions = [ - 'Storage' => substr(sprintf('%o', fileperms(base_path('storage/'))), -4) >= 755, - 'Cache' => substr(sprintf('%o', fileperms(base_path('bootstrap/cache/'))), -4) >= 755, - ]; - $correctFolderPermissions = !in_array(false, $folderPermissions); - - $fields[] = Section::make(trans('installer.requirements.sections.permissions.title')) - ->description(implode(', ', array_keys($folderPermissions))) - ->icon($correctFolderPermissions ? TablerIcon::Check : TablerIcon::X) - ->iconColor($correctFolderPermissions ? 'success' : 'danger') - ->schema([ - TextEntry::make('correct_folder_permissions') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.permissions.good')) - ->visible($correctFolderPermissions), - TextEntry::make('wrong_folder_permissions') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.permissions.bad', ['folders' => implode(', ', array_keys($folderPermissions, false))])) - ->visible(!$correctFolderPermissions), - ]); + $health = app(InstallationHealthService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + $checks = $health->systemRequirements(); + + $fields = $checks + ->map(function (Result $result) use ($health): Section { + $failed = $health->hasFailures([$result]); + + return Section::make($result->check->getLabel()) + ->description($failed ? ($result->meta['remediation'] ?? null) : null) + ->icon($failed ? TablerIcon::X : TablerIcon::Check) + ->iconColor($failed ? 'danger' : 'success') + ->schema([ + TextEntry::make($result->check->getName()) + ->hiddenLabel() + ->state($result->getNotificationMessage()), + ]); + }) + ->all(); return Step::make('requirements') ->label(trans('installer.requirements.title')) ->schema($fields) - ->afterValidation(function () use ($correctPhpVersion, $allExtensionsInstalled, $correctFolderPermissions) { - if (!$correctPhpVersion || !$allExtensionsInstalled || !$correctFolderPermissions) { + ->afterValidation(function () use ($checks, $health) { + if ($health->hasFailures($checks)) { Notification::make() - ->title(trans('installer.requirements.exception')) + ->title(trans('installer.health.preflight_failed')) ->danger() ->send(); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 54c52f2249..b598fb1d7b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,14 +2,21 @@ namespace App\Providers; +use App\Checks\AdminUserCheck; +use App\Checks\ApplicationKeyCheck; use App\Checks\CacheCheck; use App\Checks\DatabaseCheck; use App\Checks\DebugModeCheck; use App\Checks\EnvironmentCheck; +use App\Checks\InstallationFlagCheck; +use App\Checks\MigrationsCheck; use App\Checks\NodeVersionsCheck; use App\Checks\PanelVersionCheck; +use App\Checks\PhpExtensionsCheck; +use App\Checks\PhpVersionCheck; use App\Checks\ScheduleCheck; use App\Checks\UsedDiskSpaceCheck; +use App\Checks\WritablePathsCheck; use App\Extensions\Dedoc\Scramble\FractalResponseTypeInfer; use App\Extensions\Dedoc\Scramble\TransformerFactoryTypeInfer; use App\Extensions\Dedoc\Scramble\TransformerModelBindingExtension; @@ -110,6 +117,13 @@ public function boot( EnvironmentCheck::new(), CacheCheck::new(), DatabaseCheck::new(), + ApplicationKeyCheck::new(), + PhpVersionCheck::new(), + PhpExtensionsCheck::new(), + WritablePathsCheck::new(), + MigrationsCheck::new(), + AdminUserCheck::new(), + InstallationFlagCheck::new(), ScheduleCheck::new(), UsedDiskSpaceCheck::new(), PanelVersionCheck::new(), diff --git a/app/Services/Environment/InstallationHealthService.php b/app/Services/Environment/InstallationHealthService.php new file mode 100644 index 0000000000..14648e909f --- /dev/null +++ b/app/Services/Environment/InstallationHealthService.php @@ -0,0 +1,99 @@ + */ + public function systemRequirements(): Collection + { + return $this->run([ + PhpVersionCheck::new()->label(trans('installer.health.php.label')), + PhpExtensionsCheck::new()->label(trans('installer.health.extensions.label')), + WritablePathsCheck::new()->label(trans('installer.health.paths.label')), + ]); + } + + public function databaseDriverExtension(DatabaseDriver|string $driver): Result + { + return $this->runCheck( + DatabaseExtensionCheck::new() + ->driver($driver) + ->label(trans('installer.health.database_extension.label')), + ); + } + + /** @return Collection */ + public function configuredEnvironment(): Collection + { + return $this->systemRequirements()->concat($this->run([ + ApplicationKeyCheck::new()->label(trans('installer.health.app_key.label')), + DatabaseCheck::new()->label(trans('installer.health.database.label')), + MigrationsCheck::new()->label(trans('installer.health.migrations.label')), + CacheCheck::new()->label(trans('installer.health.cache.label')), + ])); + } + + /** @return Collection */ + public function completeInstallation(): Collection + { + return $this->configuredEnvironment()->concat($this->run([ + AdminUserCheck::new()->label(trans('installer.health.admin.label')), + InstallationFlagCheck::new()->label(trans('installer.health.installed.label')), + ])); + } + + /** + * @param iterable $checks + * @return Collection + */ + public function run(iterable $checks): Collection + { + return collect($checks)->map(fn (Check $check) => $this->runCheck($check))->values(); + } + + public function runCheck(Check $check): Result + { + try { + $result = $check->run(); + } catch (Throwable $exception) { + report($exception); + + $result = $check->markAsCrashed() + ->notificationMessage($exception->getMessage()); + } + + return $result + ->check($check) + ->endedAt(now()); + } + + /** @param iterable $results */ + public function hasFailures(iterable $results): bool + { + foreach ($results as $result) { + if (in_array($result->status, [Status::failed(), Status::crashed()], true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Traits/Commands/DisplaysHealthResults.php b/app/Traits/Commands/DisplaysHealthResults.php new file mode 100644 index 0000000000..c02cde6470 --- /dev/null +++ b/app/Traits/Commands/DisplaysHealthResults.php @@ -0,0 +1,40 @@ + $results */ + protected function displayHealthResults(iterable $results): void + { + $rows = []; + + foreach ($results as $result) { + $remediation = $result->meta['remediation'] ?? null; + $rows[] = [ + $result->check->getLabel(), + $this->formatHealthStatus($result->status), + $result->getNotificationMessage() . ($remediation ? "\n{$remediation}" : ''), + ]; + } + + $this->table([ + trans('commands.environment_check.check'), + trans('commands.environment_check.status'), + trans('commands.environment_check.details'), + ], $rows); + } + + private function formatHealthStatus(Status $status): string + { + return match ($status) { + Status::ok() => '' . trans('commands.environment_check.passed') . '', + Status::warning() => '' . trans('commands.environment_check.warning') . '', + Status::failed(), Status::crashed() => '' . trans('commands.environment_check.failed') . '', + default => (string) $status->value, + }; + } +} diff --git a/lang/en/commands.php b/lang/en/commands.php index a24222b3a8..f6191637df 100644 --- a/lang/en/commands.php +++ b/lang/en/commands.php @@ -1,6 +1,18 @@ [ + 'check' => 'Check', + 'status' => 'Status', + 'details' => 'Details', + 'passed' => 'Passed', + 'warning' => 'Warning', + 'failed' => 'Failed', + 'preflight_passed' => 'All requested preflight checks passed.', + 'preflight_failed' => 'Preflight failed. Resolve the checks above before continuing.', + 'health_passed' => 'The Panel installation is healthy.', + 'health_failed' => 'The Panel installation has failing health checks.', + ], 'appsettings' => [ 'comment' => [ 'author' => 'Provide the email address that eggs exported by this Panel should be from. This should be a valid email address.', diff --git a/lang/en/installer.php b/lang/en/installer.php index 43005bbc9d..10124e7159 100644 --- a/lang/en/installer.php +++ b/lang/en/installer.php @@ -23,6 +23,65 @@ ], 'exception' => 'Some requirements are missing', ], + 'health' => [ + 'preflight_failed' => 'Some server requirements are not met', + 'php' => [ + 'label' => 'PHP Version', + 'passed' => 'PHP :current meets the minimum supported version (:minimum).', + 'failed' => 'PHP :current is below the minimum supported version (:minimum).', + 'remediation' => 'Install PHP :minimum or newer before continuing.', + ], + 'extensions' => [ + 'label' => 'PHP Extensions', + 'passed' => 'All required PHP extensions are available.', + 'failed' => 'The following PHP extensions are missing: :extensions', + 'remediation' => 'Install the missing extensions for the PHP binary that runs the Panel.', + ], + 'database_extension' => [ + 'label' => 'Database Extension', + 'passed' => 'The required :extension extension is available.', + 'failed' => ':driver requires the :extension PHP extension.', + ], + 'database' => [ + 'label' => 'Database Connection', + ], + 'app_key' => [ + 'label' => 'Application Key', + 'passed' => 'The application encryption key is valid.', + 'failed' => 'The application encryption key is missing or invalid.', + 'remediation' => 'Run php artisan key:generate before continuing.', + ], + 'migrations' => [ + 'label' => 'Database Migrations', + 'passed' => 'All database migrations have completed.', + 'failed' => ':count database migration(s) are still pending.', + 'repository_missing' => 'The database migration repository does not exist.', + 'exception' => 'The migration state could not be read: :error', + 'remediation' => 'Run php artisan migrate --force and resolve any reported errors.', + ], + 'cache' => [ + 'label' => 'Cache', + ], + 'admin' => [ + 'label' => 'Administrator Account', + 'passed' => 'At least one root administrator account exists.', + 'failed' => 'No root administrator account exists.', + 'exception' => 'The administrator account check failed: :error', + 'remediation' => 'Create an administrator through the installer or php artisan p:user:make.', + ], + 'installed' => [ + 'label' => 'Installation State', + 'passed' => 'The Panel is marked as installed.', + 'failed' => 'The Panel is not marked as installed.', + 'remediation' => 'Complete the installer before serving the Panel.', + ], + 'paths' => [ + 'label' => 'Writable Paths', + 'passed' => 'The Panel can write to every required path.', + 'failed' => 'The following paths are not writable: :paths', + 'remediation' => 'Grant the web server user write access to these paths before continuing.', + ], + ], 'environment' => [ 'title' => 'Environment', 'fields' => [ @@ -57,6 +116,7 @@ 'password_help' => 'The password of your database user. Can be empty.', ], 'exceptions' => [ + 'extension' => 'Database extension missing', 'connection' => 'Database connection failed', 'migration' => 'Migrations failed', ], diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php new file mode 100644 index 0000000000..59b4b9e0bf --- /dev/null +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -0,0 +1,114 @@ +systemRequirements(); + + expect($results)->toHaveCount(3) + ->and($results->every(fn (Result $result) => isset($result->check)))->toBeTrue(); +}); + +it('fails the PHP check below the supported version', function () { + $result = PhpVersionCheck::new() + ->currentVersion('8.2.99') + ->run(); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->meta['minimum'])->toBe(PhpVersionCheck::MINIMUM_VERSION); +}); + +it('fails when a required PHP extension is unavailable', function () { + $result = PhpExtensionsCheck::new() + ->requireExtensions(['pelican_missing_extension']) + ->run(); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->getNotificationMessage())->toContain('pelican_missing_extension'); +}); + +it('maps database drivers to their PDO extensions and default ports', function () { + expect(DatabaseDriver::SQLite->requiredExtension())->toBe('pdo_sqlite') + ->and(DatabaseDriver::SQLite->defaultPort())->toBeNull() + ->and(DatabaseDriver::MariaDB->requiredExtension())->toBe('pdo_mysql') + ->and(DatabaseDriver::MariaDB->defaultPort())->toBe(3306) + ->and(DatabaseDriver::PostgreSQL->requiredExtension())->toBe('pdo_pgsql') + ->and(DatabaseDriver::PostgreSQL->defaultPort())->toBe(5432); +}); + +it('reports unwritable installer paths', function () { + $result = WritablePathsCheck::new() + ->paths([base_path('pelican-path-that-does-not-exist')]) + ->run(); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->getNotificationMessage())->toContain('pelican-path-that-does-not-exist'); +}); + +it('provides an explicit preflight bypass for emergency setup', function () { + $command = app(AppSettingsCommand::class); + + expect($command->getDefinition()->hasOption('skip-preflight'))->toBeTrue(); +}); + +it('runs the shared preflight from the command line', function () { + $this->artisan('p:environment:preflight', ['--with-database' => true]) + ->assertSuccessful(); +}); + +it('validates a complete installation after setup', function () { + config()->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + config()->set('app.installed', true); + User::factory()->create()->syncRoles(Role::getRootAdmin()); + + $this->artisan('p:environment:health') + ->assertSuccessful(); +}); + +it('keeps completed installer requests safely blocked on retries', function () { + config()->set('app.installed', true); + + $this->get(route('installer'))->assertNotFound(); + $this->get(route('installer'))->assertNotFound(); +}); + +it('can repeat installer migrations and egg dispatch without crashing', function () { + Queue::fake(); + + $installer = app(PanelInstaller::class); + $installer->data = [ + 'eggs' => [ + 'minecraft' => ['https://example.com/egg.json'], + ], + ]; + + $installer->runMigrations(); + $installer->runMigrations(); + $installer->installEggs(); + $installer->installEggs(); + + Queue::assertPushed(InstallEgg::class, 2); +}); From 6f9328c7fe59a88721ab99300fe3b3f4bcbca2d5 Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Wed, 19 Aug 2026 10:18:31 +0200 Subject: [PATCH 2/7] Validate installer database connections --- .../Environment/DatabaseSettingsCommand.php | 9 +++- app/Livewire/Installer/Steps/DatabaseStep.php | 43 ++++++------------- .../Environment/InstallationHealthService.php | 43 +++++++++++++++++++ .../Installer/InstallationHealthTest.php | 18 ++++++++ 4 files changed, 83 insertions(+), 30 deletions(-) diff --git a/app/Console/Commands/Environment/DatabaseSettingsCommand.php b/app/Console/Commands/Environment/DatabaseSettingsCommand.php index f4e51aabf1..e413095125 100644 --- a/app/Console/Commands/Environment/DatabaseSettingsCommand.php +++ b/app/Console/Commands/Environment/DatabaseSettingsCommand.php @@ -40,6 +40,13 @@ public function __construct(private DatabaseManager $database, private Kernel $c */ public function handle(): int { + $driver = $this->option('driver'); + if ($driver !== null && (!is_string($driver) || DatabaseDriver::tryFrom($driver) === null)) { + $this->error(sprintf('Unsupported database driver [%s].', is_scalar($driver) ? $driver : get_debug_type($driver))); + + return self::FAILURE; + } + $this->error('Changing the database driver will NOT move any database data!'); $this->error('Please make sure you made a database backup first!'); $this->error('After changing the driver you will have to manually move the old data to the new database.'); @@ -49,7 +56,7 @@ public function handle(): int $selected = config('database.default', 'sqlite'); $databaseDrivers = DatabaseDriver::options(recommendSQLite: true); - $this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice( + $this->variables['DB_CONNECTION'] = $driver ?? $this->choice( 'Database Driver', $databaseDrivers, array_key_exists($selected, $databaseDrivers) ? $selected : null diff --git a/app/Livewire/Installer/Steps/DatabaseStep.php b/app/Livewire/Installer/Steps/DatabaseStep.php index 3d060fcda9..b2410a360e 100644 --- a/app/Livewire/Installer/Steps/DatabaseStep.php +++ b/app/Livewire/Installer/Steps/DatabaseStep.php @@ -2,7 +2,6 @@ namespace App\Livewire\Installer\Steps; -use App\Checks\DatabaseCheck; use App\Enums\DatabaseDriver; use App\Enums\TablerIcon; use App\Livewire\Installer\PanelInstaller; @@ -14,7 +13,6 @@ use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Wizard\Step; use Filament\Support\Exceptions\Halt; -use Illuminate\Support\Facades\DB; class DatabaseStep { @@ -98,35 +96,22 @@ public static function make(PanelInstaller $installer): Step throw new Halt($extensionResult->getNotificationMessage()); } - if ($driver !== DatabaseDriver::SQLite) { - config()->set('database.connections._panel_install_test', [ - 'driver' => $driver->value, - 'host' => $get('env_database.DB_HOST'), - 'port' => $get('env_database.DB_PORT'), - 'database' => $get('env_database.DB_DATABASE'), - 'username' => $get('env_database.DB_USERNAME'), - 'password' => $get('env_database.DB_PASSWORD'), - 'collation' => 'utf8mb4_unicode_ci', - 'strict' => true, - ]); + $connectionResult = $health->databaseConnection($driver, [ + 'host' => $get('env_database.DB_HOST'), + 'port' => $get('env_database.DB_PORT'), + 'database' => $get('env_database.DB_DATABASE'), + 'username' => $get('env_database.DB_USERNAME'), + 'password' => $get('env_database.DB_PASSWORD'), + ]); - DB::purge('_panel_install_test'); - $connectionResult = $health->runCheck( - DatabaseCheck::new() - ->connectionName('_panel_install_test') - ->label(trans('installer.health.database.label')), - ); - DB::disconnect('_panel_install_test'); - - if ($health->hasFailures([$connectionResult])) { - Notification::make() - ->title(trans('installer.database.exceptions.connection')) - ->body($connectionResult->getNotificationMessage()) - ->danger() - ->send(); + if ($health->hasFailures([$connectionResult])) { + Notification::make() + ->title(trans('installer.database.exceptions.connection')) + ->body($connectionResult->getNotificationMessage()) + ->danger() + ->send(); - throw new Halt($connectionResult->getNotificationMessage()); - } + throw new Halt($connectionResult->getNotificationMessage()); } $installer->writeToEnv('env_database'); diff --git a/app/Services/Environment/InstallationHealthService.php b/app/Services/Environment/InstallationHealthService.php index 14648e909f..5d21903a88 100644 --- a/app/Services/Environment/InstallationHealthService.php +++ b/app/Services/Environment/InstallationHealthService.php @@ -14,6 +14,7 @@ use App\Checks\WritablePathsCheck; use App\Enums\DatabaseDriver; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Spatie\Health\Checks\Check; use Spatie\Health\Checks\Result; use Spatie\Health\Enums\Status; @@ -40,6 +41,48 @@ public function databaseDriverExtension(DatabaseDriver|string $driver): Result ); } + /** @param array{host?: mixed, port?: mixed, database?: mixed, username?: mixed, password?: mixed} $settings */ + public function databaseConnection(DatabaseDriver $driver, array $settings): Result + { + $database = (string) ($settings['database'] ?? ''); + if ($driver === DatabaseDriver::SQLite && !str_starts_with($database, '/') && $database !== ':memory:') { + $database = database_path($database); + } + + $configuration = $driver === DatabaseDriver::SQLite + ? [ + 'driver' => $driver->value, + 'database' => $database, + 'prefix' => '', + 'foreign_key_constraints' => true, + ] + : [ + 'driver' => $driver->value, + 'host' => $settings['host'] ?? null, + 'port' => $settings['port'] ?? $driver->defaultPort(), + 'database' => $database, + 'username' => $settings['username'] ?? null, + 'password' => $settings['password'] ?? null, + 'collation' => 'utf8mb4_unicode_ci', + 'strict' => true, + ]; + + $connection = '_panel_install_test'; + config()->set("database.connections.{$connection}", $configuration); + DB::purge($connection); + + try { + return $this->runCheck( + DatabaseCheck::new() + ->connectionName($connection) + ->label(trans('installer.health.database.label')), + ); + } finally { + DB::disconnect($connection); + DB::purge($connection); + } + } + /** @return Collection */ public function configuredEnvironment(): Collection { diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php index 59b4b9e0bf..29a1f0df56 100644 --- a/tests/Feature/Installer/InstallationHealthTest.php +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -59,6 +59,24 @@ ->and(DatabaseDriver::PostgreSQL->defaultPort())->toBe(5432); }); +it('rejects unsupported database driver command options', function () { + $this->artisan('p:environment:database', ['--driver' => 'invalid']) + ->expectsOutput('Unsupported database driver [invalid].') + ->assertFailed(); +}); + +it('checks SQLite database paths before installer configuration is written', function () { + $health = app(InstallationHealthService::class); + + $valid = $health->databaseConnection(DatabaseDriver::SQLite, ['database' => ':memory:']); + $invalid = $health->databaseConnection(DatabaseDriver::SQLite, [ + 'database' => 'missing-directory/database.sqlite', + ]); + + expect($valid->status)->toEqual(Status::ok()) + ->and($invalid->status)->toEqual(Status::failed()); +}); + it('reports unwritable installer paths', function () { $result = WritablePathsCheck::new() ->paths([base_path('pelican-path-that-does-not-exist')]) From 651f7346cacc7c8907858c3932649e6c8dc3038b Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Wed, 19 Aug 2026 10:29:32 +0200 Subject: [PATCH 3/7] Report unsupported database drivers --- app/Checks/DatabaseExtensionCheck.php | 21 ++++++++++++++----- lang/en/installer.php | 2 ++ .../Installer/InstallationHealthTest.php | 13 ++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/Checks/DatabaseExtensionCheck.php b/app/Checks/DatabaseExtensionCheck.php index 0c9cd08084..c5854139a6 100644 --- a/app/Checks/DatabaseExtensionCheck.php +++ b/app/Checks/DatabaseExtensionCheck.php @@ -8,22 +8,33 @@ class DatabaseExtensionCheck extends Check { - protected DatabaseDriver $driver = DatabaseDriver::SQLite; + protected DatabaseDriver|string $driver = DatabaseDriver::SQLite; public function driver(DatabaseDriver|string $driver): self { - $this->driver = is_string($driver) ? DatabaseDriver::from($driver) : $driver; + $this->driver = $driver; return $this; } public function run(): Result { - $extension = $this->driver->requiredExtension(); + $driver = is_string($this->driver) ? DatabaseDriver::tryFrom($this->driver) : $this->driver; + + if ($driver === null) { + return Result::make() + ->meta([ + 'driver' => $this->driver, + 'remediation' => trans('installer.health.database_extension.unsupported_remediation'), + ]) + ->failed(trans('installer.health.database_extension.unsupported', ['driver' => $this->driver])); + } + + $extension = $driver->requiredExtension(); $passed = extension_loaded($extension); $result = Result::make()->meta([ - 'driver' => $this->driver->value, + 'driver' => $driver->value, 'extension' => $extension, 'remediation' => trans('installer.health.extensions.remediation'), ]); @@ -31,7 +42,7 @@ public function run(): Result return $passed ? $result->ok(trans('installer.health.database_extension.passed', ['extension' => $extension])) : $result->failed(trans('installer.health.database_extension.failed', [ - 'driver' => $this->driver->getLabel(), + 'driver' => $driver->getLabel(), 'extension' => $extension, ])); } diff --git a/lang/en/installer.php b/lang/en/installer.php index 10124e7159..45eb134302 100644 --- a/lang/en/installer.php +++ b/lang/en/installer.php @@ -41,6 +41,8 @@ 'label' => 'Database Extension', 'passed' => 'The required :extension extension is available.', 'failed' => ':driver requires the :extension PHP extension.', + 'unsupported' => 'The configured database driver ":driver" is not supported.', + 'unsupported_remediation' => 'Set DB_CONNECTION to sqlite, mariadb, mysql, or pgsql.', ], 'database' => [ 'label' => 'Database Connection', diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php index 29a1f0df56..0a533a33a6 100644 --- a/tests/Feature/Installer/InstallationHealthTest.php +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -50,6 +50,19 @@ ->and($result->getNotificationMessage())->toContain('pelican_missing_extension'); }); +it('reports unsupported configured database drivers as failed health results', function () { + config()->set('database.default', 'invalid'); + + $result = app(InstallationHealthService::class)->databaseDriverExtension('invalid'); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->getNotificationMessage())->toContain('invalid'); + + $this->artisan('p:environment:preflight', ['--with-database' => true]) + ->expectsOutputToContain('invalid') + ->assertFailed(); +}); + it('maps database drivers to their PDO extensions and default ports', function () { expect(DatabaseDriver::SQLite->requiredExtension())->toBe('pdo_sqlite') ->and(DatabaseDriver::SQLite->defaultPort())->toBeNull() From 0cf71e0a37b7ef44d0ed472948743b410f486f8c Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Wed, 19 Aug 2026 10:39:14 +0200 Subject: [PATCH 4/7] Handle preflight failures cleanly --- .../Environment/EnvironmentPreflightCommand.php | 12 ++++++++---- app/Traits/Commands/DisplaysHealthResults.php | 5 ++++- tests/Feature/Installer/InstallationHealthTest.php | 4 ++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php index 821daba19b..00de78dbe5 100644 --- a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php +++ b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php @@ -21,10 +21,14 @@ public function handle(InstallationHealthService $health): int $results = $health->systemRequirements(); if ($this->option('with-database')) { - $results->push($health->databaseDriverExtension((string) config('database.default'))); - $results->push($health->runCheck( - DatabaseCheck::new()->label(trans('installer.health.database.label')), - )); + $driverResult = $health->databaseDriverExtension((string) config('database.default')); + $results->push($driverResult); + + if (!$health->hasFailures([$driverResult])) { + $results->push($health->runCheck( + DatabaseCheck::new()->label(trans('installer.health.database.label')), + )); + } } $this->displayHealthResults($results); diff --git a/app/Traits/Commands/DisplaysHealthResults.php b/app/Traits/Commands/DisplaysHealthResults.php index c02cde6470..4c847674fc 100644 --- a/app/Traits/Commands/DisplaysHealthResults.php +++ b/app/Traits/Commands/DisplaysHealthResults.php @@ -13,7 +13,10 @@ protected function displayHealthResults(iterable $results): void $rows = []; foreach ($results as $result) { - $remediation = $result->meta['remediation'] ?? null; + $remediation = $result->status === Status::ok() + ? null + : ($result->meta['remediation'] ?? null); + $rows[] = [ $result->check->getLabel(), $this->formatHealthStatus($result->status), diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php index 0a533a33a6..1ee62fdc46 100644 --- a/tests/Feature/Installer/InstallationHealthTest.php +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -60,6 +60,7 @@ $this->artisan('p:environment:preflight', ['--with-database' => true]) ->expectsOutputToContain('invalid') + ->doesntExpectOutputToContain('Database Connection') ->assertFailed(); }); @@ -107,6 +108,9 @@ it('runs the shared preflight from the command line', function () { $this->artisan('p:environment:preflight', ['--with-database' => true]) + ->doesntExpectOutputToContain(trans('installer.health.php.remediation', [ + 'minimum' => PhpVersionCheck::MINIMUM_VERSION, + ])) ->assertSuccessful(); }); From 9e54faa37cdbd0f8788130733ba61bd370d47d2f Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Wed, 19 Aug 2026 10:44:54 +0200 Subject: [PATCH 5/7] Defer user command database resolution --- app/Console/Commands/User/MakeUserCommand.php | 12 ++---------- tests/Feature/Installer/InstallationHealthTest.php | 3 +++ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/Console/Commands/User/MakeUserCommand.php b/app/Console/Commands/User/MakeUserCommand.php index 821765770c..c45be56bfa 100644 --- a/app/Console/Commands/User/MakeUserCommand.php +++ b/app/Console/Commands/User/MakeUserCommand.php @@ -14,21 +14,13 @@ class MakeUserCommand extends Command protected $signature = 'p:user:make {--email=} {--username=} {--password=} {--admin=} {--no-password}'; - /** - * MakeUserCommand constructor. - */ - public function __construct(private UserCreationService $creationService) - { - parent::__construct(); - } - /** * Handle command request to create a new user. * * @throws Exception * @throws DataValidationException */ - public function handle(): int + public function handle(UserCreationService $creationService): int { try { DB::connection()->getPdo(); @@ -48,7 +40,7 @@ public function handle(): int $password = $this->secret(trans('command/messages.user.ask_password')); } - $user = $this->creationService->handle(compact('email', 'username', 'password', 'root_admin')); + $user = $creationService->handle(compact('email', 'username', 'password', 'root_admin')); $this->table(['Field', 'Value'], [ ['UUID', $user->uuid], ['Email', $user->email], diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php index 1ee62fdc46..58c6b46e8f 100644 --- a/tests/Feature/Installer/InstallationHealthTest.php +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -5,6 +5,7 @@ use App\Checks\PhpVersionCheck; use App\Checks\WritablePathsCheck; use App\Console\Commands\Environment\AppSettingsCommand; +use App\Console\Commands\User\MakeUserCommand; use App\Enums\DatabaseDriver; use App\Jobs\InstallEgg; use App\Livewire\Installer\PanelInstaller; @@ -53,6 +54,8 @@ it('reports unsupported configured database drivers as failed health results', function () { config()->set('database.default', 'invalid'); + expect(fn () => app(MakeUserCommand::class))->not->toThrow(InvalidArgumentException::class); + $result = app(InstallationHealthService::class)->databaseDriverExtension('invalid'); expect($result->status)->toEqual(Status::failed()) From 5ab9c1fee82346fd0b8a8419033a4dcc19400240 Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Wed, 19 Aug 2026 11:06:48 +0200 Subject: [PATCH 6/7] Address installer health review feedback --- app/Checks/AdminUserCheck.php | 3 ++ app/Checks/ApplicationKeyCheck.php | 3 ++ app/Checks/DatabaseExtensionCheck.php | 6 +++ app/Checks/InstallationFlagCheck.php | 3 ++ app/Checks/MigrationsCheck.php | 6 +++ app/Checks/PhpExtensionsCheck.php | 9 ++++- app/Checks/PhpVersionCheck.php | 9 +++++ app/Checks/WritablePathsCheck.php | 9 ++++- .../Environment/AppSettingsCommand.php | 3 ++ .../Environment/EnvironmentHealthCommand.php | 3 ++ .../EnvironmentPreflightCommand.php | 3 ++ app/Console/Commands/User/MakeUserCommand.php | 5 ++- app/Enums/DatabaseDriver.php | 15 +++++++- app/Livewire/Installer/Steps/DatabaseStep.php | 3 ++ .../Installer/Steps/RequirementsStep.php | 3 ++ .../Environment/InstallationHealthService.php | 38 ++++++++++++++++--- app/Traits/Commands/DisplaysHealthResults.php | 9 ++++- .../Installer/InstallationHealthTest.php | 32 +++++++++++++--- 18 files changed, 146 insertions(+), 16 deletions(-) diff --git a/app/Checks/AdminUserCheck.php b/app/Checks/AdminUserCheck.php index 0adf93ace9..b6bd98b8a4 100644 --- a/app/Checks/AdminUserCheck.php +++ b/app/Checks/AdminUserCheck.php @@ -10,6 +10,9 @@ class AdminUserCheck extends Check { + /** + * Verify that at least one root administrator account exists. + */ public function run(): Result { $result = Result::make()->meta([ diff --git a/app/Checks/ApplicationKeyCheck.php b/app/Checks/ApplicationKeyCheck.php index 774dd124b4..b35329128c 100644 --- a/app/Checks/ApplicationKeyCheck.php +++ b/app/Checks/ApplicationKeyCheck.php @@ -8,6 +8,9 @@ class ApplicationKeyCheck extends Check { + /** + * Verify that the configured application key supports the selected cipher. + */ public function run(): Result { $key = config('app.key'); diff --git a/app/Checks/DatabaseExtensionCheck.php b/app/Checks/DatabaseExtensionCheck.php index c5854139a6..f4463a1888 100644 --- a/app/Checks/DatabaseExtensionCheck.php +++ b/app/Checks/DatabaseExtensionCheck.php @@ -10,6 +10,9 @@ class DatabaseExtensionCheck extends Check { protected DatabaseDriver|string $driver = DatabaseDriver::SQLite; + /** + * Select the database driver whose PHP extension should be checked. + */ public function driver(DatabaseDriver|string $driver): self { $this->driver = $driver; @@ -17,6 +20,9 @@ public function driver(DatabaseDriver|string $driver): self return $this; } + /** + * Verify that the configured database driver is supported and available. + */ public function run(): Result { $driver = is_string($this->driver) ? DatabaseDriver::tryFrom($this->driver) : $this->driver; diff --git a/app/Checks/InstallationFlagCheck.php b/app/Checks/InstallationFlagCheck.php index 27970c17e1..009496479d 100644 --- a/app/Checks/InstallationFlagCheck.php +++ b/app/Checks/InstallationFlagCheck.php @@ -7,6 +7,9 @@ class InstallationFlagCheck extends Check { + /** + * Verify that the Panel is marked as installed. + */ public function run(): Result { $result = Result::make()->meta([ diff --git a/app/Checks/MigrationsCheck.php b/app/Checks/MigrationsCheck.php index 1b081f449b..c7da54b020 100644 --- a/app/Checks/MigrationsCheck.php +++ b/app/Checks/MigrationsCheck.php @@ -9,8 +9,14 @@ class MigrationsCheck extends Check { + /** + * Create a migration health check using the configured migrator. + */ public function __construct(private readonly Migrator $migrator) {} + /** + * Verify that the migration repository exists and has no pending migrations. + */ public function run(): Result { $result = Result::make()->meta([ diff --git a/app/Checks/PhpExtensionsCheck.php b/app/Checks/PhpExtensionsCheck.php index cb9fa8a896..688bbc1d1e 100644 --- a/app/Checks/PhpExtensionsCheck.php +++ b/app/Checks/PhpExtensionsCheck.php @@ -23,7 +23,11 @@ class PhpExtensionsCheck extends Check /** @var string[] */ protected array $requiredExtensions = self::REQUIRED_EXTENSIONS; - /** @param string[] $extensions */ + /** + * Override the PHP extensions required by this check. + * + * @param string[] $extensions + */ public function requireExtensions(array $extensions): self { $this->requiredExtensions = $extensions; @@ -31,6 +35,9 @@ public function requireExtensions(array $extensions): self return $this; } + /** + * Verify the required PHP and PDO extensions are loaded. + */ public function run(): Result { $missing = array_values(array_filter( diff --git a/app/Checks/PhpVersionCheck.php b/app/Checks/PhpVersionCheck.php index e40a4ed063..a4d21dc718 100644 --- a/app/Checks/PhpVersionCheck.php +++ b/app/Checks/PhpVersionCheck.php @@ -13,6 +13,9 @@ class PhpVersionCheck extends Check protected string $currentVersion = PHP_VERSION; + /** + * Override the minimum supported PHP version. + */ public function minimumVersion(string $version): self { $this->minimumVersion = $version; @@ -20,6 +23,9 @@ public function minimumVersion(string $version): self return $this; } + /** + * Override the current PHP version for evaluation. + */ public function currentVersion(string $version): self { $this->currentVersion = $version; @@ -27,6 +33,9 @@ public function currentVersion(string $version): self return $this; } + /** + * Verify that the current PHP version meets the minimum requirement. + */ public function run(): Result { $passed = version_compare($this->currentVersion, $this->minimumVersion, '>='); diff --git a/app/Checks/WritablePathsCheck.php b/app/Checks/WritablePathsCheck.php index d16641c8c8..93424800da 100644 --- a/app/Checks/WritablePathsCheck.php +++ b/app/Checks/WritablePathsCheck.php @@ -10,7 +10,11 @@ class WritablePathsCheck extends Check /** @var string[]|null */ protected ?array $paths = null; - /** @param string[] $paths */ + /** + * Override the filesystem paths that must be writable. + * + * @param string[] $paths + */ public function paths(array $paths): self { $this->paths = $paths; @@ -18,6 +22,9 @@ public function paths(array $paths): self return $this; } + /** + * Verify that every required installer path is writable. + */ public function run(): Result { $paths = $this->paths ?? [ diff --git a/app/Console/Commands/Environment/AppSettingsCommand.php b/app/Console/Commands/Environment/AppSettingsCommand.php index e75865653b..3cf2a5c57f 100644 --- a/app/Console/Commands/Environment/AppSettingsCommand.php +++ b/app/Console/Commands/Environment/AppSettingsCommand.php @@ -15,6 +15,9 @@ class AppSettingsCommand extends Command protected $signature = 'p:environment:setup {--skip-preflight : Skip server requirement checks before setup.}'; + /** + * Configure the application after enforcing the installer preflight checks. + */ public function handle(InstallationHealthService $health): int { if (!$this->option('skip-preflight')) { diff --git a/app/Console/Commands/Environment/EnvironmentHealthCommand.php b/app/Console/Commands/Environment/EnvironmentHealthCommand.php index aa143fe435..5cd28b9565 100644 --- a/app/Console/Commands/Environment/EnvironmentHealthCommand.php +++ b/app/Console/Commands/Environment/EnvironmentHealthCommand.php @@ -14,6 +14,9 @@ class EnvironmentHealthCommand extends Command protected $signature = 'p:environment:health'; + /** + * Run and report the complete post-installation health checks. + */ public function handle(InstallationHealthService $health): int { $results = $health->completeInstallation(); diff --git a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php index 00de78dbe5..91dcd662e6 100644 --- a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php +++ b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php @@ -16,6 +16,9 @@ class EnvironmentPreflightCommand extends Command protected $signature = 'p:environment:preflight {--with-database : Also verify the currently configured database extension and connection.}'; + /** + * Run and report the pre-installation requirement checks. + */ public function handle(InstallationHealthService $health): int { $results = $health->systemRequirements(); diff --git a/app/Console/Commands/User/MakeUserCommand.php b/app/Console/Commands/User/MakeUserCommand.php index c45be56bfa..b5d6253e92 100644 --- a/app/Console/Commands/User/MakeUserCommand.php +++ b/app/Console/Commands/User/MakeUserCommand.php @@ -20,7 +20,7 @@ class MakeUserCommand extends Command * @throws Exception * @throws DataValidationException */ - public function handle(UserCreationService $creationService): int + public function handle(): int { try { DB::connection()->getPdo(); @@ -30,6 +30,9 @@ public function handle(UserCreationService $creationService): int return 1; } + /** @var UserCreationService $creationService */ + $creationService = $this->getLaravel()->make(UserCreationService::class); + $root_admin = $this->option('admin') ?? $this->confirm(trans('command/messages.user.ask_admin')); $email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email')); $username = $this->option('username') ?? $this->ask(trans('command/messages.user.ask_username')); diff --git a/app/Enums/DatabaseDriver.php b/app/Enums/DatabaseDriver.php index ad1bac6312..ebf136ad14 100644 --- a/app/Enums/DatabaseDriver.php +++ b/app/Enums/DatabaseDriver.php @@ -11,6 +11,9 @@ enum DatabaseDriver: string implements HasLabel case MySQL = 'mysql'; case PostgreSQL = 'pgsql'; + /** + * Return the human-readable database driver label. + */ public function getLabel(): string { return match ($this) { @@ -21,6 +24,9 @@ public function getLabel(): string }; } + /** + * Return the PDO extension required by this database driver. + */ public function requiredExtension(): string { return match ($this) { @@ -30,6 +36,9 @@ public function requiredExtension(): string }; } + /** + * Return the driver's default network port, when applicable. + */ public function defaultPort(): ?int { return match ($this) { @@ -39,7 +48,11 @@ public function defaultPort(): ?int }; } - /** @return array */ + /** + * Build the database driver options shown by installers and commands. + * + * @return array + */ public static function options(bool $recommendSQLite = false): array { return collect(self::cases()) diff --git a/app/Livewire/Installer/Steps/DatabaseStep.php b/app/Livewire/Installer/Steps/DatabaseStep.php index b2410a360e..805d007a2e 100644 --- a/app/Livewire/Installer/Steps/DatabaseStep.php +++ b/app/Livewire/Installer/Steps/DatabaseStep.php @@ -16,6 +16,9 @@ class DatabaseStep { + /** + * Build the database configuration step and validate it before persistence. + */ public static function make(PanelInstaller $installer): Step { return Step::make('database') diff --git a/app/Livewire/Installer/Steps/RequirementsStep.php b/app/Livewire/Installer/Steps/RequirementsStep.php index 5218ae6fd1..66fbad63e6 100644 --- a/app/Livewire/Installer/Steps/RequirementsStep.php +++ b/app/Livewire/Installer/Steps/RequirementsStep.php @@ -13,6 +13,9 @@ class RequirementsStep { + /** + * Build the installer step that displays and enforces system requirements. + */ public static function make(): Step { $health = app(InstallationHealthService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions diff --git a/app/Services/Environment/InstallationHealthService.php b/app/Services/Environment/InstallationHealthService.php index 5d21903a88..d2b482afb3 100644 --- a/app/Services/Environment/InstallationHealthService.php +++ b/app/Services/Environment/InstallationHealthService.php @@ -22,7 +22,11 @@ class InstallationHealthService { - /** @return Collection */ + /** + * Run the requirements that must pass before Panel configuration begins. + * + * @return Collection + */ public function systemRequirements(): Collection { return $this->run([ @@ -32,6 +36,9 @@ public function systemRequirements(): Collection ]); } + /** + * Verify that a database driver is supported by the current PHP runtime. + */ public function databaseDriverExtension(DatabaseDriver|string $driver): Result { return $this->runCheck( @@ -41,7 +48,11 @@ public function databaseDriverExtension(DatabaseDriver|string $driver): Result ); } - /** @param array{host?: mixed, port?: mixed, database?: mixed, username?: mixed, password?: mixed} $settings */ + /** + * Verify database connectivity using temporary, non-persistent settings. + * + * @param array{host?: mixed, port?: mixed, database?: mixed, username?: mixed, password?: mixed} $settings + */ public function databaseConnection(DatabaseDriver $driver, array $settings): Result { $database = (string) ($settings['database'] ?? ''); @@ -83,7 +94,11 @@ public function databaseConnection(DatabaseDriver $driver, array $settings): Res } } - /** @return Collection */ + /** + * Run health checks that require an already configured environment. + * + * @return Collection + */ public function configuredEnvironment(): Collection { return $this->systemRequirements()->concat($this->run([ @@ -94,7 +109,11 @@ public function configuredEnvironment(): Collection ])); } - /** @return Collection */ + /** + * Run the complete set of post-installation health checks. + * + * @return Collection + */ public function completeInstallation(): Collection { return $this->configuredEnvironment()->concat($this->run([ @@ -104,6 +123,8 @@ public function completeInstallation(): Collection } /** + * Execute a set of health checks and collect their normalized results. + * * @param iterable $checks * @return Collection */ @@ -112,6 +133,9 @@ public function run(iterable $checks): Collection return collect($checks)->map(fn (Check $check) => $this->runCheck($check))->values(); } + /** + * Execute one health check and convert thrown errors into crashed results. + */ public function runCheck(Check $check): Result { try { @@ -128,7 +152,11 @@ public function runCheck(Check $check): Result ->endedAt(now()); } - /** @param iterable $results */ + /** + * Determine whether any result represents a failed or crashed check. + * + * @param iterable $results + */ public function hasFailures(iterable $results): bool { foreach ($results as $result) { diff --git a/app/Traits/Commands/DisplaysHealthResults.php b/app/Traits/Commands/DisplaysHealthResults.php index 4c847674fc..55eaab28e8 100644 --- a/app/Traits/Commands/DisplaysHealthResults.php +++ b/app/Traits/Commands/DisplaysHealthResults.php @@ -7,7 +7,11 @@ trait DisplaysHealthResults { - /** @param iterable $results */ + /** + * Render health check results as a console table with actionable failures. + * + * @param iterable $results + */ protected function displayHealthResults(iterable $results): void { $rows = []; @@ -31,6 +35,9 @@ protected function displayHealthResults(iterable $results): void ], $rows); } + /** + * Format a health status for colorized console output. + */ private function formatHealthStatus(Status $status): string { return match ($status) { diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php index 58c6b46e8f..dce33298c2 100644 --- a/tests/Feature/Installer/InstallationHealthTest.php +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -5,7 +5,6 @@ use App\Checks\PhpVersionCheck; use App\Checks\WritablePathsCheck; use App\Console\Commands\Environment\AppSettingsCommand; -use App\Console\Commands\User\MakeUserCommand; use App\Enums\DatabaseDriver; use App\Jobs\InstallEgg; use App\Livewire\Installer\PanelInstaller; @@ -54,8 +53,6 @@ it('reports unsupported configured database drivers as failed health results', function () { config()->set('database.default', 'invalid'); - expect(fn () => app(MakeUserCommand::class))->not->toThrow(InvalidArgumentException::class); - $result = app(InstallationHealthService::class)->databaseDriverExtension('invalid'); expect($result->status)->toEqual(Status::failed()) @@ -65,6 +62,14 @@ ->expectsOutputToContain('invalid') ->doesntExpectOutputToContain('Database Connection') ->assertFailed(); + + $this->artisan('p:user:make', [ + '--email' => 'invalid-driver@example.com', + '--username' => 'invalid-driver', + '--no-password' => true, + ]) + ->expectsOutputToContain('Database connection [invalid] not configured.') + ->assertFailed(); }); it('maps database drivers to their PDO extensions and default ports', function () { @@ -103,10 +108,25 @@ ->and($result->getNotificationMessage())->toContain('pelican-path-that-does-not-exist'); }); -it('provides an explicit preflight bypass for emergency setup', function () { - $command = app(AppSettingsCommand::class); +it('only bypasses failed setup preflight checks when explicitly requested', function () { + $failedResult = app(InstallationHealthService::class)->runCheck( + PhpVersionCheck::new() + ->minimumVersion('999.0.0') + ->label('PHP Version'), + ); + + $health = Mockery::mock(InstallationHealthService::class); + $health->shouldReceive('systemRequirements')->once()->andReturn(collect([$failedResult])); + $health->shouldReceive('hasFailures')->once()->andReturnTrue(); + $this->app->instance(InstallationHealthService::class, $health); - expect($command->getDefinition()->hasOption('skip-preflight'))->toBeTrue(); + $this->artisan('p:environment:setup') + ->expectsOutputToContain(trans('commands.environment_check.preflight_failed')) + ->assertFailed(); + + $this->artisan('p:environment:setup', ['--skip-preflight' => true]) + ->expectsOutputToContain('Creating storage link') + ->assertSuccessful(); }); it('runs the shared preflight from the command line', function () { From 19f707fc5993a73b7af5a9c0830c90bf57632481 Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Tue, 25 Aug 2026 17:12:53 +0200 Subject: [PATCH 7/7] Fix installer URL validation and formatting --- .../Environment/AppSettingsCommand.php | 9 +++++++-- app/Livewire/Installer/Steps/DatabaseStep.php | 2 +- .../Installer/InstallationHealthTest.php | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/Environment/AppSettingsCommand.php b/app/Console/Commands/Environment/AppSettingsCommand.php index 66dc258f5a..0495d2689d 100644 --- a/app/Console/Commands/Environment/AppSettingsCommand.php +++ b/app/Console/Commands/Environment/AppSettingsCommand.php @@ -85,8 +85,13 @@ private function handleAppUrl(): bool return false; } - if (!str_starts_with($appUrl, 'http://') && !str_starts_with($appUrl, 'https://')) { - $this->error('Application URL need to start with either http:// or https://.'); + $url = is_string($appUrl) ? parse_url($appUrl) : false; + if ( + !is_array($url) + || !in_array($url['scheme'] ?? null, ['http', 'https'], true) + || blank($url['host'] ?? null) + ) { + $this->error('Application URL must be a valid HTTP or HTTPS URL.'); return false; } diff --git a/app/Livewire/Installer/Steps/DatabaseStep.php b/app/Livewire/Installer/Steps/DatabaseStep.php index 55810f2405..2cad0a03a8 100644 --- a/app/Livewire/Installer/Steps/DatabaseStep.php +++ b/app/Livewire/Installer/Steps/DatabaseStep.php @@ -156,7 +156,7 @@ private static function getConfiguredConnectionValue(mixed $driver, string $key, return config("database.connections.{$driver}.{$key}", $fallback); } - + private static function getConnectionPassword(string $driver, ?string $password): ?string { $configuredPassword = config("database.connections.{$driver}.password"); diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php index dce33298c2..4c12b4dc8e 100644 --- a/tests/Feature/Installer/InstallationHealthTest.php +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -124,11 +124,27 @@ ->expectsOutputToContain(trans('commands.environment_check.preflight_failed')) ->assertFailed(); - $this->artisan('p:environment:setup', ['--skip-preflight' => true]) + $this->artisan('p:environment:setup', [ + '--url' => config('app.url'), + '--skip-preflight' => true, + ]) ->expectsOutputToContain('Creating storage link') ->assertSuccessful(); }); +it('rejects malformed application URLs during setup', function (string $url) { + $this->artisan('p:environment:setup', [ + '--url' => $url, + '--skip-preflight' => true, + ]) + ->expectsOutput('Application URL must be a valid HTTP or HTTPS URL.') + ->assertFailed(); +})->with([ + 'scheme without host' => 'https://', + 'query without host' => 'http://?host=value', + 'unsupported scheme' => 'ftp://example.com', +]); + it('runs the shared preflight from the command line', function () { $this->artisan('p:environment:preflight', ['--with-database' => true]) ->doesntExpectOutputToContain(trans('installer.health.php.remediation', [