From c7f36c69cc80bfde89727788f6409f093ee95dd8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 02:19:23 +0000 Subject: [PATCH 1/7] feat: add cf setup auth onboarding command Add interactive and non-interactive setup that verifies the API token via Cloudflare's user/tokens/verify endpoint, writes credentials to .env, and documents ./cf setup as the primary configuration path. Co-authored-by: Jordan Partridge --- .env.example | 5 + .php-bin | 1 + README.md | 24 +++- app/Commands/SetupCommand.php | 130 ++++++++++++++++++ .../Cloudflare/CloudflareConnector.php | 5 + .../Cloudflare/Requests/User/VerifyToken.php | 18 +++ .../Cloudflare/Resources/UserResource.php | 21 +++ tests/Feature/SetupCommandTest.php | 68 +++++++++ 8 files changed, 268 insertions(+), 4 deletions(-) create mode 120000 .php-bin create mode 100644 app/Commands/SetupCommand.php create mode 100644 app/Integrations/Cloudflare/Requests/User/VerifyToken.php create mode 100644 app/Integrations/Cloudflare/Resources/UserResource.php create mode 100644 tests/Feature/SetupCommandTest.php diff --git a/.env.example b/.env.example index 7fc005c..12194c8 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.php-bin b/.php-bin new file mode 120000 index 0000000..5a963bd --- /dev/null +++ b/.php-bin @@ -0,0 +1 @@ +/usr/bin/php8.4 \ No newline at end of file diff --git a/README.md b/README.md index 85d7b1f..0c5b0da 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/app/Commands/SetupCommand.php b/app/Commands/SetupCommand.php new file mode 100644 index 0000000..289ce20 --- /dev/null +++ b/app/Commands/SetupCommand.php @@ -0,0 +1,130 @@ +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) { + $message = $response->json('errors.0.message') + ?? ('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 $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("/^{$key}=.*/m", $contents)) { + $contents = preg_replace("/^{$key}=.*/m", $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; + } +} diff --git a/app/Integrations/Cloudflare/CloudflareConnector.php b/app/Integrations/Cloudflare/CloudflareConnector.php index a57c16d..b938380 100644 --- a/app/Integrations/Cloudflare/CloudflareConnector.php +++ b/app/Integrations/Cloudflare/CloudflareConnector.php @@ -48,4 +48,9 @@ public function zones(): Resources\ZoneResource { return new Resources\ZoneResource($this); } + + public function user(): Resources\UserResource + { + return new Resources\UserResource($this); + } } diff --git a/app/Integrations/Cloudflare/Requests/User/VerifyToken.php b/app/Integrations/Cloudflare/Requests/User/VerifyToken.php new file mode 100644 index 0000000..805a3e5 --- /dev/null +++ b/app/Integrations/Cloudflare/Requests/User/VerifyToken.php @@ -0,0 +1,18 @@ +connector->send(new VerifyToken); + } +} diff --git a/tests/Feature/SetupCommandTest.php b/tests/Feature/SetupCommandTest.php new file mode 100644 index 0000000..f59ebed --- /dev/null +++ b/tests/Feature/SetupCommandTest.php @@ -0,0 +1,68 @@ +artisan('setup', [ + '--account-id' => 'account-123', + '--non-interactive' => true, + ]) + ->expectsOutputToContain('CLOUDFLARE_API_TOKEN is required') + ->assertExitCode(1); +}); + +it('fails non-interactively when account id is missing', function () { + $this->artisan('setup', [ + '--token' => 'cf_test_token', + '--non-interactive' => true, + ]) + ->expectsOutputToContain('CLOUDFLARE_ACCOUNT_ID is required') + ->assertExitCode(1); +}); + +it('fails non-interactively when token verification fails', function () { + MockClient::global([ + VerifyToken::class => MockResponse::make([ + 'success' => false, + 'errors' => [ + ['message' => 'Invalid API Token'], + ], + ], 401), + ]); + + $this->artisan('setup', [ + '--token' => 'bad-token', + '--account-id' => 'account-123', + '--non-interactive' => true, + ]) + ->expectsOutputToContain('Invalid API Token') + ->assertExitCode(1); +}); + +it('returns json failure payload when verification fails', function () { + MockClient::global([ + VerifyToken::class => MockResponse::make([ + 'success' => false, + 'errors' => [ + ['message' => 'Invalid API Token'], + ], + ], 401), + ]); + + $this->artisan('setup', [ + '--token' => 'bad-token', + '--account-id' => 'account-123', + '--non-interactive' => true, + '--json' => true, + ]) + ->expectsOutputToContain('Invalid API Token') + ->assertExitCode(1); +}); From 01406a9e6a7f2f314a81e52ec07e58af926987e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 02:19:31 +0000 Subject: [PATCH 2/7] chore: drop local php symlink from setup branch Co-authored-by: Jordan Partridge --- .gitignore | 1 + .php-bin | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 120000 .php-bin diff --git a/.gitignore b/.gitignore index a34968d..ca6feb2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .env /builds *.phar +.php-bin diff --git a/.php-bin b/.php-bin deleted file mode 120000 index 5a963bd..0000000 --- a/.php-bin +++ /dev/null @@ -1 +0,0 @@ -/usr/bin/php8.4 \ No newline at end of file From b21760d9501eeea6796173805f1daf15cfc22b5a Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Wed, 9 Sep 2026 23:06:45 -0700 Subject: [PATCH 3/7] test: cover successful cf setup credential save Failure paths were covered; this asserts token verify + .env write. --- tests/Feature/SetupCommandTest.php | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Feature/SetupCommandTest.php b/tests/Feature/SetupCommandTest.php index f59ebed..a3caabe 100644 --- a/tests/Feature/SetupCommandTest.php +++ b/tests/Feature/SetupCommandTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Integrations\Cloudflare\Requests\User\VerifyToken; +use Illuminate\Support\Facades\Artisan; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; @@ -66,3 +67,39 @@ ->expectsOutputToContain('Invalid API Token') ->assertExitCode(1); }); + +it('saves credentials after a successful token verify', function () { + $path = base_path('.env'); + $original = file_exists($path) ? file_get_contents($path) : null; + + MockClient::global([ + VerifyToken::class => MockResponse::make([ + 'success' => true, + 'result' => ['status' => 'active'], + ], 200), + ]); + + try { + $status = Artisan::call('setup', [ + '--token' => 'cf_good_token', + '--account-id' => 'account-123', + '--non-interactive' => true, + '--json' => true, + ]); + $output = Artisan::output(); + + expect($status)->toBe(0) + ->and($output)->toContain('"success": true') + ->and($output)->toContain('Cloudflare credentials saved to .env'); + + expect(file_get_contents($path)) + ->toContain('CLOUDFLARE_API_TOKEN=cf_good_token') + ->toContain('CLOUDFLARE_ACCOUNT_ID=account-123'); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } +}); From 779ac9bf95e81975276d5f6c1a389150b4becd9d Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:33:05 -0700 Subject: [PATCH 4/7] test: parse setup json fail payload and restore .env after write Cover missing token/account, verify fail, JSON fail, and happy-path .env write. --- tests/Feature/SetupCommandTest.php | 35 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/Feature/SetupCommandTest.php b/tests/Feature/SetupCommandTest.php index a3caabe..5884787 100644 --- a/tests/Feature/SetupCommandTest.php +++ b/tests/Feature/SetupCommandTest.php @@ -7,6 +7,10 @@ use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +beforeEach(function () { + MockClient::destroyGlobal(); +}); + afterEach(function () { MockClient::destroyGlobal(); }); @@ -29,7 +33,7 @@ ->assertExitCode(1); }); -it('fails non-interactively when token verification fails', function () { +it('fails when token verification fails', function () { MockClient::global([ VerifyToken::class => MockResponse::make([ 'success' => false, @@ -58,17 +62,21 @@ ], 401), ]); - $this->artisan('setup', [ + $status = Artisan::call('setup', [ '--token' => 'bad-token', '--account-id' => 'account-123', '--non-interactive' => true, '--json' => true, - ]) - ->expectsOutputToContain('Invalid API Token') - ->assertExitCode(1); + ]); + $payload = json_decode(Artisan::output(), true); + + expect($status)->toBe(1) + ->and($payload)->toBeArray() + ->and($payload['success'])->toBeFalse() + ->and($payload['message'])->toBe('Invalid API Token'); }); -it('saves credentials after a successful token verify', function () { +it('writes credentials to .env after a successful token verify', function () { $path = base_path('.env'); $original = file_exists($path) ? file_get_contents($path) : null; @@ -86,15 +94,16 @@ '--non-interactive' => true, '--json' => true, ]); - $output = Artisan::output(); + $payload = json_decode(Artisan::output(), true); + $env = file_get_contents($path); expect($status)->toBe(0) - ->and($output)->toContain('"success": true') - ->and($output)->toContain('Cloudflare credentials saved to .env'); - - expect(file_get_contents($path)) - ->toContain('CLOUDFLARE_API_TOKEN=cf_good_token') - ->toContain('CLOUDFLARE_ACCOUNT_ID=account-123'); + ->and($payload)->toBeArray() + ->and($payload['success'])->toBeTrue() + ->and($payload['message'])->toBe('Cloudflare credentials saved to .env') + ->and($payload['account_id'])->toBe('account-123') + ->and($env)->toContain('CLOUDFLARE_API_TOKEN=cf_good_token') + ->and($env)->toContain('CLOUDFLARE_ACCOUNT_ID=account-123'); } finally { if ($original === null) { @unlink($path); From bfc34b088936c11d908e8b665427e4c77af19aec Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:33:27 -0700 Subject: [PATCH 5/7] docs: lock cf setup SPEC (verify then .env) Write .env only after GET /user/tokens/verify succeeds. Non-interactive flags. Do not merge. --- specs/cf-setup/spec.md | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 specs/cf-setup/spec.md diff --git a/specs/cf-setup/spec.md b/specs/cf-setup/spec.md new file mode 100644 index 0000000..7a99e88 --- /dev/null +++ b/specs/cf-setup/spec.md @@ -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 `. +- 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. From 63b3dcf75fb06a9af47b3b48e1678272b6a50092 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Thu, 10 Sep 2026 06:35:18 -0700 Subject: [PATCH 6/7] fix: quote-safe .env replace in cf setup Replace existing keys with a callback so token values are not treated as preg backreferences. Verify still gates on envelope success only. --- app/Commands/SetupCommand.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/Commands/SetupCommand.php b/app/Commands/SetupCommand.php index 289ce20..2f288f7 100644 --- a/app/Commands/SetupCommand.php +++ b/app/Commands/SetupCommand.php @@ -50,8 +50,10 @@ public function handle(): int $response = $connector->user()->verifyToken(); if (! $response->successful() || $response->json('success') !== true) { - $message = $response->json('errors.0.message') - ?? ('Token verification failed: '.$response->body()); + $error = $response->json('errors.0.message'); + $message = is_string($error) && $error !== '' + ? $error + : 'Token verification failed: '.$response->body(); return $this->failSetup($message); } @@ -94,8 +96,12 @@ protected function writeEnv(array $values): void foreach ($values as $key => $value) { $line = $key.'='.$this->escapeEnvValue($value); - if (preg_match("/^{$key}=.*/m", $contents)) { - $contents = preg_replace("/^{$key}=.*/m", $line, $contents) ?? $contents; + 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; From c104a631bced444e17b80a4954158b91499c02c6 Mon Sep 17 00:00:00 2001 From: Jordan Lavar Partridge Date: Sat, 12 Sep 2026 08:38:13 -0700 Subject: [PATCH 7/7] test: prove setup never writes .env or leaks the token Also give tunnel:get OutputsJson so missing creds is JSON-safe and PHPStan is clean after the #8 merge. --- app/Commands/TunnelGetCommand.php | 6 ++++++ tests/Feature/SetupCommandTest.php | 32 ++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/Commands/TunnelGetCommand.php b/app/Commands/TunnelGetCommand.php index d998642..0389f56 100644 --- a/app/Commands/TunnelGetCommand.php +++ b/app/Commands/TunnelGetCommand.php @@ -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} @@ -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); diff --git a/tests/Feature/SetupCommandTest.php b/tests/Feature/SetupCommandTest.php index 5884787..dd1eec1 100644 --- a/tests/Feature/SetupCommandTest.php +++ b/tests/Feature/SetupCommandTest.php @@ -34,6 +34,10 @@ }); it('fails when token verification fails', function () { + $path = base_path('.env'); + $original = file_exists($path) ? file_get_contents($path) : null; + file_put_contents($path, "CLOUDFLARE_API_TOKEN=keep-me\n"); + MockClient::global([ VerifyToken::class => MockResponse::make([ 'success' => false, @@ -43,13 +47,23 @@ ], 401), ]); - $this->artisan('setup', [ - '--token' => 'bad-token', - '--account-id' => 'account-123', - '--non-interactive' => true, - ]) - ->expectsOutputToContain('Invalid API Token') - ->assertExitCode(1); + try { + $this->artisan('setup', [ + '--token' => 'bad-token', + '--account-id' => 'account-123', + '--non-interactive' => true, + ]) + ->expectsOutputToContain('Invalid API Token') + ->assertExitCode(1); + + expect(file_get_contents($path))->toBe("CLOUDFLARE_API_TOKEN=keep-me\n"); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } }); it('returns json failure payload when verification fails', function () { @@ -94,7 +108,8 @@ '--non-interactive' => true, '--json' => true, ]); - $payload = json_decode(Artisan::output(), true); + $output = Artisan::output(); + $payload = json_decode($output, true); $env = file_get_contents($path); expect($status)->toBe(0) @@ -102,6 +117,7 @@ ->and($payload['success'])->toBeTrue() ->and($payload['message'])->toBe('Cloudflare credentials saved to .env') ->and($payload['account_id'])->toBe('account-123') + ->and($output)->not->toContain('cf_good_token') ->and($env)->toContain('CLOUDFLARE_API_TOKEN=cf_good_token') ->and($env)->toContain('CLOUDFLARE_ACCOUNT_ID=account-123'); } finally {