Phase 1 sub-commit 3: bridge:doctor console command
All checks were successful
CI / Quality (push) Successful in 5s

Console command bridge:doctor surfaces actionable hints for env / wiring
problems so first-run failures aren't a "connection refused" mystery.
Checks PHP version, ext-curl, ext-json, the Publisher service is wired
(meaning BridgeBundle loaded), and the BRIDGE_TOKEN / MERCURE_URL /
MERCURE_PUBLISHER_JWT_KEY / MERCURE_SUBSCRIBER_JWT_KEY env vars. With
--connect, also probes the configured URL via plain stream context (no
extra dep) and fails the run when unreachable.

CommandTester suite covers green path, missing-env path, and an
unreachable-URL probe — 11 tests, 32 assertions, all green.

Skeleton's Makefile target stays a TBD until sub-commit 6 stands up the
Symfony app the command runs from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 01:08:09 +02:00
parent eafe12b588
commit b3932674dd
2 changed files with 224 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Command;
use PhpQml\Bridge\Publisher;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Readiness checks for the php-qml bridge. Surfaces actionable hints
* for missing env / wiring instead of "connection refused" later.
*
* See PLAN.md §8 (*Lifecycle and inspection commands*).
*/
#[AsCommand(name: 'bridge:doctor', description: 'Run readiness checks for the php-qml bridge.')]
final class BridgeDoctorCommand extends Command
{
public function __construct(
private readonly Publisher $publisher,
#[Autowire('%env(default::BRIDGE_TOKEN)%')]
private readonly string $bridgeToken,
#[Autowire('%env(default::MERCURE_URL)%')]
private readonly string $mercureUrl,
#[Autowire('%env(default::MERCURE_PUBLISHER_JWT_KEY)%')]
private readonly string $mercurePublisherKey,
#[Autowire('%env(default::MERCURE_SUBSCRIBER_JWT_KEY)%')]
private readonly string $mercureSubscriberKey,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('connect', null, InputOption::VALUE_NONE, 'Probe the backend over HTTP after the static checks.');
$this->addOption('url', null, InputOption::VALUE_REQUIRED, 'URL to probe when --connect is given.', 'http://127.0.0.1:8765/healthz');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('php-qml bridge — readiness checks');
$checks = [
['PHP version >= 8.3',
PHP_VERSION_ID >= 80300,
'Upgrade PHP to 8.3 or newer; the bundle requires it.'],
['ext-curl available',
extension_loaded('curl'),
'Install the PHP curl extension.'],
['ext-json available',
extension_loaded('json'),
'Install the PHP json extension.'],
['Publisher service wired',
$this->publisher instanceof Publisher,
'Bundle services failed to load — register PhpQml\\Bridge\\BridgeBundle in config/bundles.php.'],
['BRIDGE_TOKEN env set',
$this->bridgeToken !== '',
'Set BRIDGE_TOKEN in .env.local; the Qt host expects this as the bearer token.'],
['MERCURE_URL env set',
$this->mercureUrl !== '',
'Set MERCURE_URL in .env, e.g. http://127.0.0.1:8765/.well-known/mercure.'],
['MERCURE_PUBLISHER_JWT_KEY env set',
$this->mercurePublisherKey !== '',
'Set MERCURE_PUBLISHER_JWT_KEY in .env.local; mercure-bundle uses it to sign publish requests.'],
['MERCURE_SUBSCRIBER_JWT_KEY env set',
$this->mercureSubscriberKey !== '',
'Set MERCURE_SUBSCRIBER_JWT_KEY in .env.local; or rely on the Caddy `anonymous` directive in dev mode.'],
];
$rows = [];
$allPass = true;
foreach ($checks as [$label, $ok, $hint]) {
$rows[] = [
$ok ? '<fg=green>PASS</>' : '<fg=red>FAIL</>',
$label,
$ok ? '' : $hint,
];
$allPass = $allPass && $ok;
}
$io->table(['', 'Check', 'Hint'], $rows);
if ($input->getOption('connect')) {
$url = (string) $input->getOption('url');
$io->section("Probing {$url}");
$probe = $this->probe($url);
if ($probe['ok']) {
$io->success("Backend reachable (HTTP {$probe['status']}).");
} else {
$io->error("Backend probe failed: {$probe['detail']}");
$allPass = false;
}
}
if ($allPass) {
$io->success('All checks passed. Bridge is ready.');
return Command::SUCCESS;
}
$io->warning('Some checks failed — fix the items above before running the bridge.');
return Command::FAILURE;
}
/**
* @return array{ok: bool, status?: int, detail: string}
*/
private function probe(string $url, float $timeoutSeconds = 2.0): array
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
$err = error_get_last()['message'] ?? 'unknown error';
return ['ok' => false, 'detail' => $err];
}
$statusLine = $http_response_header[0] ?? '';
preg_match('#HTTP/\S+\s+(\d+)#', $statusLine, $m);
$status = (int) ($m[1] ?? 0);
return [
'ok' => $status === 200,
'status' => $status,
'detail' => $status === 200 ? '' : "expected 200, got {$status}",
];
}
}