discolike-cli 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/PKG-INFO +2 -2
  2. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/pyproject.toml +2 -2
  3. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/auth.py +32 -5
  4. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/main.py +2 -0
  5. discolike_cli-0.3.1/src/discolike_cli/signup.py +76 -0
  6. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_auth_oauth.py +91 -5
  7. discolike_cli-0.3.1/tests/test_signup_cli.py +104 -0
  8. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/.gitignore +0 -0
  9. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/LICENSE +0 -0
  10. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/README.md +0 -0
  11. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/__init__.py +0 -0
  12. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/_loopback.py +0 -0
  13. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/_output.py +0 -0
  14. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/account.py +0 -0
  15. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/company.py +0 -0
  16. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/contacts.py +0 -0
  17. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/discogen.py +0 -0
  18. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/discover.py +0 -0
  19. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/email.py +0 -0
  20. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/enrich.py +0 -0
  21. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/match.py +0 -0
  22. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/providers.py +0 -0
  23. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/py.typed +0 -0
  24. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/queries.py +0 -0
  25. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/conftest.py +0 -0
  26. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_account.py +0 -0
  27. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_auth.py +0 -0
  28. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_company.py +0 -0
  29. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_contacts_cli.py +0 -0
  30. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_discogen_cli.py +0 -0
  31. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_discover.py +0 -0
  32. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_email_cli.py +0 -0
  33. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_enrich_cli.py +0 -0
  34. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_match_cli.py +0 -0
  35. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_output.py +0 -0
  36. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_providers_cli.py +0 -0
  37. {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_queries_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: discolike-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Official CLI for the DiscoLike API
5
5
  Project-URL: Homepage, https://www.discolike.com
6
6
  Project-URL: Documentation, https://docs.discolike.com
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.14
25
25
  Classifier: Topic :: Utilities
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.10
28
- Requires-Dist: discolike==0.3.0
28
+ Requires-Dist: discolike==0.3.1
29
29
  Requires-Dist: rich>=13.0
30
30
  Requires-Dist: typer>=0.12
31
31
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "discolike-cli"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Official CLI for the DiscoLike API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,7 +12,7 @@ license-files = ["LICENSE"]
12
12
  requires-python = ">=3.10"
13
13
  authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
14
14
  dependencies = [
15
- "discolike==0.3.0",
15
+ "discolike==0.3.1",
16
16
  "typer>=0.12",
17
17
  "rich>=13.0",
18
18
  ]
@@ -33,11 +33,12 @@ from discolike._oauth import OAuthError
33
33
  from discolike._oauth import build_authorization_url
34
34
  from discolike._oauth import discover
35
35
  from discolike._oauth import exchange_code
36
- from discolike._oauth import pkce_pair
37
36
  from discolike._oauth import register_client
38
37
  from discolike_cli._loopback import CallbackServer
39
38
  from discolike_cli._output import emit
40
39
  from discolike_cli._output import handle_errors
40
+ from discolike_cli.signup import _is_interactive
41
+ from discolike_cli.signup import run_signup
41
42
 
42
43
  app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")
43
44
 
@@ -55,6 +56,10 @@ LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY)
55
56
  DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"})
56
57
 
57
58
 
59
+ HAS_ACCOUNT_PROMPT = "Do you already have a DiscoLike account?"
60
+ SIGNUP_FOLLOWUP_MESSAGE = "Confirm the email, then run `discolike auth login` again to sign in."
61
+
62
+
58
63
  class _DeadClientError(Exception):
59
64
  """The authorization server no longer recognises the registered client_id."""
60
65
 
@@ -63,6 +68,23 @@ def _mask(key: str) -> str:
63
68
  return "…" + key[-MASKED_VISIBLE_CHARS:]
64
69
 
65
70
 
