| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- #!/usr/bin/env php
- <?php
- declare(strict_types=1);
- use App\App\Container;
- use App\Application\Jobs\RecomputeScoresJob;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenHasher;
- use App\Domain\Auth\TokenIssuer;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Time\Clock;
- use App\Infrastructure\Auth\ServiceTokenBootstrap;
- use App\Infrastructure\Auth\TokenRecord;
- use App\Infrastructure\Auth\TokenRepository;
- use App\Infrastructure\Jobs\JobLockRepository;
- use App\Infrastructure\Jobs\JobRegistry;
- use App\Infrastructure\Jobs\JobRunner;
- use App\Infrastructure\Jobs\JobRunRepository;
- require __DIR__ . '/../vendor/autoload.php';
- $argv = $_SERVER['argv'] ?? [];
- $command = $argv[1] ?? null;
- $phinxBin = __DIR__ . '/../vendor/bin/phinx';
- $phinxConfig = __DIR__ . '/../config/phinx.php';
- $run = static function (string $phinxCommand) use ($phinxBin, $phinxConfig): never {
- $cmd = sprintf(
- '%s %s --configuration=%s',
- escapeshellarg($phinxBin),
- $phinxCommand,
- escapeshellarg($phinxConfig)
- );
- passthru($cmd, $exitCode);
- exit($exitCode);
- };
- /**
- * @param array<int, string> $argv
- */
- $flag = static function (array $argv, string $name): ?string {
- $prefix = '--' . $name . '=';
- foreach ($argv as $arg) {
- if (str_starts_with($arg, $prefix)) {
- return substr($arg, strlen($prefix));
- }
- }
- return null;
- };
- /**
- * @param array<int, string> $argv
- */
- $hasFlag = static function (array $argv, string $name): bool {
- return in_array('--' . $name, $argv, true);
- };
- switch ($command) {
- case 'db:migrate':
- $run('migrate');
- // no break
- case 'db:rollback':
- $run('rollback');
- // no break
- case 'db:seed':
- $run('seed:run');
- // no break
- case 'auth:bootstrap-service-token':
- $container = Container::build();
- /** @var ServiceTokenBootstrap $boot */
- $boot = $container->get(ServiceTokenBootstrap::class);
- /** @var string $rawToken */
- $rawToken = $container->get('settings.ui_service_token');
- $boot->bootstrap($rawToken);
- exit(0);
- case 'auth:create-token':
- $kindArg = $flag($argv, 'kind') ?? '';
- $roleArg = $flag($argv, 'role');
- $quiet = $hasFlag($argv, 'quiet');
- $kind = TokenKind::tryFrom($kindArg);
- if ($kind === null) {
- fwrite(STDERR, "Unknown --kind: {$kindArg}. Admin tokens are the only kind supported here.\n");
- exit(1);
- }
- if ($kind === TokenKind::Service) {
- fwrite(STDERR, "Refusing to create a service token via this command. Use UI_SERVICE_TOKEN + auth:bootstrap-service-token.\n");
- exit(1);
- }
- if ($kind === TokenKind::Reporter || $kind === TokenKind::Consumer) {
- fwrite(STDERR, "Reporter/consumer tokens are bound to records and are issued via M04 endpoints, not this CLI.\n");
- exit(1);
- }
- // kind=admin from here on.
- $role = $roleArg !== null ? Role::tryFrom(strtolower($roleArg)) : null;
- if ($role === null) {
- fwrite(STDERR, "Admin tokens require --role=viewer|operator|admin.\n");
- exit(1);
- }
- $container = Container::build();
- /** @var TokenIssuer $issuer */
- $issuer = $container->get(TokenIssuer::class);
- /** @var TokenHasher $hasher */
- $hasher = $container->get(TokenHasher::class);
- /** @var TokenRepository $repo */
- $repo = $container->get(TokenRepository::class);
- $raw = $issuer->issue(TokenKind::Admin);
- $hash = $hasher->hash($raw);
- $repo->create(new TokenRecord(
- id: null,
- kind: TokenKind::Admin,
- hash: $hash,
- prefix: substr($raw, 0, 8),
- reporterId: null,
- consumerId: null,
- role: $role,
- expiresAt: null,
- revokedAt: null,
- lastUsedAt: null,
- ));
- if ($quiet) {
- fwrite(STDOUT, $raw);
- } else {
- fwrite(STDOUT, $raw . "\n");
- fwrite(STDERR, "Created admin token (role={$role->value}). The token is only shown once.\n");
- }
- exit(0);
- case 'jobs:run':
- $jobName = $argv[2] ?? null;
- if ($jobName === null || str_starts_with($jobName, '--')) {
- fwrite(STDERR, "Usage: jobs:run <job-name> [--full] [--max-rows=N]\n");
- exit(1);
- }
- $container = Container::build();
- /** @var JobRegistry $registry */
- $registry = $container->get(JobRegistry::class);
- if (!$registry->has($jobName)) {
- fwrite(STDERR, "Unknown job: {$jobName}\n");
- exit(1);
- }
- /** @var JobRunner $runner */
- $runner = $container->get(JobRunner::class);
- $params = [];
- if ($hasFlag($argv, 'full')) {
- $params['full'] = true;
- }
- $maxRowsArg = $flag($argv, 'max-rows');
- if ($maxRowsArg !== null && ctype_digit($maxRowsArg)) {
- $params['max_rows'] = (int) $maxRowsArg;
- }
- $outcome = $runner->run($registry->get($jobName), $params, 'manual');
- fwrite(STDOUT, json_encode($outcome->toArray(), JSON_PRETTY_PRINT) . "\n");
- exit($outcome->status->value === 'failure' ? 1 : 0);
- case 'jobs:status':
- $container = Container::build();
- /** @var JobRegistry $registry */
- $registry = $container->get(JobRegistry::class);
- /** @var JobRunRepository $runs */
- $runs = $container->get(JobRunRepository::class);
- /** @var JobLockRepository $locks */
- $locks = $container->get(JobLockRepository::class);
- /** @var Clock $clock */
- $clock = $container->get(Clock::class);
- $now = $clock->now();
- $latest = $runs->latestPerJob();
- $rows = [];
- foreach ($registry->all() as $name => $job) {
- $row = $latest[$name] ?? null;
- $finishedAt = $row['finished_at'] ?? null;
- $overdue = $row === null
- || ($finishedAt instanceof DateTimeImmutable
- && ($now->getTimestamp() - $finishedAt->getTimestamp()) > $job->defaultIntervalSeconds());
- $rows[$name] = [
- 'name' => $name,
- 'default_interval_seconds' => $job->defaultIntervalSeconds(),
- 'overdue' => $overdue,
- 'lock' => $locks->status($name),
- 'last_run' => $row === null ? null : [
- 'id' => $row['id'],
- 'status' => $row['status'],
- 'items_processed' => $row['items_processed'],
- 'triggered_by' => $row['triggered_by'],
- 'finished_at' => $finishedAt instanceof DateTimeImmutable
- ? $finishedAt->format('Y-m-d\TH:i:s\Z')
- : null,
- ],
- ];
- }
- fwrite(STDOUT, json_encode([
- 'now' => $now->format('Y-m-d\TH:i:s\Z'),
- 'jobs' => $rows,
- ], JSON_PRETTY_PRINT) . "\n");
- exit(0);
- case 'scores:rebuild':
- $container = Container::build();
- /** @var JobRegistry $registry */
- $registry = $container->get(JobRegistry::class);
- /** @var JobRunner $runner */
- $runner = $container->get(JobRunner::class);
- $outcome = $runner->run(
- $registry->get(RecomputeScoresJob::NAME),
- ['full' => true],
- 'manual',
- );
- fwrite(STDOUT, json_encode($outcome->toArray(), JSON_PRETTY_PRINT) . "\n");
- exit($outcome->status->value === 'failure' ? 1 : 0);
- case null:
- case '--help':
- case '-h':
- fwrite(STDOUT, <<<TXT
- Usage: console <command>
- Commands:
- db:migrate Run Phinx migrations
- db:rollback Roll back the most recent migration
- db:seed Run all seeders idempotently
- auth:bootstrap-service-token Provision UI_SERVICE_TOKEN row in api_tokens
- auth:create-token --kind=admin --role=admin|operator|viewer [--quiet]
- Create an admin token; raw token printed to stdout
- jobs:run <name> [--full] [--max-rows=N]
- Invoke a registered job directly. Bypasses HTTP.
- jobs:status Print latest run + lock state for every job
- scores:rebuild Alias for `jobs:run recompute-scores --full`
- TXT);
- exit(0);
- default:
- fwrite(STDERR, "Unknown command: {$command}\n");
- exit(1);
- }
|