Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.79% covered (warning)
70.79%
63 / 89
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Commands
70.79% covered (warning)
70.79%
63 / 89
60.00% covered (warning)
60.00%
6 / 10
24.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 server
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
4
 i18n
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 runner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reloadPort
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 jobRunner
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 conn
100.00% covered (success)
100.00%
1 / 1
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
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Console;
6
7use Celema\Console\Io;
8use Celema\Console\Runner;
9use Celema\Container\Container;
10use Celema\Quma\Commands as QumaCommands;
11use Celema\Quma\Connection;
12use Celema\Quma\Database;
13use Celema\Server\FrankenInstall;
14use Celema\Server\Reload;
15use Celema\Server\Server;
16use Celema\Verba\Command\StatusCommand;
17use Celema\Verba\Command\SyncCommand;
18use Celema\Verba\Tool\Domain;
19use Celema\Verba\Tool\PhpScanner;
20use Cosray\App;
21use Cosray\Commands\CreateToken;
22use Cosray\Commands\Fulltext;
23use Cosray\Commands\ListenQueue;
24use Cosray\Commands\ListFailedJobs;
25use Cosray\Commands\ListTokens;
26use Cosray\Commands\PanelPublish;
27use Cosray\Commands\PruneJobs;
28use Cosray\Commands\RecreateSortIndex;
29use Cosray\Commands\References;
30use Cosray\Commands\RetryJobs;
31use Cosray\Commands\RevokeToken;
32use Cosray\Commands\SendTestMail;
33use Cosray\Commands\Superuser;
34use Cosray\Commands\Titles;
35use Cosray\Commands\WorkQueue;
36use Cosray\ContainerLogger;
37use Cosray\I18n\SchemaScanner;
38use Cosray\MigrationFactory;
39use Cosray\Panel\Client;
40use Cosray\Queue\Listener;
41use Cosray\Queue\Runner as JobRunner;
42use Cosray\User\PasswordPolicy;
43
44/**
45 * The base CLI command set of a Cosray application.
46 *
47 * Boots the app and registers the quma migration commands and Cosray's own
48 * commands on a console runner. `server()` and `i18n()` register the
49 * per-app dev server and translation commands; application command
50 * class-strings are lazily autowired from one request-free console scope.
51 *
52 *     $commands = new Commands($app);
53 *     $commands->server(port: 6913, watch: ['src/**\/*.php']);
54 *     $commands->i18n('mysite', locales: ['de', 'en']);
55 *     $commands->add(ImportCommand::class);
56 *
57 *     return $commands->runner();
58 *
59 * @api
60 */
61final class Commands
62{
63    private readonly Runner $runner;
64    private ?Runtime $runtime = null;
65
66    public function __construct(
67        private readonly App $app,
68        Io $io = new Io(),
69    ) {
70        $app->boot();
71        $this->runner = new Runner(
72            QumaCommands::get($this->conn(), migrationFactory: new MigrationFactory($app->container())),
73            $io,
74            debug: $app->config->debug(),
75            resolve: $this->resolve(...),
76        );
77        $this->runner->add([
78            Fulltext::class,
79            PanelPublish::class => static fn(): PanelPublish => new PanelPublish(
80                $app->config,
81                new Client($app->config),
82            ),
83            References::class => fn(): References => new References($this->conn()),
84            RecreateSortIndex::class,
85            Superuser::class => fn(): Superuser => new Superuser(
86                $this->conn(),
87                new PasswordPolicy($app->config, new ContainerLogger($this->container())),
88            ),
89            Titles::class,
90            CreateToken::class,
91            ListTokens::class,
92            RevokeToken::class,
93            WorkQueue::class => fn(): WorkQueue => new WorkQueue($this->jobRunner()),
94            ListenQueue::class => fn(): ListenQueue => new ListenQueue(
95                $this->jobRunner(...),
96                Listener::fromConnection($this->conn()),
97            ),
98            ListFailedJobs::class,
99            RetryJobs::class,
100            PruneJobs::class,
101            SendTestMail::class,
102        ]);
103    }
104
105    /**
106     * Takes the registrations a console Runner takes; class-strings are
107     * autowired in the console scope.
108     *
109     * @param array<mixed>|object|string $commands
110     */
111    public function add(array|object|string $commands): self
112    {
113        $this->runner->add($commands);
114
115        return $this;
116    }
117
118    /**
119     * Registers the development server, `frankenphp:install`, and `reload`.
120     *
121     * The server runs the built-in PHP server or, with `server:
122     * 'frankenphp'` or `php run server frankenphp`, FrankenPHP: the release
123     * `frankenphp` pins, like `'1.12.7'`, or the executable it names.
124     * Companions run alongside, like asset watchers:
125     * each name with a command line or a list of arguments. `reload` serves
126     * live reload on the port the server uses for it by default. Without a
127     * port or watch patterns, the server's own defaults apply.
128     *
129     * The commands are only registered when the optional celema/server
130     * package is installed, so production installs without dev
131     * requirements skip them.
132     *
133     * @param list<string>|string|null $watch
134     * @param array<string, list<string>|string> $companions
135     */
136    public function server(
137        ?int $port = null,
138        array|string|null $watch = null,
139        string $routePrefix = '',
140        string $server = 'builtin',
141        ?string $frankenphp = null,
142        array $companions = [],
143    ): self {
144        if (!class_exists(Server::class)) {
145            return $this;
146        }
147
148        $args = [
149            'docroot' => $this->app->config->path->public,
150            'routePrefix' => $routePrefix,
151            'server' => $server,
152            'frankenphp' => $frankenphp,
153            'companions' => $companions,
154        ];
155        $reload = ['companions' => $companions];
156
157        if ($port !== null) {
158            $args['port'] = $port;
159            $reload['port'] = self::reloadPort($port);
160        }
161
162        if ($watch !== null) {
163            $args['watch'] = $watch;
164            $reload['watch'] = $watch;
165        }
166
167        $this->runner->add([
168            Server::class => static fn(): Server => new Server(...$args),
169            Reload::class => static fn(): Reload => new Reload(...$reload),
170            new FrankenInstall(),
171        ]);
172
173        return $this;
174    }
175
176    /**
177     * Registers `i18n:sync` and `i18n:status` for one translation domain.
178     *
179     * The domain scans the given source directories (relative paths resolve
180     * from the app root) plus the app's schema labels, and claims bare
181     * `__()` calls as the default domain. Call once per domain for apps
182     * with several catalogs.
183     *
184     * @param list<string> $locales
185     * @param list<string> $scan
186     */
187    public function i18n(
188        string $name,
189        array $locales,
190        array $scan = ['src', 'views'],
191        string $dir = 'lang',
192        bool $schema = true,
193    ): self {
194        $root = $this->app->config->path->root;
195        $absolute = static fn(string $path): string => str_starts_with($path, '/')
196            ? $path
197            : "{$root}/{$path}";
198
199        $scanners = [new PhpScanner(array_map($absolute, $scan))];
200
201        if ($schema) {
202            $scanners[] = SchemaScanner::fromApp($this->app);
203        }
204
205        $domain = new Domain(
206            name: $name,
207            dir: $absolute($dir),
208            locales: $locales,
209            scanners: $scanners,
210            default: true,
211        );
212
213        $this->runner->add([
214            SyncCommand::class => static fn(): SyncCommand => new SyncCommand([$domain]),
215            StatusCommand::class => static fn(): StatusCommand => new StatusCommand([$domain]),
216        ]);
217
218        return $this;
219    }
220
221    public function runner(): Runner
222    {
223        return $this->runner;
224    }
225
226    /**
227     * The server's first choice for its live reload port, so pages keep
228     * loading the script when switching between `server` and `reload`.
229     */
230    private static function reloadPort(int $port): int
231    {
232        foreach ([$port * 10, $port + 10_000] as $candidate) {
233            if ($candidate <= 65_535) {
234                return $candidate;
235            }
236        }
237
238        return $port + 1;
239    }
240
241    private function jobRunner(): JobRunner
242    {
243        return JobRunner::scoped(
244            $this->container()->instance(Database::class),
245            new ContainerLogger($this->container()),
246            $this->container(),
247        );
248    }
249
250    private function conn(): Connection
251    {
252        return $this->container()->instance(Connection::class);
253    }
254
255    private function container(): Container
256    {
257        return $this->app->container();
258    }
259
260    /** @param class-string $class */
261    private function resolve(string $class): object
262    {
263        return ($this->runtime ??= new Runtime($this->app->container()))->get($class);
264    }
265}