'[MySQLi] Manually configured MySQL TCP/IP connection', 'mysqliSocket' => '[MySQLi] Manually configured MySQL socket connection', 'pdoMysql' => '[PDO] Manually configured MySQL TCP/IP connection', 'pdoMysqlSocket' => '[PDO] Manually configured MySQL socket connection', 'postgres' => 'Manually configured PostgreSQL connection', 'sqlite' => 'Manually configured SQLite connection', ]; public function __construct( string $name, private readonly SetupService $setupService, private readonly ConfigurationManager $configurationManager, private readonly LateBootService $lateBootService, private readonly FailsafePackageManager $packageManager, ) { parent::__construct($name); } protected function configure(): void { $this->setDescription('Setup TYPO3 via CLI using environment variables, CLI options or interactive') // Connection Parameters ->addOption( 'driver', null, InputOption::VALUE_OPTIONAL, 'Select which database driver to use', ) ->addOption( 'host', null, InputOption::VALUE_REQUIRED, 'Set the database host to use', 'db' ) ->addOption( 'port', null, InputOption::VALUE_OPTIONAL, 'Set the database port to use', '3306' ) ->addOption( 'dbname', null, InputOption::VALUE_REQUIRED, 'Set the database name to use', 'db' ) ->addOption( 'username', null, InputOption::VALUE_REQUIRED, 'Set the database username to use', 'db' ) ->addOption( 'password', null, InputOption::VALUE_REQUIRED, 'Set the database password to use' ) // User to be created ->addOption( 'admin-username', null, InputOption::VALUE_OPTIONAL, 'Set a username', 'admin' ) ->addOption( 'admin-user-password', null, InputOption::VALUE_REQUIRED, 'Set users password' ) ->addOption( 'admin-email', null, InputOption::VALUE_REQUIRED, 'Set users email', '' ) ->addOption( 'project-name', null, InputOption::VALUE_REQUIRED, 'Set the TYPO3 project name', 'New TYPO3 Project' ) ->addOption( 'create-site', null, InputOption::VALUE_REQUIRED, 'Create a basic site setup (root page and site configuration) with the given domain, ex. "https://my.domain.tld/"', false ) ->addOption( 'distribution', null, InputOption::VALUE_OPTIONAL, $this->packageManager->isPackageActive('impexp') ? 'Import a distribution during site creation (package key, e.g. "theme_camino")' : '[disabled] Requires typo3/cms-impexp to be installed' ) ->addOption( 'server-type', null, InputOption::VALUE_OPTIONAL, 'Define the web server the TYPO3 installation will be running on', 'other' ) ->addOption( 'force', null, InputOption::VALUE_NONE, 'Force settings overwrite - use this if TYPO3 has been installed already', ) ->addOption( 'no-interaction', 'n', InputOption::VALUE_NONE, 'Do not ask any interactive question', )->setHelp( <<Setup using environment variables --------------------------------- TYPO3_DB_DRIVER=mysqli \ TYPO3_DB_USERNAME=db \ TYPO3_DB_PORT=3306 \ TYPO3_DB_HOST=db \ TYPO3_DB_DBNAME=db \ TYPO3_SETUP_ADMIN_EMAIL=admin@example.com \ TYPO3_SETUP_ADMIN_USERNAME=admin \ TYPO3_SETUP_CREATE_SITE="https://your-typo3-site.com/" \ TYPO3_SETUP_DISTRIBUTION="theme_camino" \ TYPO3_PROJECT_NAME="Automated Setup" \ TYPO3_SERVER_TYPE="apache" \ ./bin/typo3 setup --force --------------------------------- Variable `TYPO3_DB_PASSWORD` (option `--password`) can be used to provide a password for the database and `TYPO3_SETUP_ADMIN_PASSWORD` (option `--admin-user-password`) for the admin user password. Using this can be a security risk since the password may end up in shell history files. Prefer the interactive mode. Additionally, writing a command to shell history can be suppressed by prefixing the command with a space when using `bash` or `zsh`. EOT ); } /** * Runs the installation / setup process */ protected function execute(InputInterface $input, OutputInterface $output): int { $input->setInteractive(!$input->getOption('no-interaction')); /** @var QuestionHelper $questionHelper */ $questionHelper = $this->getHelper('question'); // Ensure all required files and folders exist $this->setupService->createDirectoryStructure($this->getServerType($questionHelper, $input, $output)); try { $force = $input->getOption('force'); $this->setupService->prepareSystemSettings($force); } catch (ConfigurationFileAlreadyExistsException) { $configOverwriteQuestion = new ConfirmationQuestion( 'Configuration already exists do you want to overwrite it [default: no] ? ', false ); $configOverwrite = $questionHelper->ask($input, $output, $configOverwriteQuestion); if (!$configOverwrite) { return Command::FAILURE; } $this->setupService->prepareSystemSettings(true); } $container = $this->lateBootService->getContainer(); $backup = $this->lateBootService->makeCurrent($container); $setupDatabaseService = $container->get(SetupDatabaseService::class); // Get database connection details $databaseConnection = $this->getConnectionDetails($setupDatabaseService, $questionHelper, $input, $output); // Select the database and prepare it if ($exitCode = $this->selectAndImportDatabase($setupDatabaseService, $questionHelper, $input, $output, $databaseConnection)) { return $exitCode; } $container->get(CommandLineUserCreation::class)->ensureCliUserExists(); $this->lateBootService->makeCurrent(null, $backup); $username = $this->getAdminUserName($questionHelper, $input, $output); $password = $this->getAdminUserPassword($setupDatabaseService, $questionHelper, $input, $output); if ($password !== null) { $email = $this->getAdminEmailAddress($questionHelper, $input, $output); $this->setupService->createUser($username, $password, $email); $this->setupService->setInstallToolPassword($password); } elseif ($output->isVerbose()) { $errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $errOutput->writeln('No admin password defined. Skipped user creation.'); } $siteName = $this->getProjectName($questionHelper, $input, $output); $this->setupService->setSiteName($siteName); $distributions = $this->setupService->getAvailableDistributions(); $distributionFromCli = $this->getFallbackValueEnvOrOption($input, 'distribution', 'TYPO3_SETUP_DISTRIBUTION'); $createSiteFromCli = $this->getFallbackValueEnvOrOption($input, 'create-site', 'TYPO3_SETUP_CREATE_SITE'); if ($distributionFromCli !== false && $createSiteFromCli !== false) { throw new \RuntimeException( 'The --distribution and --create-site commandline options may not be used at the same time', 1775034289 ); } if ($distributions['active'] === []) { $selectedDistribution = $this->getDistributionForActivation($distributions['inactive'], $questionHelper, $input, $output); if ($selectedDistribution !== null) { // Distribution handles all site creation (pages, content, site configuration) $this->setupService->activateDistributionPackage($selectedDistribution); } else { $siteUrl = $this->getSiteSetup($questionHelper, $input, $output); if ($siteUrl) { $this->setupService->createSite('main', $siteUrl); } } } elseif ($distributionFromCli !== false || $createSiteFromCli !== false) { $this->writeWarning( $output, 'The --distribution and --create-site commandline options have no effect, when distributions are already active' ); } // The new container is kept in GeneralUtility because the following code, especially // the current state of the data import during extension setup still relies on GeneralUtility::makeInstance // to fetch objects from the container $container = $this->lateBootService->loadExtLocalconfDatabase(false); $setupDatabaseService->markWizardsDone($container); Bootstrap::initializeBackendAuthentication(); $this->setupService->setupExtensions($container); $this->writeSuccess($output, 'Congratulations - TYPO3 Setup is done.'); return Command::SUCCESS; } protected function selectAndImportDatabase( SetupDatabaseService $setupDatabaseService, QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output, mixed $databaseConnection, ): int { if ($databaseConnection['driver'] !== 'pdo_sqlite') { // Set temporary database configuration, so we are able to // get the available databases listed $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME] = $databaseConnection; try { $databaseList = $setupDatabaseService->getDatabaseList(); } catch (DBALException $exception) { $this->writeError($output, $exception->getMessage()); return Command::FAILURE; } if ($databaseList === []) { $this->writeError($output, 'No databases are available to the specified user. At least one usable database needs to be accessible.'); return Command::FAILURE; } $dbChoices = []; foreach ($databaseList as $database) { $usable = $database['tables'] > 0 ? '☓' : '✓'; $dbChoices[$database['name']] = $database['name'] . ' (Tables ' . $database['tables'] . ' ' . $usable . ')'; } $dbNameValidator = static function ($dbname) use ($dbChoices, $databaseList) { if (!($dbChoices[$dbname] ?? false)) { throw new \RuntimeException( 'The selected database "' . $dbname . '" is not available, pick one of these: ' . implode( ', ', array_keys($dbChoices) ) . '.', 1669747192, ); } $selectedDatabase = $databaseList[array_search($dbname, array_keys($dbChoices), true)]; if ($selectedDatabase['tables'] !== 0) { throw new \RuntimeException( 'The selected database contains already ' . $selectedDatabase['tables'] . ' tables. Please delete all tables or select another database.', 1669747200, ); } return $dbname; }; $dbnameFromCli = $this->getFallbackValueEnvOrOption($input, 'dbname', 'TYPO3_DB_DBNAME'); if ($dbnameFromCli === false && $input->isInteractive()) { $dbname = new ChoiceQuestion('Select which database to use: ', $dbChoices); $dbname->setValidator($dbNameValidator); $databaseConnection['database'] = $questionHelper->ask($input, $output, $dbname); } else { try { $dbNameValidator($dbnameFromCli); } catch (\RuntimeException $e) { $this->writeError($output, $e->getMessage()); return Command::FAILURE; } $databaseConnection['database'] = $dbnameFromCli; } $checkDatabase = $setupDatabaseService->checkExistingDatabase($databaseConnection['database']); if ($checkDatabase->getSeverity() !== ContextualFeedbackSeverity::OK) { $this->writeError($output, $checkDatabase->getMessage()); return Command::FAILURE; } } else { $databaseConnection['availableSet'] = 'sqliteManualConfiguration'; } [$success, $messages] = $setupDatabaseService->setDefaultConnectionSettings($databaseConnection); if (!$success) { foreach ($messages as $message) { $this->writeError($output, $message->getMessage()); } return Command::FAILURE; } // Load the actual config written to disk $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME] = $this->configurationManager->getLocalConfigurationValueByPath('DB/Connections/Default'); $setupDatabaseService->checkRequiredDatabasePermissions(); $importResults = $setupDatabaseService->importDatabaseData(); foreach ($importResults as $result) { $this->writeError($output, (string)$result); } if (count($importResults) > 0) { $this->writeError($output, 'Database import failed. Please see the errors shown above'); return Command::FAILURE; } return Command::SUCCESS; } protected function getConnectionDetails( SetupDatabaseService $setupDatabaseService, QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output, ): array { $input->hasParameterOption('--driver'); $driverTypeCli = $this->getFallbackValueEnvOrOption($input, 'driver', 'TYPO3_DB_DRIVER'); $driverOptions = $setupDatabaseService->getDriverOptions(); $availableConnectionTypes = implode(', ', array_keys($this->connectionLabels)); $connectionValidator = static function ($connectionType) use ($driverOptions, $availableConnectionTypes) { if (!isset($driverOptions[$connectionType . 'ManualConfigurationOptions'])) { throw new \RuntimeException( 'The connection type "' . $connectionType . '" does not exist. Please use one of the following ' . $availableConnectionTypes, 1669905551, ); } return $connectionType; }; if ($driverTypeCli === false && $input->isInteractive()) { $driver = new ChoiceQuestion('Database driver?', $this->connectionLabels); $driver->setValidator($connectionValidator); $driverType = $questionHelper->ask($input, $output, $driver); } else { $driverType = $connectionValidator($driverTypeCli); } $databaseConnectionOptions = $driverOptions[$driverType . 'ManualConfigurationOptions']; // Ask for connection details foreach ($databaseConnectionOptions as $key => $value) { switch ($key) { case 'database': case 'socket': case 'driver': break; case 'username': $usernameFromCli = $this->getFallbackValueEnvOrOption($input, 'username', 'TYPO3_DB_USERNAME'); $emptyValidator = static function ($value) { if (empty($value)) { throw new \RuntimeException( 'The value must not be empty.', 1669747578, ); } return $value; }; if ($usernameFromCli === false && $input->isInteractive()) { $default = $this->getDefinition()->getOption($key)->getDefault(); $defaultLabel = ' [default: ' . $default . ']'; $question = new Question('Enter the database "username"' . $defaultLabel . ' ? ', $default); $question->setValidator($emptyValidator); $username = $questionHelper->ask($input, $output, $question); // @todo: Investigate the difference between username and user.... why? $databaseConnectionOptions['username'] = $username; $databaseConnectionOptions['user'] = $username; break; } $validUsername = $emptyValidator($usernameFromCli); $databaseConnectionOptions['username'] = $validUsername; $databaseConnectionOptions['user'] = $validUsername; break; default: $envValue = $this->getFallbackValueEnvOrOption($input, $key, 'TYPO3_DB_' . strtoupper($key)); $default = $this->getDefinition()->getOption($key)->getDefault(); $defaultLabel = empty($value) ? '' : ' [default: ' . $default . ']'; $question = new Question('Enter the database "' . $key . '"' . $defaultLabel . ' ? ', $default); if ($key === 'password') { $question = new Question('Enter the database "' . $key . '" ? ', $default); $question->setHidden(true); $question->setHiddenFallback(false); } elseif ($key === 'host') { $hostValidator = function ($host) use ($setupDatabaseService) { if (!$setupDatabaseService->isValidDbHost($host)) { throw new \RuntimeException( 'Please enter a valid database host name.', 1669747572 ); } return $host; }; $question->setValidator($hostValidator); } elseif ($key === 'port') { $portValidator = function ($port) use ($setupDatabaseService) { $port = (int)$port; if (!$setupDatabaseService->isValidDbPort($port)) { throw new \RuntimeException( 'Please use a port in the range between 1 and 65535.', 1669747592, ); } return $port; }; $question->setValidator($portValidator); } else { $emptyValidator = function ($value) { if (empty($value)) { throw new \RuntimeException( 'The value must not be empty.', 1669747601, ); } return $value; }; $question->setValidator($emptyValidator); } if ($envValue === false && $key === 'password') { // Force this question if no `TYPO3_DB_PASSWORD` set via cli. // Thus, the user will always be prompted for a password even --no-interaction is set. $currentlyInteractive = $input->isInteractive(); $input->setInteractive(true); $value = $questionHelper->ask($input, $output, $question); $input->setInteractive($currentlyInteractive); } elseif ($envValue === false && $input->isInteractive()) { $value = $questionHelper->ask($input, $output, $question); } else { // All passed in values should go through the set validator, // therefore, we can't break early $validator = $question->getValidator(); $envValue = $envValue ?: $default; $value = $validator ? $validator($envValue) : $envValue; } $databaseConnectionOptions[$key] = $value; } } return $databaseConnectionOptions; } protected function getServerType(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): WebserverType { $serverTypeValidator = static function (?string $serverType): WebserverType { if (!array_key_exists($serverType, WebserverType::getDescriptions())) { throw new \RuntimeException( 'Webserver must be any of ' . implode(', ', array_keys(WebserverType::getDescriptions())), 1682329380, ); } return WebserverType::from($serverType); }; $serverTypeFromCli = $this->getFallbackValueEnvOrOption($input, 'server-type', 'TYPO3_SERVER_TYPE'); if ($serverTypeFromCli === false && $input->isInteractive()) { $questionServerType = new ChoiceQuestion('Which web server is used?', WebserverType::getDescriptions()); $questionServerType->setValidator($serverTypeValidator); return $questionHelper->ask($input, $output, $questionServerType); } return $serverTypeValidator($serverTypeFromCli); } protected function getAdminUserName(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string { $usernameValidator = static function ($username) { if (empty($username)) { throw new \RuntimeException( 'Admin username must not be empty.', 1669747607, ); } return $username; }; $usernameFromCli = $this->getFallbackValueEnvOrOption($input, 'admin-username', 'TYPO3_SETUP_ADMIN_USERNAME'); if ($usernameFromCli === false && $input->isInteractive()) { $questionUsername = new Question('Admin username (user will be "system maintainer") ? '); $questionUsername->setValidator($usernameValidator); return $questionHelper->ask($input, $output, $questionUsername); } // Use default value for 'admin-username' if in non-interactive mode if ($usernameFromCli === false && !$input->isInteractive()) { $usernameFromCli = $this->getDefinition()->getOption('admin-username')->getDefault(); } return $usernameValidator($usernameFromCli); } protected function getAdminUserPassword( SetupDatabaseService $setupDatabaseService, QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output, ): ?string { $passwordValidator = function ($password) use ($setupDatabaseService) { $passwordValidationErrors = $setupDatabaseService->getBackendUserPasswordValidationErrors((string)$password); if (!empty($passwordValidationErrors)) { throw new \RuntimeException( 'Administrator password not secure enough!' . PHP_EOL . '* ' . implode(PHP_EOL . '* ', $passwordValidationErrors), 1669747614, ); } return $password; }; $passwordFromCli = $this->getFallbackValueEnvOrOption($input, 'admin-user-password', 'TYPO3_SETUP_ADMIN_PASSWORD'); if ($passwordFromCli === false && $input->isInteractive()) { $currentlyInteractive = $input->isInteractive(); $input->setInteractive(true); $questionPassword = new Question('Admin user and installer password ? '); $questionPassword->setHidden(true); $questionPassword->setHiddenFallback(false); $questionPassword->setValidator($passwordValidator); $password = $questionHelper->ask($input, $output, $questionPassword); $input->setInteractive($currentlyInteractive); return $password; } if ($passwordFromCli === false) { return null; } return $passwordValidator($passwordFromCli); } protected function getAdminEmailAddress(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string { $emailValidator = static function ($email) { if (!empty($email) && !GeneralUtility::validEmail($email)) { throw new \RuntimeException( 'The given Email is not valid! Please try again.', 1669747620, ); } return $email; }; $emailFromCli = $this->getFallbackValueEnvOrOption($input, 'admin-email', 'TYPO3_SETUP_ADMIN_EMAIL'); if ($emailFromCli === false && $input->isInteractive()) { $questionEmail = new Question('Admin user email ? ', ''); $questionEmail->setValidator($emailValidator); return $questionHelper->ask($input, $output, $questionEmail); } return (string)$emailValidator($emailFromCli); } protected function getProjectName(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string { $nameFromCli = $this->getFallbackValueEnvOrOption($input, 'project-name', 'TYPO3_PROJECT_NAME'); $defaultProjectName = $this->getDefinition()->getOption('project-name')->getDefault(); if ($nameFromCli === false && $input->isInteractive()) { $question = new Question( 'Give your project a name [default: ' . $defaultProjectName . '] ? ', $defaultProjectName ); return $questionHelper->ask($input, $output, $question); } return $nameFromCli ?: $defaultProjectName; } protected function getSiteSetup(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string|bool { $urlValidator = static function ($url) { if (!is_string($url) || in_array(strtolower($url), ['no', 'n'], true)) { return false; } if (empty($url) || !GeneralUtility::isValidUrl($url)) { throw new \RuntimeException( 'Invalid URL provided for the site name. Please provide a valid URL.', 1669747625, ); } return $url; }; $createSiteFromCli = $this->getFallbackValueEnvOrOption($input, 'create-site', 'TYPO3_SETUP_CREATE_SITE'); if ($createSiteFromCli === false && $input->isInteractive()) { $questionCreateSite = new Question('Create a basic site? Please enter a URL [default: no] ', false); $questionCreateSite->setValidator($urlValidator); return $questionHelper->ask($input, $output, $questionCreateSite); } return $urlValidator($createSiteFromCli); } protected function getDistributionForActivation(array $inactiveDistributions, QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): ?string { $distributionFromCli = $this->getFallbackValueEnvOrOption($input, 'distribution', 'TYPO3_SETUP_DISTRIBUTION'); if (!$this->packageManager->isPackageActive('impexp')) { if ($distributionFromCli !== false) { throw new \RuntimeException( sprintf('Distribution "%s" is not installable, please require typo3/cms-impexp.', $distributionFromCli), 1775034287 ); } return null; } if ($distributionFromCli !== false) { if (!isset($inactiveDistributions[$distributionFromCli])) { throw new \RuntimeException( sprintf('Distribution "%s" is not available.', $distributionFromCli), 1775034288 ); } return $distributionFromCli; } if ($input->isInteractive()) { $choices = ['none' => 'Do not import a distribution']; foreach ($inactiveDistributions as $packageKey => $info) { $choices[$packageKey] = $info['title']; } $question = new ChoiceQuestion('Select a distribution to import [default: none]', $choices, 'none'); $answer = $questionHelper->ask($input, $output, $question); return $answer === 'none' ? null : $answer; } return null; } protected function writeSuccess(OutputInterface $output, string $message): void { $output->writeln('✓ ' . $message); } protected function writeWarning(OutputInterface $output, string $message): void { $output->writeln('! [Warning]: ' . $message); } protected function writeError(OutputInterface $output, string $message): void { $output->writeln('☓ [Error]: ' . $message); } /** * Get a value from * * 1. cli option `$option` * 2. environment variable `$envVar` * * Note that cli option has higher precedences and wins over environment variable. */ protected function getFallbackValueEnvOrOption(InputInterface $input, string $option, string $envVar): string|false { $value = ($input->hasParameterOption('--' . $option)) ? $input->getOption($option) : getenv($envVar); return is_string($value) ? $value : false; } }