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..c57f040 --- /dev/null +++ b/app/Commands/DnsDeleteCommand.php @@ -0,0 +1,90 @@ +getConnector(); + $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)) { + $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..ee4ea60 --- /dev/null +++ b/app/Commands/DnsUpdateCommand.php @@ -0,0 +1,100 @@ +getConnector(); + $zone = (string) $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( + (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()); + + 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..d998642 --- /dev/null +++ b/app/Commands/TunnelGetCommand.php @@ -0,0 +1,51 @@ +getConnector(); + $tunnelId = (string) $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; + } +} 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/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 diff --git a/tests/Feature/DnsTunnelCrudCommandsTest.php b/tests/Feature/DnsTunnelCrudCommandsTest.php new file mode 100644 index 0000000..d174bcd --- /dev/null +++ b/tests/Feature/DnsTunnelCrudCommandsTest.php @@ -0,0 +1,195 @@ + $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') + ->expectsOutputToContain('dns:update') + ->expectsOutputToContain('tunnel:get') + ->assertSuccessful(); +}); + +it('deletes a dns record with --force and --json', function () { + $zoneId = 'abc123def456abc123def456abc123de'; + $recordId = 'rec11122233344455566677788899900'; + + $mock = MockClient::global([ + DeleteDnsRecord::class => MockResponse::make([ + 'success' => true, + 'result' => ['id' => $recordId], + ], 200), + ]); + + $exit = Artisan::call('dns:delete', [ + 'zone' => $zoneId, + 'id' => $recordId, + '--force' => true, + '--json' => true, + ]); + $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 () { + $zoneId = 'abc123def456abc123def456abc123de'; + $recordId = 'rec11122233344455566677788899900'; + + $mock = 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), + ]); + + $exit = Artisan::call('dns:update', [ + 'zone' => $zoneId, + 'id' => $recordId, + 'type' => 'A', + 'name' => 'api', + 'content' => '5.6.7.8', + ]); + $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 () { + $tunnelId = 'tun-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + $mock = 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), + ]); + + $exit = Artisan::call('tunnel:get', [ + 'id' => $tunnelId, + '--json' => true, + ]); + $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 () { + $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); +});