#!/usr/bin/env php $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 $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 [--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, << 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 [--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); }