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
9 changes: 3 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,19 @@ A standalone CLI tool for [Flare](https://flareapp.io) built on Laravel Zero. Us
## Architecture

- **Laravel Zero 12** — PHP CLI micro-framework
- **spatie/laravel-openapi-cli** — reads `resources/openapi/flare-api.yaml` and registers one command per API endpoint using `operationId`-based naming with `flare:` prefix
- **spatie/laravel-openapi-cli** — fetches the spec from `https://flareapp.io/downloads/flare-api.yaml` (cached for 24h) and registers one command per API endpoint using `operationId`-based naming with `flare:` prefix
- **CredentialStore** (`app/Services/CredentialStore.php`) — reads/writes API token to `~/.flare/config.json`
- **LoginCommand / LogoutCommand** — custom commands (not from OpenAPI spec) for auth flow

## Key files

- `flare` — CLI entry point (the binary)
- `app/Providers/AppServiceProvider.php` — registers CredentialStore singleton and OpenApiCli
- `app/Providers/AppServiceProvider.php` — registers CredentialStore singleton and OpenApiCli (spec URL, auth, error messaging)
- `app/Services/CredentialStore.php` — credential persistence to `~/.flare/config.json`
- `app/Commands/LoginCommand.php` — `flare login`
- `app/Commands/LogoutCommand.php` — `flare logout`
- `resources/openapi/flare-api.yaml` — bundled Flare API spec
- `config/app.php` — providers list (must manually register `OpenApiCliServiceProvider`)
- `box.json` — PHAR build config (must include `resources` directory)
- `box.json` — PHAR build config

## Development setup

Expand All @@ -38,8 +37,6 @@ When updating the AI agent skill (e.g. filters, sorts, available commands), alwa

- Laravel Zero disables package auto-discovery. Any package service providers must be registered manually in `config/app.php`.
- The `.auth()` callable (not `.bearer()`) is used for dynamic credential resolution from `CredentialStore`.
- The `resources/` directory must be in `box.json` `directories` for the spec to be bundled in the PHAR.
- `resource_path()` resolves to `phar://` paths when running inside a PHAR — this works for reading but not writing.

## Coding standards

Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,25 @@ composer global require spatie/flare-cli
### Authentication

```bash
# Log in with your Flare API token
# Browser-based OAuth login (default)
flare login

# Log out
# Device-code flow — useful over SSH, in containers, or any headless terminal
flare login --device

# Paste an existing personal access token
flare login --token

# Log out of the active host
flare logout

# Log out of every host
flare logout --all
```

Get your API token at [flareapp.io/settings/api-tokens](https://flareapp.io/settings/api-tokens).
`flare login` opens your browser to flareapp.io to confirm the requested scopes. The CLI listens on a random loopback port for the callback, exchanges the authorization code for tokens, and refreshes them transparently on subsequent calls. Personal access tokens are still supported via `--token` for scripts and CI; generate one at [flareapp.io/settings/api-tokens](https://flareapp.io/settings/api-tokens).

Set `FLARE_BASE_URL` to point the CLI at a non-production environment (for example `FLARE_BASE_URL=https://passport-oauth.test/api`). Set `FLARE_OAUTH_CLIENT_ID` to override the baked-in Flare CLI OAuth client UUID.

### Commands

Expand Down
155 changes: 148 additions & 7 deletions app/Commands/LoginCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
use App\Concerns\RendersBanner;
use App\Services\CredentialStore;
use App\Services\FlareUrlResolver;
use App\Services\OAuth\DeviceAuthorization;
use App\Services\OAuth\DeviceLoginFlow;
use App\Services\OAuth\OAuthException;
use App\Services\OAuth\PkceLoginFlow;
use App\Services\OAuth\TokenRecord;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use LaravelZero\Framework\Commands\Command;
Expand All @@ -13,19 +18,65 @@ class LoginCommand extends Command
{
use RendersBanner;

protected $signature = 'login';
protected $signature = 'login
{--token : Paste a personal access token instead of using the browser flow}
{--device : Use the device code flow (for headless terminals)}
{--timeout=120 : Seconds to wait for the OAuth callback}';

protected $description = 'Store your Flare API token for authentication';
protected $description = 'Authenticate with Flare via OAuth, device code, or a personal access token';

public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolver): int
{
public function handle(
CredentialStore $credentials,
FlareUrlResolver $urlResolver,
PkceLoginFlow $pkce,
DeviceLoginFlow $device,
): int {
$this->renderBanner($this->output);
$this->showActiveContext($urlResolver);

if ($this->option('token')) {
return $this->loginWithPersonalAccessToken($credentials, $urlResolver);
}

if ($this->option('device')) {
return $this->loginWithDeviceCode($credentials, $urlResolver, $device);
}

if (! $this->isInteractiveTerminal()) {
$this->warn('Non-interactive terminal detected. Falling back to --device.');
$this->newLine();

return $this->loginWithDeviceCode($credentials, $urlResolver, $device);
}

return $this->loginWithBrowser($credentials, $urlResolver, $pkce);
}

private function isInteractiveTerminal(): bool
{
if ($this->input->isInteractive()) {
return true;
}

return defined('STDIN') && function_exists('stream_isatty') && @stream_isatty(STDIN);
}

private function showActiveContext(FlareUrlResolver $urlResolver): void
{
$this->line("Active API base URL: <href={$urlResolver->getApiBaseUrl()}>{$urlResolver->getApiBaseUrl()}</>");
$this->line("Active auth host: {$urlResolver->getHostKey()}");
$this->newLine();
$tokenUrl = "{$urlResolver->getAppUrl()}/account/api-tokens";
}

private function loginWithPersonalAccessToken(CredentialStore $credentials, FlareUrlResolver $urlResolver): int
{
if ($credentials->getRecord() !== null) {
$this->warn('A browser-based OAuth session already exists for this host.');
$this->line('Continuing will replace it with the personal access token below.');
$this->newLine();
}

$tokenUrl = "{$urlResolver->getAppUrl()}/account/api-tokens";
$this->line("You can generate a token at <href={$tokenUrl}>{$tokenUrl}</>");
$this->newLine();

Expand All @@ -39,7 +90,7 @@ public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolv

try {
$response = Http::withToken($token)->get("{$urlResolver->getApiBaseUrl()}/me");
} catch (ConnectionException $e) {
} catch (ConnectionException) {
$this->error('Could not connect to Flare. Please check your internet connection.');

return self::FAILURE;
Expand All @@ -53,8 +104,55 @@ public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolv

$credentials->setToken($token);

$email = $response->json('email', 'unknown');
return $this->reportSuccess($response->json('email', 'unknown'), $urlResolver);
}

private function loginWithBrowser(
CredentialStore $credentials,
FlareUrlResolver $urlResolver,
PkceLoginFlow $pkce,
): int {
$this->line('Opening your browser to log in. Sign in and approve the requested permissions.');
$this->line('Run `<comment>flare login --token</comment>` if you prefer pasting a personal access token.');
$this->newLine();

$opener = $this->makeBrowserOpener();
$logger = fn (string $message) => $this->line($message);

try {
$record = $pkce->run($opener, $logger, timeoutSeconds: (int) $this->option('timeout'));
} catch (OAuthException $e) {
$this->error($e->getMessage());

return self::FAILURE;
}

$credentials->setRecord($record);

$email = $this->fetchEmail($record, $urlResolver);

return $this->reportSuccess($email ?? 'unknown', $urlResolver);
}

private function fetchEmail(TokenRecord $record, FlareUrlResolver $urlResolver): ?string
{
try {
$response = Http::withToken($record->accessToken)->get("{$urlResolver->getApiBaseUrl()}/me");
} catch (ConnectionException) {
return null;
}

if (! $response->successful()) {
return null;
}

$email = $response->json('email');

return is_string($email) ? $email : null;
}

private function reportSuccess(string $email, FlareUrlResolver $urlResolver): int
{
$this->newLine();
$this->info(" 🎉 Successfully logged in as {$email} ");
$this->line("Stored credentials for {$urlResolver->getHostKey()}.");
Expand All @@ -66,4 +164,47 @@ public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolv

return self::SUCCESS;
}

/**
* @return callable(string): void
*/
private function makeBrowserOpener(): callable
{
return PkceLoginFlow::defaultBrowserOpener(...);
}

private function loginWithDeviceCode(
CredentialStore $credentials,
FlareUrlResolver $urlResolver,
DeviceLoginFlow $device,
): int {
$this->line('Starting device code authentication.');
$this->newLine();

$announce = function (DeviceAuthorization $auth): void {
$verificationUrl = $auth->verificationUriComplete ?? $auth->verificationUri;

$this->line(' Open this URL in any browser and confirm the code below:');
$this->newLine();
$this->line(" <href={$verificationUrl}>{$verificationUrl}</>");
$this->newLine();
$this->line(" User code: <comment>{$auth->userCode}</comment>");
$this->newLine();
$this->line('Waiting for confirmation...');
};

try {
$record = $device->run($announce);
} catch (OAuthException $e) {
$this->error($e->getMessage());

return self::FAILURE;
}

$credentials->setRecord($record);

$email = $this->fetchEmail($record, $urlResolver);

return $this->reportSuccess($email ?? 'unknown', $urlResolver);
}
}
18 changes: 17 additions & 1 deletion app/Commands/LogoutCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,28 @@

class LogoutCommand extends Command
{
protected $signature = 'logout';
protected $signature = 'logout {--all : Remove credentials for every configured host}';

protected $description = 'Clear your stored Flare credentials';

public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolver): int
{
if ($this->option('all')) {
$hosts = $credentials->getConfiguredHosts();

if ($hosts === []) {
$this->info('No stored credentials to remove.');

return self::SUCCESS;
}

$credentials->flushAll();

$this->info('Removed credentials for: '.implode(', ', $hosts).'.');

return self::SUCCESS;
}

$credentials->flush();

$this->info("Logged out of {$urlResolver->getHostKey()} successfully.");
Expand Down
55 changes: 54 additions & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
use App\Services\CredentialStore;
use App\Services\FlareDescriber;
use App\Services\FlareUrlResolver;
use App\Services\OAuth\DeviceLoginFlow;
use App\Services\OAuth\OAuthEndpoints;
use App\Services\OAuth\OAuthHttpClient;
use App\Services\OAuth\PkceLoginFlow;
use App\Services\OAuth\TokenRefresher;
use Illuminate\Console\Command;
use Illuminate\Http\Client\Response;
use Illuminate\Support\ServiceProvider;
Expand All @@ -25,7 +30,14 @@ public function boot(): void
->useOperationIds()
->baseUrl($urlResolver->getApiBaseUrl())
->cache(ttl: 60 * 60 * 24)
->auth(fn () => app(CredentialStore::class)->getToken())
->auth(fn () => app(CredentialStore::class)->getAccessToken())
->retryOn(function (Response $response) {
if ($response->status() !== 401) {
return false;
}

return app(CredentialStore::class)->forceRefresh();
})
->onError(function (Response $response, Command $command) {
if ($response->status() === 401) {
$command->error(
Expand All @@ -35,6 +47,23 @@ public function boot(): void
return true;
}

if ($response->status() === 403) {
$message = $response->json('message');

// Token-grant denials from Flare's ApiAccessAuthorizer all start
// with "Token " ("Token is missing the 'write' scope.", "Token
// does not grant access to this team.", ...). Other 403s are
// genuine permission errors that a re-login won't fix.
if (is_string($message) && str_starts_with($message, 'Token ')) {
$command->error($message);
$command->line(
'Run `flare login` to re-authenticate and adjust the scopes, teams, and projects granted to the CLI.',
);

return true;
}
}

return false;
});
}
Expand All @@ -46,5 +75,29 @@ public function register(): void
{
$this->app->singleton(FlareUrlResolver::class);
$this->app->singleton(CredentialStore::class);

$this->app->singleton(OAuthEndpoints::class);

$this->app->singleton(OAuthHttpClient::class, fn ($app) => new OAuthHttpClient(
$app->make(OAuthEndpoints::class),
(string) config('flare.oauth.client_id'),
));

$this->app->singleton(TokenRefresher::class, fn ($app) => new TokenRefresher(
$app->make(OAuthHttpClient::class),
(int) config('flare.oauth.refresh_threshold_seconds', 60),
));

$this->app->bind(PkceLoginFlow::class, fn ($app) => new PkceLoginFlow(
$app->make(OAuthHttpClient::class),
$app->make(OAuthEndpoints::class),
(string) config('flare.oauth.client_id'),
(array) config('flare.oauth.scopes', ['read', 'write', 'admin']),
));

$this->app->bind(DeviceLoginFlow::class, fn ($app) => new DeviceLoginFlow(
$app->make(OAuthHttpClient::class),
(array) config('flare.oauth.scopes', ['read', 'write', 'admin']),
));
}
}
Loading