All concepts
12 — Concept

The CLI

A browser sign-in that ends with a tenant API key on your machine, and commands that parse the server’s own schemas.

A thin client, not a second implementation

Every internal tool and every B2B product eventually wants a command line — for scripts, for continuous integration, for the person who would rather type than click. The risk is that the CLI quietly grows its own idea of what the API returns, and then drifts.

So the CLI is deliberately thin. It calls the same /api/* routes the browser calls, authenticated with a tenant API key, and parses every response with the same @rocketflare/shared schema the server validated the response against. There is one fetch call site — apps/cli/src/api.ts — which attaches the bearer token, parses the error envelope and maps a status onto an exit code. Commands themselves are options in, one API call, output out.

Why one contract, three consumers. A response type hand-written in the CLI is a copy that nothing keeps honest. Importing the server's own schema means a field that changes shape breaks the type check in the CLI at the same moment it breaks in the UI — before anyone runs anything.

Adding a command follows the same order every time: schema in the shared package if the surface is new, then the route, then a file under apps/cli/src/commands/ that calls the API client. Never the other way round.

Logging in is a browser handoff

rocketflare login avoids asking anyone to paste a token. The sequence is small enough to describe completely:

rocketflare login: the terminal opens the browser, the app mints a key and sends it back to a loopback listener Terminal Browser App listen on 127.0.0.1:<port> open /auth/cli?redirect_uri=… sign in, pick a tenant mint key cli:<hostname> 302 → callback?key=&tenant_id= GET /callback?key=… GET /api/me verifies the key write config.json (0600)
The key crosses exactly one boundary — the app to a listener on the same machine — and only because the callback address is pinned to 127.0.0.1 or localhost.
  1. The CLI starts a loopback HTTP listener on the first free port in 8765–8770.
  2. It opens the browser at /auth/cli on the server, passing the callback URL and the machine's hostname.
  3. The server authenticates the person normally — no session sends them to the login page, a session without an organisation sends them to the tenant picker, which is skipped entirely in single-tenant mode.
  4. It then mints a tenant API key named cli:<hostname> through the same helper the settings page uses, records the creation in the activity log, and redirects back to the loopback URL with the key, the tenant id and the tenant name.
  5. The listener answers with a self-closing page, verifies the key against /api/me, writes it to ~/.rocketflare/config.json with mode 0600, and shuts down. The whole thing times out after five minutes.

Because the key is created through the ordinary helper, it appears in Settings → API keys like any other and is revoked in the same place. It is a normal tenant key that happens to have a recognisable name.

Why a key in a query string is acceptable here

Handing a credential back in a URL is usually a bad idea, and it is only defensible because the destination is pinned. The redirect_uri must be exactly http://127.0.0.1:<port>/callback or http://localhost:<port>/callback — any port, http only, no query string, no fragment, no user info. Anything else is a 400 before the server does any work.

Why an allowlist rather than a check. An open redirect on this route would mail a working API key to whoever asked. Validating the shape of the target — rather than trying to spot bad ones — means the key can only ever be delivered to a listener on the same machine that started the login.

There is no device-code flow and no refresh. A key that needs replacing is revoked server-side and a fresh login mints another; that is a smaller surface than a token lifecycle nobody exercises.

Configuration, and running without a browser

The key lands in ~/.rocketflare/config.json alongside the server URL, the active tenant and the signed-in user. The directory is created 0700 and the file 0600, re-tightened on every write rather than only at creation.

Environment variables win over the file. ROCKETFLARE_API_KEY and ROCKETFLARE_URL make the CLI work in continuous integration with no browser and no config file at all, and ROCKETFLARE_CONFIG_DIR relocates the directory, which is how the tests keep away from a real home directory.

No command ever prints a full key. whoami, status and keys list show a prefix and four characters; login reports where the key was stored, not what it is; and config prints the effective configuration with the key masked. A secret that is never rendered cannot be scrolled back to in a terminal buffer or captured in a CI log.

Output a script can use

Human output is a coloured table on standard output; diagnostics and progress go to standard error, so redirecting one does not swallow the other. Every list and read command accepts --json, which switches the whole output to the parsed response and nothing else — no colour, no headers, no summary line — so it pipes straight into jq.

The command set is small and covers the things a script actually wants: login, logout, whoami, status, config, and members list, keys list and activity list with paging. --server and --json are global options.

rocketflare login --server https://app.example.com
rocketflare whoami
rocketflare members list --json | jq -r '.items[].email'

Exit codes tell failures apart

A script needs to distinguish "you are not signed in" from "you are signed in and not allowed", because the first is fixable by logging in again and the second is not. So those are separate codes rather than a single generic failure.

CodeMeaning
0success
1an API failure other than 401 or 403, a network problem, bad options, anything unexpected
2not logged in — no key in the config or the environment, or the server answered 401. The hint tells you to run login
3forbidden — the key's role in that organisation does not allow the action

Commands throw a typed error and never call process.exit themselves. One handler at the top prints once and sets the exit code, which is also what lets the test suite run commands in-process with an injected fetch and assert on the code without spawning anything.

Shipped in the repository, not on a registry

The package is private. It is built by continuous integration as a compile check and run from the repository with pnpm cli <command>, or installed from a built dist/cli.js.

Publishing a CLI means owning a public name, a release cadence and a support expectation. That is a decision for the product built on the kit, not one the kit should make on its behalf — so it ships private, and flipping that is a deliberate act.