Skip to content
1 change: 1 addition & 0 deletions packages/reflex-hosting-cli/news/6918.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`REFLEX_ACCESS_TOKEN` now takes precedence over the token stored by `reflex login`. Previously the stored token won and the environment variable was consulted only when no token was stored, so exporting it to run a script against a different account had no effect on a machine that had ever logged in — silently, and with no way to tell which credential was in use. Exporting the variable is an explicit choice for that invocation; the config file is ambient state left behind by an earlier login. This changes behavior only when both are present and differ. `reflex cloud whoami` reports which source is in use.
1 change: 1 addition & 0 deletions packages/reflex-hosting-cli/news/6918.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Neither helper reports write failures to the caller (`save_token_to_config` logs a warning, `delete_token_from_config` only a debug message), so this was easy to miss. Both now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. A config that exists but cannot be read is no longer treated as empty either, so `delete_token_from_config` leaves a malformed file alone instead of replacing it; `save_token_to_config` still starts fresh from one, so a corrupt config cannot block re-authenticating. This also covers `reflex login` and `reflex logout`, which share these helpers.
1 change: 1 addition & 0 deletions packages/reflex-hosting-cli/news/6918.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `reflex cloud whoami` and `reflex cloud token`. `whoami` reports the account, org, tier and token source that the CLI is authenticating as, resolving the token against the control plane without ever starting a browser login and without printing the token — it shows a non-reversible fingerprint instead, so two machines can be compared without anyone sharing a secret. `reflex cloud token` takes exactly one of `--print`, `--set TOKEN` or `--clear`: `--print` writes the raw token to stdout for capture (`export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`), `--set` validates the token with the control plane before storing it and leaves the previous one in place if it is rejected, and `--clear` removes the stored token, noting when `REFLEX_ACCESS_TOKEN` remains set and will take over.
1 change: 1 addition & 0 deletions packages/reflex-hosting-cli/news/6918.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`reflex cloud token --set` accepts the token on stdin — pass `-`, or omit the value entirely — so live credentials need not appear in shell history or the process list. When stdin is a terminal it prompts without echoing. `reflex cloud whoami` writes its output directly rather than through the shared console, which applies rich markup and wraps to the terminal width: identifiers now print in full instead of being truncated to fit, and `--json` stays on one line so it can be piped.
162 changes: 129 additions & 33 deletions packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
import importlib.metadata
import json
import logging
import os
import platform
import re
import subprocess
import sys
import tempfile
import time
import uuid
import webbrowser
Expand Down Expand Up @@ -348,33 +350,56 @@ def open(self, url: str, new: int = 0, autoraise: bool = True):
webbrowser.BackgroundBrowser = SilentBackgroundBrowser


def get_existing_access_token() -> str:
"""Fetch the access token from the existing config if applicable.
class TokenSource(str, Enum):
"""Where an access token was loaded from."""

CONFIG = "config file"
ENVIRONMENT = "REFLEX_ACCESS_TOKEN environment variable"
OPTION = "--token option"
NONE = "none"


def get_existing_access_token_with_source() -> tuple[str, TokenSource]:
"""Fetch the access token from the environment or existing config, and say where it came from.

``REFLEX_ACCESS_TOKEN`` takes precedence: exporting it is an explicit
choice for this invocation, while the config file is ambient state left
behind by an earlier ``reflex login``.

Returns:
The access token.
If not found, return empty string for it instead.
The access token and the source it was loaded from.
If not found, return empty string and ``TokenSource.NONE`` instead.

"""
import os
access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "")
Comment thread
Kastier1 marked this conversation as resolved.
if access_token:
logger.debug("Using REFLEX_ACCESS_TOKEN from environment")
return access_token, TokenSource.ENVIRONMENT

logger.debug("Fetching token from existing config...")
access_token = ""
try:
with constants.Hosting.HOSTING_JSON.open() as config_file:
hosting_config = json.load(config_file)
access_token = hosting_config.get("access_token", "")
except Exception as ex:
access_token = stored_access_token()
except (OSError, ValueError) as ex:
Comment thread
Kastier1 marked this conversation as resolved.
logger.debug(
f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}"
)
return "", TokenSource.NONE

if not access_token:
access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "")
if access_token:
logger.debug("Using REFLEX_ACCESS_TOKEN from environment")
if access_token:
return access_token, TokenSource.CONFIG
Comment thread
Kastier1 marked this conversation as resolved.

return access_token
return "", TokenSource.NONE


def get_existing_access_token() -> str:
"""Fetch the access token from the existing config if applicable.

Returns:
The access token.
If not found, return empty string for it instead.

