deepctl-cmd-login 0.2.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-login
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Login command for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepctl-cmd-login"
7
- version = "0.2.0" # x-release-please-version
7
+ version = "0.2.1" # x-release-please-version
8
8
  description = "Login command for deepctl"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,18 +1,46 @@
1
1
  """Login command for deepctl."""
2
2
 
3
+ import sys
3
4
  from typing import Any
4
5
 
6
+ import click
5
7
  from deepctl_core import (
6
8
  AuthenticationError,
7
9
  AuthManager,
8
10
  BaseCommand,
11
+ CodedErrorResult,
9
12
  Config,
13
+ CredentialHostError,
10
14
  DeepgramClient,
11
15
  ProfileInfo,
12
16
  ProfilesResult,
13
17
  get_output_format,
18
+ is_agentic,
19
+ is_ai_agent,
20
+ )
21
+ from deepctl_core import messages as msg
22
+ from deepctl_core.hosts import (
23
+ SOURCE_ENV,
24
+ SOURCE_FLAG,
25
+ canonical_url,
26
+ check_identity_host,
27
+ evaluate,
28
+ host_entry,
29
+ is_allowlisted,
30
+ is_loopback,
31
+ is_regional,
32
+ parse_origin,
33
+ report,
34
+ user_config_display,
35
+ )
36
+ from deepctl_core.output import (
37
+ _output_config,
38
+ print_error,
39
+ print_error_hint,
40
+ print_hint,
41
+ print_info,
42
+ print_warning,
14
43
  )
15
- from deepctl_core.output import print_warning
16
44
  from rich.console import Console
17
45
  from rich.markup import escape
18
46
  from rich.prompt import Prompt
@@ -22,6 +50,11 @@ from .models import LoginResult, LogoutResult, WhoamiResult
22
50
  console = Console()
23
51
 
24
52
 
53
+ def _interactive_terminal() -> bool:
54
+ """A person can answer a prompt: stdin and stderr are both terminals."""
55
+ return sys.stdin.isatty() and sys.stderr.isatty()
56
+
57
+
25
58
  class LoginCommand(BaseCommand):
26
59
  """Login command for authenticating with Deepgram."""
27
60
 
@@ -29,6 +62,10 @@ class LoginCommand(BaseCommand):
29
62
  help = "Log in to Deepgram with browser-based authentication or API key"
30
63
  short_help = "Log in to Deepgram"
31
64
 
65
+ # Set by --save-base-url once a person confirmed it.
66
+ _saved_origin: str | None = None
67
+ _added_host: str | None = None
68
+
32
69
  # Login doesn't require existing auth
33
70
  requires_auth = False
34
71
  requires_project = False
@@ -39,6 +76,7 @@ class LoginCommand(BaseCommand):
39
76
  "dg login --api-key YOUR_API_KEY",
40
77
  "dg login --api-key YOUR_KEY --project-id YOUR_PROJECT",
41
78
  "dg login --profile staging --api-key YOUR_KEY",
79
+ "dg --base-url https://api.eu.deepgram.com login --save-base-url",
42
80
  ]
43
81
  agent_help = (
44
82
  "Authenticate with Deepgram. Can accept an API key directly via "
@@ -77,6 +115,15 @@ class LoginCommand(BaseCommand):
77
115
  "required": False,
78
116
  "is_option": True,
79
117
  },
118
+ {
119
+ "names": ["--save-base-url"],
120
+ "help": (
121
+ "Save the --base-url URL as this profile's API URL "
122
+ "(asks for confirmation in a terminal)"
123
+ ),
124
+ "is_flag": True,
125
+ "is_option": True,
126
+ },
80
127
  ]
81
128
 
82
129
  def handle(
@@ -91,6 +138,7 @@ class LoginCommand(BaseCommand):
91
138
  project_id = kwargs.get("project_id")
92
139
  force_write = kwargs.get("force_write", False)
93
140
  profile = kwargs.get("profile")
141
+ save_base_url = kwargs.get("save_base_url", False)
94
142
 
95
143
  # Set profile if provided
96
144
  if profile:
@@ -98,6 +146,26 @@ class LoginCommand(BaseCommand):
98
146
 
99
147
  current_profile = config.profile or "default"
100
148
 
149
+ # Decide where the key may go before doing anything else. A base URL
150
+ # from --base-url or DEEPGRAM_BASE_URL is used for this login only;
151
+ # --save-base-url, confirmed by a person, is the one way to save it.
152
+ if save_base_url:
153
+ stop = self._confirm_save_base_url(config, current_profile)
154
+ if stop is not None:
155
+ return stop
156
+ if not api_key:
157
+ has_key, _ = auth_manager.has_profile_credentials(current_profile)
158
+ if has_key:
159
+ # The confirmed URL is the change; keep the stored key.
160
+ return self._save_base_url_for_existing_key(
161
+ config, auth_manager, current_profile
162
+ )
163
+ else:
164
+ try:
165
+ auth_manager.check_base_url()
166
+ except CredentialHostError as e:
167
+ return CodedErrorResult(error=e.error, code=e.code, hint=e.hint)
168
+
101
169
  # Check for environment variables
102
170
  has_env_key, has_env_project = auth_manager.has_env_credentials()
103
171
 
@@ -168,6 +236,7 @@ class LoginCommand(BaseCommand):
168
236
  )
