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/.gitignore b/.gitignore index a34968d..ca6feb2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .env /builds *.phar +.php-bin 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..2f288f7 --- /dev/null +++ b/app/Commands/SetupCommand.php @@ -0,0 +1,136 @@ +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 $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; + } +} 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/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/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. diff --git a/tests/Feature/SetupCommandTest.php b/tests/Feature/SetupCommandTest.php new file mode 100644 index 0000000..dd1eec1 --- /dev/null +++ b/tests/Feature/SetupCommandTest.php @@ -0,0 +1,130 @@ +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 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, + 'errors' => [ + ['message' => 'Invalid API Token'], + ], + ], 401), + ]); + + 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 () { + MockClient::global([ + VerifyToken::class => MockResponse::make([ + 'success' => false, + 'errors' => [ + ['message' => 'Invalid API Token'], + ], + ], 401), + ]); + + $status = Artisan::call('setup', [ + '--token' => 'bad-token', + '--account-id' => 'account-123', + '--non-interactive' => true, + '--json' => true, + ]); + $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('writes credentials to .env 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(); + $payload = json_decode($output, true); + $env = file_get_contents($path); + + expect($status)->toBe(0) + ->and($payload)->toBeArray() + ->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 { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } +});