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.
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/PKG-INFO +1 -1
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/pyproject.toml +1 -1
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login/command.py +223 -2
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/PKG-INFO +1 -1
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/README.md +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/setup.cfg +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login/__init__.py +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login/models.py +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/SOURCES.txt +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/dependency_links.txt +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/entry_points.txt +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/requires.txt +0 -0
- {deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/top_level.txt +0 -0
|
@@ -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}",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/requires.txt
RENAMED
|
File without changes
|
{deepctl_cmd_login-0.2.0 → deepctl_cmd_login-0.2.1}/src/deepctl_cmd_login.egg-info/top_level.txt
RENAMED
|
File without changes
|