From 20ed20bc5d98b31d8cfdbe33accc4f9a717bc239 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 02:17:25 +0000 Subject: [PATCH 1/4] feat: wire orphaned DNS/tunnel API into dns:delete, dns:update, tunnel:get Complete CRUD gaps by adding DnsDeleteCommand and TunnelGetCommand for existing DeleteDnsRecord/GetTunnel requests, plus UpdateDnsRecord (PUT) with DnsUpdateCommand. Includes Pest coverage with Saloon mocks and CI. Co-authored-by: Jordan Partridge --- README.md | 6 + app/Commands/DnsDeleteCommand.php | 90 ++++++++++++ app/Commands/DnsUpdateCommand.php | 100 +++++++++++++ app/Commands/TunnelGetCommand.php | 62 ++++++++ .../Requests/Dns/UpdateDnsRecord.php | 43 ++++++ .../Cloudflare/Resources/DnsResource.php | 20 +++ tests/Feature/DnsTunnelCrudCommandsTest.php | 138 ++++++++++++++++++ 7 files changed, 459 insertions(+) create mode 100644 app/Commands/DnsDeleteCommand.php create mode 100644 app/Commands/DnsUpdateCommand.php create mode 100644 app/Commands/TunnelGetCommand.php create mode 100644 app/Integrations/Cloudflare/Requests/Dns/UpdateDnsRecord.php create mode 100644 tests/Feature/DnsTunnelCrudCommandsTest.php diff --git a/README.md b/README.md index 07fff64..85d7b1f 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,17 @@ Pass `--json` for a stable envelope on stdout: `{"ok":true,"data":...}` on succe ./cf dns:list jordanpartridge.us --type=A # Filter by type ./cf dns:create A api 1.2.3.4 # Create A record ./cf dns:create CNAME www example.com --proxied +./cf dns:update A api 5.6.7.8 # Update record (PUT) +./cf dns:update A api 5.6.7.8 --proxied --ttl=300 --json +./cf dns:delete # Delete record (prompts) +./cf dns:delete --force --json # Skip confirmation ``` ### Tunnels ```bash ./cf tunnel:list # List all tunnels +./cf tunnel:get # Get tunnel details +./cf tunnel:get --json ./cf tunnel:create # Create tunnel (prints token/credentials) ./cf tunnel:expose [hostname] [url] # Create + route + cloudflared steps ./cf tunnel:delete # Delete tunnel diff --git a/app/Commands/DnsDeleteCommand.php b/app/Commands/DnsDeleteCommand.php new file mode 100644 index 0000000..086690f --- /dev/null +++ b/app/Commands/DnsDeleteCommand.php @@ -0,0 +1,90 @@ +getConnector(); + $zone = $this->argument('zone'); + $recordId = $this->argument('id'); + + // 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; + } + $zone = $zoneId; + } + + if (! $this->option('force')) { + if (! $this->confirm("Delete DNS record {$recordId}?")) { + $this->info('Cancelled.'); + + return self::SUCCESS; + } + } + + $response = $connector->dns($zone)->delete($recordId); + + if (! $response->successful()) { + $this->error('Failed to delete DNS record: ' . $response->body()); + + return self::FAILURE; + } + + $result = $response->json('result'); + + if ($this->option('json')) { + $this->line(json_encode($result ?? ['id' => $recordId], JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $this->info('DNS record deleted successfully.'); + + return self::SUCCESS; + } + + protected function resolveZoneId(CloudflareConnector $connector, string $name): ?string + { + $response = $connector->zones()->list($name); + if ($response->successful()) { + $zones = $response->json('result', []); + + return $zones[0]['id'] ?? null; + } + + 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/DnsUpdateCommand.php b/app/Commands/DnsUpdateCommand.php new file mode 100644 index 0000000..8ff0e3f --- /dev/null +++ b/app/Commands/DnsUpdateCommand.php @@ -0,0 +1,100 @@ +getConnector(); + $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; + } + $zone = $zoneId; + } + + $response = $connector->dns($zone)->update( + $this->argument('id'), + $this->argument('type'), + $this->argument('name'), + $this->argument('content'), + (bool) $this->option('proxied'), + (int) $this->option('ttl') + ); + + if (! $response->successful()) { + $this->error('Failed to update DNS record: ' . $response->body()); + + return self::FAILURE; + } + + $record = $response->json('result'); + + if ($this->option('json')) { + $this->line(json_encode($record, JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $this->info('DNS record updated successfully!'); + $this->table(['Field', 'Value'], [ + ['ID', $record['id']], + ['Type', $record['type']], + ['Name', $record['name']], + ['Content', $record['content']], + ['Proxied', $record['proxied'] ? 'Yes' : 'No'], + ['TTL', $record['ttl'] === 1 ? 'Auto' : $record['ttl']], + ]); + + return self::SUCCESS; + } + + protected function resolveZoneId(CloudflareConnector $connector, string $name): ?string + { + $response = $connector->zones()->list($name); + if ($response->successful()) { + $zones = $response->json('result', []); + + return $zones[0]['id'] ?? null; + } + + 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/TunnelGetCommand.php b/app/Commands/TunnelGetCommand.php new file mode 100644 index 0000000..ffb66f5 --- /dev/null +++ b/app/Commands/TunnelGetCommand.php @@ -0,0 +1,62 @@ +getConnector(); + $tunnelId = $this->argument('id'); + + $response = $connector->tunnels()->get($tunnelId); + + if (! $response->successful()) { + $this->error('Failed to get tunnel: ' . $response->body()); + + return self::FAILURE; + } + + $tunnel = $response->json('result'); + + if ($this->option('json')) { + $this->line(json_encode($tunnel, JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $this->table(['Field', 'Value'], [ + ['ID', $tunnel['id']], + ['Name', $tunnel['name']], + ['Status', $tunnel['status'] ?? 'unknown'], + ['Created', $tunnel['created_at'] ?? ''], + ['Connections', count($tunnel['connections'] ?? [])], + ]); + + return self::SUCCESS; + } + + 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); + } + + return new CloudflareConnector($token, $accountId); + } +} diff --git a/app/Integrations/Cloudflare/Requests/Dns/UpdateDnsRecord.php b/app/Integrations/Cloudflare/Requests/Dns/UpdateDnsRecord.php new file mode 100644 index 0000000..837c877 --- /dev/null +++ b/app/Integrations/Cloudflare/Requests/Dns/UpdateDnsRecord.php @@ -0,0 +1,43 @@ +zoneId}/dns_records/{$this->recordId}"; + } + + protected function defaultBody(): array + { + return [ + 'type' => $this->type, + 'name' => $this->name, + 'content' => $this->content, + 'proxied' => $this->proxied, + 'ttl' => $this->ttl, + ]; + } +} diff --git a/app/Integrations/Cloudflare/Resources/DnsResource.php b/app/Integrations/Cloudflare/Resources/DnsResource.php index bdbe5eb..c047f09 100644 --- a/app/Integrations/Cloudflare/Resources/DnsResource.php +++ b/app/Integrations/Cloudflare/Resources/DnsResource.php @@ -8,6 +8,7 @@ use App\Integrations\Cloudflare\Requests\Dns\CreateDnsRecord; use App\Integrations\Cloudflare\Requests\Dns\DeleteDnsRecord; use App\Integrations\Cloudflare\Requests\Dns\ListDnsRecords; +use App\Integrations\Cloudflare\Requests\Dns\UpdateDnsRecord; use Saloon\Http\Response; class DnsResource @@ -43,6 +44,25 @@ public function create( )); } + public function update( + string $recordId, + string $type, + string $name, + string $content, + bool $proxied = false, + int $ttl = 1 + ): Response { + return $this->connector->send(new UpdateDnsRecord( + $this->zoneId, + $recordId, + $type, + $name, + $content, + $proxied, + $ttl + )); + } + public function delete(string $recordId): Response { return $this->connector->send(new DeleteDnsRecord( diff --git a/tests/Feature/DnsTunnelCrudCommandsTest.php b/tests/Feature/DnsTunnelCrudCommandsTest.php new file mode 100644 index 0000000..3420406 --- /dev/null +++ b/tests/Feature/DnsTunnelCrudCommandsTest.php @@ -0,0 +1,138 @@ +artisan('list') + ->expectsOutputToContain('dns:delete') + ->expectsOutputToContain('dns:update') + ->expectsOutputToContain('tunnel:get') + ->assertSuccessful(); +}); + +it('deletes a dns record with --force and --json', 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'; + + $zoneId = 'abc123def456abc123def456abc123de'; + $recordId = 'rec11122233344455566677788899900'; + + MockClient::global([ + DeleteDnsRecord::class => MockResponse::make([ + 'success' => true, + 'result' => ['id' => $recordId], + ], 200), + ]); + + $this->artisan('dns:delete', [ + 'zone' => $zoneId, + 'id' => $recordId, + '--force' => true, + '--json' => true, + ]) + ->expectsOutputToContain($recordId) + ->assertSuccessful(); +}); + +it('updates a dns record and prints a table', function () { + putenv('CLOUDFLARE_API_TOKEN=test-token'); + $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; + + $zoneId = 'abc123def456abc123def456abc123de'; + $recordId = 'rec11122233344455566677788899900'; + + MockClient::global([ + UpdateDnsRecord::class => MockResponse::make([ + 'success' => true, + 'result' => [ + 'id' => $recordId, + 'type' => 'A', + 'name' => 'api.example.com', + 'content' => '5.6.7.8', + 'proxied' => false, + 'ttl' => 1, + ], + ], 200), + ]); + + $this->artisan('dns:update', [ + 'zone' => $zoneId, + 'id' => $recordId, + 'type' => 'A', + 'name' => 'api', + 'content' => '5.6.7.8', + ]) + ->expectsOutputToContain('DNS record updated successfully!') + ->expectsOutputToContain($recordId) + ->assertSuccessful(); +}); + +it('gets a tunnel and supports --json', 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'; + + $tunnelId = 'tun-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + MockClient::global([ + GetTunnel::class => MockResponse::make([ + 'success' => true, + 'result' => [ + 'id' => $tunnelId, + 'name' => 'my-app', + 'status' => 'healthy', + 'created_at' => '2024-01-15T12:00:00Z', + 'connections' => [['id' => 'c1']], + ], + ], 200), + ]); + + $this->artisan('tunnel:get', [ + 'id' => $tunnelId, + '--json' => true, + ])->assertSuccessful(); +}); + +it('builds update, delete, and get request endpoints', function () { + $update = new UpdateDnsRecord('zone1', 'rec1', 'A', 'api', '1.2.3.4', true, 300); + expect($update->resolveEndpoint())->toBe('/zones/zone1/dns_records/rec1'); + + $delete = new DeleteDnsRecord('zone1', 'rec1'); + expect($delete->resolveEndpoint())->toBe('/zones/zone1/dns_records/rec1'); + + $get = new GetTunnel('acct1', 'tun1'); + expect($get->resolveEndpoint())->toBe('/accounts/acct1/cfd_tunnel/tun1'); +}); + +it('wires DnsResource::update through the connector', function () { + $mock = new MockClient([ + UpdateDnsRecord::class => MockResponse::make([ + 'success' => true, + 'result' => ['id' => 'rec1'], + ], 200), + ]); + + $connector = new CloudflareConnector('token', 'account'); + $connector->withMockClient($mock); + + $response = $connector->dns('zone1')->update('rec1', 'A', 'api', '9.9.9.9'); + + expect($response->successful())->toBeTrue() + ->and($response->json('result.id'))->toBe('rec1'); + + $mock->assertSent(UpdateDnsRecord::class); +}); From c6c753d3f3c33fa605a96a95e5444f6a4ec3dd6f Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:33:54 -0700 Subject: [PATCH 2/4] test: prove dns/tunnel crud register, force-delete json, update table, get json Decode --json payloads for dns:delete --force and tunnel:get, and assert dns:update table fields. Keep expose coverage in the Pest run. --- tests/Feature/DnsTunnelCrudCommandsTest.php | 111 +++++++++++++++----- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/tests/Feature/DnsTunnelCrudCommandsTest.php b/tests/Feature/DnsTunnelCrudCommandsTest.php index 3420406..d174bcd 100644 --- a/tests/Feature/DnsTunnelCrudCommandsTest.php +++ b/tests/Feature/DnsTunnelCrudCommandsTest.php @@ -6,13 +6,50 @@ use App\Integrations\Cloudflare\Requests\Dns\DeleteDnsRecord; use App\Integrations\Cloudflare\Requests\Dns\UpdateDnsRecord; use App\Integrations\Cloudflare\Requests\Tunnels\GetTunnel; +use Illuminate\Support\Facades\Artisan; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +beforeEach(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'; + + MockClient::destroyGlobal(); +}); + afterEach(function () { MockClient::destroyGlobal(); }); +function decodeCommandJson(string $output): array +{ + $trimmed = trim($output); + $decoded = json_decode($trimmed, true); + + if (is_array($decoded)) { + return $decoded; + } + + $start = strpos($trimmed, '{'); + $end = strrpos($trimmed, '}'); + + if ($start !== false && $end !== false && $end > $start) { + $decoded = json_decode(substr($trimmed, $start, $end - $start + 1), true); + + if (is_array($decoded)) { + return $decoded; + } + } + + expect($trimmed)->toBeJson(); + + return []; +} + it('registers dns:delete, dns:update, and tunnel:get commands', function () { $this->artisan('list') ->expectsOutputToContain('dns:delete') @@ -22,39 +59,36 @@ }); it('deletes a dns record with --force and --json', 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'; - $zoneId = 'abc123def456abc123def456abc123de'; $recordId = 'rec11122233344455566677788899900'; - MockClient::global([ + $mock = MockClient::global([ DeleteDnsRecord::class => MockResponse::make([ 'success' => true, 'result' => ['id' => $recordId], ], 200), ]); - $this->artisan('dns:delete', [ + $exit = Artisan::call('dns:delete', [ 'zone' => $zoneId, 'id' => $recordId, '--force' => true, '--json' => true, - ]) - ->expectsOutputToContain($recordId) - ->assertSuccessful(); + ]); + $payload = decodeCommandJson(Artisan::output()); + + expect($exit)->toBe(0) + ->and($payload)->toHaveKey('id') + ->and($payload['id'])->toBe($recordId); + + $mock->assertSent(DeleteDnsRecord::class); }); it('updates a dns record and prints a table', function () { - putenv('CLOUDFLARE_API_TOKEN=test-token'); - $_ENV['CLOUDFLARE_API_TOKEN'] = 'test-token'; - $zoneId = 'abc123def456abc123def456abc123de'; $recordId = 'rec11122233344455566677788899900'; - MockClient::global([ + $mock = MockClient::global([ UpdateDnsRecord::class => MockResponse::make([ 'success' => true, 'result' => [ @@ -68,27 +102,39 @@ ], 200), ]); - $this->artisan('dns:update', [ + $exit = Artisan::call('dns:update', [ 'zone' => $zoneId, 'id' => $recordId, 'type' => 'A', 'name' => 'api', 'content' => '5.6.7.8', - ]) - ->expectsOutputToContain('DNS record updated successfully!') - ->expectsOutputToContain($recordId) - ->assertSuccessful(); + ]); + $output = Artisan::output(); + + expect($exit)->toBe(0) + ->and($output)->toContain('DNS record updated successfully!') + ->and($output)->toContain('Field') + ->and($output)->toContain('Value') + ->and($output)->toContain($recordId) + ->and($output)->toContain('Type') + ->and($output)->toContain('A') + ->and($output)->toContain('Name') + ->and($output)->toContain('api.example.com') + ->and($output)->toContain('Content') + ->and($output)->toContain('5.6.7.8') + ->and($output)->toContain('Proxied') + ->and($output)->toContain('No') + ->and($output)->toContain('TTL') + ->and($output)->toContain('Auto') + ->and($output)->not->toContain('"id":'); + + $mock->assertSent(UpdateDnsRecord::class); }); it('gets a tunnel and supports --json', 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'; - $tunnelId = 'tun-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; - MockClient::global([ + $mock = MockClient::global([ GetTunnel::class => MockResponse::make([ 'success' => true, 'result' => [ @@ -101,10 +147,21 @@ ], 200), ]); - $this->artisan('tunnel:get', [ + $exit = Artisan::call('tunnel:get', [ 'id' => $tunnelId, '--json' => true, - ])->assertSuccessful(); + ]); + $payload = decodeCommandJson(Artisan::output()); + + expect($exit)->toBe(0) + ->and($payload['id'])->toBe($tunnelId) + ->and($payload['name'])->toBe('my-app') + ->and($payload['status'])->toBe('healthy') + ->and($payload['created_at'])->toBe('2024-01-15T12:00:00Z') + ->and($payload['connections'])->toHaveCount(1) + ->and($payload['connections'][0]['id'])->toBe('c1'); + + $mock->assertSent(GetTunnel::class); }); it('builds update, delete, and get request endpoints', function () { From 940816ef5fe205061f919802b18b18b73fc1379f Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:34:28 -0700 Subject: [PATCH 3/4] spec: lock dns:delete, dns:update, tunnel:get (PR #8) Research seat. Commands + Saloon PUT overwrite. Raw --json, not the #7 envelope. Keep tunnel:expose README. Do not merge. --- specs/dns-tunnel-crud/spec.md | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 specs/dns-tunnel-crud/spec.md diff --git a/specs/dns-tunnel-crud/spec.md b/specs/dns-tunnel-crud/spec.md new file mode 100644 index 0000000..f98cd7d --- /dev/null +++ b/specs/dns-tunnel-crud/spec.md @@ -0,0 +1,58 @@ +# SPEC: dns:delete, dns:update, tunnel:get + +Status: LOCKED + +**Story:** `./cf` said DNS and tunnels were covered. On `main` they are not: `DeleteDnsRecord` and `GetTunnel` exist in Saloon with resource methods and no commands; there is no `UpdateDnsRecord` at all. Humans and agents cannot overwrite or delete a record, or inspect one tunnel, without curling `api.cloudflare.com`. + +PR: https://github.com/conduit-ui/cloudflare/pull/8 + +## Locked + +- Three Laravel Zero commands, autoloaded from `app/Commands` (do not edit `config/commands.php`). +- New Saloon request only: `UpdateDnsRecord` as **PUT** overwrite + `PUT /zones/{zoneId}/dns_records/{recordId}` + body = `CreateDnsRecord`: `type`, `name`, `content`, `proxied`, `ttl`. + Docs: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/update/ +- Reuse existing `DeleteDnsRecord` (`DELETE` same path) and `GetTunnel` + (`GET /accounts/{accountId}/cfd_tunnel/{tunnelId}`). +- Wire `DnsResource::update(...)`. `delete` / `TunnelResource::get` already exist on `main`. +- Signatures (match README lines this PR adds): + +``` +./cf dns:update [--proxied] [--ttl=1] [--json] +./cf dns:delete [--force] [--json] +./cf tunnel:get [--json] +``` + +- Zone arg: 32 hex chars = ID; otherwise resolve via `zones()->list($name)` like `dns:create`. +- `dns:delete` confirms unless `--force`. Decline → exit 0, `Cancelled.` (`tunnel:delete`). +- DNS commands copy `DnsCreateCommand` connector (token only). Do not put them on `InteractsWithCloudflare` (that trait requires `CLOUDFLARE_ACCOUNT_ID`). +- `tunnel:get` uses `InteractsWithCloudflare` like the other tunnel commands. +- `--json` prints the Cloudflare API **`result`** with `JSON_PRETTY_PRINT`. Same as `dns:create` / `tunnel:list` on `main`. +- Human mode: success table / info line; API failure → `error(...)` + exit 1. +- README: add those command lines under DNS / Tunnels. **Keep the entire `## Expose a local app` section** (heading, examples, cloudflared token + `route dns` fallback). Do not rewrite `tunnel:expose`. +- Tests: Pest + Saloon `MockClient`, same style as `tests/Feature/TunnelExposeCommandTest.php`. +- You own (build): `app/Commands/DnsDeleteCommand.php`, `DnsUpdateCommand.php`, `TunnelGetCommand.php`, `app/Integrations/Cloudflare/Requests/Dns/UpdateDnsRecord.php`, `DnsResource::update`. +- You own (test): `tests/Feature/DnsTunnelCrudCommandsTest.php` plus a README expose-section assertion if missing. +- Research owns: `specs/`, `~/.grok/tmp/8-notes.md`. + +## Done when + +- [ ] `./cf list` shows `dns:delete`, `dns:update`, `tunnel:get` +- [ ] `vendor/bin/pest --compact tests/Feature/DnsTunnelCrudCommandsTest.php tests/Feature/TunnelExposeCommandTest.php` is green +- [ ] A test asserts `UpdateDnsRecord` is `Method::PUT`, endpoint `/zones/{z}/dns_records/{id}`, body keys `type` `name` `content` `proxied` `ttl` +- [ ] `--json` success output is the API `result` object (record id / tunnel id present) and does **not** contain top-level `"ok"` / `"data"` / `"error"` +- [ ] `README.md` still contains `## Expose a local app` and `./cf tunnel:expose my-app` +- [ ] `./bin/ci` is green +- [ ] This PR is **not** merged from this clone + +## Out of scope + +- PR #7 agent JSON envelope: `OutputsJson`, `docs/agent-output.md`, `{ok, data|error}`, wrapping existing commands. Optional `--json` **raw result** is this PR; the envelope is #7. +- `dns:upsert` (issue 14), `--dry-run` (15), `tunnel:status` / `--wait` (16) +- PATCH partial update (`/api/.../records/methods/edit/`) +- MX `--priority`, CAA `data` maps, comments/tags (create does not have them) +- Dedicated tunnel connections endpoint (`connections` is deprecated on GET tunnel; keep the same `count(connections ?? [])` as `tunnel:list`) +- Refactoring every command onto one trait / splitting `InteractsWithCloudflare` +- Touching `tunnel:expose` behavior, composer.lock, CI workflows +- Merge or deploy From bf63912bfe860732f07bc088f5d602b817ae3043 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:35:36 -0700 Subject: [PATCH 4/4] fix: tunnel:get uses InteractsWithCloudflare like other tunnel verbs DNS delete/update keep the dns:create token-only connector and pint concat. --- app/Commands/DnsDeleteCommand.php | 6 +++--- app/Commands/DnsUpdateCommand.php | 12 ++++++------ app/Commands/TunnelGetCommand.php | 21 +++++---------------- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/app/Commands/DnsDeleteCommand.php b/app/Commands/DnsDeleteCommand.php index 086690f..c57f040 100644 --- a/app/Commands/DnsDeleteCommand.php +++ b/app/Commands/DnsDeleteCommand.php @@ -20,8 +20,8 @@ class DnsDeleteCommand extends Command public function handle(): int { $connector = $this->getConnector(); - $zone = $this->argument('zone'); - $recordId = $this->argument('id'); + $zone = (string) $this->argument('zone'); + $recordId = (string) $this->argument('id'); // If zone looks like a domain, resolve it to ID if (! preg_match('/^[a-f0-9]{32}$/', $zone)) { @@ -45,7 +45,7 @@ public function handle(): int $response = $connector->dns($zone)->delete($recordId); if (! $response->successful()) { - $this->error('Failed to delete DNS record: ' . $response->body()); + $this->error('Failed to delete DNS record: '.$response->body()); return self::FAILURE; } diff --git a/app/Commands/DnsUpdateCommand.php b/app/Commands/DnsUpdateCommand.php index 8ff0e3f..ee4ea60 100644 --- a/app/Commands/DnsUpdateCommand.php +++ b/app/Commands/DnsUpdateCommand.php @@ -24,7 +24,7 @@ class DnsUpdateCommand extends Command public function handle(): int { $connector = $this->getConnector(); - $zone = $this->argument('zone'); + $zone = (string) $this->argument('zone'); // If zone looks like a domain, resolve it to ID if (! preg_match('/^[a-f0-9]{32}$/', $zone)) { @@ -38,16 +38,16 @@ public function handle(): int } $response = $connector->dns($zone)->update( - $this->argument('id'), - $this->argument('type'), - $this->argument('name'), - $this->argument('content'), + (string) $this->argument('id'), + (string) $this->argument('type'), + (string) $this->argument('name'), + (string) $this->argument('content'), (bool) $this->option('proxied'), (int) $this->option('ttl') ); if (! $response->successful()) { - $this->error('Failed to update DNS record: ' . $response->body()); + $this->error('Failed to update DNS record: '.$response->body()); return self::FAILURE; } diff --git a/app/Commands/TunnelGetCommand.php b/app/Commands/TunnelGetCommand.php index ffb66f5..d998642 100644 --- a/app/Commands/TunnelGetCommand.php +++ b/app/Commands/TunnelGetCommand.php @@ -4,11 +4,13 @@ namespace App\Commands; -use App\Integrations\Cloudflare\CloudflareConnector; +use App\Commands\Concerns\InteractsWithCloudflare; use LaravelZero\Framework\Commands\Command; class TunnelGetCommand extends Command { + use InteractsWithCloudflare; + protected $signature = 'tunnel:get {id : Tunnel ID} {--json : Output as JSON}'; @@ -18,12 +20,12 @@ class TunnelGetCommand extends Command public function handle(): int { $connector = $this->getConnector(); - $tunnelId = $this->argument('id'); + $tunnelId = (string) $this->argument('id'); $response = $connector->tunnels()->get($tunnelId); if (! $response->successful()) { - $this->error('Failed to get tunnel: ' . $response->body()); + $this->error('Failed to get tunnel: '.$response->body()); return self::FAILURE; } @@ -46,17 +48,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->error('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set'); - exit(1); - } - - return new CloudflareConnector($token, $accountId); - } }