"""
return get_existing_access_token_with_source()[0]


def is_reflex_enterprise_installed() -> bool:
Expand Down Expand Up @@ -466,23 +491,95 @@ def validate_token(token: str) -> dict[str, Any]:
raise TokenValidationError("internal errors", request_id=request_id) from ex


def _read_hosting_config() -> dict[str, Any]:
"""Read the hosting config file.

A config that exists but cannot be read is reported rather than treated as
empty, so callers do not overwrite entries they were unable to see.

Returns:
The stored config, or an empty dict if the file does not exist.

Raises:
OSError: If the config exists but cannot be read.
ValueError: If the config exists but does not hold a JSON object.

"""
try:
with constants.Hosting.HOSTING_JSON.open(encoding="utf-8") as config_file:
hosting_config = json.load(config_file)
except FileNotFoundError:
return {}
# Valid JSON is not necessarily the object every caller indexes into.
if not isinstance(hosting_config, dict):
msg = f"{constants.Hosting.HOSTING_JSON} does not hold a JSON object"
raise ValueError(msg)
return hosting_config


def stored_access_token() -> str:
"""Read the access token held in the config file.

Unlike ``get_existing_access_token`` this ignores ``REFLEX_ACCESS_TOKEN``
and reports read failures, so callers can tell "no token stored" apart from
"cannot tell what is stored".

Returns:
The stored token, or an empty string if the config holds none.

Raises:
OSError: If the config exists but cannot be read.
ValueError: If the config exists but does not hold valid JSON.

"""
return _read_hosting_config().get("access_token", "")
Comment thread
Kastier1 marked this conversation as resolved.


def _write_hosting_config(hosting_config: dict[str, Any]):
"""Write the hosting config file atomically.

The config is written to a temporary file alongside the target and moved
into place, so a failed or interrupted write leaves the previous
credentials intact rather than truncating them.

Args:
hosting_config: The config to persist.

"""
target = constants.Hosting.HOSTING_JSON
target.parent.mkdir(parents=True, exist_ok=True)
# Close the handle before replacing: Windows cannot rename an open file.
temp_fd, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
temp_path = Path(temp_name)
try:
with os.fdopen(temp_fd, "w", encoding="utf-8") as config_file:
json.dump(hosting_config, config_file)
config_file.flush()
os.fsync(config_file.fileno())
temp_path.replace(target)
except BaseException:
temp_path.unlink(missing_ok=True)
raise


def delete_token_from_config():
"""Delete the invalid token from the config file if applicable."""
if constants.Hosting.HOSTING_JSON.exists():
try:
with constants.Hosting.HOSTING_JSON.open("r") as config_file:
hosting_config = json.load(config_file)
hosting_config = _read_hosting_config()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
hosting_config.pop("access_token", None)
with constants.Hosting.HOSTING_JSON.open("w") as config_file:
json.dump(hosting_config, config_file)
_write_hosting_config(hosting_config)
except Exception as ex:
# Best efforts removing invalid token is OK
logger.debug(
f"Unable to delete the invalid token from config file, err: {ex}"
)
# Delete the previous hosting service data if present.
if constants.Hosting.HOSTING_JSON_V0.exists():
constants.Hosting.HOSTING_JSON_V0.unlink()
# Delete the previous hosting service data if present. Best efforts, like
# the rest of this function: the legacy file holds no token the CLI reads.
try:
constants.Hosting.HOSTING_JSON_V0.unlink(missing_ok=True)
except OSError as ex:
logger.debug(f"Unable to remove {constants.Hosting.HOSTING_JSON_V0}: {ex}")


def save_token_to_config(token: str):
Expand All @@ -493,18 +590,17 @@ def save_token_to_config(token: str):

"""
try:
if not Path(constants.Reflex.DIR).exists():
Path(constants.Reflex.DIR).mkdir(parents=True, exist_ok=True)
hosting_config: dict[str, str] = {}
if constants.Hosting.HOSTING_JSON.exists():
try:
with constants.Hosting.HOSTING_JSON.open("r") as config_file:
hosting_config = json.load(config_file)
except (OSError, ValueError):
hosting_config = {}
try:
hosting_config = _read_hosting_config()
except (OSError, ValueError) as ex:
# An unreadable config must not block re-authenticating; the token
# is what makes the file useful, so start over from an empty one.
logger.debug(
f"Discarding unreadable {constants.Hosting.HOSTING_JSON}: {ex}"
)
hosting_config = {}
hosting_config["access_token"] = token
with constants.Hosting.HOSTING_JSON.open("w") as config_file:
json.dump(hosting_config, config_file)
_write_hosting_config(hosting_config)
except Exception as ex:
logger.warning(
f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}"
Expand Down
Loading
Loading