dataspring-cli 0.4.1__tar.gz → 0.4.3__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.
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/PKG-INFO +9 -5
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/README.md +8 -4
- dataspring_cli-0.4.3/cli/auth.py +487 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/bundled_manifest.py +1 -1
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/contract.py +108 -17
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/generated.py +84 -53
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/main.py +642 -84
- dataspring_cli-0.4.3/cli/refusals.py +249 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/runtime.py +1 -2
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/upgrade.py +29 -10
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/pyproject.toml +1 -1
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/skills/dataspring-author/SKILL.md +159 -17
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/skills/dataspring-consume/SKILL.md +106 -24
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/skills/dataspring-correct/SKILL.md +4 -3
- dataspring_cli-0.4.1/cli/auth.py +0 -279
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/.gitignore +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/__init__.py +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/output.py +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/skills_commands.py +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/skilltree.py +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/cli/version.py +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/hatch_build.py +0 -0
- {dataspring_cli-0.4.1 → dataspring_cli-0.4.3}/settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dataspring-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: DataSpring CLI - Query metrics and manage dashboards from the terminal
|
|
5
5
|
Project-URL: Homepage, https://dataspring.app
|
|
6
6
|
Project-URL: Documentation, https://dataspring.app/docs
|
|
@@ -79,13 +79,17 @@ dataspring query -m total_revenue --format json > data.json
|
|
|
79
79
|
### Authentication
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
dataspring login
|
|
83
|
-
dataspring whoami
|
|
84
|
-
dataspring logout
|
|
82
|
+
dataspring auth login # Device flow: approve the code in a browser signed in to DataSpring
|
|
83
|
+
dataspring whoami --json # The person, the org, where the org came from (org_from), the role
|
|
84
|
+
dataspring auth logout # Revoke this machine's keys and forget them
|
|
85
85
|
dataspring org list # List your organizations
|
|
86
|
-
dataspring org switch ID # Switch to a different organization
|
|
87
86
|
```
|
|
88
87
|
|
|
88
|
+
Every command names its organization: `--org <org>`, else `DATASPRING_ORG`
|
|
89
|
+
then `FORSPRING_ORG`, else `.forspring.json` (`{"org": "<org>"}`) in this
|
|
90
|
+
directory or a parent, else `DATASPRING_API_KEY`'s own org, else the only
|
|
91
|
+
stored key; otherwise it is refused with `org_required` (exit 2).
|
|
92
|
+
|
|
89
93
|
### Querying Metrics
|
|
90
94
|
|
|
91
95
|
```bash
|
|
@@ -47,13 +47,17 @@ dataspring query -m total_revenue --format json > data.json
|
|
|
47
47
|
### Authentication
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
dataspring login
|
|
51
|
-
dataspring whoami
|
|
52
|
-
dataspring logout
|
|
50
|
+
dataspring auth login # Device flow: approve the code in a browser signed in to DataSpring
|
|
51
|
+
dataspring whoami --json # The person, the org, where the org came from (org_from), the role
|
|
52
|
+
dataspring auth logout # Revoke this machine's keys and forget them
|
|
53
53
|
dataspring org list # List your organizations
|
|
54
|
-
dataspring org switch ID # Switch to a different organization
|
|
55
54
|
```
|
|
56
55
|
|
|
56
|
+
Every command names its organization: `--org <org>`, else `DATASPRING_ORG`
|
|
57
|
+
then `FORSPRING_ORG`, else `.forspring.json` (`{"org": "<org>"}`) in this
|
|
58
|
+
directory or a parent, else `DATASPRING_API_KEY`'s own org, else the only
|
|
59
|
+
stored key; otherwise it is refused with `org_required` (exit 2).
|
|
60
|
+
|
|
57
61
|
### Querying Metrics
|
|
58
62
|
|
|
59
63
|
```bash
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""CLI authentication: suite standard §3 (/home/dev/workspace/forspring/docs/suite-standards.md).
|
|
2
|
+
|
|
3
|
+
``dataspring auth login`` runs the device flow (RFC 8628 with PKCE,
|
|
4
|
+
backend/routes_device.py): the CLI shows a code and a URL, a person signed in
|
|
5
|
+
to DataSpring in any browser (this machine needs none) approves it, and the
|
|
6
|
+
CLI collects an ordinary ``ds_`` API key labelled ``CLI on <hostname>`` (one
|
|
7
|
+
per org with ``--all-orgs``).
|
|
8
|
+
|
|
9
|
+
Credentials live in ``~/.dataspring/credentials.json`` (mode 0600): a profile
|
|
10
|
+
(a key) per org. Nothing machine-wide says which one a command uses (hub
|
|
11
|
+
D-99: ``auth use`` went, because two agents on one machine moved each other
|
|
12
|
+
between orgs); every command resolves its org afresh, in this order
|
|
13
|
+
(:meth:`CLIAuthManager.resolve`):
|
|
14
|
+
|
|
15
|
+
1. ``--org`` on the command;
|
|
16
|
+
2. ``DATASPRING_ORG``, then ``FORSPRING_ORG``;
|
|
17
|
+
3. ``.forspring.json`` (``{"org": "noon"}``) in this directory or a parent;
|
|
18
|
+
4. the key's own org, when ``DATASPRING_API_KEY`` is set (a key belongs to
|
|
19
|
+
one org);
|
|
20
|
+
5. the only stored profile;
|
|
21
|
+
|
|
22
|
+
else it refuses with ``org_required``, the stored orgs and the three ways to
|
|
23
|
+
name one, and never prompts. ``DATASPRING_API_KEY`` always wins over the
|
|
24
|
+
stored keys: with it, a named org that is not the key's is ``org_mismatch``.
|
|
25
|
+
``--key`` stores a key you already have, for CI. ``dataspring auth logout``
|
|
26
|
+
revokes the stored keys on the server, then forgets them.
|
|
27
|
+
|
|
28
|
+
Who the person is and which org a key is for are the server's answer to
|
|
29
|
+
``/api/me``; nothing here opens Firestore.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import secrets
|
|
37
|
+
import socket
|
|
38
|
+
import time
|
|
39
|
+
import webbrowser
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
from cli.refusals import EN, NAME_AN_ORG, Refusal, classify, from_body
|
|
46
|
+
|
|
47
|
+
ENV_KEY = "DATASPRING_API_KEY"
|
|
48
|
+
|
|
49
|
+
#: Step 2 of the org order: the product's own variable, then the suite's.
|
|
50
|
+
ORG_ENV_VARS = ("DATASPRING_ORG", "FORSPRING_ORG")
|
|
51
|
+
|
|
52
|
+
#: Step 3: the per-project file, committed next to the code (``{"org": "noon"}``).
|
|
53
|
+
PROJECT_FILE = ".forspring.json"
|
|
54
|
+
|
|
55
|
+
#: ``org_from`` for steps 4 and 5.
|
|
56
|
+
FROM_KEY = ENV_KEY
|
|
57
|
+
FROM_ONLY_PROFILE = "only_profile"
|
|
58
|
+
|
|
59
|
+
#: Step 1, ``--org``: set by the root command (``cli.main``) for this
|
|
60
|
+
#: invocation; ``None`` when not given.
|
|
61
|
+
_org_flag: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_org_flag(org: str | None) -> None:
|
|
65
|
+
"""``--org`` for this invocation (``cli.main``'s root callback)."""
|
|
66
|
+
global _org_flag
|
|
67
|
+
_org_flag = (org or "").strip() or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _host() -> str:
|
|
71
|
+
"""The backend's root (https://dataspring.app; locally http://localhost:8000)."""
|
|
72
|
+
from settings import get_settings
|
|
73
|
+
|
|
74
|
+
return get_settings().mcp_server_base_url.removesuffix("/api")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class UserContext:
|
|
79
|
+
"""The signed-in user as ``GET /api/me`` reports them.
|
|
80
|
+
|
|
81
|
+
Mirrors the fields of ``services.auth.UserContext`` that the CLI shows,
|
|
82
|
+
without importing the service layer: the CLI wheel ships without it.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
uid: str
|
|
86
|
+
email: str
|
|
87
|
+
org_id: str | None
|
|
88
|
+
org_name: str | None
|
|
89
|
+
role: str | None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def has_org(self) -> bool:
|
|
93
|
+
"""False for an account no administrator has added to an org yet.
|
|
94
|
+
|
|
95
|
+
Since D25 sign-up creates no organization, so ``/api/me`` answers 200
|
|
96
|
+
with the three org fields null. The CLI says so rather than crashing
|
|
97
|
+
on a ``KeyError`` the way it would have.
|
|
98
|
+
"""
|
|
99
|
+
return self.org_id is not None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AuthenticationError(Exception):
|
|
103
|
+
"""Raised when authentication fails or user is not logged in.
|
|
104
|
+
|
|
105
|
+
``exit_code`` is what the CLI ends with (suite standards §6): 1, a plain
|
|
106
|
+
failure, unless a subclass says otherwise.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
exit_code = 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Refused(AuthenticationError):
|
|
113
|
+
"""A D-99 refusal (``cli.refusals``): not signed in, no org named, a key
|
|
114
|
+
for another org, or the server's own refusal of the key."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, reason: str, note: str, fix: str | None = None):
|
|
117
|
+
self.refusal = Refusal(reason, note, fix)
|
|
118
|
+
super().__init__(self.refusal.message)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def reason(self) -> str:
|
|
122
|
+
return self.refusal.reason
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def exit_code(self) -> int: # type: ignore[override]
|
|
126
|
+
return self.refusal.exit_code
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ProjectFileError(AuthenticationError):
|
|
130
|
+
"""A ``.forspring.json`` that names no org: invalid input (2)."""
|
|
131
|
+
|
|
132
|
+
exit_code = 2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class ResolvedOrg:
|
|
137
|
+
"""Which key a command sends, for which org, and where the org came from.
|
|
138
|
+
|
|
139
|
+
``org_id`` is ``None`` only for ``DATASPRING_API_KEY`` with no org named
|
|
140
|
+
and no stored profile holding that key: the key knows its org, and the
|
|
141
|
+
server says which (``GET /api/me``). ``org_from`` is ``--org``,
|
|
142
|
+
``DATASPRING_ORG``, ``FORSPRING_ORG``, ``.forspring.json``,
|
|
143
|
+
``DATASPRING_API_KEY`` or ``only_profile``; ``org_file`` is the file
|
|
144
|
+
step 3 read.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
key: str
|
|
148
|
+
org_id: str | None
|
|
149
|
+
org_from: str
|
|
150
|
+
org_file: str | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _not_signed_in(detail: str = "Not logged in.") -> Refused:
|
|
154
|
+
return Refused("not_signed_in", detail, "Run 'dataspring auth login' (or set DATASPRING_API_KEY).")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_project_file(start: Path | None = None) -> Path | None:
|
|
158
|
+
"""The nearest ``.forspring.json`` in ``start`` (default: the working
|
|
159
|
+
directory) or a parent."""
|
|
160
|
+
here = (start or Path.cwd()).resolve()
|
|
161
|
+
for directory in (here, *here.parents):
|
|
162
|
+
candidate = directory / PROJECT_FILE
|
|
163
|
+
if candidate.is_file():
|
|
164
|
+
return candidate
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_project_org(path: Path) -> str:
|
|
169
|
+
"""The ``org`` a ``.forspring.json`` names; a file that names none is an error."""
|
|
170
|
+
shape = '{"org": "<org>"}'
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
173
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
174
|
+
raise ProjectFileError(f"{path} is not valid JSON ({e}); it should hold {shape}.")
|
|
175
|
+
org = data.get("org") if isinstance(data, dict) else None
|
|
176
|
+
if not isinstance(org, str) or not org.strip():
|
|
177
|
+
raise ProjectFileError(f"{path} names no org; it should hold {shape}.")
|
|
178
|
+
return org.strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def named_org() -> tuple[str, str, str | None] | None:
|
|
182
|
+
"""Steps 1-3 of the org order: ``(org, org_from, file)``, or None."""
|
|
183
|
+
if _org_flag:
|
|
184
|
+
return _org_flag, "--org", None
|
|
185
|
+
for var in ORG_ENV_VARS:
|
|
186
|
+
value = (os.environ.get(var) or "").strip()
|
|
187
|
+
if value:
|
|
188
|
+
return value, var, None
|
|
189
|
+
path = find_project_file()
|
|
190
|
+
if path is not None:
|
|
191
|
+
return read_project_org(path), PROJECT_FILE, str(path)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CLIAuthManager:
|
|
196
|
+
"""API keys for the CLI: device-flow login, a profile per org, the env var."""
|
|
197
|
+
|
|
198
|
+
CONFIG_DIR = Path.home() / ".dataspring"
|
|
199
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
200
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
201
|
+
|
|
202
|
+
def __init__(self):
|
|
203
|
+
"""Initialize the auth manager."""
|
|
204
|
+
self._ensure_config_dir()
|
|
205
|
+
|
|
206
|
+
def _ensure_config_dir(self):
|
|
207
|
+
"""Create config directory if it doesn't exist."""
|
|
208
|
+
self.CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
# -- stored profiles -------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def _load(self) -> dict:
|
|
213
|
+
"""``{profiles: {org: {api_key, key_id, org_name, email}}}``; empty
|
|
214
|
+
for none, or for what an older CLI stored (Google credentials, or CLI
|
|
215
|
+
tokens): those sign nobody in now. A ``current`` an older CLI wrote
|
|
216
|
+
(``auth use``) is ignored: nothing machine-wide picks the org (D-99)."""
|
|
217
|
+
if not self.CREDENTIALS_FILE.exists():
|
|
218
|
+
return {}
|
|
219
|
+
try:
|
|
220
|
+
data = json.loads(self.CREDENTIALS_FILE.read_text())
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
return {}
|
|
223
|
+
return data if isinstance(data.get("profiles"), dict) else {}
|
|
224
|
+
|
|
225
|
+
def _save(self, data: dict) -> None:
|
|
226
|
+
data.pop("current", None)
|
|
227
|
+
self.CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
|
|
228
|
+
self.CREDENTIALS_FILE.chmod(0o600)
|
|
229
|
+
|
|
230
|
+
def _add_profile(self, data: dict, entry: dict, email: str | None) -> None:
|
|
231
|
+
data.setdefault("profiles", {})[entry["org_id"]] = {
|
|
232
|
+
"api_key": entry["api_key"],
|
|
233
|
+
"key_id": entry.get("key_id"),
|
|
234
|
+
"org_name": entry.get("org_name") or entry["org_id"],
|
|
235
|
+
"email": email,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def profiles(self) -> dict[str, dict]:
|
|
239
|
+
return self._load().get("profiles", {})
|
|
240
|
+
|
|
241
|
+
# -- which org, which key (hub D-99) ---------------------------------
|
|
242
|
+
|
|
243
|
+
def resolve(self) -> ResolvedOrg:
|
|
244
|
+
"""The org and key for this command, in the D-99 order (module doc).
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
Refused: ``not_signed_in`` (no key at all, or none stored for the
|
|
248
|
+
org named), ``org_required`` (several stored, none named;
|
|
249
|
+
never a prompt), ``org_mismatch`` (``DATASPRING_API_KEY`` is
|
|
250
|
+
a key for another org than the one named).
|
|
251
|
+
ProjectFileError: a ``.forspring.json`` that names no org.
|
|
252
|
+
"""
|
|
253
|
+
named = named_org()
|
|
254
|
+
env_key = os.environ.get(ENV_KEY)
|
|
255
|
+
profiles = self.profiles()
|
|
256
|
+
|
|
257
|
+
if env_key:
|
|
258
|
+
stored_org = next((org for org, p in profiles.items() if p.get("api_key") == env_key), None)
|
|
259
|
+
if named is None:
|
|
260
|
+
return ResolvedOrg(env_key, stored_org, FROM_KEY)
|
|
261
|
+
org, source, path = named
|
|
262
|
+
key_org = stored_org or self._key_org(env_key)
|
|
263
|
+
if key_org != org:
|
|
264
|
+
raise Refused(
|
|
265
|
+
"org_mismatch",
|
|
266
|
+
f"DATASPRING_API_KEY is a key for '{key_org}', but {source} names '{org}'.",
|
|
267
|
+
f"Name '{key_org}', or set DATASPRING_API_KEY to a key for '{org}' "
|
|
268
|
+
"(or unset it to use a stored key).",
|
|
269
|
+
)
|
|
270
|
+
return ResolvedOrg(env_key, org, source, path)
|
|
271
|
+
|
|
272
|
+
if not profiles:
|
|
273
|
+
raise _not_signed_in()
|
|
274
|
+
if named is not None:
|
|
275
|
+
org, source, path = named
|
|
276
|
+
profile = profiles.get(org)
|
|
277
|
+
if profile is None:
|
|
278
|
+
raise Refused(
|
|
279
|
+
"not_signed_in",
|
|
280
|
+
f"No key for '{org}' on this machine ({source} names it); "
|
|
281
|
+
f"stored: {', '.join(sorted(profiles))}.",
|
|
282
|
+
f"Run 'dataspring auth login' and approve {org}, or name one of the stored orgs.",
|
|
283
|
+
)
|
|
284
|
+
return ResolvedOrg(profile["api_key"], org, source, path)
|
|
285
|
+
if len(profiles) == 1:
|
|
286
|
+
org, profile = next(iter(profiles.items()))
|
|
287
|
+
return ResolvedOrg(profile["api_key"], org, FROM_ONLY_PROFILE)
|
|
288
|
+
raise Refused(
|
|
289
|
+
"org_required",
|
|
290
|
+
"Several organizations have keys on this machine and this command names none: "
|
|
291
|
+
f"{', '.join(sorted(profiles))}.",
|
|
292
|
+
f"Name one: {NAME_AN_ORG}.",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _key_org(self, key: str) -> str | None:
|
|
296
|
+
"""The org a key not stored here belongs to, as the server says.
|
|
297
|
+
Synchronous: :meth:`resolve` runs inside the async commands too."""
|
|
298
|
+
r = httpx.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"}, timeout=15.0)
|
|
299
|
+
return self._me_answer(r).get("org_id")
|
|
300
|
+
|
|
301
|
+
def resolved_org(self) -> str | None:
|
|
302
|
+
"""The org a command here would use, or None when none resolves."""
|
|
303
|
+
try:
|
|
304
|
+
return self.resolve().org_id
|
|
305
|
+
except AuthenticationError:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
# -- login -----------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
async def login(self, *, all_orgs: bool = False, open_browser: bool = True, timeout: float = 600.0) -> "UserContext":
|
|
311
|
+
"""The device flow: show a code, wait for approval in a browser, store the key(s)."""
|
|
312
|
+
verifier = secrets.token_urlsafe(48)
|
|
313
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
|
|
314
|
+
try:
|
|
315
|
+
r = httpx.post(f"{_host()}/api/device/code", json={
|
|
316
|
+
"code_challenge": challenge, "code_challenge_method": "S256",
|
|
317
|
+
"hostname": socket.gethostname(), "all_orgs": all_orgs,
|
|
318
|
+
}, timeout=15.0)
|
|
319
|
+
except httpx.HTTPError as e:
|
|
320
|
+
raise AuthenticationError(f"Could not reach DataSpring: {e}")
|
|
321
|
+
if r.status_code != 200:
|
|
322
|
+
raise AuthenticationError(f"Could not start the sign-in (HTTP {r.status_code})")
|
|
323
|
+
start = r.json()
|
|
324
|
+
# flush: an agent runs this with stdout redirected, where Python
|
|
325
|
+
# buffers it; unflushed, the code appeared only after the CLI exited,
|
|
326
|
+
# too late for anyone to approve it (tick 373).
|
|
327
|
+
print(f"To sign in, open {start['verification_uri']} and enter the code {start['user_code']}")
|
|
328
|
+
print(f" (or open {start['verification_uri_complete']})", flush=True)
|
|
329
|
+
if open_browser:
|
|
330
|
+
try:
|
|
331
|
+
webbrowser.open(start["verification_uri_complete"])
|
|
332
|
+
except Exception:
|
|
333
|
+
pass # no browser on this machine: the URL above is enough
|
|
334
|
+
|
|
335
|
+
interval = float(start.get("interval", 5))
|
|
336
|
+
deadline = time.monotonic() + min(timeout, float(start.get("expires_in", 600)))
|
|
337
|
+
while time.monotonic() < deadline:
|
|
338
|
+
time.sleep(interval)
|
|
339
|
+
r = httpx.post(f"{_host()}/api/device/token", json={"device_code": start["device_code"], "code_verifier": verifier}, timeout=15.0)
|
|
340
|
+
if r.status_code == 200:
|
|
341
|
+
body = r.json()
|
|
342
|
+
data = self._load()
|
|
343
|
+
self._add_profile(data, body, body.get("email"))
|
|
344
|
+
for other in body.get("other_orgs") or []:
|
|
345
|
+
self._add_profile(data, other, body.get("email"))
|
|
346
|
+
self._save(data)
|
|
347
|
+
if body.get("email"):
|
|
348
|
+
self._save_config({"email": body["email"]})
|
|
349
|
+
# The approved org's key: the one this login is about, whatever
|
|
350
|
+
# else is stored.
|
|
351
|
+
return self._context(await self._me(body["api_key"]))
|
|
352
|
+
error = (r.json() or {}).get("error") if r.headers.get("content-type", "").startswith("application/json") else None
|
|
353
|
+
if error in ("authorization_pending", "temporarily_unavailable"):
|
|
354
|
+
continue # temporarily_unavailable: the Forspring account did not answer; the approval waits
|
|
355
|
+
if error == "slow_down":
|
|
356
|
+
interval += 5
|
|
357
|
+
continue
|
|
358
|
+
if error == "access_denied":
|
|
359
|
+
raise AuthenticationError("The sign-in was denied in the browser.")
|
|
360
|
+
raise AuthenticationError("The sign-in expired or failed. Run 'dataspring auth login' again.")
|
|
361
|
+
raise AuthenticationError("The sign-in timed out. Run 'dataspring auth login' again.")
|
|
362
|
+
|
|
363
|
+
async def store_key(self, key: str) -> "UserContext":
|
|
364
|
+
"""``--key``: keep a key you already have (CI). The server says which org it is for."""
|
|
365
|
+
me = await self._me(key)
|
|
366
|
+
data = self._load()
|
|
367
|
+
self._add_profile(data, {"api_key": key, "key_id": None, "org_id": me["org_id"], "org_name": me.get("org_name")}, me.get("email"))
|
|
368
|
+
self._save(data)
|
|
369
|
+
return self._context(me)
|
|
370
|
+
|
|
371
|
+
# -- using the key ---------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def get_access_token(self) -> str:
|
|
374
|
+
"""The key this command sends: ``DATASPRING_API_KEY`` if set, else the
|
|
375
|
+
stored key for the org :meth:`resolve` names.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
Refused: not signed in, no org named among several, or a mismatch
|
|
379
|
+
"""
|
|
380
|
+
return self.resolve().key
|
|
381
|
+
|
|
382
|
+
async def _me(self, key: str) -> dict:
|
|
383
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
384
|
+
r = await client.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"})
|
|
385
|
+
return self._me_answer(r)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _me_answer(r: httpx.Response) -> dict:
|
|
389
|
+
"""``GET /api/me``'s body, or the refusal it answered with (D-99)."""
|
|
390
|
+
if r.status_code == 200:
|
|
391
|
+
return r.json()
|
|
392
|
+
try:
|
|
393
|
+
body = r.json()
|
|
394
|
+
except ValueError:
|
|
395
|
+
body = None
|
|
396
|
+
refusal = from_body(body)
|
|
397
|
+
if refusal is not None:
|
|
398
|
+
raise Refused(refusal.reason, refusal.note, refusal.fix)
|
|
399
|
+
if r.status_code == 401:
|
|
400
|
+
raise _not_signed_in("This key is unknown, revoked or expired.")
|
|
401
|
+
# ``note`` since tick 3sb, ``detail`` from older servers and FastAPI.
|
|
402
|
+
detail = (body.get("note") or body.get("detail")) if isinstance(body, dict) else None
|
|
403
|
+
if r.status_code == 403:
|
|
404
|
+
text = str(detail or "Access denied")
|
|
405
|
+
raise Refused(
|
|
406
|
+
classify(text) or "not_a_member", text,
|
|
407
|
+
"An owner or admin of the org fixes this at https://account.forspring.ai; "
|
|
408
|
+
"or run 'dataspring auth login' again.",
|
|
409
|
+
)
|
|
410
|
+
if r.status_code == 503:
|
|
411
|
+
raise Refused("account_unavailable", str(detail or "The Forspring account cannot be reached."),
|
|
412
|
+
EN["account_unavailable"]["fix"])
|
|
413
|
+
raise AuthenticationError(f"Failed to resolve user context (HTTP {r.status_code})")
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _context(me: dict) -> "UserContext":
|
|
417
|
+
org_id = me.get("org_id")
|
|
418
|
+
return UserContext(uid=me["uid"], email=me.get("email") or "", org_id=org_id,
|
|
419
|
+
org_name=me.get("org_name") or org_id, role=me.get("org_role"))
|
|
420
|
+
|
|
421
|
+
async def get_user(self) -> "UserContext":
|
|
422
|
+
"""The person and the key's org, as ``GET /api/me`` reports them."""
|
|
423
|
+
return self._context(await self._me(self.get_access_token()))
|
|
424
|
+
|
|
425
|
+
async def whoami(self) -> dict:
|
|
426
|
+
"""D-99's ``whoami --json``: the person, the org, where the org came
|
|
427
|
+
from (:class:`ResolvedOrg`'s ``org_from``), the role."""
|
|
428
|
+
resolved = self.resolve()
|
|
429
|
+
me = await self._me(resolved.key)
|
|
430
|
+
out = {
|
|
431
|
+
"person": me.get("email"),
|
|
432
|
+
"org": me.get("org_id"),
|
|
433
|
+
"org_name": me.get("org_name"),
|
|
434
|
+
"org_from": resolved.org_from,
|
|
435
|
+
"role": me.get("org_role"),
|
|
436
|
+
}
|
|
437
|
+
if resolved.org_file:
|
|
438
|
+
out["org_file"] = resolved.org_file
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
async def logout(self):
|
|
442
|
+
"""Revoke every stored key on the server, then forget them."""
|
|
443
|
+
for profile in self.profiles().values():
|
|
444
|
+
if not profile.get("key_id"):
|
|
445
|
+
continue # a --key key: not ours to revoke, just forgotten
|
|
446
|
+
try:
|
|
447
|
+
httpx.delete(f"{_host()}/api/keys/{profile['key_id']}",
|
|
448
|
+
headers={"Authorization": f"Bearer {profile['api_key']}"}, timeout=10.0)
|
|
449
|
+
except httpx.HTTPError:
|
|
450
|
+
pass # offline: revoke it from /api/keys later
|
|
451
|
+
if self.CREDENTIALS_FILE.exists():
|
|
452
|
+
self.CREDENTIALS_FILE.unlink()
|
|
453
|
+
if self.CONFIG_FILE.exists():
|
|
454
|
+
self.CONFIG_FILE.unlink()
|
|
455
|
+
|
|
456
|
+
def is_logged_in(self) -> bool:
|
|
457
|
+
"""A key exists to use: ``DATASPRING_API_KEY`` or a stored one (which
|
|
458
|
+
one a command uses is :meth:`resolve`'s)."""
|
|
459
|
+
return bool(os.environ.get(ENV_KEY)) or bool(self.profiles())
|
|
460
|
+
|
|
461
|
+
def get_stored_email(self) -> str | None:
|
|
462
|
+
"""Get stored email from config (doesn't validate the key)."""
|
|
463
|
+
return self._load_config().get("email")
|
|
464
|
+
|
|
465
|
+
def _save_config(self, data: dict):
|
|
466
|
+
"""Save config data (non-sensitive)."""
|
|
467
|
+
existing = {}
|
|
468
|
+
if self.CONFIG_FILE.exists():
|
|
469
|
+
try:
|
|
470
|
+
existing = json.loads(self.CONFIG_FILE.read_text())
|
|
471
|
+
except json.JSONDecodeError:
|
|
472
|
+
pass
|
|
473
|
+
existing.update(data)
|
|
474
|
+
self.CONFIG_FILE.write_text(json.dumps(existing, indent=2))
|
|
475
|
+
|
|
476
|
+
def _load_config(self) -> dict:
|
|
477
|
+
"""Load config data."""
|
|
478
|
+
if not self.CONFIG_FILE.exists():
|
|
479
|
+
return {}
|
|
480
|
+
try:
|
|
481
|
+
return json.loads(self.CONFIG_FILE.read_text())
|
|
482
|
+
except json.JSONDecodeError:
|
|
483
|
+
return {}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# Global auth manager instance
|
|
487
|
+
auth_manager = CLIAuthManager()
|
|
@@ -6,7 +6,7 @@ the server's to say whether the server has operations this CLI does not
|
|
|
6
6
|
know. tests/test_skills_surface.py asserts it is current.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
MANIFEST_HASH = "
|
|
9
|
+
MANIFEST_HASH = "e61e4a47f3c193beb2f6d731efde82839f5e62788bfa18560277459487bb26a7"
|
|
10
10
|
TOOL_KEYS = (
|
|
11
11
|
"business_context_edit",
|
|
12
12
|
"semantic_model_edit",
|