datalegion-cli 1.2.2__tar.gz → 1.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 (28) hide show
  1. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/PKG-INFO +12 -2
  2. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/README.md +11 -1
  3. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/pyproject.toml +1 -1
  4. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/config.py +1 -0
  5. datalegion_cli-1.3.1/src/datalegion_cli/login.py +151 -0
  6. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/main.py +78 -6
  7. datalegion_cli-1.3.1/tests/test_login.py +238 -0
  8. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/.gitignore +0 -0
  9. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/__init__.py +0 -0
  10. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/async_typer.py +0 -0
  11. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/client.py +0 -0
  12. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/__init__.py +0 -0
  13. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/company.py +0 -0
  14. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/credits.py +0 -0
  15. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/person.py +0 -0
  16. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/utility.py +0 -0
  17. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/logging.py +0 -0
  18. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/output.py +0 -0
  19. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/update.py +0 -0
  20. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/__init__.py +0 -0
  21. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/conftest.py +0 -0
  22. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_client.py +0 -0
  23. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_commands.py +0 -0
  24. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_config.py +0 -0
  25. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_logging.py +0 -0
  26. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_output.py +0 -0
  27. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_package_contents.py +0 -0
  28. {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datalegion-cli
3
- Version: 1.2.2
3
+ Version: 1.3.1
4
4
  Summary: CLI for the Data Legion API — agent-friendly, fully async
5
5
  Project-URL: Homepage, https://www.datalegion.ai
6
6
  Project-URL: Documentation, https://www.datalegion.ai/docs
@@ -96,7 +96,17 @@ uv sync
96
96
 
97
97
  ## Configuration
98
98
 
99
- Set your API key (stored encrypted on disk):
99
+ Sign in with your Data Legion account (admin or developer role) and the CLI stores a key for this machine:
100
+
101
+ ```bash
102
+ datalegion-cli login # opens a sign-in link; confirm the code it shows
103
+ datalegion-cli login --team-id 7 # when your account has several teams
104
+ datalegion-cli login --no-browser # headless: prints the link to open on another device
105
+ ```
106
+
107
+ The key belongs to this machine, named with a random id (`cli-` plus 8 hex characters) that the CLI generates on first login and keeps in its config, so nothing about the machine (such as its hostname) is sent. Pass `--device <name>` for a readable label instead. Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
108
+
109
+ Or set an API key yourself (stored encrypted on disk):
100
110
 
101
111
  ```bash
102
112
  datalegion-cli config set api_key legion_your_key_here
@@ -62,7 +62,17 @@ uv sync
62
62
 
63
63
  ## Configuration
64
64
 
65
- Set your API key (stored encrypted on disk):
65
+ Sign in with your Data Legion account (admin or developer role) and the CLI stores a key for this machine:
66
+
67
+ ```bash
68
+ datalegion-cli login # opens a sign-in link; confirm the code it shows
69
+ datalegion-cli login --team-id 7 # when your account has several teams
70
+ datalegion-cli login --no-browser # headless: prints the link to open on another device
71
+ ```
72
+
73
+ The key belongs to this machine, named with a random id (`cli-` plus 8 hex characters) that the CLI generates on first login and keeps in its config, so nothing about the machine (such as its hostname) is sent. Pass `--device <name>` for a readable label instead. Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
74
+
75
+ Or set an API key yourself (stored encrypted on disk):
66
76
 
67
77
  ```bash
68
78
  datalegion-cli config set api_key legion_your_key_here
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datalegion-cli"
3
- version = "1.2.2"
3
+ version = "1.3.1"
4
4
  description = "CLI for the Data Legion API — agent-friendly, fully async"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -27,6 +27,7 @@ _DEFAULTS: dict[str, object] = {
27
27
  "api_key": "",
28
28
  "default_output": "json", # json | table | raw
29
29
  "install_method": "", # brew | github | git | pip
30
+ "device_id": "", # random name for this machine's login key (see login.new_device_id)
30
31
  }
31
32
 
32
33
  # Fields that are stored encrypted on disk
@@ -0,0 +1,151 @@
1
+ """Sign in from the terminal and get a key for this machine (OAuth device authorization).
2
+
3
+ ``datalegion-cli login`` asks Data Legion's sign-in server (WorkOS AuthKit) for a
4
+ device code, shows the person a link and a short code to approve, polls until they
5
+ do, then trades the access token at ``POST /agent/key`` for an API key that belongs
6
+ to this machine. The key is stored encrypted like one set with ``config set``.
7
+ Signing in again on the same machine replaces its key; the person can disconnect it
8
+ from the Connected Apps page in the dashboard.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import re
16
+ import secrets
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Awaitable, Callable
21
+
22
+ import httpx
23
+
24
+ # The sign-in server and the CLI's OAuth app ("Data Legion CLI", a public PKCE app;
25
+ # the id is not a secret). Overridable for testing against another environment.
26
+ SIGNIN_URL = os.environ.get("LEGION_SIGNIN_URL", "https://signin.datalegion.ai")
27
+ CLI_CLIENT_ID = os.environ.get("LEGION_CLI_CLIENT_ID", "client_01M4ABTVGMNFRS52XMF8YDC1H5")
28
+
29
+ _DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
30
+ # What the API accepts as a device name: words separated by single spaces.
31
+ _DEVICE_OK = re.compile(r"^[\w.@:-]+( [\w.@:-]+)*$")
32
+
33
+
34
+ class LoginError(Exception):
35
+ """Sign-in failed; the message is fit to show the person."""
36
+
37
+ def __init__(self, message: str, *, code: str = "login_failed", details: Any = None):
38
+ super().__init__(message)
39
+ self.code = code
40
+ self.details = details
41
+
42
+
43
+ def new_device_id() -> str:
44
+ """A random name for this machine's key: ``cli-`` plus 8 hex characters.
45
+
46
+ Random rather than the hostname (or a hash of it): hostnames often carry a
47
+ person's name and are guessable, so nothing about the machine leaves it. Saved
48
+ in the config on first login and reused, so signing in again replaces this
49
+ machine's key instead of adding another.
50
+ """
51
+ return f"cli-{secrets.token_hex(4)}"
52
+
53
+
54
+ def check_device_name(name: str) -> str:
55
+ """Return the name if the API will accept it, else raise LoginError."""
56
+ if not (1 <= len(name) <= 100) or not _DEVICE_OK.match(name):
57
+ raise LoginError(
58
+ "Device names use letters, digits and . _ @ : - (single spaces between words), "
59
+ "up to 100 characters.",
60
+ code="invalid_device",
61
+ )
62
+ return name
63
+
64
+
65
+ async def start_device_authorization(client: httpx.AsyncClient) -> dict[str, Any]:
66
+ """Ask the sign-in server for a device code and the link the person approves at."""
67
+ resp = await client.post(
68
+ f"{SIGNIN_URL}/oauth2/device_authorization",
69
+ data={"client_id": CLI_CLIENT_ID, "scope": "openid"},
70
+ )
71
+ if resp.status_code != 200:
72
+ raise LoginError(f"Sign-in could not start ({resp.status_code}): {_oauth_error(resp)}")
73
+ return resp.json()
74
+
75
+
76
+ async def poll_for_token(
77
+ client: httpx.AsyncClient,
78
+ device: dict[str, Any],
79
+ *,
80
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
81
+ ) -> str:
82
+ """Poll until the person approves (RFC 8628), returning the access token."""
83
+ interval = float(device.get("interval") or 5)
84
+ deadline = asyncio.get_running_loop().time() + float(device.get("expires_in") or 300)
85
+ while asyncio.get_running_loop().time() < deadline:
86
+ await sleep(interval)
87
+ resp = await client.post(
88
+ f"{SIGNIN_URL}/oauth2/token",
89
+ data={
90
+ "grant_type": _DEVICE_GRANT,
91
+ "device_code": device["device_code"],
92
+ "client_id": CLI_CLIENT_ID,
93
+ },
94
+ )
95
+ if resp.status_code == 200:
96
+ token = resp.json().get("access_token")
97
+ if token:
98
+ return str(token)
99
+ raise LoginError("Sign-in returned no access token.")
100
+ error = _oauth_error(resp)
101
+ if error == "authorization_pending":
102
+ continue
103
+ if error == "slow_down":
104
+ interval += 5
105
+ continue
106
+ if error == "access_denied":
107
+ raise LoginError("Sign-in was declined.", code="access_denied")
108
+ if error == "expired_token":
109
+ break
110
+ raise LoginError(f"Sign-in failed: {error}")
111
+ raise LoginError("The sign-in code expired. Run `datalegion-cli login` again.", code="expired")
112
+
113
+
114
+ async def exchange_for_key(
115
+ client: httpx.AsyncClient,
116
+ api_base_url: str,
117
+ access_token: str,
118
+ device_name: str,
119
+ team_id: int | None,
120
+ ) -> dict[str, Any]:
121
+ """Trade the access token for this machine's API key (POST /agent/key)."""
122
+ body: dict[str, Any] = {"device": device_name}
123
+ if team_id is not None:
124
+ body["team_id"] = team_id
125
+ resp = await client.post(
126
+ f"{api_base_url.rstrip('/')}/agent/key",
127
+ json=body,
128
+ headers={"Authorization": f"Bearer {access_token}"},
129
+ )
130
+ try:
131
+ payload = resp.json()
132
+ except ValueError:
133
+ payload = {}
134
+ if resp.status_code == 200 and payload.get("api_key"):
135
+ return payload
136
+ message = payload.get("message") or f"Getting a key failed ({resp.status_code})."
137
+ details = payload.get("details")
138
+ if resp.status_code == 409:
139
+ message = "This account has several teams. Run again with --team-id <id> to choose one."
140
+ raise LoginError(message, code="choose_team", details=details)
141
+ raise LoginError(
142
+ message, code=str(payload.get("error") or "key_exchange_failed"), details=details
143
+ )
144
+
145
+
146
+ def _oauth_error(resp: httpx.Response) -> str:
147
+ try:
148
+ body = resp.json()
149
+ except ValueError:
150
+ return resp.text[:200] or str(resp.status_code)
151
+ return str(body.get("error") or body.get("message") or resp.status_code)
@@ -10,9 +10,9 @@ from loguru import logger
10
10
  from datalegion_cli import __version__
11
11
  from datalegion_cli.async_typer import async_command
12
12
  from datalegion_cli.commands import company, credits, person, utility
13
- from datalegion_cli.config import load_config, save_config
13
+ from datalegion_cli.config import get_base_url, load_config, save_config
14
14
  from datalegion_cli.logging import setup_logging
15
- from datalegion_cli.output import emit_json
15
+ from datalegion_cli.output import console, emit_error, emit_json
16
16
  from datalegion_cli.update import check_for_update, self_update
17
17
 
18
18
  app = typer.Typer(
@@ -79,8 +79,7 @@ async def config(
79
79
  # Mask the API key for display
80
80
  display = dict(cfg)
81
81
  if display.get("api_key"):
82
- k = str(display["api_key"])
83
- display["api_key"] = k[:10] + "..." + k[-4:] if len(k) > 14 else "***"
82
+ display["api_key"] = _mask(str(display["api_key"]))
84
83
  emit_json(display)
85
84
  return
86
85
 
@@ -104,13 +103,86 @@ async def config(
104
103
  else:
105
104
  cfg[key] = value
106
105
  save_config(cfg)
107
- logger.info("Set {} = {}", key, cfg[key])
108
- emit_json({"ok": True, "key": key, "value": cfg[key]})
106
+ shown = _mask(str(cfg[key])) if key in _SECRET_KEYS else cfg[key]
107
+ logger.info("Set {} = {}", key, shown)
108
+ emit_json({"ok": True, "key": key, "value": shown})
109
109
  return
110
110
 
111
111
  raise typer.BadParameter(f"Unknown action: {action}. Use get, set, list, or path.")
112
112
 
113
113
 
114
+ _SECRET_KEYS = {"api_key"}
115
+
116
+
117
+ def _mask(value: str) -> str:
118
+ """Show enough of a secret to recognize it, never the whole value."""
119
+ return value[:10] + "..." + value[-4:] if len(value) > 14 else "***"
120
+
121
+
122
+ @app.command()
123
+ @async_command
124
+ async def login(
125
+ team_id: Annotated[
126
+ int | None, typer.Option("--team-id", help="Team the key bills, when you have several.")
127
+ ] = None,
128
+ device: Annotated[
129
+ str | None,
130
+ typer.Option(
131
+ "--device", help="Name for this machine (default: a random id saved locally)."
132
+ ),
133
+ ] = None,
134
+ no_browser: Annotated[
135
+ bool, typer.Option("--no-browser", help="Print the sign-in link without opening it.")
136
+ ] = False,
137
+ ) -> None:
138
+ """Sign in with your Data Legion account and store a key for this machine.
139
+
140
+ Opens a sign-in link (approve the code it shows), then saves an API key that
141
+ belongs to this machine. Signing in again replaces it. Disconnect it any time
142
+ from Connected Apps in the dashboard.
143
+ """
144
+ import webbrowser
145
+
146
+ import httpx
147
+
148
+ from datalegion_cli import login as flow
149
+
150
+ try:
151
+ cfg = load_config()
152
+ if not device and not cfg.get("device_id"):
153
+ cfg["device_id"] = flow.new_device_id()
154
+ save_config(cfg)
155
+ device_name = flow.check_device_name(device or str(cfg["device_id"]))
156
+ async with httpx.AsyncClient(timeout=30.0) as client:
157
+ started = await flow.start_device_authorization(client)
158
+ link = started.get("verification_uri_complete") or started["verification_uri"]
159
+ console.print(
160
+ f"Open [bold]{link}[/bold] and confirm the code [bold]{started['user_code']}[/bold]."
161
+ )
162
+ if not no_browser:
163
+ webbrowser.open(link)
164
+ token = await flow.poll_for_token(client, started)
165
+ issued = await flow.exchange_for_key(
166
+ client, get_base_url(), token, device_name, team_id
167
+ )
168
+ except flow.LoginError as exc:
169
+ emit_error(str(exc), code=exc.code, details=exc.details, as_json=True)
170
+ raise typer.Exit(1) from exc
171
+
172
+ cfg = load_config()
173
+ cfg["api_key"] = issued["api_key"]
174
+ save_config(cfg)
175
+ emit_json(
176
+ {
177
+ "ok": True,
178
+ "device": device_name,
179
+ "team_id": issued["team_id"],
180
+ "api_key": _mask(issued["api_key"]),
181
+ "replaced": issued.get("replaced", 0),
182
+ }
183
+ )
184
+
185
+
114
186
  @app.command()
115
187
  @async_command
116
188
  async def update(
@@ -0,0 +1,238 @@
1
+ """`datalegion-cli login`: device authorization, polling, the key exchange, and what is stored."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ import httpx
10
+ import pytest
11
+ from typer.testing import CliRunner
12
+
13
+ from datalegion_cli import login as flow
14
+ from datalegion_cli.main import app
15
+
16
+ runner = CliRunner()
17
+ KEY = "legion_" + "c0ffee" * 10 + "abcd"
18
+
19
+
20
+ def _client(handler) -> httpx.AsyncClient:
21
+ return httpx.AsyncClient(transport=httpx.MockTransport(handler))
22
+
23
+
24
+ async def _no_sleep(_seconds: float) -> None:
25
+ return None
26
+
27
+
28
+ @pytest.fixture
29
+ def tmp_config(tmp_config_dir, monkeypatch):
30
+ """A throwaway config dir with no LEGION_API_KEY override."""
31
+ monkeypatch.delenv("LEGION_API_KEY", raising=False)
32
+ return tmp_config_dir
33
+
34
+
35
+ class TestDeviceName:
36
+ def test_new_ids_are_random_and_api_safe(self):
37
+ ids = {flow.new_device_id() for _ in range(50)}
38
+ assert len(ids) == 50
39
+ for device_id in ids:
40
+ assert re.fullmatch(r"cli-[0-9a-f]{8}", device_id)
41
+ assert flow.check_device_name(device_id) == device_id
42
+
43
+ def test_the_hostname_is_never_read(self):
44
+ assert not hasattr(flow, "platform")
45
+
46
+ @pytest.mark.parametrize("name", ["", " lead", "a\nb", "x" * 101, "semi;colon", "two spaces"])
47
+ def test_rejects_what_the_api_rejects(self, name):
48
+ with pytest.raises(flow.LoginError):
49
+ flow.check_device_name(name)
50
+
51
+ @pytest.mark.parametrize("name", ["laptop", "build-box.internal", "ci runner 3", "me@host:1"])
52
+ def test_accepts_ordinary_names(self, name):
53
+ assert flow.check_device_name(name) == name
54
+
55
+
56
+ class TestFlow:
57
+ async def test_start_sends_the_cli_client(self):
58
+ seen = {}
59
+
60
+ def handler(req: httpx.Request) -> httpx.Response:
61
+ seen["url"] = str(req.url)
62
+ seen["body"] = req.content.decode()
63
+ return httpx.Response(200, json={"device_code": "d", "user_code": "BCDF-GHJK"})
64
+
65
+ async with _client(handler) as c:
66
+ out = await flow.start_device_authorization(c)
67
+ assert out["user_code"] == "BCDF-GHJK"
68
+ assert seen["url"] == "https://signin.datalegion.ai/oauth2/device_authorization"
69
+ assert f"client_id={flow.CLI_CLIENT_ID}" in seen["body"]
70
+
71
+ async def test_start_failure_is_reported(self):
72
+ async with _client(
73
+ lambda r: httpx.Response(400, json={"error": "unauthorized_client"})
74
+ ) as c:
75
+ with pytest.raises(flow.LoginError, match="unauthorized_client"):
76
+ await flow.start_device_authorization(c)
77
+
78
+ async def test_poll_waits_through_pending_and_slow_down(self):
79
+ answers = iter(
80
+ [
81
+ httpx.Response(400, json={"error": "authorization_pending"}),
82
+ httpx.Response(400, json={"error": "slow_down"}),
83
+ httpx.Response(200, json={"access_token": "tok"}),
84
+ ]
85
+ )
86
+ slept: list[float] = []
87
+
88
+ async def sleep(s: float) -> None:
89
+ slept.append(s)
90
+
91
+ async with _client(lambda r: next(answers)) as c:
92
+ token = await flow.poll_for_token(
93
+ c, {"device_code": "d", "interval": 5, "expires_in": 300}, sleep=sleep
94
+ )
95
+ assert token == "tok"
96
+ assert slept == [5, 5, 10] # slow_down adds 5 seconds
97
+
98
+ @pytest.mark.parametrize(
99
+ ("error", "code"), [("access_denied", "access_denied"), ("expired_token", "expired")]
100
+ )
101
+ async def test_poll_terminal_answers(self, error, code):
102
+ async with _client(lambda r: httpx.Response(400, json={"error": error})) as c:
103
+ with pytest.raises(flow.LoginError) as exc:
104
+ await flow.poll_for_token(c, {"device_code": "d", "interval": 1}, sleep=_no_sleep)
105
+ assert exc.value.code == code
106
+
107
+ async def test_exchange_sends_the_bearer_and_device(self):
108
+ seen = {}
109
+
110
+ def handler(req: httpx.Request) -> httpx.Response:
111
+ seen["auth"] = req.headers["authorization"]
112
+ seen["url"] = str(req.url)
113
+ seen["body"] = json.loads(req.content)
114
+ return httpx.Response(200, json={"api_key": KEY, "team_id": 7, "replaced": 0})
115
+
116
+ async with _client(handler) as c:
117
+ out = await flow.exchange_for_key(c, "https://api.datalegion.ai/", "tok", "laptop", 7)
118
+ assert out["api_key"] == KEY
119
+ assert seen == {
120
+ "auth": "Bearer tok",
121
+ "url": "https://api.datalegion.ai/agent/key",
122
+ "body": {"device": "laptop", "team_id": 7},
123
+ }
124
+
125
+ async def test_exchange_several_teams(self):
126
+ teams = [{"team_id": 7, "team_name": "Sales"}]
127
+ reply = httpx.Response(
128
+ 409, json={"error": "conflict", "message": "x", "details": {"teams": teams}}
129
+ )
130
+ async with _client(lambda r: reply) as c:
131
+ with pytest.raises(flow.LoginError) as exc:
132
+ await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
133
+ assert exc.value.code == "choose_team" and "--team-id" in str(exc.value)
134
+ assert exc.value.details == {"teams": teams}
135
+
136
+ async def test_exchange_refusal_relays_the_message(self):
137
+ reply = httpx.Response(
138
+ 403,
139
+ json={
140
+ "error": "forbidden",
141
+ "message": "Your Data Legion account is waiting for approval.",
142
+ },
143
+ )
144
+ async with _client(lambda r: reply) as c:
145
+ with pytest.raises(flow.LoginError, match="waiting for approval"):
146
+ await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
147
+
148
+
149
+ class TestLoginCommand:
150
+ @pytest.fixture
151
+ def steps(self):
152
+ with (
153
+ patch.object(
154
+ flow,
155
+ "start_device_authorization",
156
+ new=AsyncMock(
157
+ return_value={
158
+ "verification_uri": "https://signin.datalegion.ai/device",
159
+ "user_code": "BCDF-GHJK",
160
+ "device_code": "d",
161
+ }
162
+ ),
163
+ ),
164
+ patch.object(flow, "poll_for_token", new=AsyncMock(return_value="tok")),
165
+ patch.object(
166
+ flow,
167
+ "exchange_for_key",
168
+ new=AsyncMock(return_value={"api_key": KEY, "team_id": 7, "replaced": 1}),
169
+ ) as exchange,
170
+ patch("webbrowser.open") as browser,
171
+ ):
172
+ yield exchange, browser
173
+
174
+ def test_stores_the_key_and_never_prints_it(self, steps, tmp_config):
175
+ exchange, browser = steps
176
+ result = runner.invoke(app, ["login", "--device", "laptop", "--team-id", "7"])
177
+ assert result.exit_code == 0, result.output
178
+ out = json.loads(result.stdout)
179
+ assert out["ok"] is True and out["team_id"] == 7 and out["device"] == "laptop"
180
+ assert KEY not in result.output and out["api_key"].endswith(KEY[-4:])
181
+ assert exchange.await_args.args[3:] == ("laptop", 7)
182
+ browser.assert_called_once()
183
+ from datalegion_cli.config import load_config
184
+
185
+ assert load_config()["api_key"] == KEY
186
+
187
+ def test_default_device_is_a_saved_random_id_reused_next_time(self, steps, tmp_config):
188
+ exchange, _ = steps
189
+ assert runner.invoke(app, ["login"]).exit_code == 0
190
+ first = exchange.await_args.args[3]
191
+ assert re.fullmatch(r"cli-[0-9a-f]{8}", first)
192
+ from datalegion_cli.config import load_config
193
+
194
+ assert load_config()["device_id"] == first
195
+ assert runner.invoke(app, ["login"]).exit_code == 0
196
+ assert exchange.await_args.args[3] == first # same machine, same key slot
197
+
198
+ def test_device_flag_wins_and_leaves_the_saved_id(self, steps, tmp_config):
199
+ exchange, _ = steps
200
+ assert runner.invoke(app, ["login"]).exit_code == 0
201
+ from datalegion_cli.config import load_config
202
+
203
+ saved = load_config()["device_id"]
204
+ assert runner.invoke(app, ["login", "--device", "laptop"]).exit_code == 0
205
+ assert exchange.await_args.args[3] == "laptop"
206
+ assert load_config()["device_id"] == saved
207
+
208
+ def test_no_browser(self, steps, tmp_config):
209
+ _, browser = steps
210
+ assert runner.invoke(app, ["login", "--device", "laptop", "--no-browser"]).exit_code == 0
211
+ browser.assert_not_called()
212
+
213
+ def test_a_refusal_is_json_and_exit_1(self, steps, tmp_config):
214
+ exchange, _ = steps
215
+ exchange.side_effect = flow.LoginError(
216
+ "Pick a team.", code="choose_team", details={"teams": []}
217
+ )
218
+ result = runner.invoke(app, ["login", "--device", "laptop"])
219
+ assert result.exit_code == 1
220
+ assert json.loads(result.stdout) == {
221
+ "ok": False,
222
+ "error": "choose_team",
223
+ "message": "Pick a team.",
224
+ "details": {"teams": []},
225
+ }
226
+
227
+ def test_a_bad_device_name_stops_before_sign_in(self, steps, tmp_config):
228
+ result = runner.invoke(app, ["login", "--device", "semi;colon"])
229
+ assert result.exit_code == 1
230
+ assert json.loads(result.stdout)["error"] == "invalid_device"
231
+ flow.start_device_authorization.assert_not_awaited()
232
+
233
+
234
+ def test_config_set_never_echoes_the_key(tmp_config):
235
+ result = runner.invoke(app, ["config", "set", "api_key", KEY])
236
+ assert result.exit_code == 0
237
+ assert KEY not in result.output
238
+ assert json.loads(result.stdout)["value"].endswith(KEY[-4:])