diff --git a/pyproject.toml b/pyproject.toml index 1747385..1c585fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "goodeye" -version = "0.18.1" +version = "0.18.2" description = "Goodeye CLI: share and run outcome-aligned AI workflows from the terminal." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/goodeye_cli/commands/login.py b/src/goodeye_cli/commands/login.py index 9066213..3362083 100644 --- a/src/goodeye_cli/commands/login.py +++ b/src/goodeye_cli/commands/login.py @@ -13,6 +13,32 @@ from goodeye_cli.config import get_server, save_client_config, save_credentials +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. + + Shared by ``goodeye login`` and ``goodeye register``: the hosted sign-in + page creates the account for new users and signs in returning users, so both + commands drive the same interactive path. Redeems ``referral_code`` once + credentials are saved, if one was supplied. + """ + with GoodeyeClient(server) as client: + client_config = client.get_client_config() + save_client_config(client_config.model_dump()) + hostname = platform.node() or "unknown" + api_key = device_code_login( + server, + workos_client_id=client_config.workos_client_id, + workos_device_authorization_uri=client_config.workos_device_authorization_uri, + workos_token_uri=client_config.workos_token_uri, + hostname=hostname, + console=console, + ) + + 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) + + def login( email: str | None = typer.Option( None, @@ -49,22 +75,7 @@ def login( ) return - with GoodeyeClient(server) as client: - client_config = client.get_client_config() - save_client_config(client_config.model_dump()) - hostname = platform.node() or "unknown" - api_key = device_code_login( - server, - workos_client_id=client_config.workos_client_id, - workos_device_authorization_uri=client_config.workos_device_authorization_uri, - workos_token_uri=client_config.workos_token_uri, - hostname=hostname, - console=console, - ) - - 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) + run_interactive_login(server, console, referral_code) def login_verify( diff --git a/src/goodeye_cli/commands/register.py b/src/goodeye_cli/commands/register.py index d4241b4..d25cf9e 100644 --- a/src/goodeye_cli/commands/register.py +++ b/src/goodeye_cli/commands/register.py @@ -6,13 +6,14 @@ from rich.console import Console from goodeye_cli.client import GoodeyeClient +from goodeye_cli.commands.login import run_interactive_login from goodeye_cli.commands.referrals import _maybe_redeem_referral from goodeye_cli.config import get_server, save_credentials def register( - email: str = typer.Option( - ..., + email: str | None = typer.Option( + None, "--email", "-e", help=( @@ -20,15 +21,28 @@ def register( "Use `goodeye register-verify --email --code ` to finish." ), ), + referral_code: str | None = typer.Option( + None, + "--referral-code", + help="Referral code to claim a bonus after registering.", + ), ) -> None: - """Start non-interactive Goodeye account registration. + """Create a Goodeye account on this machine. - This command sends the email-code request and exits without prompting so an - AI agent or automation can run the verify command after the user supplies - the emailed code. + With no options, runs the interactive browser/device-code flow for humans: + the hosted sign-in page creates the account for new users (and signs in + returning users), so registering and signing in share one path. With + ``--email``, starts a non-interactive email-code registration for agents and + automation, then exits so the emailed code can be supplied in a separate + command. """ console = Console() server = get_server() + + if email is None: + run_interactive_login(server, console, referral_code) + return + with GoodeyeClient(server) as client: client.register(email) console.print("Check your email for next steps.") diff --git a/tests/test_commands_login_register_referral.py b/tests/test_commands_login_register_referral.py index 2e80e1d..52160ef 100644 --- a/tests/test_commands_login_register_referral.py +++ b/tests/test_commands_login_register_referral.py @@ -297,6 +297,77 @@ def test_login_device_code_referral_failure_is_nonfatal( assert "already used a referral code" in result.output +# --------------------------------------------------------------------------- +# register (no --email) --referral-code (interactive device-code path) +# --------------------------------------------------------------------------- + + +def test_register_device_code_with_referral_code_success( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Bare `register` runs the interactive device-code flow and redeems the referral code.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_regdc1", + ) as mock_dcl, + patch("goodeye_cli.commands.login.save_client_config"), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke(app, ["register", "--referral-code", "GOODEYE-REGX"]) + + assert result.exit_code == 0, result.output + assert mock_dcl.called + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_regdc1" + assert "Referral bonus applied" in result.output + assert "$5.00" in result.output + + +def test_register_device_code_without_referral_code_does_not_call_redeem( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Bare `register` without --referral-code runs interactively and skips redeem.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_regdc2", + ) as mock_dcl, + patch("goodeye_cli.commands.login.save_client_config"), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + redeem_route = respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke(app, ["register"]) + + assert result.exit_code == 0, result.output + assert mock_dcl.called + assert redeem_route.call_count == 0 + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_regdc2" + assert "Referral" not in result.output + + # --------------------------------------------------------------------------- # login-verify --referral-code # --------------------------------------------------------------------------- diff --git a/tests/test_commands_meta.py b/tests/test_commands_meta.py index 3e81f38..fd795aa 100644 --- a/tests/test_commands_meta.py +++ b/tests/test_commands_meta.py @@ -224,6 +224,53 @@ def test_login_help_documents_interactive_and_non_interactive_modes() -> None: assert "non-interactive" in out +def test_register_help_documents_interactive_and_non_interactive_modes() -> None: + runner = CliRunner() + result = runner.invoke(app, ["register", "--help"]) + + assert result.exit_code == 0, result.output + out = result.output.lower() + assert "interactive" in out + assert "non-interactive" in out + + +def test_register_no_args_runs_interactive_device_code( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Bare `register` (no --email) must drive the interactive device-code flow, + not error on a missing option the way the old non-interactive-only command did.""" + _env(monkeypatch, tmp_config_paths, api_key=None) + + from unittest.mock import patch + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_reg_int", + ) as mock_dcl, + patch("goodeye_cli.commands.login.save_client_config"), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response( + 200, + json={ + "workos_client_id": "client_X", + "workos_device_authorization_uri": DEVICE_URI, + "workos_token_uri": TOKEN_URI, + }, + ) + ) + runner = CliRunner() + result = runner.invoke(app, ["register"]) + + assert result.exit_code == 0, result.output + assert mock_dcl.called + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_reg_int" + + def test_logout_removes_credentials(tmp_config_paths: ConfigPaths, monkeypatch) -> None: _env(monkeypatch, tmp_config_paths, api_key=None) save_credentials({"api_key": "good_live_EXAMPLE", "server": SERVER}, tmp_config_paths) diff --git a/uv.lock b/uv.lock index feb7b2e..4e42315 100644 --- a/uv.lock +++ b/uv.lock @@ -176,7 +176,7 @@ wheels = [ [[package]] name = "goodeye" -version = "0.18.1" +version = "0.18.2" source = { editable = "." } dependencies = [ { name = "httpx" },