diff --git a/src/goodeye_cli/commands/login.py b/src/goodeye_cli/commands/login.py index e8905f5..dc45ecd 100644 --- a/src/goodeye_cli/commands/login.py +++ b/src/goodeye_cli/commands/login.py @@ -3,6 +3,7 @@ from __future__ import annotations import platform +import sys import typer from rich.console import Console @@ -13,6 +14,50 @@ from goodeye_cli.config import get_server, save_client_config, save_credentials +def _is_tty() -> bool: + """Return True when stdout is an interactive terminal.""" + return sys.stdout.isatty() + + +def _print_post_login_banner(server: str, api_key: str, console: Console) -> None: + """Print a one-line nudge about shared workflows and pending invitations. + + Silent and non-blocking: any error, non-TTY session, or offline state + results in nothing printed. Login always succeeds regardless. + """ + if not _is_tty(): + return + try: + with GoodeyeClient(server, api_key=api_key, timeout=0.9) as client: + workflows = client.list_workflows(filter_="shared-with-me") + invitations = client.list_invitations(filter_="received", state="pending") + n = len(workflows.items) + m = len(invitations.items) + # A present next_cursor means the first page did not exhaust the + # results, so the counts are lower bounds; render them as "N+". + wf_more = workflows.next_cursor is not None + inv_more = invitations.next_cursor is not None + except Exception: + return + if n == 0 and m == 0: + return + wf_count = f"{n}+" if wf_more else str(n) + inv_count = f"{m}+" if inv_more else str(m) + wf_noun = "workflow" if n == 1 and not wf_more else "workflows" + inv_noun = "invitation" if m == 1 and not inv_more else "invitations" + # Name only the nonzero categories so a user with shared workflows but no + # invitations (or vice versa) never sees an awkward "0 ..." count. + clauses: list[str] = [] + commands: list[str] = [] + if n > 0: + clauses.append(f"{wf_count} {wf_noun} shared with you") + commands.append("'goodeye workflows list --filter shared-with-me'") + if m > 0: + clauses.append(f"{inv_count} pending {inv_noun}") + commands.append("'goodeye invitations list'") + console.print(f"You have {' and '.join(clauses)}. Run {' or '.join(commands)} to see them.") + + def run_interactive_login(server: str, console: Console, referral_code: str | None) -> None: """Run the browser/device-code sign-in flow and save local credentials. @@ -37,6 +82,7 @@ def run_interactive_login(server: str, console: Console, referral_code: str | No path = save_credentials({"api_key": api_key, "server": server}) console.print(f"[green]Signed in.[/green] Credentials saved to {path}") _maybe_redeem_referral(server, api_key, referral_code, console) + _print_post_login_banner(server, api_key, console) def login( diff --git a/tests/test_login_banner.py b/tests/test_login_banner.py new file mode 100644 index 0000000..62e9573 --- /dev/null +++ b/tests/test_login_banner.py @@ -0,0 +1,229 @@ +"""Tests for the post-login shared-with-you banner. + +After a successful interactive login the CLI prints a one-line nudge +when workflows are shared with the user or invitations are pending. +The banner is silent on non-TTY sessions and on any list-endpoint error. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import httpx +import respx +from typer.testing import CliRunner + +from goodeye_cli.app import app +from goodeye_cli.config import ConfigPaths + +SERVER = "https://example.test" +DEVICE_URI = "https://api.workos.com/user_management/authorize/device" +TOKEN_URI = "https://api.workos.com/user_management/authenticate" + +_CLIENT_CONFIG_BODY = { + "workos_client_id": "client_X", + "workos_device_authorization_uri": DEVICE_URI, + "workos_token_uri": TOKEN_URI, +} + +_WORKFLOW_ITEMS = [ + {"id": "wf-01", "name": "alpha", "current_version": 1}, + {"id": "wf-02", "name": "beta", "current_version": 2}, +] + +_INVITATION_ITEMS = [ + { + "id": "inv-01", + "kind": "team_membership", + "target_id": "t-01", + "target_label": "@acme", + "proposed_by_handle": "alice", + "proposed_to_handle": "bob", + "created_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-02-01T00:00:00Z", + }, +] + + +def _env(monkeypatch, tmp_config_paths: ConfigPaths) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_config_paths.config_dir.parent)) + monkeypatch.setenv("GOODEYE_SERVER", SERVER) + monkeypatch.delenv("GOODEYE_API_KEY", raising=False) + + +def test_login_banner_interactive_shows_counts(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + """Interactive login prints shared-workflow and invitation counts when both are nonzero.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner1", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None}) + ) + respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + # Normalize whitespace to handle Rich's line wrapping in the captured output. + flat = " ".join(result.output.split()) + assert "shared with you" in flat + assert "pending invitation" in flat + assert "goodeye workflows list --filter shared-with-me" in flat + assert "goodeye invitations list" in flat + # Confirm the counts appear + assert "2" in flat # 2 workflows + assert "1" in flat # 1 invitation + + +def test_login_banner_only_workflows_omits_zero_invitations( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """With shared workflows but no pending invitations, the banner names only workflows.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner4", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None}) + ) + respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + flat = " ".join(result.output.split()) + assert "shared with you" in flat + assert "goodeye workflows list --filter shared-with-me" in flat + # The zero category is omitted entirely: no "0 ..." count, no invitation copy. + assert "pending invitation" not in flat + assert "goodeye invitations list" not in flat + + +def test_login_banner_only_invitations_omits_zero_workflows( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """With pending invitations but no shared workflows, the banner names only invitations.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner5", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) + ) + respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + flat = " ".join(result.output.split()) + assert "pending invitation" in flat + assert "goodeye invitations list" in flat + # The zero category is omitted entirely: no "0 ..." count, no workflow copy. + assert "shared with you" not in flat + assert "goodeye workflows list --filter shared-with-me" not in flat + + +def test_login_banner_no_tty_prints_nothing(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + """Non-TTY session produces no banner and does not call the list endpoints.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner2", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=False), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + wf_route = respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None}) + ) + inv_route = respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + assert "shared with you" not in result.output + # List endpoints must not be called when not a TTY + assert wf_route.call_count == 0 + assert inv_route.call_count == 0 + + +def test_login_banner_api_error_prints_nothing_and_login_succeeds( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """When a list endpoint returns an error the banner is suppressed and login exits 0.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner3", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response( + 500, + json={"error": "server_error", "message": "Internal error."}, + ) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + assert "shared with you" not in result.output + assert "Signed in" in result.output diff --git a/uv.lock b/uv.lock index 431ec83..7f649aa 100644 --- a/uv.lock +++ b/uv.lock @@ -176,7 +176,7 @@ wheels = [ [[package]] name = "goodeye" -version = "0.19.3" +version = "0.20.0" source = { editable = "." } dependencies = [ { name = "httpx" },