Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.42% covered (warning)
67.42%
89 / 132
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstallCommand
67.42% covered (warning)
67.42%
89 / 132
75.00% covered (warning)
75.00%
3 / 4
22.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
1 / 1
1
 execute
25.86% covered (danger)
25.86%
15 / 58
0.00% covered (danger)
0.00%
0 / 1
50.75
 env
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Installs phpMyFAQ non-interactively from CLI options or environment variables.
5 *
6 * This wraps the programmatic installer (Installer::startInstall()) so a fresh
7 * instance can be set up headlessly — for Docker provisioning, CI pipelines, and
8 * the end-to-end test suite — without going through the web-based setup wizard.
9 *
10 * This Source Code Form is subject to the terms of the Mozilla Public License,
11 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
12 * obtain one at https://mozilla.org/MPL/2.0/.
13 *
14 * @package   phpMyFAQ
15 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
16 * @copyright 2026 phpMyFAQ Team
17 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
18 * @link      https://www.phpmyfaq.de
19 * @since     2026-06-07
20 */
21
22declare(strict_types=1);
23
24namespace phpMyFAQ\Command;
25
26use phpMyFAQ\Database;
27use phpMyFAQ\Setup\Installer;
28use phpMyFAQ\System;
29use Symfony\Component\Console\Attribute\AsCommand;
30use Symfony\Component\Console\Command\Command;
31use Symfony\Component\Console\Input\InputInterface;
32use Symfony\Component\Console\Input\InputOption;
33use Symfony\Component\Console\Output\OutputInterface;
34use Symfony\Component\Console\Style\SymfonyStyle;
35use Throwable;
36
37#[AsCommand(
38    name: 'phpmyfaq:install',
39    description: 'Installs phpMyFAQ non-interactively (headless setup for Docker, CI and e2e tests)',
40)]
41class InstallCommand extends Command
42{
43    public function __construct(
44        private readonly System $system,
45    ) {
46        parent::__construct();
47    }
48
49    protected function configure(): void
50    {
51        $this
52            ->addOption(
53                'db-type',
54                null,
55                InputOption::VALUE_REQUIRED,
56                'Database driver (mysqli, pdo_mysql, pgsql, sqlite3, ...)',
57                $this->env('PMF_DB_TYPE', 'mysqli'),
58            )
59            ->addOption(
60                'db-server',
61                null,
62                InputOption::VALUE_REQUIRED,
63                'Database host, or absolute file path for SQLite',
64                $this->env('PMF_DB_HOST', ''),
65            )
66            ->addOption(
67                'db-port',
68                null,
69                InputOption::VALUE_REQUIRED,
70                'Database port (ignored for SQLite)',
71                $this->env('PMF_DB_PORT', '3306'),
72            )
73            ->addOption(
74                'db-user',
75                null,
76                InputOption::VALUE_REQUIRED,
77                'Database user (ignored for SQLite)',
78                $this->env('PMF_DB_USER', ''),
79            )
80            ->addOption(
81                'db-password',
82                null,
83                InputOption::VALUE_REQUIRED,
84                'Database password (ignored for SQLite)',
85                $this->env('PMF_DB_PASSWORD', ''),
86            )
87            ->addOption(
88                'db-name',
89                null,
90                InputOption::VALUE_REQUIRED,
91                'Database name (ignored for SQLite)',
92                $this->env('PMF_DB_NAME', 'phpmyfaq'),
93            )
94            ->addOption(
95                'admin-user',
96                null,
97                InputOption::VALUE_REQUIRED,
98                'Admin login name',
99                $this->env('PMF_ADMIN_USER', 'admin'),
100            )
101            ->addOption(
102                'admin-password',
103                null,
104                InputOption::VALUE_REQUIRED,
105                'Admin password (minimum 8 characters)',
106                $this->env('PMF_ADMIN_PASSWORD', ''),
107            )
108            ->addOption(
109                'force',
110                null,
111                InputOption::VALUE_NONE,
112                'Remove an existing config/database.php before installing (for fresh CI databases)',
113            )
114            ->addOption(
115                'base-url',
116                null,
117                InputOption::VALUE_REQUIRED,
118                'Public base URL stored as main.referenceURL (e.g. http://localhost:8765). '
119                . 'Required for correct absolute links when installing headlessly.',
120                $this->env('PMF_BASE_URL', ''),
121            );
122    }
123
124    protected function execute(InputInterface $input, OutputInterface $output): int
125    {
126        $io = new SymfonyStyle($input, $output);
127        $io->title('phpMyFAQ Headless Installer');
128
129        if ($input->getOption('force')) {
130            Installer::cleanFailedInstallationFiles();
131            $io->note('Removed any existing installation config files (--force).');
132        }
133
134        /** @var string $dbType */
135        $dbType = (string) $input->getOption('db-type');
136        $isSqlite = System::isSqlite($dbType);
137
138        /** @var string $adminPassword */
139        $adminPassword = (string) $input->getOption('admin-password');
140        if (strlen($adminPassword) < 8) {
141            $io->error(
142                'The admin password must be at least 8 characters. Pass --admin-password or set PMF_ADMIN_PASSWORD.',
143            );
144            return Command::FAILURE;
145        }
146
147        $dbServer = (string) $input->getOption('db-server');
148        if ($dbServer === '' && !$isSqlite) {
149            $io->error('A database server is required. Pass --db-server or set PMF_DB_HOST.');
150            return Command::FAILURE;
151        }
152
153        $setup = [
154            'dbType' => $dbType,
155            'dbServer' => $dbServer,
156            'dbPort' => $isSqlite ? null : (int) $input->getOption('db-port'),
157            'dbUser' => (string) $input->getOption('db-user'),
158            'dbPassword' => (string) $input->getOption('db-password'),
159            'dbDatabaseName' => (string) $input->getOption('db-name'),
160            'loginname' => (string) $input->getOption('admin-user'),
161            'password' => $adminPassword,
162            'password_retyped' => $adminPassword,
163        ];
164
165        $baseUrl = (string) $input->getOption('base-url');
166
167        try {
168            $installer = new Installer($this->system);
169            $installer->checkBasicStuff();
170            $installer->startInstall($setup);
171
172            // When installing headlessly there is no HTTP host to derive the public
173            // URL from, so the stored main.referenceURL would be wrong (e.g. the CLI
174            // script path). Override it with the explicit base URL when provided.
175            // Use a fresh connection: the installer's own connection lifecycle does
176            // not reliably flush a late write back to the database file.
177            if ($baseUrl !== '') {
178                $database = Database::factory($dbType);
179                $database->connect(
180                    $setup['dbServer'],
181                    $setup['dbUser'],
182                    $setup['dbPassword'],
183                    $setup['dbDatabaseName'],
184                    $setup['dbPort'],
185                );
186                $database->query(sprintf(
187                    "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'",
188                    Database::getTablePrefix(),
189                    $database->escape($baseUrl),
190                ));
191            }
192        } catch (Throwable $throwable) {
193            Installer::cleanFailedInstallationFiles();
194            $io->error('Installation failed: ' . strip_tags($throwable->getMessage()));
195            if ($output->isVerbose()) {
196                $io->writeln($throwable->getTraceAsString());
197            }
198
199            return Command::FAILURE;
200        }
201
202        $io->success(sprintf(
203            'phpMyFAQ installed (%s) with admin user "%s".',
204            $isSqlite ? 'SQLite: ' . $dbServer : $dbType . '://' . $dbServer,
205            (string) $input->getOption('admin-user'),
206        ));
207
208        return Command::SUCCESS;
209    }
210
211    private function env(string $name, string $default): string
212    {
213        $value = $_ENV[$name] ?? $_SERVER[$name] ?? getenv($name);
214        return is_string($value) && $value !== '' ? $value : $default;
215    }
216}