Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
24 changes: 21 additions & 3 deletions app/Commands/Concerns/InteractsWithCloudflare.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
65 changes: 65 additions & 0 deletions app/Commands/Concerns/OutputsJson.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace App\Commands\Concerns;

/**
* Agent-first JSON envelope for CLI commands.
*
* Contract (when --json is set) — both envelopes go to stdout:
* - Success → {"ok":true,"data":...}
* - Failure → {"ok":false,"error":"..."}
*
* Always check the process exit code (0 = SUCCESS, 1 = FAILURE).
* Human (non-JSON) mode is unchanged: tables/info on stdout, errors via $this->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<string, mixed> $payload
*/
protected function encodeEnvelope(array $payload): string
{
return json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{"ok":false,"error":"Failed to encode JSON"}';
}
}
37 changes: 13 additions & 24 deletions app/Commands/DnsCreateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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;
}
Expand All @@ -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!');
Expand All @@ -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);
}
}
37 changes: 13 additions & 24 deletions app/Commands/DnsListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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;
}
Expand All @@ -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)) {
Expand Down Expand Up @@ -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);
}
}
22 changes: 13 additions & 9 deletions app/Commands/TunnelCreateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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();
Expand Down
19 changes: 16 additions & 3 deletions app/Commands/TunnelDeleteCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.');
Expand Down
Loading
Loading