From 8d36d0266e7b18bf5cdf28a6fbc336ba9f5bb54c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 02:18:30 +0000 Subject: [PATCH 1/7] feat: agent-first JSON output contract for CLI commands Add OutputsJson concern with {"ok":true,"data":...} / {"ok":false,"error":"..."} envelopes on stdout, apply across zones/dns/tunnel commands, document the contract, add Pest coverage with Saloon mocks, and wire PHP 8.3 CI. Co-authored-by: Jordan Partridge --- README.md | 6 +- app/Commands/Concerns/OutputsJson.php | 65 +++++++++++++++++++ app/Commands/DnsCreateCommand.php | 29 +++++---- app/Commands/DnsListCommand.php | 29 +++++---- app/Commands/TunnelCreateCommand.php | 55 ++++++++++++---- app/Commands/TunnelDeleteCommand.php | 36 +++++++++-- app/Commands/TunnelListCommand.php | 33 +++++++--- app/Commands/ZonesCommand.php | 24 ++++--- docs/agent-output.md | 37 +++++++++++ tests/Feature/AgentJsonOutputTest.php | 93 +++++++++++++++++++++++++++ 10 files changed, 340 insertions(+), 67 deletions(-) create mode 100644 app/Commands/Concerns/OutputsJson.php create mode 100644 docs/agent-output.md create mode 100644 tests/Feature/AgentJsonOutputTest.php diff --git a/README.md b/README.md index 904c125..e776aca 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 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..2a0280f 100644 --- a/app/Commands/DnsCreateCommand.php +++ b/app/Commands/DnsCreateCommand.php @@ -4,11 +4,14 @@ namespace App\Commands; +use App\Commands\Concerns\OutputsJson; use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class DnsCreateCommand extends Command { + use OutputsJson; + protected $signature = 'dns:create {zone : Zone ID or domain name} {type : Record type (A, AAAA, CNAME, TXT, MX, etc)} @@ -23,15 +26,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 +49,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!'); @@ -82,14 +82,15 @@ protected function resolveZoneId(CloudflareConnector $connector, string $name): return null; } - protected function getConnector(): CloudflareConnector + 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); + $this->jsonFail('CLOUDFLARE_API_TOKEN not set'); + + return null; } return new CloudflareConnector($token, $accountId); diff --git a/app/Commands/DnsListCommand.php b/app/Commands/DnsListCommand.php index af2916e..b77a831 100644 --- a/app/Commands/DnsListCommand.php +++ b/app/Commands/DnsListCommand.php @@ -4,11 +4,14 @@ namespace App\Commands; +use App\Commands\Concerns\OutputsJson; use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class DnsListCommand extends Command { + use OutputsJson; + protected $signature = 'dns:list {zone : Zone ID or domain name} {--type= : Filter by record type (A, CNAME, TXT, etc)} @@ -20,15 +23,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 +43,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)) { @@ -91,14 +91,15 @@ protected function truncate(string $value, int $length): string : $value; } - protected function getConnector(): CloudflareConnector + 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); + $this->jsonFail('CLOUDFLARE_API_TOKEN not set'); + + return null; } return new CloudflareConnector($token, $accountId); diff --git a/app/Commands/TunnelCreateCommand.php b/app/Commands/TunnelCreateCommand.php index 999bcd1..0847271 100644 --- a/app/Commands/TunnelCreateCommand.php +++ b/app/Commands/TunnelCreateCommand.php @@ -4,12 +4,13 @@ namespace App\Commands; -use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; +use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class TunnelCreateCommand extends Command { - use InteractsWithCloudflare; + use OutputsJson; protected $signature = 'tunnel:create {name : Name of the tunnel} @@ -20,31 +21,57 @@ 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') ?? []; + $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(); $this->info('Tunnel created successfully!'); - $this->displayTunnelCredentials($tunnel); - $this->displayCloudflaredRunSteps($tunnel, 'http://localhost:8000'); + $this->table(['Field', 'Value'], [ + ['ID', $tunnel['id']], + ['Name', $tunnel['name']], + ['Status', $tunnel['status'] ?? 'inactive'], + ['Created', $tunnel['created_at'] ?? 'now'], + ]); + + $this->newLine(); + $this->comment('Next steps:'); + $this->line(' 1. Configure ingress rules in ~/.cloudflared/config.yml'); + $this->line(' 2. Run: cloudflared tunnel route dns '.$name.' '); + $this->line(' 3. Start tunnel: cloudflared tunnel run '.$name); return self::SUCCESS; } + + protected function getConnector(): ?CloudflareConnector + { + $token = env('CLOUDFLARE_API_TOKEN'); + $accountId = env('CLOUDFLARE_ACCOUNT_ID'); + + if (! $token || ! $accountId) { + $this->jsonFail('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'); + + return null; + } + + return new CloudflareConnector($token, $accountId); + } } diff --git a/app/Commands/TunnelDeleteCommand.php b/app/Commands/TunnelDeleteCommand.php index eb56ca2..1a9f2db 100644 --- a/app/Commands/TunnelDeleteCommand.php +++ b/app/Commands/TunnelDeleteCommand.php @@ -4,26 +4,36 @@ namespace App\Commands; -use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; +use App\Integrations\Cloudflare\CloudflareConnector; 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,13 +43,29 @@ 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.'); return self::SUCCESS; } + + protected function getConnector(): ?CloudflareConnector + { + $token = env('CLOUDFLARE_API_TOKEN'); + $accountId = env('CLOUDFLARE_ACCOUNT_ID'); + + if (! $token || ! $accountId) { + $this->jsonFail('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'); + + return null; + } + + return new CloudflareConnector($token, $accountId); + } } diff --git a/app/Commands/TunnelListCommand.php b/app/Commands/TunnelListCommand.php index dfe074d..06fcf78 100644 --- a/app/Commands/TunnelListCommand.php +++ b/app/Commands/TunnelListCommand.php @@ -4,12 +4,13 @@ namespace App\Commands; -use App\Commands\Concerns\InteractsWithCloudflare; +use App\Commands\Concerns\OutputsJson; +use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class TunnelListCommand extends Command { - use InteractsWithCloudflare; + use OutputsJson; protected $signature = 'tunnel:list {--json : Output as JSON}'; @@ -19,20 +20,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)) { @@ -54,4 +55,18 @@ public function handle(): int return self::SUCCESS; } + + protected function getConnector(): ?CloudflareConnector + { + $token = env('CLOUDFLARE_API_TOKEN'); + $accountId = env('CLOUDFLARE_ACCOUNT_ID'); + + if (! $token) { + $this->jsonFail('CLOUDFLARE_API_TOKEN not set'); + + return null; + } + + return new CloudflareConnector($token, $accountId); + } } diff --git a/app/Commands/ZonesCommand.php b/app/Commands/ZonesCommand.php index b2c4f16..f83e3c4 100644 --- a/app/Commands/ZonesCommand.php +++ b/app/Commands/ZonesCommand.php @@ -4,11 +4,14 @@ namespace App\Commands; +use App\Commands\Concerns\OutputsJson; use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class ZonesCommand extends Command { + use OutputsJson; + protected $signature = 'zones {--name= : Filter by zone name} {--json : Output as JSON}'; @@ -18,20 +21,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)) { @@ -53,14 +56,15 @@ public function handle(): int return self::SUCCESS; } - protected function getConnector(): CloudflareConnector + 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); + $this->jsonFail('CLOUDFLARE_API_TOKEN not set'); + + return null; } return new CloudflareConnector($token, $accountId); diff --git a/docs/agent-output.md b/docs/agent-output.md new file mode 100644 index 0000000..4431025 --- /dev/null +++ b/docs/agent-output.md @@ -0,0 +1,37 @@ +# 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. +- `data` is the Cloudflare API `result` payload (array or object), or a small command-specific object (e.g. tunnel delete). +- `error` is a human-readable string (may include the API response body). + +## 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 | +| `tunnel:delete` | yes | `{ "deleted": true, "id": "..." }` (or cancelled) | + +## 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 + +Shared helpers live in `App\Commands\Concerns\OutputsJson` (`jsonSuccess()`, `jsonFail()`, `wantsJson()`). diff --git a/tests/Feature/AgentJsonOutputTest.php b/tests/Feature/AgentJsonOutputTest.php new file mode 100644 index 0000000..1b977e0 --- /dev/null +++ b/tests/Feature/AgentJsonOutputTest.php @@ -0,0 +1,93 @@ +toBeArray(); + + return $decoded; +} + +it('returns failure JSON envelope when API token is missing', function () { + putenv('CLOUDFLARE_API_TOKEN'); + putenv('CLOUDFLARE_ACCOUNT_ID'); + $_ENV['CLOUDFLARE_API_TOKEN'] = ''; + $_ENV['CLOUDFLARE_ACCOUNT_ID'] = ''; + unset($_SERVER['CLOUDFLARE_API_TOKEN'], $_SERVER['CLOUDFLARE_ACCOUNT_ID']); + + $status = Artisan::call('zones', ['--json' => true]); + $payload = decodeAgentJson(Artisan::output()); + + expect($status)->toBe(1) + ->and($payload['ok'])->toBeFalse() + ->and($payload['error'])->toContain('CLOUDFLARE_API_TOKEN not set'); +}); + +it('returns success JSON envelope for zones with mocked HTTP', function () { + putenv('CLOUDFLARE_API_TOKEN=test-token'); + putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); + $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; + $_ENV['CLOUDFLARE_ACCOUNT_ID'] = '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['data'])->toBe($zones); +}); + +it('returns failure JSON envelope when Cloudflare API errors', function () { + putenv('CLOUDFLARE_API_TOKEN=test-token'); + putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); + $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; + $_ENV['CLOUDFLARE_ACCOUNT_ID'] = '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['error'])->toContain('Failed to list zones'); +}); From 1036dfb16f0bbdc2431c8c7cc661fdf028f95bef Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Wed, 9 Sep 2026 22:54:46 -0700 Subject: [PATCH 2/7] fix: keep tunnel:create credential display with JSON contract Human mode still prints create-time token and cloudflared run steps after rebasing onto tunnel:expose. --- app/Commands/TunnelCreateCommand.php | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/app/Commands/TunnelCreateCommand.php b/app/Commands/TunnelCreateCommand.php index 0847271..920cb0e 100644 --- a/app/Commands/TunnelCreateCommand.php +++ b/app/Commands/TunnelCreateCommand.php @@ -4,12 +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 TunnelCreateCommand extends Command { + use InteractsWithCloudflare { + getConnector as private unusedTraitGetConnector; + } use OutputsJson; protected $signature = 'tunnel:create @@ -37,7 +41,7 @@ public function handle(): int return $this->jsonFail('Failed to create tunnel: '.$response->body()); } - $tunnel = $response->json('result'); + $tunnel = $response->json('result') ?? []; if ($this->wantsJson()) { return $this->jsonSuccess($tunnel); @@ -45,18 +49,8 @@ public function handle(): int $this->newLine(); $this->info('Tunnel created successfully!'); - $this->table(['Field', 'Value'], [ - ['ID', $tunnel['id']], - ['Name', $tunnel['name']], - ['Status', $tunnel['status'] ?? 'inactive'], - ['Created', $tunnel['created_at'] ?? 'now'], - ]); - - $this->newLine(); - $this->comment('Next steps:'); - $this->line(' 1. Configure ingress rules in ~/.cloudflared/config.yml'); - $this->line(' 2. Run: cloudflared tunnel route dns '.$name.' '); - $this->line(' 3. Start tunnel: cloudflared tunnel run '.$name); + $this->displayTunnelCredentials($tunnel); + $this->displayCloudflaredRunSteps($tunnel, 'http://localhost:8000'); return self::SUCCESS; } From 6bb9cabd382c3a216a5869ae2f29ca2bc459aad4 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Wed, 9 Sep 2026 23:05:46 -0700 Subject: [PATCH 3/7] fix: use InteractsWithCloudflare for tunnel create/delete JSON Drop the getConnector alias on tunnel:create. Trait now returns null instead of exit(1) so --json can emit the envelope. Cover cancelled tunnel:delete JSON. --- .../Concerns/InteractsWithCloudflare.php | 13 ++++++++++--- app/Commands/TunnelCreateCommand.php | 19 +------------------ app/Commands/TunnelDeleteCommand.php | 17 ++--------------- app/Commands/TunnelExposeCommand.php | 4 ++++ tests/Feature/AgentJsonOutputTest.php | 19 +++++++++++++++++++ 5 files changed, 36 insertions(+), 36 deletions(-) diff --git a/app/Commands/Concerns/InteractsWithCloudflare.php b/app/Commands/Concerns/InteractsWithCloudflare.php index ce40704..d879ba2 100644 --- a/app/Commands/Concerns/InteractsWithCloudflare.php +++ b/app/Commands/Concerns/InteractsWithCloudflare.php @@ -8,14 +8,21 @@ trait InteractsWithCloudflare { - protected function getConnector(): CloudflareConnector + 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); + $message = 'CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'; + + if (method_exists($this, 'jsonFail')) { + $this->jsonFail($message); + } else { + $this->error($message); + } + + return null; } return new CloudflareConnector($token, $accountId); diff --git a/app/Commands/TunnelCreateCommand.php b/app/Commands/TunnelCreateCommand.php index 920cb0e..a534808 100644 --- a/app/Commands/TunnelCreateCommand.php +++ b/app/Commands/TunnelCreateCommand.php @@ -6,14 +6,11 @@ use App\Commands\Concerns\InteractsWithCloudflare; use App\Commands\Concerns\OutputsJson; -use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class TunnelCreateCommand extends Command { - use InteractsWithCloudflare { - getConnector as private unusedTraitGetConnector; - } + use InteractsWithCloudflare; use OutputsJson; protected $signature = 'tunnel:create @@ -54,18 +51,4 @@ public function handle(): int return self::SUCCESS; } - - protected function getConnector(): ?CloudflareConnector - { - $token = env('CLOUDFLARE_API_TOKEN'); - $accountId = env('CLOUDFLARE_ACCOUNT_ID'); - - if (! $token || ! $accountId) { - $this->jsonFail('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'); - - return null; - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/TunnelDeleteCommand.php b/app/Commands/TunnelDeleteCommand.php index 1a9f2db..4b07623 100644 --- a/app/Commands/TunnelDeleteCommand.php +++ b/app/Commands/TunnelDeleteCommand.php @@ -4,12 +4,13 @@ namespace App\Commands; +use App\Commands\Concerns\InteractsWithCloudflare; use App\Commands\Concerns\OutputsJson; -use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class TunnelDeleteCommand extends Command { + use InteractsWithCloudflare; use OutputsJson; protected $signature = 'tunnel:delete @@ -54,18 +55,4 @@ public function handle(): int return self::SUCCESS; } - - protected function getConnector(): ?CloudflareConnector - { - $token = env('CLOUDFLARE_API_TOKEN'); - $accountId = env('CLOUDFLARE_ACCOUNT_ID'); - - if (! $token || ! $accountId) { - $this->jsonFail('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'); - - return null; - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/TunnelExposeCommand.php b/app/Commands/TunnelExposeCommand.php index 8c48d84..c4da6ac 100644 --- a/app/Commands/TunnelExposeCommand.php +++ b/app/Commands/TunnelExposeCommand.php @@ -22,6 +22,10 @@ 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'); diff --git a/tests/Feature/AgentJsonOutputTest.php b/tests/Feature/AgentJsonOutputTest.php index 1b977e0..ae81f19 100644 --- a/tests/Feature/AgentJsonOutputTest.php +++ b/tests/Feature/AgentJsonOutputTest.php @@ -70,6 +70,25 @@ function decodeAgentJson(string $output): array ->and($payload['data'])->toBe($zones); }); +it('returns cancelled JSON envelope when tunnel:delete is not confirmed', function () { + putenv('CLOUDFLARE_API_TOKEN=test-token'); + putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); + $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; + $_ENV['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; + + $status = Artisan::call('tunnel:delete', [ + 'id' => 'tun-1', + '--json' => true, + '--no-interaction' => true, + ]); + $output = Artisan::output(); + + expect($status)->toBe(0) + ->and($output)->toContain('"ok": true') + ->and($output)->toContain('"cancelled": true') + ->and($output)->toContain('"deleted": false'); +}); + it('returns failure JSON envelope when Cloudflare API errors', function () { putenv('CLOUDFLARE_API_TOKEN=test-token'); putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); From cf783d362d9311b6e8f61264fb523b425444a2d7 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:35:08 -0700 Subject: [PATCH 4/7] test: decode cancelled tunnel:delete JSON envelope Prove --json --no-interaction returns {ok:true,data:{deleted:false,cancelled:true}} as JSON, not substring matches. --- tests/Feature/AgentJsonOutputTest.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/Feature/AgentJsonOutputTest.php b/tests/Feature/AgentJsonOutputTest.php index ae81f19..e572255 100644 --- a/tests/Feature/AgentJsonOutputTest.php +++ b/tests/Feature/AgentJsonOutputTest.php @@ -75,18 +75,23 @@ function decodeAgentJson(string $output): array putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; $_ENV['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; + $_SERVER['CLOUDFLARE_API_TOKEN'] = 'test-token'; + $_SERVER['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; $status = Artisan::call('tunnel:delete', [ 'id' => 'tun-1', '--json' => true, '--no-interaction' => true, ]); - $output = Artisan::output(); + $payload = decodeAgentJson(Artisan::output()); expect($status)->toBe(0) - ->and($output)->toContain('"ok": true') - ->and($output)->toContain('"cancelled": true') - ->and($output)->toContain('"deleted": false'); + ->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 () { From 4ca832533a600db1353a84b764f15de2437e0b59 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:36:02 -0700 Subject: [PATCH 5/7] spec: lock agent JSON envelope for PR #7 --json stdout is {ok,data} / {ok,error} exit 0/1. InteractsWithCloudflare + OutputsJson; no CloudflareCommand; no exit(1). --- specs/agent-json-envelope/spec.md | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 specs/agent-json-envelope/spec.md 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 From 9170534110e807ba13c0d7a341cdc0941d3f385b Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:37:43 -0700 Subject: [PATCH 6/7] test: lock agent JSON proves to SPEC error string and expose envelope Missing token must be CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set. Cancelled tunnel:delete --json --no-interaction stays ok:true exit 0. tunnel:expose --json wraps the existing object so token/url toContain stays green. --- tests/Feature/AgentJsonOutputTest.php | 105 +++++++++++++++++++++----- 1 file changed, 85 insertions(+), 20 deletions(-) diff --git a/tests/Feature/AgentJsonOutputTest.php b/tests/Feature/AgentJsonOutputTest.php index e572255..c586d24 100644 --- a/tests/Feature/AgentJsonOutputTest.php +++ b/tests/Feature/AgentJsonOutputTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Integrations\Cloudflare\Requests\Tunnels\CreateTunnel; use App\Integrations\Cloudflare\Requests\Zones\ListZones; use Illuminate\Support\Facades\Artisan; use Saloon\Http\Faking\MockClient; @@ -23,26 +24,44 @@ function decodeAgentJson(string $output): array 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 () { - putenv('CLOUDFLARE_API_TOKEN'); - putenv('CLOUDFLARE_ACCOUNT_ID'); - $_ENV['CLOUDFLARE_API_TOKEN'] = ''; - $_ENV['CLOUDFLARE_ACCOUNT_ID'] = ''; - unset($_SERVER['CLOUDFLARE_API_TOKEN'], $_SERVER['CLOUDFLARE_ACCOUNT_ID']); + setCloudflareTestEnv(null, null); $status = Artisan::call('zones', ['--json' => true]); $payload = decodeAgentJson(Artisan::output()); expect($status)->toBe(1) ->and($payload['ok'])->toBeFalse() - ->and($payload['error'])->toContain('CLOUDFLARE_API_TOKEN not set'); + ->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 () { - putenv('CLOUDFLARE_API_TOKEN=test-token'); - putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); - $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; - $_ENV['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; + setCloudflareTestEnv('test-token', 'test-account'); $zones = [ [ @@ -67,16 +86,12 @@ function decodeAgentJson(string $output): array 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 () { - putenv('CLOUDFLARE_API_TOKEN=test-token'); - putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); - $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; - $_ENV['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; - $_SERVER['CLOUDFLARE_API_TOKEN'] = 'test-token'; - $_SERVER['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; + setCloudflareTestEnv('test-token', 'test-account'); $status = Artisan::call('tunnel:delete', [ 'id' => 'tun-1', @@ -95,10 +110,7 @@ function decodeAgentJson(string $output): array }); it('returns failure JSON envelope when Cloudflare API errors', function () { - putenv('CLOUDFLARE_API_TOKEN=test-token'); - putenv('CLOUDFLARE_ACCOUNT_ID=test-account'); - $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; - $_ENV['CLOUDFLARE_ACCOUNT_ID'] = 'test-account'; + setCloudflareTestEnv('test-token', 'test-account'); MockClient::global([ ListZones::class => MockResponse::make([ @@ -113,5 +125,58 @@ function decodeAgentJson(string $output): array 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.'"'); +}); From 9124ed1347574fadae3f1822f6bb7cf8945629ff Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:40:33 -0700 Subject: [PATCH 7/7] fix: wire JSON commands onto InteractsWithCloudflare Drop duplicated getConnector on zones, dns, and tunnel:list. Wrap tunnel:expose --json in the agent envelope. getConnector stays nullable and never exits. Human tunnel:create still prints the create-time token. --- README.md | 2 +- .../Concerns/InteractsWithCloudflare.php | 25 +++++++++++++------ app/Commands/DnsCreateCommand.php | 16 ++---------- app/Commands/DnsListCommand.php | 16 ++---------- app/Commands/TunnelExposeCommand.php | 17 ++++++------- app/Commands/TunnelListCommand.php | 17 ++----------- app/Commands/ZonesCommand.php | 17 ++----------- docs/agent-output.md | 15 ++++++++--- 8 files changed, 45 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index e776aca..fa5fd3f 100644 --- a/README.md +++ b/README.md @@ -86,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 d879ba2..de291e1 100644 --- a/app/Commands/Concerns/InteractsWithCloudflare.php +++ b/app/Commands/Concerns/InteractsWithCloudflare.php @@ -8,19 +8,17 @@ trait InteractsWithCloudflare { + /** + * 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) { - $message = 'CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'; - - if (method_exists($this, 'jsonFail')) { - $this->jsonFail($message); - } else { - $this->error($message); - } + $this->reportMissingCloudflareCredentials(); return null; } @@ -28,6 +26,19 @@ protected function getConnector(): ?CloudflareConnector 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/DnsCreateCommand.php b/app/Commands/DnsCreateCommand.php index 2a0280f..6cc96ed 100644 --- a/app/Commands/DnsCreateCommand.php +++ b/app/Commands/DnsCreateCommand.php @@ -4,12 +4,14 @@ 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 @@ -81,18 +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->jsonFail('CLOUDFLARE_API_TOKEN not set'); - - return null; - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/DnsListCommand.php b/app/Commands/DnsListCommand.php index b77a831..21e094b 100644 --- a/app/Commands/DnsListCommand.php +++ b/app/Commands/DnsListCommand.php @@ -4,12 +4,14 @@ 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 @@ -90,18 +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->jsonFail('CLOUDFLARE_API_TOKEN not set'); - - return null; - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/TunnelExposeCommand.php b/app/Commands/TunnelExposeCommand.php index c4da6ac..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} @@ -29,18 +31,15 @@ public function handle(): int $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') ?? []; @@ -89,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, @@ -99,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 06fcf78..b57e510 100644 --- a/app/Commands/TunnelListCommand.php +++ b/app/Commands/TunnelListCommand.php @@ -4,12 +4,13 @@ namespace App\Commands; +use App\Commands\Concerns\InteractsWithCloudflare; use App\Commands\Concerns\OutputsJson; -use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class TunnelListCommand extends Command { + use InteractsWithCloudflare; use OutputsJson; protected $signature = 'tunnel:list @@ -55,18 +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->jsonFail('CLOUDFLARE_API_TOKEN not set'); - - return null; - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/app/Commands/ZonesCommand.php b/app/Commands/ZonesCommand.php index f83e3c4..57231c9 100644 --- a/app/Commands/ZonesCommand.php +++ b/app/Commands/ZonesCommand.php @@ -4,12 +4,13 @@ namespace App\Commands; +use App\Commands\Concerns\InteractsWithCloudflare; use App\Commands\Concerns\OutputsJson; -use App\Integrations\Cloudflare\CloudflareConnector; use LaravelZero\Framework\Commands\Command; class ZonesCommand extends Command { + use InteractsWithCloudflare; use OutputsJson; protected $signature = 'zones @@ -55,18 +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->jsonFail('CLOUDFLARE_API_TOKEN not set'); - - return null; - } - - return new CloudflareConnector($token, $accountId); - } } diff --git a/docs/agent-output.md b/docs/agent-output.md index 4431025..ed4f220 100644 --- a/docs/agent-output.md +++ b/docs/agent-output.md @@ -10,8 +10,10 @@ Conduit agents should pass `--json` on Cloudflare CLI commands and parse a stabl | Failure | `1` (`FAILURE`) | `{"ok":false,"error":"..."}` | - Pretty-printed JSON (`JSON_PRETTY_PRINT`), unescaped slashes. -- `data` is the Cloudflare API `result` payload (array or object), or a small command-specific object (e.g. tunnel delete). +- 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 @@ -21,8 +23,11 @@ Conduit agents should pass `--json` on Cloudflare CLI commands and parse a stabl | `dns:list` | yes | DNS record list | | `dns:create` | yes | created record | | `tunnel:list` | yes | tunnel list | -| `tunnel:create` | yes | created tunnel | -| `tunnel:delete` | yes | `{ "deleted": true, "id": "..." }` (or cancelled) | +| `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 @@ -34,4 +39,6 @@ Conduit agents should pass `--json` on Cloudflare CLI commands and parse a stabl ## Implementation -Shared helpers live in `App\Commands\Concerns\OutputsJson` (`jsonSuccess()`, `jsonFail()`, `wantsJson()`). +- 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.