1
0

console 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/env php
  2. <?php
  3. declare(strict_types=1);
  4. use App\App\Container;
  5. use App\Application\Jobs\RecomputeScoresJob;
  6. use App\Domain\Auth\Role;
  7. use App\Domain\Auth\TokenHasher;
  8. use App\Domain\Auth\TokenIssuer;
  9. use App\Domain\Auth\TokenKind;
  10. use App\Domain\Time\Clock;
  11. use App\Infrastructure\Auth\ServiceTokenBootstrap;
  12. use App\Infrastructure\Auth\TokenRecord;
  13. use App\Infrastructure\Auth\TokenRepository;
  14. use App\Infrastructure\Jobs\JobLockRepository;
  15. use App\Infrastructure\Jobs\JobRegistry;
  16. use App\Infrastructure\Jobs\JobRunner;
  17. use App\Infrastructure\Jobs\JobRunRepository;
  18. require __DIR__ . '/../vendor/autoload.php';
  19. $argv = $_SERVER['argv'] ?? [];
  20. $command = $argv[1] ?? null;
  21. $phinxBin = __DIR__ . '/../vendor/bin/phinx';
  22. $phinxConfig = __DIR__ . '/../config/phinx.php';
  23. $run = static function (string $phinxCommand) use ($phinxBin, $phinxConfig): never {
  24. $cmd = sprintf(
  25. '%s %s --configuration=%s',
  26. escapeshellarg($phinxBin),
  27. $phinxCommand,
  28. escapeshellarg($phinxConfig)
  29. );
  30. passthru($cmd, $exitCode);
  31. exit($exitCode);
  32. };
  33. /**
  34. * @param array<int, string> $argv
  35. */
  36. $flag = static function (array $argv, string $name): ?string {
  37. $prefix = '--' . $name . '=';
  38. foreach ($argv as $arg) {
  39. if (str_starts_with($arg, $prefix)) {
  40. return substr($arg, strlen($prefix));
  41. }
  42. }
  43. return null;
  44. };
  45. /**
  46. * @param array<int, string> $argv
  47. */
  48. $hasFlag = static function (array $argv, string $name): bool {
  49. return in_array('--' . $name, $argv, true);
  50. };
  51. switch ($command) {
  52. case 'db:migrate':
  53. $run('migrate');
  54. // no break
  55. case 'db:rollback':
  56. $run('rollback');
  57. // no break
  58. case 'db:seed':
  59. $run('seed:run');
  60. // no break
  61. case 'auth:bootstrap-service-token':
  62. $container = Container::build();
  63. /** @var ServiceTokenBootstrap $boot */
  64. $boot = $container->get(ServiceTokenBootstrap::class);
  65. /** @var string $rawToken */
  66. $rawToken = $container->get('settings.ui_service_token');
  67. $boot->bootstrap($rawToken);
  68. exit(0);
  69. case 'auth:create-token':
  70. $kindArg = $flag($argv, 'kind') ?? '';
  71. $roleArg = $flag($argv, 'role');
  72. $quiet = $hasFlag($argv, 'quiet');
  73. $kind = TokenKind::tryFrom($kindArg);
  74. if ($kind === null) {
  75. fwrite(STDERR, "Unknown --kind: {$kindArg}. Admin tokens are the only kind supported here.\n");
  76. exit(1);
  77. }
  78. if ($kind === TokenKind::Service) {
  79. fwrite(STDERR, "Refusing to create a service token via this command. Use UI_SERVICE_TOKEN + auth:bootstrap-service-token.\n");
  80. exit(1);
  81. }
  82. if ($kind === TokenKind::Reporter || $kind === TokenKind::Consumer) {
  83. fwrite(STDERR, "Reporter/consumer tokens are bound to records and are issued via M04 endpoints, not this CLI.\n");
  84. exit(1);
  85. }
  86. // kind=admin from here on.
  87. $role = $roleArg !== null ? Role::tryFrom(strtolower($roleArg)) : null;
  88. if ($role === null) {
  89. fwrite(STDERR, "Admin tokens require --role=viewer|operator|admin.\n");
  90. exit(1);
  91. }
  92. $container = Container::build();
  93. /** @var TokenIssuer $issuer */
  94. $issuer = $container->get(TokenIssuer::class);
  95. /** @var TokenHasher $hasher */
  96. $hasher = $container->get(TokenHasher::class);
  97. /** @var TokenRepository $repo */
  98. $repo = $container->get(TokenRepository::class);
  99. $raw = $issuer->issue(TokenKind::Admin);
  100. $hash = $hasher->hash($raw);
  101. $repo->create(new TokenRecord(
  102. id: null,
  103. kind: TokenKind::Admin,
  104. hash: $hash,
  105. prefix: substr($raw, 0, 8),
  106. reporterId: null,
  107. consumerId: null,
  108. role: $role,
  109. expiresAt: null,
  110. revokedAt: null,
  111. lastUsedAt: null,
  112. ));
  113. if ($quiet) {
  114. fwrite(STDOUT, $raw);
  115. } else {
  116. fwrite(STDOUT, $raw . "\n");
  117. fwrite(STDERR, "Created admin token (role={$role->value}). The token is only shown once.\n");
  118. }
  119. exit(0);
  120. case 'jobs:run':
  121. $jobName = $argv[2] ?? null;
  122. if ($jobName === null || str_starts_with($jobName, '--')) {
  123. fwrite(STDERR, "Usage: jobs:run <job-name> [--full] [--max-rows=N]\n");
  124. exit(1);
  125. }
  126. $container = Container::build();
  127. /** @var JobRegistry $registry */
  128. $registry = $container->get(JobRegistry::class);
  129. if (!$registry->has($jobName)) {
  130. fwrite(STDERR, "Unknown job: {$jobName}\n");
  131. exit(1);
  132. }
  133. /** @var JobRunner $runner */
  134. $runner = $container->get(JobRunner::class);
  135. $params = [];
  136. if ($hasFlag($argv, 'full')) {
  137. $params['full'] = true;
  138. }
  139. $maxRowsArg = $flag($argv, 'max-rows');
  140. if ($maxRowsArg !== null && ctype_digit($maxRowsArg)) {
  141. $params['max_rows'] = (int) $maxRowsArg;
  142. }
  143. $outcome = $runner->run($registry->get($jobName), $params, 'manual');
  144. fwrite(STDOUT, json_encode($outcome->toArray(), JSON_PRETTY_PRINT) . "\n");
  145. exit($outcome->status->value === 'failure' ? 1 : 0);
  146. case 'jobs:status':
  147. $container = Container::build();
  148. /** @var JobRegistry $registry */
  149. $registry = $container->get(JobRegistry::class);
  150. /** @var JobRunRepository $runs */
  151. $runs = $container->get(JobRunRepository::class);
  152. /** @var JobLockRepository $locks */
  153. $locks = $container->get(JobLockRepository::class);
  154. /** @var Clock $clock */
  155. $clock = $container->get(Clock::class);
  156. $now = $clock->now();
  157. $latest = $runs->latestPerJob();
  158. $rows = [];
  159. foreach ($registry->all() as $name => $job) {
  160. $row = $latest[$name] ?? null;
  161. $finishedAt = $row['finished_at'] ?? null;
  162. $overdue = $row === null
  163. || ($finishedAt instanceof DateTimeImmutable
  164. && ($now->getTimestamp() - $finishedAt->getTimestamp()) > $job->defaultIntervalSeconds());
  165. $rows[$name] = [
  166. 'name' => $name,
  167. 'default_interval_seconds' => $job->defaultIntervalSeconds(),
  168. 'overdue' => $overdue,
  169. 'lock' => $locks->status($name),
  170. 'last_run' => $row === null ? null : [
  171. 'id' => $row['id'],
  172. 'status' => $row['status'],
  173. 'items_processed' => $row['items_processed'],
  174. 'triggered_by' => $row['triggered_by'],
  175. 'finished_at' => $finishedAt instanceof DateTimeImmutable
  176. ? $finishedAt->format('Y-m-d\TH:i:s\Z')
  177. : null,
  178. ],
  179. ];
  180. }
  181. fwrite(STDOUT, json_encode([
  182. 'now' => $now->format('Y-m-d\TH:i:s\Z'),
  183. 'jobs' => $rows,
  184. ], JSON_PRETTY_PRINT) . "\n");
  185. exit(0);
  186. case 'scores:rebuild':
  187. $container = Container::build();
  188. /** @var JobRegistry $registry */
  189. $registry = $container->get(JobRegistry::class);
  190. /** @var JobRunner $runner */
  191. $runner = $container->get(JobRunner::class);
  192. $outcome = $runner->run(
  193. $registry->get(RecomputeScoresJob::NAME),
  194. ['full' => true],
  195. 'manual',
  196. );
  197. fwrite(STDOUT, json_encode($outcome->toArray(), JSON_PRETTY_PRINT) . "\n");
  198. exit($outcome->status->value === 'failure' ? 1 : 0);
  199. case null:
  200. case '--help':
  201. case '-h':
  202. fwrite(STDOUT, <<<TXT
  203. Usage: console <command>
  204. Commands:
  205. db:migrate Run Phinx migrations
  206. db:rollback Roll back the most recent migration
  207. db:seed Run all seeders idempotently
  208. auth:bootstrap-service-token Provision UI_SERVICE_TOKEN row in api_tokens
  209. auth:create-token --kind=admin --role=admin|operator|viewer [--quiet]
  210. Create an admin token; raw token printed to stdout
  211. jobs:run <name> [--full] [--max-rows=N]
  212. Invoke a registered job directly. Bypasses HTTP.
  213. jobs:status Print latest run + lock state for every job
  214. scores:rebuild Alias for `jobs:run recompute-scores --full`
  215. TXT);
  216. exit(0);
  217. default:
  218. fwrite(STDERR, "Unknown command: {$command}\n");
  219. exit(1);
  220. }