169
237
 
170
238
  # Determine authentication method
239
+ result: LoginResult | CodedErrorResult
171
240
  if api_key:
172
241
  result = self._cli_auth(
173
242
  config, auth_manager, str(api_key), project_id, force_write
@@ -175,12 +244,153 @@ class LoginCommand(BaseCommand):
175
244
  else:
176
245
  result = self._web_auth(config, auth_manager, force_write)
177
246
 
247
+ if result.status == "success":
248
+ self._report_base_url(config, current_profile)
249
+
178
250
  # Post-login: offer skills setup if login succeeded and no skills installed
179
251
  if result.status == "success":
180
252
  self._maybe_prompt_skills_setup()
181
253
 
182
254
  return result
183
255
 
256
+ def _confirm_save_base_url(self, config: Config, profile: str) -> Any | None:
257
+ """Ask a person to approve saving the base URL.
258
+
259
+ Returns a result to stop with, or ``None`` to continue with the URL
260
+ approved for saving.
261
+ """
262
+ override = config.base_url_override
263
+ if override is None or override[1] not in (SOURCE_FLAG, SOURCE_ENV):
264
+ raise click.UsageError(msg.E_SAVE_NOURL)
265
+ url, source = override
266
+
267
+ agent = is_agentic() or is_ai_agent()
268
+ if agent or not _interactive_terminal():
269
+ hint = msg.E_SAVE_NOTTY_AGENT_HINT if agent else msg.E_SAVE_NOTTY_HINT
270
+ print_error(msg.E_SAVE_NOTTY)
271
+ print_hint(hint)
272
+ return CodedErrorResult(
273
+ error=msg.E_SAVE_NOTTY,
274
+ code=msg.CODE_CONFIRMATION_REQUIRED,
275
+ hint=hint,
276
+ )
277
+
278
+ # Only the default user config can approve hosts.
279
+ if not config.trusted_file:
280
+ raise click.UsageError(
281
+ msg.E_SAVE_CUSTOM_CONFIG.format(user_config=user_config_display())
282
+ )
283
+
284
+ # Only https, or http to a loopback host, can be saved.
285
+ origin = parse_origin(url)
286
+ if origin is None:
287
+ raise click.UsageError(msg.E_SAVE_NOURL)
288
+ if not origin.secure and not is_loopback(origin.host):
289
+ try:
290
+ evaluate(url, source, profile=profile, agentic=False)
291
+ except CredentialHostError as e:
292
+ report(e)
293
+ return CodedErrorResult(error=e.error, code=e.code, hint=e.hint)
294
+
295
+ host = msg.display(origin.host)
296
+ shown_origin = msg.display(origin.origin)
297
+ needs_allowlist = not is_regional(origin) and not is_allowlisted(
298
+ origin, config.allowed_hosts()
299
+ )
300
+ if needs_allowlist:
301
+ click.echo(msg.P_SAVE_BASEURL_FOREIGN.format(host=host), err=True)
302
+ try:
303
+ confirmed = click.confirm(
304
+ msg.P_SAVE_BASEURL.format(
305
+ origin=shown_origin, profile=msg.display(profile)
306
+ ),
307
+ default=False,
308
+ err=True,
309
+ )
310
+ except click.Abort:
311
+ confirmed = False
312
+ if not confirmed:
313
+ print_error(msg.E_SAVE_DECLINED.format(host=host))
314
+ return LoginResult(
315
+ status="cancelled",
316
+ message=msg.E_SAVE_DECLINED.format(host=host),
317
+ profile=profile,
318
+ api_key_masked=None,
319
+ )
320
+
321
+ config.approve_base_url(profile, canonical_url(url))
322
+ if needs_allowlist:
323
+ self._added_host = host_entry(origin)
324
+ config.add_allowed_host(self._added_host)
325
+ self._saved_origin = shown_origin
326
+ return None
327
+
328
+ def _save_base_url_for_existing_key(
329
+ self, config: Config, auth_manager: AuthManager, profile: str
330
+ ) -> Any:
331
+ """Save the approved base URL for a profile that already has a key.
332
+
333
+ The stored key is checked against the approved URL first; nothing
334
+ is saved if that fails.
335
+ """
336
+ api_key = auth_manager.get_api_key(ignore_env=True)
337
+ success, message, _ = auth_manager.verify_api_key(api_key)
338
+ if not success:
339
+ print_error(escape(message))
340
+ return LoginResult(
341
+ status="error",
342
+ message=message,
343
+ profile=profile,
344
+ api_key_masked=None,
345
+ )
346
+ config.save()
347
+ self._report_base_url(config, profile)
348
+ return LoginResult(
349
+ status="success",
350
+ message=msg.S_SAVE_BASEURL.format(
351
+ origin=self._saved_origin, profile=msg.display(profile)
352
+ ),
353
+ profile=profile,
354
+ api_key_masked=f"****{api_key[-4:]}" if api_key else None,
355
+ config_path=str(config.config_path),
356
+ )
357
+
358
+ def _report_base_url(self, config: Config, profile: str) -> None:
359
+ """Tell the user whether the base URL used for login was saved."""
360
+ if _output_config["quiet"]:
361
+ return
362
+ if self._saved_origin:
363
+ text = msg.S_SAVE_BASEURL.format(
364
+ origin=self._saved_origin, profile=msg.display(profile)
365
+ )
366
+ if self._added_host:
367
+ text += msg.S_SAVE_BASEURL_ADDED.format(
368
+ host=msg.display(self._added_host)
369
+ )
370
+ print_info(escape(text), stderr=True)
371
+ return
372
+ unsaved = config.unsaved_base_url(profile)
373
+ if not isinstance(unsaved, tuple):
374
+ return
375
+ used = parse_origin(unsaved[0])
376
+ was = parse_origin(unsaved[1])
377
+ saved = unsaved[1]
378
+ if used is None or (was is not None and used.origin == was.origin):
379
+ return
380
+ print_info(
381
+ escape(
382
+ msg.I_LOGIN_NOT_SAVED.format(
383
+ origin=msg.display(used.origin),
384
+ profile=msg.display(profile),
385
+ saved_origin=msg.display(was.origin if was else saved),
386
+ )
387
+ ),
388
+ stderr=True,
389
+ )
390
+ print_hint(
391
+ escape(msg.I_LOGIN_NOT_SAVED_HINT.format(origin=msg.display(used.origin)))
392
+ )
393
+
184
394
  def _maybe_prompt_skills_setup(self) -> None:
185
395
  """After login, offer interactive skills setup for detected AI tools."""
186
396
  import sys
@@ -335,6 +545,7 @@ class LoginCommand(BaseCommand):
335
545
 
336
546
  except Exception as e:
337
547
  console.print(f"[red]Error:[/red] {e}")
548
+ print_error_hint(e)
338
549
  return LoginResult(
339
550
  status="error",
340
551
  message=str(e),
@@ -344,8 +555,15 @@ class LoginCommand(BaseCommand):
344
555
 
345
556
  def _web_auth(
346
557
  self, config: Config, auth_manager: AuthManager, force_write: bool
347
- ) -> LoginResult:
558
+ ) -> LoginResult | CodedErrorResult:
348
559
  """Handle web authentication with device flow."""
560
+ # The identity host mints the key: check it before anything else, and
561
+ # keep a refusal off stdout apart from the structured result.
562
+ try:
563
+ check_identity_host(config)
564
+ except CredentialHostError as e:
565
+ return CodedErrorResult(error=e.error, code=e.code, hint=e.hint)
566
+
349
567
  console.print("[blue]Starting web authentication...[/blue]")
350
568
 
351
569
  # No need to check for existing config - we handle that in the main flow
@@ -398,6 +616,7 @@ class LoginCommand(BaseCommand):
398
616
 
399
617
  except Exception as e:
400
618
  console.print(f"[red]Error during web authentication:[/red] {e}")
619
+ print_error_hint(e)
401
620
  return LoginResult(
402
621
  status="error",
403
622
  message=str(e),
@@ -518,6 +737,7 @@ class LogoutCommand(BaseCommand):
518
737
 
519
738
  except Exception as e:
520
739
  console.print(f"[red]Error during logout:[/red] {e}")
740
+ print_error_hint(e)
521
741
  return LogoutResult(status="error", message=str(e))
522
742
 
523
743
 
@@ -681,6 +901,7 @@ class ProfilesCommand(BaseCommand):
681
901
 
682
902
  except Exception as e:
683
903
  console.print(f"[red]Error accessing credentials:[/red] {e}")
904
+ print_error_hint(e)
684
905
  return ProfilesResult(
685
906
  status="error",
686
907
  message=f"Could not access credentials: {e}",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-login
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Login command for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>