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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# Cloudflare API credentials
# Run `./cf setup` to configure interactively, or set values below.
# Create a token: https://dash.cloudflare.com/profile/api-tokens
CLOUDFLARE_API_TOKEN=

# Account ID appears in the right sidebar on any zone overview page
CLOUDFLARE_ACCOUNT_ID=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
.env
/builds
*.phar
.php-bin
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,27 @@ composer global require conduit-ui/cloudflare
git clone https://github.com/conduit-ui/cloudflare.git
cd cloudflare
composer install
cp .env.example .env
```

Build a local PHAR after `composer install` with `composer build` (or `php cf app:build cf`). Artifact: `builds/cf`.

## Configuration

Edit `.env`:
Run setup first to validate credentials and write `.env`:

```bash
./cf setup
# or non-interactive (agents/CI):
./cf setup --token=cf_xxx --account-id=abc123 --non-interactive
./cf setup --token=cf_xxx --account-id=abc123 --non-interactive --json
```

You can also copy `.env.example` and edit by hand:

```bash
CLOUDFLARE_API_TOKEN=your_token_here
CLOUDFLARE_ACCOUNT_ID=your_account_id
cp .env.example .env
# CLOUDFLARE_API_TOKEN=your_token_here
# CLOUDFLARE_ACCOUNT_ID=your_account_id
```

Get credentials from:
Expand All @@ -60,6 +70,12 @@ Pass `--json` for a stable envelope on stdout: `{"ok":true,"data":...}` on succe

## Commands

### Setup
```bash
./cf setup # Interactive auth onboarding
./cf setup --non-interactive --token=... --account-id=...
```

### Zones
```bash
./cf zones # List all zones
Expand Down
136 changes: 136 additions & 0 deletions app/Commands/SetupCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace App\Commands;

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

class SetupCommand extends Command
{
protected $signature = 'setup
{--token= : Cloudflare API token}
{--account-id= : Cloudflare account ID}
{--non-interactive : Fail instead of prompting for missing values}
{--json : Output as JSON}';

protected $description = 'Configure Cloudflare API credentials in .env';

public function handle(): int
{
$token = $this->option('token') ?: null;
$accountId = $this->option('account-id') ?: null;
$nonInteractive = (bool) $this->option('non-interactive');

if (! $token) {
if ($nonInteractive) {
return $this->failSetup('CLOUDFLARE_API_TOKEN is required. Pass --token= or run without --non-interactive.');
}

$token = $this->secret('Cloudflare API token');
}

if (! $accountId) {
if ($nonInteractive) {
return $this->failSetup('CLOUDFLARE_ACCOUNT_ID is required. Pass --account-id= or run without --non-interactive.');
}

$accountId = $this->ask('Cloudflare account ID');
}

$token = is_string($token) ? trim($token) : '';
$accountId = is_string($accountId) ? trim($accountId) : '';

if ($token === '' || $accountId === '') {
return $this->failSetup('Both API token and account ID are required.');
}

$connector = new CloudflareConnector($token, $accountId);
$response = $connector->user()->verifyToken();

if (! $response->successful() || $response->json('success') !== true) {
$error = $response->json('errors.0.message');
$message = is_string($error) && $error !== ''
? $error
: 'Token verification failed: '.$response->body();

return $this->failSetup($message);
}

$this->writeEnv([
'CLOUDFLARE_API_TOKEN' => $token,
'CLOUDFLARE_ACCOUNT_ID' => $accountId,
]);

$payload = [
'success' => true,
'message' => 'Cloudflare credentials saved to .env',
'account_id' => $accountId,
];

if ($this->option('json')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));

return self::SUCCESS;
}

$this->info($payload['message']);
$this->line('Account ID: '.$accountId);

return self::SUCCESS;
}

/**
* @param array<string, string> $values
*/
protected function writeEnv(array $values): void
{
$path = base_path('.env');

$contents = file_exists($path) ? file_get_contents($path) : '';
if ($contents === false) {
$contents = '';
}

foreach ($values as $key => $value) {
$line = $key.'='.$this->escapeEnvValue($value);

if (preg_match('/^'.preg_quote($key, '/').'=.*/m', $contents)) {
$contents = preg_replace_callback(
'/^'.preg_quote($key, '/').'=.*/m',
static fn (): string => $line,
$contents
) ?? $contents;
} else {
$contents = rtrim($contents);
$contents .= ($contents === '' ? '' : PHP_EOL).$line.PHP_EOL;
}
}

file_put_contents($path, $contents);
}

protected function escapeEnvValue(string $value): string
{
if ($value === '' || preg_match('/\s|#|"|\'|=/', $value)) {
return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
}

return $value;
}

