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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <zone> A api 1.2.3.4 # Create A record
./cf dns:create <zone> CNAME www example.com --proxied
./cf dns:update <zone> <id> A api 5.6.7.8 # Update record (PUT)
./cf dns:update <zone> <id> A api 5.6.7.8 --proxied --ttl=300 --json
./cf dns:delete <zone> <id> # Delete record (prompts)
./cf dns:delete <zone> <id> --force --json # Skip confirmation
```

### Tunnels
```bash
./cf tunnel:list # List all tunnels
./cf tunnel:get <id> # Get tunnel details
./cf tunnel:get <id> --json
./cf tunnel:create <name> # Create tunnel (prints token/credentials)
./cf tunnel:expose <name> [hostname] [url] # Create + route + cloudflared steps
./cf tunnel:delete <id> # Delete tunnel
Expand Down
90 changes: 90 additions & 0 deletions app/Commands/DnsDeleteCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace App\Commands;

use App\Integrations\Cloudflare\CloudflareConnector;
use LaravelZero\Framework\Commands\Command;

class DnsDeleteCommand extends Command
{
protected $signature = 'dns:delete
{zone : Zone ID or domain name}
{id : DNS record ID to delete}
{--force : Skip confirmation}
{--json : Output as JSON}';

protected $description = 'Delete a DNS record';

public function handle(): int
{
$connector = $this->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);
}
}
100 changes: 100 additions & 0 deletions app/Commands/DnsUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace App\Commands;

use App\Integrations\Cloudflare\CloudflareConnector;
use LaravelZero\Framework\Commands\Command;

class DnsUpdateCommand extends Command
{
protected $signature = 'dns:update
{zone : Zone ID or domain name}
{id : DNS record ID to update}
{type : Record type (A, AAAA, CNAME, TXT, MX, etc)}
{name : Record name}
{content : Record content}
{--proxied : Enable Cloudflare proxy}
{--ttl=1 : TTL in seconds (1 = auto)}
{--json : Output as JSON}';

protected $description = 'Update a DNS record';

public function handle(): int
{
$connector = $this->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);
}
}
51 changes: 51 additions & 0 deletions app/Commands/TunnelGetCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Commands;

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}';

protected $description = 'Get details for a Cloudflare tunnel';

public function handle(): int
{
$connector = $this->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;
}
}
43 changes: 43 additions & 0 deletions app/Integrations/Cloudflare/Requests/Dns/UpdateDnsRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Integrations\Cloudflare\Requests\Dns;

use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Traits\Body\HasJsonBody;

class UpdateDnsRecord extends Request implements HasBody
{
use HasJsonBody;

protected Method $method = Method::PUT;

public function __construct(
protected string $zoneId,
protected string $recordId,
protected string $type,
protected string $name,
protected string $content,
protected bool $proxied = false,
protected int $ttl = 1,
) {}

public function resolveEndpoint(): string
{
return "/zones/{$this->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,
];
}
}
20 changes: 20 additions & 0 deletions app/Integrations/Cloudflare/Resources/DnsResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions specs/dns-tunnel-crud/spec.md
Original file line number Diff line number Diff line change
@@ -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 <zone> <id> <type> <name> <content> [--proxied] [--ttl=1] [--json]
./cf dns:delete <zone> <id> [--force] [--json]
./cf tunnel:get <id> [--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
Loading
Loading