diff --git a/README.md b/README.md index 904c125..fa5fd3f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,16 @@ Get credentials from: | `dns:*` | DNS:Edit | | `tunnel:*` | Cloudflare Tunnel:Edit | +## Agent JSON output + +Pass `--json` for a stable envelope on stdout: `{"ok":true,"data":...}` on success, `{"ok":false,"error":"..."}` on failure (exit `0` / `1`). See [docs/agent-output.md](docs/agent-output.md). + ## Commands ### Zones ```bash ./cf zones # List all zones -./cf zones --json # JSON output +./cf zones --json # Agent JSON envelope ``` ### DNS Records @@ -82,7 +86,7 @@ If DNS was not created via API: cloudflared tunnel route dns my-app app.example.com ``` -`tunnel:create` masks secrets in the summary table and prints full token / credentials once. Use `--json` for the raw API result. +`tunnel:create` masks secrets in the summary table and prints full token / credentials once. Use `--json` for the agent envelope (`{"ok":true,"data":...}`). ## Architecture diff --git a/app/Commands/Concerns/InteractsWithCloudflare.php b/app/Commands/Concerns/InteractsWithCloudflare.php index ce40704..de291e1 100644 --- a/app/Commands/Concerns/InteractsWithCloudflare.php +++ b/app/Commands/Concerns/InteractsWithCloudflare.php @@ -8,19 +8,37 @@ trait InteractsWithCloudflare { - protected function getConnector(): CloudflareConnector + /** + * Resolve the Cloudflare connector from the environment. + * Prints the missing-creds error once, returns null, never exits. + */ + protected function getConnector(): ?CloudflareConnector { $token = env('CLOUDFLARE_API_TOKEN'); $accountId = env('CLOUDFLARE_ACCOUNT_ID'); if (! $token || ! $accountId) { - $this->error('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'); - exit(1); + $this->reportMissingCloudflareCredentials(); + + return null; } return new CloudflareConnector($token, $accountId); } + protected function reportMissingCloudflareCredentials(): void + { + $message = 'CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'; + + if (in_array('jsonFail', get_class_methods($this), true)) { + $this->jsonFail($message); + + return; + } + + $this->error($message); + } + protected function maskSecret(string $value): string { $length = strlen($value); diff --git a/app/Commands/Concerns/OutputsJson.php b/app/Commands/Concerns/OutputsJson.php new file mode 100644 index 0000000..9f45b91 --- /dev/null +++ b/app/Commands/Concerns/OutputsJson.php @@ -0,0 +1,65 @@ +error(). + * + * @see docs/agent-output.md + */ +trait OutputsJson +{ + protected function wantsJson(): bool + { + return (bool) $this->option('json'); + } + + /** + * Emit a success envelope to stdout and return SUCCESS. + */ + protected function jsonSuccess(mixed $data): int + { + $this->line($this->encodeEnvelope([ + 'ok' => true, + 'data' => $data, + ])); + + return self::SUCCESS; + } + + /** + * Emit a failure envelope to stdout when --json, else human error; return FAILURE. + */ + protected function jsonFail(string $message): int + { + if ($this->wantsJson()) { + $this->line($this->encodeEnvelope([ + 'ok' => false, + 'error' => $message, + ])); + + return self::FAILURE; + } + + $this->error($message); + + return self::FAILURE; + } + + /** + * @param array $payload + */ + protected function encodeEnvelope(array $payload): string + { + return json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{"ok":false,"error":"Failed to encode JSON"}'; + } +} diff --git a/app/Commands/DnsCreateCommand.php b/app/Commands/DnsCreateCommand.php index 58d657c..6cc96ed 100644 --- a/app/Commands/DnsCreateCommand.php +++ b/app/Commands/DnsCreateCommand.php @@ -4,11 +4,16 @@ namespace App\Commands; +use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class DnsCreateCommand extends Command { + use InteractsWithCloudflare; + use OutputsJson; + protected $signature = 'dns:create {zone : Zone ID or domain name} {type : Record type (A, AAAA, CNAME, TXT, MX, etc)} @@ -23,15 +28,16 @@ class DnsCreateCommand extends Command public function handle(): int { $connector = $this->getConnector(); + if ($connector === null) { + return self::FAILURE; + } + $zone = $this->argument('zone'); - // If zone looks like a domain, resolve it to ID if (! preg_match('/^[a-f0-9]{32}$/', $zone)) { $zoneId = $this->resolveZoneId($connector, $zone); if (! $zoneId) { - $this->error("Zone not found: {$zone}"); - - return self::FAILURE; + return $this->jsonFail("Zone not found: {$zone}"); } $zone = $zoneId; } @@ -45,17 +51,13 @@ public function handle(): int ); if (! $response->successful()) { - $this->error('Failed to create DNS record: '.$response->body()); - - return self::FAILURE; + return $this->jsonFail('Failed to create DNS record: '.$response->body()); } $record = $response->json('result'); - if ($this->option('json')) { - $this->line(json_encode($record, JSON_PRETTY_PRINT)); - - return self::SUCCESS; + if ($this->wantsJson()) { + return $this->jsonSuccess($record); } $this->info('DNS record created successfully!'); @@ -81,17 +83,4 @@ protected function resolveZoneId(CloudflareConnector $connector, string $name): return null; } - - protected function getConnector(): CloudflareConnector - { - $token = env('CLOUDFLARE_API_TOKEN'); - $accountId = env('CLOUDFLARE_ACCOUNT_ID'); - - if (! $token) { - $this->error('CLOUDFLARE_API_TOKEN not set'); - exit(1); - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/DnsListCommand.php b/app/Commands/DnsListCommand.php index af2916e..21e094b 100644 --- a/app/Commands/DnsListCommand.php +++ b/app/Commands/DnsListCommand.php @@ -4,11 +4,16 @@ namespace App\Commands; +use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class DnsListCommand extends Command { + use InteractsWithCloudflare; + use OutputsJson; + protected $signature = 'dns:list {zone : Zone ID or domain name} {--type= : Filter by record type (A, CNAME, TXT, etc)} @@ -20,15 +25,16 @@ class DnsListCommand extends Command public function handle(): int { $connector = $this->getConnector(); + if ($connector === null) { + return self::FAILURE; + } + $zone = $this->argument('zone'); - // If zone looks like a domain, resolve it to ID if (! preg_match('/^[a-f0-9]{32}$/', $zone)) { $zoneId = $this->resolveZoneId($connector, $zone); if (! $zoneId) { - $this->error("Zone not found: {$zone}"); - - return self::FAILURE; + return $this->jsonFail("Zone not found: {$zone}"); } $zone = $zoneId; } @@ -39,17 +45,13 @@ public function handle(): int ); if (! $response->successful()) { - $this->error('Failed to list DNS records: '.$response->body()); - - return self::FAILURE; + return $this->jsonFail('Failed to list DNS records: '.$response->body()); } $records = $response->json('result', []); - if ($this->option('json')) { - $this->line(json_encode($records, JSON_PRETTY_PRINT)); - - return self::SUCCESS; + if ($this->wantsJson()) { + return $this->jsonSuccess($records); } if (empty($records)) { @@ -90,17 +92,4 @@ protected function truncate(string $value, int $length): string ? substr($value, 0, $length - 3).'...' : $value; } - - protected function getConnector(): CloudflareConnector - { - $token = env('CLOUDFLARE_API_TOKEN'); - $accountId = env('CLOUDFLARE_ACCOUNT_ID'); - - if (! $token) { - $this->error('CLOUDFLARE_API_TOKEN not set'); - exit(1); - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/TunnelCreateCommand.php b/app/Commands/TunnelCreateCommand.php index 999bcd1..a534808 100644 --- a/app/Commands/TunnelCreateCommand.php +++ b/app/Commands/TunnelCreateCommand.php @@ -5,11 +5,13 @@ namespace App\Commands; use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use LaravelZero\Framework\Commands\Command; class TunnelCreateCommand extends Command { use InteractsWithCloudflare; + use OutputsJson; protected $signature = 'tunnel:create {name : Name of the tunnel} @@ -20,24 +22,26 @@ class TunnelCreateCommand extends Command public function handle(): int { $connector = $this->getConnector(); - $name = (string) $this->argument('name'); + if ($connector === null) { + return self::FAILURE; + } - $this->info("Creating tunnel: {$name}..."); + $name = $this->argument('name'); + + if (! $this->wantsJson()) { + $this->info("Creating tunnel: {$name}..."); + } $response = $connector->tunnels()->create($name); if (! $response->successful()) { - $this->error('Failed to create tunnel: '.$response->body()); - - return self::FAILURE; + return $this->jsonFail('Failed to create tunnel: '.$response->body()); } $tunnel = $response->json('result') ?? []; - if ($this->option('json')) { - $this->line(json_encode($tunnel, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return self::SUCCESS; + if ($this->wantsJson()) { + return $this->jsonSuccess($tunnel); } $this->newLine(); diff --git a/app/Commands/TunnelDeleteCommand.php b/app/Commands/TunnelDeleteCommand.php index eb56ca2..4b07623 100644 --- a/app/Commands/TunnelDeleteCommand.php +++ b/app/Commands/TunnelDeleteCommand.php @@ -5,25 +5,36 @@ namespace App\Commands; use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use LaravelZero\Framework\Commands\Command; class TunnelDeleteCommand extends Command { use InteractsWithCloudflare; + use OutputsJson; protected $signature = 'tunnel:delete {id : Tunnel ID to delete} - {--force : Skip confirmation}'; + {--force : Skip confirmation} + {--json : Output as JSON}'; protected $description = 'Delete a Cloudflare tunnel'; public function handle(): int { $connector = $this->getConnector(); + if ($connector === null) { + return self::FAILURE; + } + $tunnelId = $this->argument('id'); if (! $this->option('force')) { if (! $this->confirm("Delete tunnel {$tunnelId}?")) { + if ($this->wantsJson()) { + return $this->jsonSuccess(['deleted' => false, 'cancelled' => true]); + } + $this->info('Cancelled.'); return self::SUCCESS; @@ -33,9 +44,11 @@ public function handle(): int $response = $connector->tunnels()->delete($tunnelId); if (! $response->successful()) { - $this->error('Failed to delete tunnel: '.$response->body()); + return $this->jsonFail('Failed to delete tunnel: '.$response->body()); + } - return self::FAILURE; + if ($this->wantsJson()) { + return $this->jsonSuccess(['deleted' => true, 'id' => $tunnelId]); } $this->info('Tunnel deleted successfully.'); diff --git a/app/Commands/TunnelExposeCommand.php b/app/Commands/TunnelExposeCommand.php index 8c48d84..0c422eb 100644 --- a/app/Commands/TunnelExposeCommand.php +++ b/app/Commands/TunnelExposeCommand.php @@ -5,11 +5,13 @@ namespace App\Commands; use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use LaravelZero\Framework\Commands\Command; class TunnelExposeCommand extends Command { use InteractsWithCloudflare; + use OutputsJson; protected $signature = 'tunnel:expose {name : Name of the tunnel} @@ -22,21 +24,22 @@ class TunnelExposeCommand extends Command public function handle(): int { $connector = $this->getConnector(); + if ($connector === null) { + return self::FAILURE; + } + $name = (string) $this->argument('name'); $hostname = $this->argument('hostname') ? (string) $this->argument('hostname') : null; $url = (string) $this->argument('url'); - $asJson = (bool) $this->option('json'); - if (! $asJson) { + if (! $this->wantsJson()) { $this->info("Creating tunnel \"{$name}\" to expose {$url}..."); } $response = $connector->tunnels()->create($name); if (! $response->successful()) { - $this->error('Failed to create tunnel: '.$response->body()); - - return self::FAILURE; + return $this->jsonFail('Failed to create tunnel: '.$response->body()); } $tunnel = $response->json('result') ?? []; @@ -85,8 +88,8 @@ public function handle(): int } } - if ($asJson) { - $this->line(json_encode([ + if ($this->wantsJson()) { + return $this->jsonSuccess([ 'tunnel' => $tunnel, 'hostname' => $hostname, 'url' => $url, @@ -95,9 +98,7 @@ public function handle(): int 'dns_record' => $dnsRecord, 'configuration' => $configuration, 'dns_route_command' => $dnsRouteCommand, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return self::SUCCESS; + ]); } $this->newLine(); diff --git a/app/Commands/TunnelListCommand.php b/app/Commands/TunnelListCommand.php index dfe074d..b57e510 100644 --- a/app/Commands/TunnelListCommand.php +++ b/app/Commands/TunnelListCommand.php @@ -5,11 +5,13 @@ namespace App\Commands; use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use LaravelZero\Framework\Commands\Command; class TunnelListCommand extends Command { use InteractsWithCloudflare; + use OutputsJson; protected $signature = 'tunnel:list {--json : Output as JSON}'; @@ -19,20 +21,20 @@ class TunnelListCommand extends Command public function handle(): int { $connector = $this->getConnector(); + if ($connector === null) { + return self::FAILURE; + } + $response = $connector->tunnels()->list(); if (! $response->successful()) { - $this->error('Failed to list tunnels: '.$response->body()); - - return self::FAILURE; + return $this->jsonFail('Failed to list tunnels: '.$response->body()); } $tunnels = $response->json('result', []); - if ($this->option('json')) { - $this->line(json_encode($tunnels, JSON_PRETTY_PRINT)); - - return self::SUCCESS; + if ($this->wantsJson()) { + return $this->jsonSuccess($tunnels); } if (empty($tunnels)) { diff --git a/app/Commands/ZonesCommand.php b/app/Commands/ZonesCommand.php index b2c4f16..57231c9 100644 --- a/app/Commands/ZonesCommand.php +++ b/app/Commands/ZonesCommand.php @@ -4,11 +4,15 @@ namespace App\Commands; -use App\Integrations\Cloudflare\CloudflareConnector; +use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; use LaravelZero\Framework\Commands\Command; class ZonesCommand extends Command { + use InteractsWithCloudflare; + use OutputsJson; + protected $signature = 'zones {--name= : Filter by zone name} {--json : Output as JSON}'; @@ -18,20 +22,20 @@ class ZonesCommand extends Command public function handle(): int { $connector = $this->getConnector(); + if ($connector === null) { + return self::FAILURE; + } + $response = $connector->zones()->list($this->option('name')); if (! $response->successful()) { - $this->error('Failed to list zones: '.$response->body()); - - return self::FAILURE; + return $this->jsonFail('Failed to list zones: '.$response->body()); } $zones = $response->json('result', []); - if ($this->option('json')) { - $this->line(json_encode($zones, JSON_PRETTY_PRINT)); - - return self::SUCCESS; + if ($this->wantsJson()) { + return $this->jsonSuccess($zones); } if (empty($zones)) { @@ -52,17 +56,4 @@ public function handle(): int return self::SUCCESS; } - - protected function getConnector(): CloudflareConnector - { - $token = env('CLOUDFLARE_API_TOKEN'); - $accountId = env('CLOUDFLARE_ACCOUNT_ID'); - - if (! $token) { - $this->error('CLOUDFLARE_API_TOKEN not set'); - exit(1); - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/docs/agent-output.md b/docs/agent-output.md new file mode 100644 index 0000000..ed4f220 --- /dev/null +++ b/docs/agent-output.md @@ -0,0 +1,44 @@ +# Agent JSON output contract + +Conduit agents should pass `--json` on Cloudflare CLI commands and parse a stable envelope from **stdout**. + +## Envelope + +| Outcome | Exit code | stdout JSON | +|---------|-----------|-------------| +| Success | `0` (`SUCCESS`) | `{"ok":true,"data":...}` | +| Failure | `1` (`FAILURE`) | `{"ok":false,"error":"..."}` | + +- Pretty-printed JSON (`JSON_PRETTY_PRINT`), unescaped slashes. +- Both envelopes go to **stdout** (not stderr). +- `data` is the Cloudflare API `result` payload (array or object), or a small command-specific object (e.g. tunnel delete / expose). +- `error` is a human-readable string (may include the API response body). +- Missing token **or** account id: failure envelope, exit `1`, `error` is exactly `CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set`. `getConnector()` prints that once, returns `null`, and does not `exit()`. + +## Commands + +| Command | `--json` | `data` on success | +|---------|----------|-------------------| +| `zones` | yes | zone list | +| `dns:list` | yes | DNS record list | +| `dns:create` | yes | created record | +| `tunnel:list` | yes | tunnel list | +| `tunnel:create` | yes | created tunnel (includes create-time `token` / `credentials_file` when the API returns them) | +| `tunnel:delete` | yes | `{ "deleted": true, "id": "..." }` — or `{ "deleted": false, "cancelled": true }` (exit `0`) when confirmation is declined | +| `tunnel:expose` | yes | `{ tunnel, hostname, url, ingress_configured, dns_created, dns_record, configuration, dns_route_command }` | + +Human (non-JSON) `tunnel:create` and `tunnel:expose` still print the create-time token and cloudflared run steps. + +## Agent checklist + +1. Always pass `--json`. +2. Check exit code first. +3. Decode stdout as JSON; require `ok` boolean. +4. On `ok: true`, use `data`. On `ok: false`, surface `error`. +5. Do not scrape human table/info output. + +## Implementation + +- Envelopes: `App\Commands\Concerns\OutputsJson` (`jsonSuccess()`, `jsonFail()`, `wantsJson()`). +- Credentials: `App\Commands\Concerns\InteractsWithCloudflare::getConnector()` prints the missing-creds error once, returns `?CloudflareConnector`, and never exits. Commands return `FAILURE` when it is `null`. +- There is no `CloudflareCommand` base class. diff --git a/specs/agent-json-envelope/spec.md b/specs/agent-json-envelope/spec.md new file mode 100644 index 0000000..9070792 --- /dev/null +++ b/specs/agent-json-envelope/spec.md @@ -0,0 +1,82 @@ +# SPEC: Agent JSON envelope (`--json`) + +Status: **LOCKED** +PR: https://github.com/conduit-ui/cloudflare/pull/7 +Repo: `conduit-ui/cloudflare` + +**Story:** Agents pass `--json` and still get a table, a raw Cloudflare `result`, or a process that `exit(1)`s before an envelope. Human mode (no `--json`) already works. The lie is machine stdout, not the tables. + +## Locked + +- `--json` stdout is exactly one envelope: + - success → `{"ok":true,"data":...}` exit `0` + - failure → `{"ok":false,"error":"..."}` exit `1` +- Success has `ok` + `data` only (no `error` key). Failure has `ok` + `error` only (no `data` key). +- Pretty-print: `JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES`. Both envelopes go to **stdout** (`$this->line`). +- Human mode (no `--json`) is unchanged: tables / info / `displayTunnelCredentials` / cloudflared steps. Failures use `$this->error()`. +- Implementation: `InteractsWithCloudflare` + `OutputsJson`. Commands keep extending `LaravelZero\Framework\Commands\Command`. +- **Do not** add `app/Commands/CloudflareCommand.php`. PR #6 is parked; it fights this one (base `connector()` vs trait `getConnector()`, raw `json_encode`, `$this->error` on fail, token-only auth). +- **No `exit(1)`** (or any `exit(`) under `app/Commands`. `getConnector(): ?CloudflareConnector` prints once, returns `null`; `handle()` returns `self::FAILURE`. Do not print the error twice. +- Missing creds string (token **or** account id): `CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set`. +- `jsonFail` / `jsonSuccess` / `wantsJson` live only in `OutputsJson`. Trait `getConnector` calls `jsonFail` when that method exists, else `$this->error`. +- `--json` must not print tables, "Creating tunnel…", "Cancelled.", or mixed prose. + +## Commands (`--json` `data`) + +| Command | `data` on success | Fail | +|---------|-------------------|------| +| `zones` | API `result` (zone list) | list error | +| `dns:list` | API `result` (records) | zone not found / list error | +| `dns:create` | API `result` (record) | zone not found / create error | +| `tunnel:list` | API `result` (tunnels) | list error | +| `tunnel:create` | API `result` (tunnel, including token / credentials_file) | create error | +| `tunnel:delete` | `{deleted:true,id}` or **cancelled** `{deleted:false,cancelled:true}` | delete error | +| `tunnel:expose` | wrap the **current** object (do not invent keys): `tunnel`, `hostname`, `url`, `ingress_configured`, `dns_created`, `dns_record`, `configuration`, `dns_route_command` | create error | + +Cancelled `tunnel:delete` (`--no-interaction` → `confirm()` false, or user says no): **success** envelope, exit `0`. Agents read `data.deleted`, not `ok`. + +Partial `tunnel:expose` (tunnel created, DNS/ingress skipped): **success** envelope, flags stay false. Human warnings stay human-only. + +`inspire` has no `--json`. Out of scope. + +## Shape (build) + +Model: `TunnelCreateCommand` / `TunnelDeleteCommand` (both traits, no local `getConnector`). + +Still wrong on the rebased branch: + +1. `ZonesCommand`, `DnsListCommand`, `DnsCreateCommand`, `TunnelListCommand` — drop the local `getConnector`. `use InteractsWithCloudflare` + `use OutputsJson`. Keep each command's `resolveZoneId` / `truncate` (do not steal PR #6's base class for that). +2. `TunnelExposeCommand` — `use OutputsJson`. Replace the raw `json_encode(...)` with `jsonSuccess($payload)`. Create failure → `jsonFail(...)`. Missing token with `--json` must envelope (today it only `$this->error` because the trait's `jsonFail` is missing). +3. `docs/agent-output.md` must list `tunnel:expose`. README must not say "raw API result" for `--json`. +4. Rebase conflicts: take `jsonFail` / `jsonSuccess`, not `$this->error` + bare `json_encode`. Keep main's quality-runners CI (`#17`). Do not resurrect GHA-as-gate. + +You own: `app/Commands/Concerns/OutputsJson.php`, JSON wiring in `app/Commands/*`, `docs/agent-output.md`. +Do not: merge, deploy, `CloudflareCommand`, Saloon resource rewrites, `--dry-run` / `dns:upsert` / `tunnel:status`. + +## Shape (test) + +You own: `tests/Feature/AgentJsonOutputTest.php` and JSON proves. + +Must cover: + +1. Missing token → `zones --json` → `ok: false`, exit `1`, `error` contains `CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set` (not `CLOUDFLARE_API_TOKEN not set` — that string is the duplicate `getConnector`). +2. Success envelope → mocked `zones --json` → `ok: true`, `data` is the API `result`. +3. `tunnel:delete --json --no-interaction` → `ok: true`, `deleted: false`, `cancelled: true`, exit `0`. +4. API error → `ok: false`, exit `1` (existing zones 401 case). +5. Optional but in-contract: `tunnel:expose --json` decodes as envelope (`ok` + `data.tunnel.token`); do **not** rewrite `TunnelExposeCommandTest` assertions that `toContain` token / `localhost:8000` — wrapping must keep those green. + +`vendor/bin/pest --compact` must stay green with origin/main `TunnelExposeCommandTest` (human create still prints full token + `cloudflared tunnel run --token`). + +## Done when + +- [ ] `rg 'exit\(' app/Commands` is empty +- [ ] `test ! -f app/Commands/CloudflareCommand.php` +- [ ] Every `--json` command uses **both** traits and has **no** local `getConnector` +- [ ] `vendor/bin/pest --compact` green (Agent JSON + tunnel:expose tests) + +## Out of scope + +- PR #6 `CloudflareCommand` / `formatApiError` / token-only connector +- Extra envelope keys (`schema_version`, `command`, `error.code`) +- Minified JSON +- Merging or deploying diff --git a/tests/Feature/AgentJsonOutputTest.php b/tests/Feature/AgentJsonOutputTest.php new file mode 100644 index 0000000..c586d24 --- /dev/null +++ b/tests/Feature/AgentJsonOutputTest.php @@ -0,0 +1,182 @@ +toBeArray(); + + return $decoded; +} + +function setCloudflareTestEnv(?string $token, ?string $accountId): void +{ + if ($token === null || $token === '') { + putenv('CLOUDFLARE_API_TOKEN'); + $_ENV['CLOUDFLARE_API_TOKEN'] = ''; + unset($_SERVER['CLOUDFLARE_API_TOKEN']); + } else { + putenv('CLOUDFLARE_API_TOKEN='.$token); + $_ENV['CLOUDFLARE_API_TOKEN'] = $token; + $_SERVER['CLOUDFLARE_API_TOKEN'] = $token; + } + + if ($accountId === null || $accountId === '') { + putenv('CLOUDFLARE_ACCOUNT_ID'); + $_ENV['CLOUDFLARE_ACCOUNT_ID'] = ''; + unset($_SERVER['CLOUDFLARE_ACCOUNT_ID']); + } else { + putenv('CLOUDFLARE_ACCOUNT_ID='.$accountId); + $_ENV['CLOUDFLARE_ACCOUNT_ID'] = $accountId; + $_SERVER['CLOUDFLARE_ACCOUNT_ID'] = $accountId; + } +} + +it('returns failure JSON envelope when API token is missing', function () { + setCloudflareTestEnv(null, null); + + $status = Artisan::call('zones', ['--json' => true]); + $payload = decodeAgentJson(Artisan::output()); + + expect($status)->toBe(1) + ->and($payload['ok'])->toBeFalse() + ->and($payload)->not->toHaveKey('data') + ->and($payload['error'])->toBe('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set') + ->and($payload['error'])->not->toBe('CLOUDFLARE_API_TOKEN not set'); +}); + +it('returns success JSON envelope for zones with mocked HTTP', function () { + setCloudflareTestEnv('test-token', 'test-account'); + + $zones = [ + [ + 'id' => 'abc123abc123abc123abc123abc123ab', + 'name' => 'example.com', + 'status' => 'active', + 'plan' => ['name' => 'Free'], + ], + ]; + + MockClient::global([ + ListZones::class => MockResponse::make([ + 'success' => true, + 'errors' => [], + 'messages' => [], + 'result' => $zones, + ], 200), + ]); + + $status = Artisan::call('zones', ['--json' => true]); + $payload = decodeAgentJson(Artisan::output()); + + expect($status)->toBe(0) + ->and($payload['ok'])->toBeTrue() + ->and($payload)->not->toHaveKey('error') + ->and($payload['data'])->toBe($zones); +}); + +it('returns cancelled JSON envelope when tunnel:delete is not confirmed', function () { + setCloudflareTestEnv('test-token', 'test-account'); + + $status = Artisan::call('tunnel:delete', [ + 'id' => 'tun-1', + '--json' => true, + '--no-interaction' => true, + ]); + $payload = decodeAgentJson(Artisan::output()); + + expect($status)->toBe(0) + ->and($payload['ok'])->toBeTrue() + ->and($payload)->not->toHaveKey('error') + ->and($payload['data'])->toMatchArray([ + 'deleted' => false, + 'cancelled' => true, + ]); +}); + +it('returns failure JSON envelope when Cloudflare API errors', function () { + setCloudflareTestEnv('test-token', 'test-account'); + + MockClient::global([ + ListZones::class => MockResponse::make([ + 'success' => false, + 'errors' => [['message' => 'Authentication error']], + 'result' => null, + ], 401), + ]); + + $status = Artisan::call('zones', ['--json' => true]); + $payload = decodeAgentJson(Artisan::output()); + + expect($status)->toBe(1) + ->and($payload['ok'])->toBeFalse() + ->and($payload)->not->toHaveKey('data') + ->and($payload['error'])->toContain('Failed to list zones'); +}); + +it('wraps tunnel:expose --json as an envelope around the existing payload', function () { + setCloudflareTestEnv('test-token', 'test-account'); + + $token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo-token'; + + MockClient::global([ + CreateTunnel::class => MockResponse::make([ + 'success' => true, + 'errors' => [], + 'messages' => [], + 'result' => [ + 'id' => 'c1744f8b-faa1-48a4-9e5c-02ac921467fa', + 'name' => 'demo-app', + 'status' => 'inactive', + 'created_at' => '2025-02-18T22:41:43.534395Z', + 'token' => $token, + 'credentials_file' => [ + 'AccountTag' => 'account-tag', + 'TunnelID' => 'c1744f8b-faa1-48a4-9e5c-02ac921467fa', + 'TunnelName' => 'demo-app', + 'TunnelSecret' => 'super-secret-tunnel-value', + ], + ], + ]), + ]); + + $status = Artisan::call('tunnel:expose', [ + 'name' => 'demo-app', + '--json' => true, + ]); + $output = Artisan::output(); + $payload = decodeAgentJson($output); + + expect($status)->toBe(0) + ->and($payload['ok'])->toBeTrue() + ->and($payload)->not->toHaveKey('error') + ->and(array_keys($payload['data']))->toBe([ + 'tunnel', + 'hostname', + 'url', + 'ingress_configured', + 'dns_created', + 'dns_record', + 'configuration', + 'dns_route_command', + ]) + ->and($payload['data']['url'])->toBe('http://localhost:8000') + ->and($payload['data']['tunnel']['token'])->toBe($token) + ->and($output)->toContain('localhost:8000') + ->and($output)->toContain('"token": "'.$token.'"'); +});