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:
127.0.0.1 or localhost.
- The CLI starts a loopback HTTP listener on the first free port in
8765–8770. -
It opens the browser at
/auth/clion the server, passing the callback URL and the machine's hostname. - 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.
-
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. -
The listener answers with a self-closing page, verifies the key against
/api/me, writes it to~/.rocketflare/config.jsonwith mode0600, 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.
| Code | Meaning |
|---|---|
0 | success |
1 | an API failure other than 401 or 403, a network problem, bad options, anything unexpected |
2 | not logged in — no key in the config or the environment, or the server answered 401. The hint tells you to run login |
3 | forbidden — 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.