Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.67% covered (warning)
65.67%
44 / 67
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Commands
65.67% covered (warning)
65.67%
44 / 67
60.00% covered (warning)
60.00%
6 / 10
31.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 server
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 i18n
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 runner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 commands
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 conn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 container
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withAutowiredClasses
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Console;
6
7use Celema\Console\Commands as BaseCommands;
8use Celema\Console\Runner;
9use Celema\Container\Container;
10use Celema\Quma\Commands as QumaCommands;
11use Celema\Quma\Connection;
12use Celema\Server\FrankenPhp;
13use Celema\Server\Server;
14use Celema\Server\Setup;
15use Celema\Verba\Command\StatusCommand;
16use Celema\Verba\Command\SyncCommand;
17use Celema\Verba\Tool\Domain;
18use Celema\Verba\Tool\PhpScanner;
19use Closure;
20use Cosray\App;
21use Cosray\Commands\Fulltext;
22use Cosray\Commands\InstallPanel;
23use Cosray\Commands\RecreateSortIndex;
24use Cosray\Commands\References;
25use Cosray\Commands\Superuser;
26use Cosray\Commands\Titles;
27use Cosray\I18n\SchemaScanner;
28use Cosray\MigrationFactory;
29
30/**
31 * The base CLI command set of a Cosray application.
32 *
33 * Boots the app and bundles the quma migration commands and Cosray's own
34 * commands as lazy factories. `server()` and `i18n()` register the per-app
35 * dev server and translation commands; application command class-strings
36 * are lazily autowired from one request-free console scope.
37 *
38 *     $commands = new Commands($app);
39 *     $commands->server(port: 6913, watch: ['src/**\/*.php']);
40 *     $commands->i18n('mysite', locales: ['de', 'en']);
41 *     $commands->add(ImportCommand::class);
42 *
43 *     return $commands->runner();
44 *
45 * @api
46 */
47final class Commands
48{
49    private readonly BaseCommands $commands;
50    private ?Runtime $runtime = null;
51
52    public function __construct(
53        private readonly App $app,
54    ) {
55        $app->boot();
56        $container = $app->container();
57
58        $this->commands = QumaCommands::get(
59            $this->conn(),
60            migrationFactory: new MigrationFactory($container),
61        );
62        $this->commands->add([
63            Fulltext::class => fn(): Fulltext => new Fulltext($this->conn()),
64            References::class => fn(): References => new References($this->conn()),
65            RecreateSortIndex::class => fn(): RecreateSortIndex => new RecreateSortIndex($this->conn()),
66            Superuser::class => fn(): Superuser => new Superuser($this->conn()),
67            InstallPanel::class => fn(): InstallPanel => new InstallPanel($this->app->config),
68            Titles::class => fn(): Titles => $this->resolve(Titles::class),
69        ]);
70    }
71
72    public function add(
73        array|object|string $commands,
74        string $description = '',
75        ?Closure $command = null,
76    ): self {
77        if (is_string($commands)) {
78            $this->commands->add([$commands => fn(): object => $this->resolve($commands)]);
79
80            return $this;
81        }
82
83        if (is_array($commands)) {
84            $commands = $this->withAutowiredClasses($commands);
85        }
86
87        $this->commands->add($commands, $description, $command);
88
89        return $this;
90    }
91
92    /**
93     * Registers the builtin and FrankenPHP dev servers.
94     *
95     * The commands are only registered when the optional celema/server
96     * package is installed, so production installs without dev
97     * requirements skip them.
98     *
99     * @param list<string>|string|null $watch
100     */
101    public function server(
102        int $port = 1983,
103        array|string|null $watch = null,
104        string $routePrefix = '',
105    ): self {
106        if (!class_exists(Server::class)) {
107            return $this;
108        }
109
110        $watch ??= Setup::DEFAULT_WATCH;
111        $public = $this->app->config->path->public;
112
113        $this->commands->add([
114            Server::class => static fn(): Server => new Server($public, $port, $routePrefix, $watch),
115            FrankenPhp::class => static fn(): FrankenPhp => new FrankenPhp(
116                $public,
117                $port,
118                $routePrefix,
119                $watch,
120            ),
121        ]);
122
123        return $this;
124    }
125
126    /**
127     * Registers `i18n:sync` and `i18n:status` for one translation domain.
128     *
129     * The domain scans the given source directories (relative paths resolve
130     * from the app root) plus the app's schema labels, and claims bare
131     * `__()` calls as the default domain. Call once per domain for apps
132     * with several catalogs.
133     *
134     * @param list<string> $locales
135     * @param list<string> $scan
136     */
137    public function i18n(
138        string $name,
139        array $locales,
140        array $scan = ['src', 'views'],
141        string $dir = 'lang',
142        bool $schema = true,
143    ): self {
144        $root = $this->app->config->path->root;
145        $absolute = static fn(string $path): string => str_starts_with($path, '/')
146            ? $path
147            : "{$root}/{$path}";
148
149        $scanners = [new PhpScanner(array_map($absolute, $scan))];
150
151        if ($schema) {
152            $scanners[] = SchemaScanner::fromApp($this->app);
153        }
154
155        $domain = new Domain(
156            name: $name,
157            dir: $absolute($dir),
158            locales: $locales,
159            scanners: $scanners,
160            default: true,
161        );
162
163        $this->commands->add([
164            SyncCommand::class => static fn(): SyncCommand => new SyncCommand([$domain]),
165            StatusCommand::class => static fn(): StatusCommand => new StatusCommand([$domain]),
166        ]);
167
168        return $this;
169    }
170
171    public function runner(?bool $debug = null): Runner
172    {
173        return new Runner($this->commands, debug: $debug ?? $this->app->config->debug());
174    }
175
176    public function commands(): BaseCommands
177    {
178        return $this->commands;
179    }
180
181    private function conn(): Connection
182    {
183        $conn = $this->container()->get(Connection::class);
184        assert($conn instanceof Connection, 'The database connection must be available');
185
186        return $conn;
187    }
188
189    private function container(): Container
190    {
191        return $this->app->container();
192    }
193
194    /** @param class-string $class */
195    private function resolve(string $class): object
196    {
197        return ($this->runtime ??= new Runtime($this->app))->get($class);
198    }
199
200    private function withAutowiredClasses(array $commands): array
201    {
202        $result = [];
203
204        foreach ($commands as $key => $command) {
205            if (is_int($key) && is_string($command)) {
206                $result[$command] = fn(): object => $this->resolve($command);
207
208                continue;
209            }
210
211            $result[$key] = $command;
212        }
213
214        return $result;
215    }
216}