71
+ def _was_passed_on_command_line(ctx: typer.Context, name: str) -> bool:
72
+ source = ctx.get_parameter_source(name)
73
+ return source is not None and source.name == "COMMANDLINE"
74
+
75
+
76
+ def _offer_signup(ctx: typer.Context) -> None:
77
+ email = typer.prompt("Work email")
78
+ first_name = typer.prompt("First name")
79
+ last_name = typer.prompt("Last name")
80
+ base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
81
+ run_signup(
82
+ email=email, first_name=first_name, last_name=last_name, agent=None, base_url=base_url, yes=False, fmt=None
83
+ )
84
+ typer.echo(SIGNUP_FOLLOWUP_MESSAGE)
85
+ raise typer.Exit(code=0)
86
+
87
+
66
88
  def _iso(epoch_seconds: float) -> str:
67
89
  return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()
68
90
 
@@ -135,13 +157,11 @@ def _authorize(
135
157
  open_browser: bool,
136
158
  http: httpx2.Client,
137
159
  ) -> OAuthCredential:
138
- verifier, challenge = pkce_pair()
139
160
  state = secrets.token_urlsafe(STATE_BYTES)
140
- url = build_authorization_url(
161
+ url, verifier = build_authorization_url(
141
162
  metadata,
142
163
  client_id=registration.client_id,
143
164
  redirect_uri=registration.redirect_uri,
144
- code_challenge=challenge,
145
165
  state=state,
146
166
  resource=resource,
147
167
  )
@@ -168,7 +188,6 @@ def _authorize(
168
188
  code_verifier=verifier,
169
189
  redirect_uri=registration.redirect_uri,
170
190
  resource=resource,
171
- client=http,
172
191
  )
173
192
  except OAuthError as exc:
174
193
  if exc.error in DEAD_CLIENT_ERRORS:
@@ -230,6 +249,14 @@ def login(
230
249
  if method not in LOGIN_METHODS:
231
250
  raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
232
251
  global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
252
+ if (
253
+ _is_interactive()
254
+ and api_key is None
255
+ and not global_key_passed
256
+ and not _was_passed_on_command_line(ctx, "method")
257
+ and not typer.confirm(HAS_ACCOUNT_PROMPT, default=True)
258
+ ):
259
+ _offer_signup(ctx)
233
260
  if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
234
261
  _api_key_login(ctx, api_key=api_key)
235
262
  return
@@ -18,6 +18,7 @@ from discolike_cli import enrich
18
18
  from discolike_cli import match
19
19
  from discolike_cli import providers
20
20
  from discolike_cli import queries
21
+ from discolike_cli import signup
21
22
 
22
23
  app = typer.Typer(
23
24
  name="discolike",
@@ -70,3 +71,4 @@ app.command(name="extract")(company.extract_command)
70
71
  app.command(name="validate-icp")(enrich.validate_icp_command)
71
72
  app.command(name="append")(enrich.append_command)
72
73
  app.command(name="segment")(enrich.segment_command)
74
+ app.command(name="signup")(signup.signup_command)
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from importlib.metadata import version as package_version
5
+
6
+ import typer
7
+
8
+ from discolike._config import DEFAULT_BASE_URL
9
+ from discolike._config import load_signup_email
10
+ from discolike.signup import signup
11
+ from discolike_cli._output import emit
12
+ from discolike_cli._output import handle_errors
13
+
14
+ FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
15
+ YES_HELP = "Skip the confirmation when signing up a different email than last time."
16
+ CLI_AGENT = f"discolike-cli/{package_version('discolike-cli')}"
17
+
18
+
19
+ def _is_interactive() -> bool:
20
+ return sys.stdin.isatty()
21
+
22
+
23
+ def _confirm_email_change(previous: str, email: str, *, yes: bool) -> bool:
24
+ if yes:
25
+ return True
26
+ prompt = f"This machine already signed up {previous}. Sign up {email} as well?"
27
+ if _is_interactive():
28
+ return typer.confirm(prompt, default=False)
29
+ typer.echo(f"{prompt} re-run with --yes", err=True)
30
+ return False
31
+
32
+
33
+ def run_signup(
34
+ *,
35
+ email: str,
36
+ first_name: str,
37
+ last_name: str,
38
+ agent: str | None,
39
+ base_url: str,
40
+ yes: bool,
41
+ fmt: str | None,
42
+ ) -> None:
43
+ previous = load_signup_email()
44
+ allow_new_email = False
45
+ if previous is not None and previous.lower() != email.lower():
46
+ if not _confirm_email_change(previous, email, yes=yes):
47
+ raise typer.Exit(code=1)
48
+ allow_new_email = True
49
+ emit(
50
+ signup(
51
+ email=email,
52
+ first_name=first_name,
53
+ last_name=last_name,
54
+ agent=agent or CLI_AGENT,
55
+ base_url=base_url,
56
+ allow_new_email=allow_new_email,
57
+ ),
58
+ fmt=fmt,
59
+ )
60
+
61
+
62
+ @handle_errors
63
+ def signup_command(
64
+ ctx: typer.Context,
65
+ email: str = typer.Option(..., "--email", help="The person's work email. Becomes the login."),
66
+ first_name: str = typer.Option(..., "--first-name"),
67
+ last_name: str = typer.Option(..., "--last-name"),
68
+ agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
69
+ yes: bool = typer.Option(False, "--yes", "-y", help=YES_HELP),
70
+ fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
71
+ ) -> None:
72
+ """Create a DiscoLike account for a person. No login needed; they confirm by email."""
73
+ base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
74
+ run_signup(
75
+ email=email, first_name=first_name, last_name=last_name, agent=agent, base_url=base_url, yes=yes, fmt=fmt
76
+ )
@@ -7,6 +7,7 @@ import threading
7
7
  import time
8
8
  from collections.abc import Callable
9
9
  from typing import Any
10
+ from unittest.mock import patch
10
11
  from urllib.parse import parse_qs
11
12
  from urllib.parse import urlparse
12
13
  from urllib.request import urlopen
@@ -75,18 +76,18 @@ class FakeProvider:
75
76
  self.register_calls.append(redirect_uris)
76
77
  return "client-1"
77
78
 
78
- def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential:
79
+ def exchange_code(self, metadata: AuthServerMetadata, **kwargs: Any) -> OAuthCredential:
79
80
  self.exchange_calls.append(kwargs)
80
81
  if self.exchange_failures:
81
82
  raise self.exchange_failures.pop(0)
82
83
  return CREDENTIAL
83
84
 
84
- def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str:
85
- url = self.real_build_authorization_url(metadata, **kwargs)
85
+ def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> tuple[str, str]:
86
+ url, verifier = self.real_build_authorization_url(metadata, **kwargs)
86
87
  query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()}
87
88
  callback = f"{query['redirect_uri']}?{self.callback_query(query)}"
88
89
  threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server
89
- return url
90
+ return url, verifier
90
91
 
91
92
  def open(self, url: str) -> bool:
92
93
  self.opened_urls.append(url)
@@ -170,7 +171,9 @@ def test_login_timeout_exits_1(
170
171
  ) -> None:
171
172
  install_build_client(_usage_ok)
172
173
  monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2)
173
- monkeypatch.setattr(auth_module, "build_authorization_url", lambda metadata, **kwargs: "https://auth.test/never")
174
+ monkeypatch.setattr(
175
+ auth_module, "build_authorization_url", lambda metadata, **kwargs: ("https://auth.test/never", "v")
176
+ )
174
177
  result = runner.invoke(app, ["auth", "login"])
175
178
  assert result.exit_code == 1
176
179
  assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"]
@@ -409,3 +412,86 @@ def test_login_forged_error_callback_cannot_evict_stored_client(
409
412
  assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"]
410
413
  assert provider.register_calls == []
411
414
  assert load_oauth_client() == registration
415
+
416
+
417
+ def test_login_tty_confirms_existing_account_runs_oauth(
418
+ provider: FakeProvider,
419
+ install_build_client: Callable[[Handler], None],
420
+ monkeypatch: pytest.MonkeyPatch,
421
+ ) -> None:
422
+ install_build_client(_usage_ok)
423
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
424
+ with patch("discolike_cli.auth.run_signup", autospec=True) as run_signup_mock:
425
+ result = runner.invoke(app, ["auth", "login"], input="y\n")
426
+ assert result.exit_code == 0, result.output
427
+ assert provider.discover_calls == [DEFAULT_BASE_URL]
428
+ run_signup_mock.assert_not_called()
429
+
430
+
431
+ def test_login_tty_declines_account_runs_signup(
432
+ provider: FakeProvider,
433
+ install_build_client: Callable[[Handler], None],
434
+ monkeypatch: pytest.MonkeyPatch,
435
+ ) -> None:
436
+ install_build_client(_usage_ok)
437
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
438
+ with patch("discolike_cli.auth.run_signup", autospec=True, return_value=None) as run_signup_mock:
439
+ result = runner.invoke(app, ["auth", "login"], input="n\njane@acme.com\nJane\nDoe\n")
440
+ assert result.exit_code == 0, result.output
441
+ run_signup_mock.assert_called_once_with(
442
+ email="jane@acme.com",
443
+ first_name="Jane",
444
+ last_name="Doe",
445
+ agent=None,
446
+ base_url=DEFAULT_BASE_URL,
447
+ yes=False,
448
+ fmt=None,
449
+ )
450
+ assert "run `discolike auth login` again" in result.output
451
+ assert provider.discover_calls == []
452
+
453
+
454
+ def test_login_with_api_key_skips_account_question(install_build_client: Callable[[Handler], None]) -> None:
455
+ install_build_client(_usage_ok)
456
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
457
+ result = runner.invoke(app, ["auth", "login", "--api-key", "dk-1"])
458
+ assert result.exit_code == 0, result.output
459
+ confirm_mock.assert_not_called()
460
+
461
+
462
+ def test_login_with_global_api_key_skips_account_question(
463
+ install_build_client: Callable[[Handler], None],
464
+ monkeypatch: pytest.MonkeyPatch,
465
+ ) -> None:
466
+ install_build_client(_usage_ok)
467
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
468
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
469
+ result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login"])
470
+ assert result.exit_code == 0, result.output
471
+ confirm_mock.assert_not_called()
472
+ assert json.loads(config_path().read_text())["api_key"] == "dk-global"
473
+
474
+
475
+ def test_login_with_explicit_api_key_method_skips_account_question(
476
+ install_build_client: Callable[[Handler], None],
477
+ monkeypatch: pytest.MonkeyPatch,
478
+ ) -> None:
479
+ install_build_client(_usage_ok)
480
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
481
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
482
+ result = runner.invoke(app, ["auth", "login", "--method", "api_key", "--api-key", "dk-x"])
483
+ assert result.exit_code == 0, result.output
484
+ confirm_mock.assert_not_called()
485
+ assert json.loads(config_path().read_text())["api_key"] == "dk-x"
486
+
487
+
488
+ def test_login_rejects_unknown_method_on_a_tty_without_asking(
489
+ provider: FakeProvider,
490
+ monkeypatch: pytest.MonkeyPatch,
491
+ ) -> None:
492
+ monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
493
+ with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
494
+ result = runner.invoke(app, ["auth", "login", "--method", "bogus"])
495
+ assert result.exit_code == 2
496
+ confirm_mock.assert_not_called()
497
+ assert provider.discover_calls == []
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from unittest.mock import patch
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from discolike._config import save_signup_email
9
+ from discolike.signup import SignupResult
10
+ from discolike_cli.main import app
11
+
12
+ runner = CliRunner()
13
+
14
+ _RESULT = SignupResult(
15
+ status="created",
16
+ email="jane@acme.com",
17
+ org_domain="acme.com",
18
+ org_status="created",
19
+ next_step="A confirmation email was sent to jane@acme.com.",
20
+ )
21
+
22
+
23
+ def test_signup_calls_sdk_without_client() -> None:
24
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
25
+ result = runner.invoke(
26
+ app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane", "--last-name", "Doe"]
27
+ )
28
+ assert result.exit_code == 0, result.output
29
+ sdk_signup.assert_called_once()
30
+ kwargs = sdk_signup.call_args.kwargs
31
+ assert kwargs["email"] == "jane@acme.com"
32
+ assert kwargs["agent"].startswith("discolike-cli/")
33
+ assert json.loads(result.stdout)["next_step"] == _RESULT.next_step
34
+
35
+
36
+ def test_signup_honours_base_url_option() -> None:
37
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
38
+ result = runner.invoke(
39
+ app,
40
+ [
41
+ "--base-url",
42
+ "https://api.dev.test/v1",
43
+ "signup",
44
+ "--email",
45
+ "j@acme.com",
46
+ "--first-name",
47
+ "J",
48
+ "--last-name",
49
+ "D",
50
+ ],
51
+ )
52
+ assert result.exit_code == 0, result.output
53
+ assert sdk_signup.call_args.kwargs["base_url"] == "https://api.dev.test/v1"
54
+
55
+
56
+ def test_signup_does_not_require_credentials(monkeypatch) -> None:
57
+ monkeypatch.delenv("DISCOLIKE_API_KEY", raising=False)
58
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT):
59
+ result = runner.invoke(app, ["signup", "--email", "j@acme.com", "--first-name", "J", "--last-name", "D"])
60
+ assert result.exit_code == 0, result.output
61
+
62
+
63
+ def test_signup_different_email_non_tty_exits_without_calling_sdk() -> None:
64
+ save_signup_email("jane@acme.com")
65
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
66
+ result = runner.invoke(
67
+ app, ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person"]
68
+ )
69
+ assert result.exit_code == 1
70
+ sdk_signup.assert_not_called()
71
+ assert "re-run with --yes" in result.output
72
+
73
+
74
+ def test_signup_different_email_with_yes_allows_new_email() -> None:
75
+ save_signup_email("jane@acme.com")
76
+ other_result = SignupResult(**{**_RESULT.to_dict(), "email": "other@acme.com"})
77
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=other_result) as sdk_signup:
78
+ result = runner.invoke(
79
+ app,
80
+ ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person", "--yes"],
81
+ )
82
+ assert result.exit_code == 0, result.output
83
+ assert sdk_signup.call_args.kwargs["allow_new_email"] is True
84
+
85
+
86
+ def test_signup_different_email_tty_confirm_proceeds(monkeypatch) -> None:
87
+ save_signup_email("jane@acme.com")
88
+ monkeypatch.setattr("discolike_cli.signup._is_interactive", lambda: True)
89
+ other_result = SignupResult(**{**_RESULT.to_dict(), "email": "other@acme.com"})
90
+ with patch("discolike_cli.signup.signup", autospec=True, return_value=other_result) as sdk_signup:
91
+ result = runner.invoke(
92
+ app,
93
+ ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person"],
94
+ input="y\n",
95
+ )
96
+ assert result.exit_code == 0, result.output
97
+ assert sdk_signup.call_args.kwargs["allow_new_email"] is True
98
+
99
+
100
+ def test_signup_rejected_name_exits_nonzero_with_message_on_stderr() -> None:
101
+ result = runner.invoke(app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane<b>", "--last-name", "Doe"])
102
+ assert result.exit_code != 0
103
+ payload = json.loads(result.stderr)
104
+ assert "must contain a letter and no angle brackets or control characters" in payload["message"]
File without changes
File without changes
File without changes