protected function failSetup(string $message): int
{
if ($this->option('json')) {
$this->line(json_encode([
'success' => false,
'message' => $message,
], JSON_PRETTY_PRINT));
} else {
$this->error($message);
}

return self::FAILURE;
}
}
6 changes: 6 additions & 0 deletions app/Commands/TunnelGetCommand.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 TunnelGetCommand extends Command
{
use InteractsWithCloudflare;
use OutputsJson;

protected $signature = 'tunnel:get
{id : Tunnel ID}
Expand All @@ -20,6 +22,10 @@ class TunnelGetCommand extends Command
public function handle(): int
{
$connector = $this->getConnector();
if ($connector === null) {
return self::FAILURE;
}

$tunnelId = (string) $this->argument('id');

$response = $connector->tunnels()->get($tunnelId);
Expand Down
5 changes: 5 additions & 0 deletions app/Integrations/Cloudflare/CloudflareConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ public function zones(): Resources\ZoneResource
{
return new Resources\ZoneResource($this);
}

public function user(): Resources\UserResource
{
return new Resources\UserResource($this);
}
}
18 changes: 18 additions & 0 deletions app/Integrations/Cloudflare/Requests/User/VerifyToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Integrations\Cloudflare\Requests\User;

use Saloon\Enums\Method;
use Saloon\Http\Request;

class VerifyToken extends Request
{
protected Method $method = Method::GET;

public function resolveEndpoint(): string
{
return '/user/tokens/verify';
}
}
21 changes: 21 additions & 0 deletions app/Integrations/Cloudflare/Resources/UserResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Integrations\Cloudflare\Resources;

use App\Integrations\Cloudflare\CloudflareConnector;
use App\Integrations\Cloudflare\Requests\User\VerifyToken;
use Saloon\Http\Response;

class UserResource
{
public function __construct(
protected CloudflareConnector $connector,
) {}

public function verifyToken(): Response
{
return $this->connector->send(new VerifyToken);
}
}
51 changes: 51 additions & 0 deletions specs/cf-setup/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPEC: cf setup writes .env after VerifyToken

Status: LOCKED
PR: [conduit-ui/cloudflare#10](https://github.com/conduit-ui/cloudflare/pull/10)
Branch: `cursor/cf-setup-auth-6412`

**Story:** Other `cf` commands say the token and account id must be set. The body copies `.env.example` by hand and finds out the token is dead on the first real call. Agents have no non-interactive onboarding.

## Locked

- `./cf setup` is the onboarding path. Hand-copy of `.env.example` stays as fallback in README.
- Verify first: Saloon `GET https://api.cloudflare.com/client/v4/user/tokens/verify` (`App\Integrations\Cloudflare\Requests\User\VerifyToken`) with `Authorization: Bearer <token>`.
- Write `base_path('.env')` **only after** HTTP success **and** envelope `success === true`. Failure, missing values, or empty-after-trim must not create or mutate `.env`.
- Account ID is stored as given. Verify does not prove the account exists or that the token can see it.
- Envelope success is enough. Do not add `result.status === active`, permission/scope checks, or an accounts list call.
- Non-interactive flags:
- `--token=`
- `--account-id=`
- `--non-interactive` (missing value → exit 1, no prompt)
- `--json` (pretty JSON; never the API token)
- Interactive (no `--non-interactive`): `secret()` for the token, `ask()` for the account id. Do not read existing `CLOUDFLARE_*` env as defaults.
- `.env` keys: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`. Replace an existing key line; append if absent. Quote values that contain whitespace `# " ' =`.
- Success JSON: `{success: true, message, account_id}`. Failure JSON: `{success: false, message}`. Text success may print account id, never the token.
- Saloon layout matches zones/tunnels: `CloudflareConnector::user()` → `UserResource` → `Requests\User\VerifyToken`.
- No `.php-bin` symlink. `.gitignore` already has `.php-bin`.
- You own (research): `specs/`, `~/.grok/tmp/10-notes.md`.
- Build owns: `app/Commands/SetupCommand.php`, `UserResource.php`, `Requests/User/VerifyToken.php`, `CloudflareConnector::user()`, `.env.example`, README setup blurb.
- Test owns: `tests/Feature/SetupCommandTest.php`.
- Do not: merge, deploy, new wrapper, Asgard exec of `cf` (that is `cf-asgard-readonly` / Asgard#250).

## Done when

- [ ] `vendor/bin/pest tests/Feature/SetupCommandTest.php --compact` covers:
- `--non-interactive` missing `--token` → exit 1
- `--non-interactive` missing `--account-id` → exit 1
- mocked verify fail → exit 1 **and** `.env` unchanged
- `--json` verify fail → JSON `success: false`, exit 1
- mocked verify success + `--non-interactive --json` → exit 0, `.env` contains both keys, JSON `success: true`, output does not contain the token; restore `.env` in `finally`
- [ ] `./bin/ci` on this clone (Pint, PHPStan, Rector dry-run, Pest)

## Out of scope

- `gh pr merge` / land-when-gated / deploy
- Account membership lookup, token permission matrix (Zone:Read / DNS:Edit / Tunnel:Edit)
- Interactive Pest (manual only)
- Wrangler / `cloudflared login`
- `--dry-run` (#15), `dns:upsert` (#14), `tunnel:status --wait` (#16)

## Open decisions

None. Disabled/expired `result.status` with envelope `success: true` stays write-allowed unless a later SPEC says otherwise.
Loading
Loading