Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.33% covered (warning)
73.33%
55 / 75
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SystemUpdateConsoleCommand
72.97% covered (warning)
72.97%
54 / 74
85.71% covered (warning)
85.71%
6 / 7
21.05
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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 resolvePackagePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateAndConfirm
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 performUpdate
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 createDefaultUpdateManager
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
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\Updater\Command;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Updater\Service\DatabaseMigrationRunner;
12use App\Core\Updater\Service\UpdateManagerService;
13use PDO;
14use Symfony\Component\Console\Command\Command;
15use Symfony\Component\Console\Input\InputArgument;
16use Symfony\Component\Console\Input\InputInterface;
17use Symfony\Component\Console\Input\InputOption;
18use Symfony\Component\Console\Output\OutputInterface;
19use Symfony\Component\Console\Style\SymfonyStyle;
20use Throwable;
21
22/**
23 * Console command for applying platform updates from verified ZIP packages.
24 *
25 * Usage: php bin/yii system:update <package.zip> [--profile=admin] [--force]
26 *
27 * @package App\Core\Updater\Command
28 */
29final class SystemUpdateConsoleCommand extends Command
30{
31    /**
32     * SystemUpdateConsoleCommand constructor.
33     */
34    public function __construct(
35        private readonly string $projectRoot,
36        private readonly ?UpdateManagerService $updateManager = null
37    ) {
38        parent::__construct('system:update');
39    }
40
41    /**
42     * Configures console arguments and options.
43     */
44    protected function configure(): void
45    {
46        $this
47            ->setDescription('Applies platform updates from a verified ZIP package.')
48            ->addArgument('package', InputArgument::REQUIRED, 'Path to the update ZIP package')
49            ->addOption('profile', 'p', InputOption::VALUE_REQUIRED, 'Target profile (admin or client)', 'admin')
50            ->addOption('force', 'f', InputOption::VALUE_NONE, 'Execute update without interactive confirmation');
51    }
52
53    /**
54     * Executes the update console command.
55     */
56    protected function execute(InputInterface $input, OutputInterface $output): int
57    {
58        $io = new SymfonyStyle($input, $output);
59        $packageArg = (string) $input->getArgument('package');
60        $profile = (string) $input->getOption('profile');
61        $force = (bool) $input->getOption('force');
62
63        $packagePath = $this->resolvePackagePath($packageArg);
64
65        $checkResult = $this->validateAndConfirm($io, $packagePath, $profile, $force);
66        if ($checkResult !== null) {
67            return $checkResult;
68        }
69
70        return $this->performUpdate($packagePath, $profile, $io);
71    }
72
73    private function resolvePackagePath(string $packageArg): string
74    {
75        if (str_starts_with($packageArg, '/') || preg_match('/^[A-Za-z]:[\\\\\/]/', $packageArg)) {
76            return $packageArg;
77        }
78
79        return $this->projectRoot . '/' . ltrim($packageArg, '/\\');
80    }
81
82    private function validateAndConfirm(
83        SymfonyStyle $io,
84        string $packagePath,
85        string $profile,
86        bool $force
87    ): ?int {
88        if (!file_exists($packagePath)) {
89            $io->error(sprintf('Update package file not found: %s', $packagePath));
90            return Command::FAILURE;
91        }
92
93        $io->title('Ammonly Platform Updater');
94        $io->text(sprintf('Package: <info>%s</info>', $packagePath));
95        $io->text(sprintf('Profile: <comment>%s</comment>', $profile));
96
97        if (!$force) {
98            $confirmed = $io->confirm('Are you sure you want to apply this platform update?', true);
99            if (!$confirmed) {
100                $io->warning('Update cancelled by user.');
101                return Command::SUCCESS;
102            }
103        }
104
105        return null;
106    }
107
108    private function performUpdate(string $packagePath, string $profile, SymfonyStyle $io): int
109    {
110        try {
111            $manager = $this->updateManager ?? $this->createDefaultUpdateManager($profile);
112
113            $result = $manager->update(
114                $packagePath,
115                $profile,
116                static function (string $step, string $msg) use ($io): void {
117                    $io->writeln(sprintf('  <comment>[%s]</comment> %s', $step, $msg));
118                }
119            );
120
121            $io->newLine();
122            $io->success([
123                sprintf('Update to version %s completed successfully!', $result['to_version']),
124                sprintf('Previous version: %s', $result['from_version'] ?? 'None'),
125                sprintf('Files updated: %d | Files deleted: %d', $result['files_updated'], $result['files_deleted']),
126                sprintf('Safety backup archived at: %s', $result['backup_path']),
127            ]);
128
129            return Command::SUCCESS;
130        } catch (Throwable $e) {
131            $io->newLine();
132            $io->error([
133                'Update process failed!',
134                $e->getMessage(),
135                'Any partial changes have been rolled back to safety backup.',
136            ]);
137
138            return Command::FAILURE;
139        }
140    }
141
142    /**
143     * Creates default UpdateManagerService from local database configuration.
144     */
145    private function createDefaultUpdateManager(string $profile): UpdateManagerService
146    {
147        $configFile = $this->projectRoot . '/database/config/installer.php';
148        $dbConfig = [];
149        if (file_exists($configFile)) {
150            /** @var array{db?: array<string, mixed>} $loaded */
151            $loaded = require_once $configFile;
152            $dbConfig = $loaded['db'] ?? [];
153        }
154
155        $host = (string) ($dbConfig['host'] ?? '127.0.0.1');
156        $port = (int) ($dbConfig['port'] ?? 3306);
157        $dbname = ($profile === 'admin')
158            ? (string) ($dbConfig['dbname'] ?? 'ammonly_admin')
159            : 'ammonly_client';
160        $user = (string) ($dbConfig['username'] ?? 'root');
161        $pass = (string) ($dbConfig['password'] ?? '');
162        $prefix = (string) ($dbConfig['table_prefix'] ?? 'a_');
163
164        $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $dbname);
165        $pdo = new PDO($dsn, $user, $pass, [
166            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
167            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
168        ]);
169
170        $runner = new DatabaseMigrationRunner($pdo, $prefix);
171        return new UpdateManagerService($this->projectRoot, $runner);
172    }
173}