feat(cloud): add `reflex cloud whoami` and `reflex cloud token`

Merged
KA
3mo ago
+1110-66

Why

Answering "which credentials is this machine actually using?" meant reading hosting_v1.json by hand. That came up chasing a customer whose token appeared not to update, and there was no supported way to confirm or disprove it.

What

reflex cloud whoami — resolves the token against the control plane and reports the identity, plus which source the token came from:

$ reflex cloud whoami
 field              value
 email              user@example.com
 user_id            …
 org_id             …
 tier               Pro
 is_service_account False
 token_source       config file
 token_fingerprint  sha256:714a6f6d0f454649
  • Never starts a browser login (unlike get_authenticated_client), so it is safe in CI and answers "am I logged in?" without changing the answer.
  • Never prints the token. token_fingerprint is a truncated sha256, so two machines can be compared in a support thread without anyone pasting a secret.
  • --json for scripting, --token to inspect a specific token.
  • On rejection it surfaces the auth request id for correlation with server-side logs.

reflex cloud token --print / --set TOKEN / --clear — exactly one required.

  • --print writes the raw token to stdout via click.echo, deliberately bypassing console.print, which wraps at 80 columns when piped and would corrupt a long token in export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print). The log level is forced to ERROR for this path, because the shared console writes everything below ERROR to stdout; stdout now carries the token or nothing.
  • --set takes the token on stdin (-, a bare --set, or a hidden prompt on a tty) so live credentials stay out of shell history and the process list; --set TOKEN still works. It validates before saving, then reads back to confirm the write — save_token_to_config swallows write errors, so success was previously unverifiable. A rejected token exits non-zero and leaves the existing one untouched.
  • --clear removes the stored token and confirms removal, distinguishing three outcomes: gone (success), still present (exit 1), config unreadable (exit 1). It notes when REFLEX_ACCESS_TOKEN is still set and will now take over.

get_existing_access_token_with_source reports which source a token came from, so both commands can show it. Its precedence is now REFLEX_ACCESS_TOKEN first, then the config file — flipped in its own commit, see below. get_existing_access_token delegates to it — no behavior change. stored_access_token reads the config directly, ignoring the environment and propagating read errors, so callers can tell "no token stored" from "cannot tell what is stored".

Bug found in review: config writes were destructive

save_token_to_config and delete_token_from_config open the config with mode "w", which truncates on open, before json.dump runs. Any write failure therefore destroyed the stored credentials — and since neither helper reports failure to the caller, it surfaced only as a log line. Reproduced by patching json.dump to raise:

before: {"access_token": "GOOD-TOKEN", "project": "p1"}
after:  (empty file)

Both now serialize to a temporary file alongside the target and move it into place with Path.replace, closing the handle first so Windows can rename it and cleaning up the temp file on failure. A failed write leaves the previous credentials byte-identical. This also fixes reflex login and reflex logout, which share these helpers.

A follow-up review round caught a regression in that first fix: routing reads through a helper that returned {} on failure turned "cannot read" into "is empty", so delete_token_from_config replaced a malformed config with {}. Verified against a main worktree — main left the file untouched. Reads now propagate errors, delete leaves an unreadable config alone, and save keeps its fallback of starting fresh so a corrupt config cannot block re-authenticating.

Incidental fix: tests were destroying the developer's login

Not cosmetic, and the reason this PR touches conftest.py:

  • test_save_token_to_config mocks Path.exists and Path.mkdir but not Path.open, so it overwrote the real hosting_v1.json with {"access_token": "test_token"}.
  • test_authenticated_token_found_but_invalid calls the real delete_token_from_config, emptying it to {}.

Verified with a sentinel: before, pytest tests/units/reflex_cli reduced the file to {}; after, it is untouched. An autouse fixture now points Reflex.DIR and both HOSTING_JSON paths at a tmp dir, so no reflex_cli test can reach the real file.

With that isolation in place, the config-file tests were rewritten to assert real on-disk contents instead of mock call counts. The old assertions (mocked_open.call_count == 2) pinned the exact non-atomic write sequence, so they would have blocked the fix above while proving nothing about the resulting file.

Testing

  • 20 tests in tests/units/reflex_cli/v2/test_auth.py; reflex_cli/v2/auth.py at 100% coverage. Includes: a bad --set neither saves nor deletes, a failed write preserves the previous token and leaks no temp file, an unreadable config is preserved rather than replaced, --print round-trips a 300-char token verbatim and stays clean under --loglevel debug, --clear distinguishes all three removal outcomes, whoami never opens a browser, and the token never appears in either output mode.
  • The --print stdout test exercises the real lookup rather than mocking it, because the contaminating debug records originate inside the helper. Confirmed it fails when the fix is reverted.
  • Config-file and token-precedence tests reworked against the real (isolated) filesystem.
  • Full suite: 7639 passed, coverage 74.80%. ruff check/format clean. pyright reflex tests unchanged at 5 pre-existing errors (recharts/lucide stubs, unrelated).
  • Smoke-tested against prod: whoami, --print under --loglevel debug, --clear, and the usage error.

Docs need no change — docs/.../cloud_cliref.py generates the CLI reference from the click tree, so both commands appear automatically. Command docstrings carry no Args:/Raises: sections, matching every other command in the package, because a click docstring is its --help text.

Deliberate choices worth a reviewer's attention

  1. Both commands call hosting.validate_token directly rather than validate_token_with_retries, which deletes the cached token on access-denied — a bad side effect for --set in particular. The trade-off is no retry on a transient failure.
  2. --set has no offline escape hatch; if validation cannot reach the control plane, nothing is saved. Strict on purpose, since silently storing an unusable token is the failure mode this PR exists to expose. --token and REFLEX_ACCESS_TOKEN remain available meanwhile.
  3. --print suppresses diagnostics rather than routing them to stderr. The stdout-for-non-errors split is a global convention of the shared console; rerouting it belongs in its own change. reflex cloud whoami --loglevel debug gives the lookup diagnostics.
  4. save and delete deliberately disagree about an unreadable config: save starts fresh so login always works, delete refuses to touch what it cannot read. Both are tested as policies.

Behavior change: token precedence (own commit, droppable)

REFLEX_ACCESS_TOKEN now beats the token stored by reflex login, rather than the reverse. Raised by @masenf, and it is the defect behind the customer report that motivated this PR: exporting the variable for a scripted deploy did nothing on any machine that had ever logged in, silently. Exporting is an explicit per-invocation choice; the config file is ambient state. Matches gh/aws/docker.

Only changes behavior when both are present and differ. Carried as commit 27b71f7b2 with a breaking news fragment so it can be dropped and shipped separately.

Follow-ups, not in this PR

  • A rejected token reports as server error, not access denied: validate_token maps a 401 (raise_for_status → HTTPStatusError ⊂ httpx.HTTPError) to TokenValidationError, and only a JSON parse failure reaches TokenAccessDeniedError. Since validate_token_with_retries only clears the cached token on ValueError, a revoked token is never evicted from hosting_v1.json — and keeps shadowing a valid REFLEX_ACCESS_TOKEN indefinitely.
  • print_table truncates values across the hosting CLI (1532f93f-41b6-4a78-893d-a…), so humans are pushed to --json to get a value they can paste back into the CLI. whoami sidesteps it by not using print_table; the general fix wants its own design decision.
  • reflex-hosting-cli ignores REFLEX_DIR, which relocates the framework's data dir, so the token file diverges from the rest of reflex state. Fixing it moves an existing file and needs a read-fallback migration.

🤖 Generated with Claude Code