Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
43 changes: 27 additions & 16 deletions src/goodeye_cli/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 20 additions & 6 deletions src/goodeye_cli/commands/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,43 @@
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=(
"Start non-interactive account registration. "
"Use `goodeye register-verify --email <email> --code <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.")
Expand Down
71 changes: 71 additions & 0 deletions tests/test_commands_login_register_referral.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
47 changes: 47 additions & 0 deletions tests/test_commands_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.