feat: add a Horizon host option to login

This commit is contained in:
Edward Park 2026-08-07 22:49:47 -07:00
commit e877785511
6 changed files with 70 additions and 1 deletions

View file

@ -98,6 +98,13 @@ fastmcp logout
It opens a browser when the terminal supports it.
If the browser does not open, use the shown URL and code on another device.
Use `--host` to connect to another Horizon environment.
The CLI saves the host and clears the stored key before a host change.
```bash
fastmcp login --host https://horizon.example.com
```
Login stores only the personal Horizon API key.
It does not select or store a deployment organization.
`fastmcp whoami` gets the current user and organization memberships from Horizon.

View file

@ -24,6 +24,8 @@ fastmcp login
The command shows a verification URL and code before it opens the browser.
If the browser cannot open, visit the shown URL and enter the code.
New users can register and create their first Horizon organization in the browser.
Use `fastmcp login --host <url>` to save a different Horizon host.
A host change clears the stored credential before login.
Check the active account and its current organization memberships after login.

View file

@ -48,6 +48,13 @@ JsonOption = Annotated[
negative=(),
),
]
HostOption = Annotated[
str | None,
Parameter(
name="--host",
help="Use and save a different Horizon host URL",
),
]
def _can_open_browser() -> bool:
@ -149,13 +156,29 @@ async def _get_identity(
async def login(
*,
host: HostOption = None,
json_output: JsonOption = False,
) -> None:
"""Sign in to Prefect Horizon."""
credentials = CredentialStore()
try:
configuration = ConfigurationStore().load()
configuration_store = ConfigurationStore()
if host is None:
configuration = configuration_store.load()
else:
try:
configuration = configuration_store.set_api_origin(
host,
credentials=credentials,
)
except ValueError:
_fail(
"login",
"invalid_host",
"The Horizon host must be an HTTP origin.",
json_output=json_output,
)
async def device_authorization():
async with HorizonClient(configuration.api_origin) as client:

View file

@ -23,6 +23,7 @@ ErrorCategory = Literal[
"authorization_failed",
"horizon_error",
"horizon_unavailable",
"invalid_host",
"remote_revocation_failed",
"state_error",
]

View file

@ -154,6 +154,35 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr(
assert not (fastmcp.settings.home / "cli" / "config.json").exists()
async def test_login_host_is_saved_before_device_authorization(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(host="https://dev.horizon.prefect.io/", json_output=True)
assert json.loads(capsys.readouterr().out)["ok"] is True
configuration_path = fastmcp.settings.home / "cli" / "config.json"
assert json.loads(configuration_path.read_text()) == {
"schemaVersion": 1,
"apiOrigin": "https://dev.horizon.prefect.io",
}
assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
async def test_login_rejects_an_invalid_host(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit, match="1"):
await login(host="https://horizon.prefect.io/path", json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "invalid_host"
assert CredentialStore().path.exists() is False
async def test_tty_login_survives_browser_open_failure(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],

View file

@ -42,6 +42,13 @@ class TestMainCLI:
assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
assert bound.arguments == {"json_output": True}
def test_login_accepts_a_horizon_host(self):
_, bound, _ = app.parse_args(
["login", "--host", "https://dev.horizon.prefect.io"]
)
assert bound.arguments == {"host": "https://dev.horizon.prefect.io"}
class TestVersionCommand:
"""Test the version command."""