Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.24% covered (success)
99.24%
131 / 132
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SystemInstallConsoleCommand
99.24% covered (success)
99.24%
130 / 131
85.71% covered (warning)
85.71%
6 / 7
18
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 checkPreFlight
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 resolveInputDto
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 askInteractiveQuestions
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
5
 runInstallation
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Installer\Command;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Installer\Dto\InstallerInputDto;
12use App\Core\Installer\Service\SystemInstallerService;
13use App\Core\Installer\Service\SystemInstallerServiceInterface;
14use App\Core\Installer\Service\SystemRequirementsChecker;
15use App\Core\Installer\Service\SystemRequirementsCheckerInterface;
16use Symfony\Component\Console\Command\Command;
17use Symfony\Component\Console\Input\InputInterface;
18use Symfony\Component\Console\Input\InputOption;
19use Symfony\Component\Console\Output\OutputInterface;
20use Symfony\Component\Console\Question\ChoiceQuestion;
21use Symfony\Component\Console\Question\ConfirmationQuestion;
22use Symfony\Component\Console\Question\Question;
23use Symfony\Component\Console\Style\SymfonyStyle;
24use Throwable;
25
26/**
27 * Interactive and Unattended CLI System Installation Command.
28 *
29 * @package App\Core\Installer\Command
30 */
31final class SystemInstallConsoleCommand extends Command
32{
33    /**
34     * SystemInstallConsoleCommand constructor.
35     *
36     * @param string $basePath Application base directory path.
37     * @param SystemInstallerServiceInterface|null $installer Optional custom installer service.
38     * @param SystemRequirementsCheckerInterface|null $checker Optional requirements checker service.
39     */
40    public function __construct(
41        private readonly string $basePath,
42        private readonly ?SystemInstallerServiceInterface $installer = null,
43        private readonly ?SystemRequirementsCheckerInterface $checker = null
44    ) {
45        parent::__construct('system:install');
46    }
47
48    /** {@inheritdoc} */
49    protected function configure(): void
50    {
51        $this->setDescription('Initializes platform database, sets up baseline schema, and creates super-admin.')
52            ->addOption('profile', 'p', InputOption::VALUE_OPTIONAL, 'Profile: admin or client', 'admin')
53            ->addOption('db-host', null, InputOption::VALUE_OPTIONAL, 'Database host', '127.0.0.1')
54            ->addOption('db-port', null, InputOption::VALUE_OPTIONAL, 'Database port', '3306')
55            ->addOption('db-name', null, InputOption::VALUE_OPTIONAL, 'Database name', 'ammonly_admin')
56            ->addOption('db-user', null, InputOption::VALUE_OPTIONAL, 'Database user', 'root')
57            ->addOption('db-pass', null, InputOption::VALUE_OPTIONAL, 'Database password', '')
58            ->addOption('admin-email', null, InputOption::VALUE_OPTIONAL, 'Super-admin email', 'help@ammonly.com')
59            ->addOption('admin-pass', null, InputOption::VALUE_OPTIONAL, 'Super-admin password', '')
60            ->addOption('with-optional', null, InputOption::VALUE_NONE, 'Include optional dictionary datasets')
61            ->addOption('with-demo', null, InputOption::VALUE_NONE, 'Include demo datasets');
62    }
63
64    /** {@inheritdoc} */
65    protected function execute(InputInterface $input, OutputInterface $output): int
66    {
67        $io = new SymfonyStyle($input, $output);
68        $io->title('Ammonly Platform Installer [v' . SystemInstallerService::INITIAL_VERSION . ']');
69
70        if (!$this->checkPreFlight($io)) {
71            return Command::FAILURE;
72        }
73
74        $dto = $this->resolveInputDto($input, $output);
75        if ($dto === null) {
76            $io->warning('Installation cancelled by user.');
77            return Command::SUCCESS;
78        }
79
80        $installer = $this->installer ?? new SystemInstallerService($this->basePath);
81        return $this->runInstallation($installer, $dto, $io);
82    }
83
84    private function checkPreFlight(SymfonyStyle $io): bool
85    {
86        $checker = $this->checker ?? new SystemRequirementsChecker($this->basePath);
87        $checkResult = $checker->check();
88        if (!$checkResult['passed']) {
89            $io->error('Host environment pre-flight check failed.');
90            foreach ($checkResult['checks'] as $name => $chk) {
91                if (!$chk['status']) {
92                    $io->writeln(sprintf(
93                        ' - <error>[FAILED]</error> %s: %s (Required: %s)',
94                        $name,
95                        $chk['current'],
96                        $chk['required']
97                    ));
98                }
99            }
100            return false;
101        }
102
103        $io->success('Host environment requirements verified.');
104        return true;
105    }
106
107    private function resolveInputDto(InputInterface $input, OutputInterface $output): ?InstallerInputDto
108    {
109        $dto = new InstallerInputDto(
110            profile: (string) $input->getOption('profile'),
111            dbHost: (string) $input->getOption('db-host'),
112            dbPort: (int) $input->getOption('db-port'),
113            dbName: (string) $input->getOption('db-name'),
114            dbUser: (string) $input->getOption('db-user'),
115            dbPass: (string) $input->getOption('db-pass'),
116            adminEmail: (string) $input->getOption('admin-email'),
117            adminPassword: (string) $input->getOption('admin-pass'),
118            withOptional: (bool) $input->getOption('with-optional'),
119            withDemo: (bool) $input->getOption('with-demo')
120        );
121
122        if ($input->isInteractive()) {
123            return $this->askInteractiveQuestions($input, $output, $dto);
124        }
125
126        return $dto;
127    }
128
129    private function askInteractiveQuestions(
130        InputInterface $input,
131        OutputInterface $output,
132        InstallerInputDto $initial
133    ): ?InstallerInputDto {
134        $helper = $this->getHelper('question');
135
136        $profileChoice = new ChoiceQuestion(
137            'Select deployment profile [admin/client]:',
138            ['admin', 'client'],
139            $initial->profile
140        );
141        $profile = (string) $helper->ask($input, $output, $profileChoice);
142
143        $dbHost = (string) $helper->ask(
144            $input,
145            $output,
146            new Question(sprintf('Database host [%s]: ', $initial->dbHost), $initial->dbHost)
147        );
148        $dbPort = (int) $helper->ask(
149            $input,
150            $output,
151            new Question(sprintf('Database port [%d]: ', $initial->dbPort), (string) $initial->dbPort)
152        );
153
154        $defaultDbName = $profile === 'admin' ? 'ammonly_admin' : 'ammonly_client';
155        $chosenDbName = (string) $helper->ask(
156            $input,
157            $output,
158            new Question(sprintf('Database name [%s]: ', $defaultDbName), $defaultDbName)
159        );
160
161        $dbUser = (string) $helper->ask(
162            $input,
163            $output,
164            new Question(sprintf('Database user [%s]: ', $initial->dbUser), $initial->dbUser)
165        );
166
167        $dbPass = $initial->dbPass;
168        if ($dbPass === '') {
169            $dbPassQ = new Question('Database password: ');
170            $dbPassQ->setHidden(true)->setHiddenFallback(true);
171            $dbPass = (string) $helper->ask($input, $output, $dbPassQ);
172        }
173
174        $adminEmail = (string) $helper->ask(
175            $input,
176            $output,
177            new Question(sprintf('Administrator email [%s]: ', $initial->adminEmail), $initial->adminEmail)
178        );
179
180        $adminPass = $initial->adminPassword;
181        if ($adminPass === '') {
182            $adminPassQ = new Question('Administrator password: ');
183            $adminPassQ->setHidden(true)->setHiddenFallback(true);
184            $adminPass = (string) $helper->ask($input, $output, $adminPassQ);
185        }
186
187        $confirmPrompt = sprintf('Proceed with installation on database %s? [y/N] ', $chosenDbName);
188        if (!$helper->ask($input, $output, new ConfirmationQuestion($confirmPrompt, false))) {
189            return null;
190        }
191
192        return new InstallerInputDto(
193            profile: $profile,
194            dbHost: $dbHost,
195            dbPort: $dbPort,
196            dbName: $chosenDbName,
197            dbUser: $dbUser,
198            dbPass: $dbPass,
199            adminEmail: $adminEmail,
200            adminPassword: $adminPass,
201            withOptional: $initial->withOptional,
202            withDemo: $initial->withDemo
203        );
204    }
205
206    private function runInstallation(
207        SystemInstallerServiceInterface $installer,
208        InstallerInputDto $dto,
209        SymfonyStyle $io
210    ): int {
211        try {
212            $io->section('Installing Ammonly Platform...');
213            $result = $installer->install($dto, static function (string $step, string $msg) use ($io): void {
214                $io->writeln(sprintf(' <info>•</info> [%s] %s', $step, $msg));
215            });
216
217            $io->success(sprintf(
218                'Installation completed successfully! Platform version %s is ready.',
219                $result['version']
220            ));
221            $io->table(
222                ['Parameter', 'Configuration'],
223                [
224                    ['Platform Version', $result['version']],
225                    ['Profile', $result['profile']],
226                    ['Database', $result['db_name']],
227                    ['Administrator', $dto->adminEmail],
228                ]
229            );
230
231            return Command::SUCCESS;
232        } catch (Throwable $e) {
233            $io->error('Installation failed: ' . $e->getMessage());
234            return Command::FAILURE;
235        }
236    }
237}