feat(cloud): add `reflex cloud whoami` and `reflex cloud token`
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_fingerprintis a truncated sha256, so two machines can be compared in a support thread without anyone pasting a secret. --jsonfor scripting,--tokento 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.
--printwrites the raw token to stdout viaclick.echo, deliberately bypassingconsole.print, which wraps at 80 columns when piped and would corrupt a long token inexport 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.--settakes 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 TOKENstill works. It validates before saving, then reads back to confirm the write —save_token_to_configswallows write errors, so success was previously unverifiable. A rejected token exits non-zero and leaves the existing one untouched.--clearremoves the stored token and confirms removal, distinguishing three outcomes: gone (success), still present (exit 1), config unreadable (exit 1). It notes whenREFLEX_ACCESS_TOKENis 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_configmocksPath.existsandPath.mkdirbut notPath.open, so it overwrote the realhosting_v1.jsonwith{"access_token": "test_token"}.test_authenticated_token_found_but_invalidcalls the realdelete_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.pyat 100% coverage. Includes: a bad--setneither saves nor deletes, a failed write preserves the previous token and leaks no temp file, an unreadable config is preserved rather than replaced,--printround-trips a 300-char token verbatim and stays clean under--loglevel debug,--cleardistinguishes all three removal outcomes,whoaminever opens a browser, and the token never appears in either output mode. - The
--printstdout 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/formatclean.pyright reflex testsunchanged at 5 pre-existing errors (recharts/lucide stubs, unrelated). - Smoke-tested against prod:
whoami,--printunder--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
- Both commands call
hosting.validate_tokendirectly rather thanvalidate_token_with_retries, which deletes the cached token on access-denied — a bad side effect for--setin particular. The trade-off is no retry on a transient failure. --sethas 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.--tokenandREFLEX_ACCESS_TOKENremain available meanwhile.--printsuppresses 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 debuggives the lookup diagnostics.saveanddeletedeliberately disagree about an unreadable config:savestarts fresh so login always works,deleterefuses 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, notaccess denied:validate_tokenmaps a 401 (raise_for_status→HTTPStatusError⊂httpx.HTTPError) toTokenValidationError, and only a JSON parse failure reachesTokenAccessDeniedError. Sincevalidate_token_with_retriesonly clears the cached token onValueError, a revoked token is never evicted fromhosting_v1.json— and keeps shadowing a validREFLEX_ACCESS_TOKENindefinitely. print_tabletruncates values across the hosting CLI (1532f93f-41b6-4a78-893d-a…), so humans are pushed to--jsonto get a value they can paste back into the CLI.whoamisidesteps it by not usingprint_table; the general fix wants its own design decision.reflex-hosting-cliignoresREFLEX_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