dataspring-cli 0.3.0__tar.gz → 0.4.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.
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/PKG-INFO +1 -1
- dataspring_cli-0.4.1/cli/auth.py +279 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/bundled_manifest.py +7 -9
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/contract.py +460 -374
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/generated.py +334 -468
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/main.py +141 -459
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/output.py +22 -8
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/pyproject.toml +8 -2
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/settings.py +32 -15
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/skills/dataspring-author/SKILL.md +87 -2
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/skills/dataspring-consume/SKILL.md +151 -6
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/skills/dataspring-correct/SKILL.md +1 -1
- dataspring_cli-0.3.0/cli/auth.py +0 -375
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/.gitignore +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/README.md +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/__init__.py +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/runtime.py +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/skills_commands.py +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/skilltree.py +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/upgrade.py +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/version.py +0 -0
- {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/hatch_build.py +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
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``, the default).
|
|
8
|
+
|
|
9
|
+
Credentials live in ``~/.dataspring/credentials.json`` (mode 0600): a profile
|
|
10
|
+
per org and which one is current. ``dataspring auth use <org>`` switches
|
|
11
|
+
profile; a key belongs to its org, so that is the only switching there is
|
|
12
|
+
(hub D-96: no server-side switch). ``--key`` stores a key you already have,
|
|
13
|
+
for CI, and the ``DATASPRING_API_KEY`` environment variable always wins over
|
|
14
|
+
anything stored. ``dataspring auth logout`` revokes the stored keys on the
|
|
15
|
+
server, then forgets them.
|
|
16
|
+
|
|
17
|
+
Who the person is and which org a key is for are the server's answer to
|
|
18
|
+
``/api/me``; nothing here opens Firestore.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import secrets
|
|
26
|
+
import socket
|
|
27
|
+
import time
|
|
28
|
+
import webbrowser
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
ENV_KEY = "DATASPRING_API_KEY"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _host() -> str:
|
|
38
|
+
"""The backend's root (https://dataspring.app; locally http://localhost:8000)."""
|
|
39
|
+
from settings import get_settings
|
|
40
|
+
|
|
41
|
+
return get_settings().mcp_server_base_url.removesuffix("/api")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class UserContext:
|
|
46
|
+
"""The signed-in user as ``GET /api/me`` reports them.
|
|
47
|
+
|
|
48
|
+
Mirrors the fields of ``services.auth.UserContext`` that the CLI shows,
|
|
49
|
+
without importing the service layer: the CLI wheel ships without it.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
uid: str
|
|
53
|
+
email: str
|
|
54
|
+
org_id: str | None
|
|
55
|
+
org_name: str | None
|
|
56
|
+
role: str | None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def has_org(self) -> bool:
|
|
60
|
+
"""False for an account no administrator has added to an org yet.
|
|
61
|
+
|
|
62
|
+
Since D25 sign-up creates no organization, so ``/api/me`` answers 200
|
|
63
|
+
with the three org fields null. The CLI says so rather than crashing
|
|
64
|
+
on a ``KeyError`` the way it would have.
|
|
65
|
+
"""
|
|
66
|
+
return self.org_id is not None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuthenticationError(Exception):
|
|
70
|
+
"""Raised when authentication fails or user is not logged in."""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CLIAuthManager:
|
|
75
|
+
"""API keys for the CLI: device-flow login, profiles per org, the env var."""
|
|
76
|
+
|
|
77
|
+
CONFIG_DIR = Path.home() / ".dataspring"
|
|
78
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
79
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
80
|
+
|
|
81
|
+
def __init__(self):
|
|
82
|
+
"""Initialize the auth manager."""
|
|
83
|
+
self._ensure_config_dir()
|
|
84
|
+
|
|
85
|
+
def _ensure_config_dir(self):
|
|
86
|
+
"""Create config directory if it doesn't exist."""
|
|
87
|
+
self.CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
# -- stored profiles -------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _load(self) -> dict:
|
|
92
|
+
"""``{current, profiles: {org: {api_key, key_id, org_name, email}}}``;
|
|
93
|
+
empty for none, or for what an older CLI stored (Google credentials,
|
|
94
|
+
or CLI tokens): those sign nobody in now."""
|
|
95
|
+
if not self.CREDENTIALS_FILE.exists():
|
|
96
|
+
return {}
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(self.CREDENTIALS_FILE.read_text())
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return {}
|
|
101
|
+
return data if isinstance(data.get("profiles"), dict) else {}
|
|
102
|
+
|
|
103
|
+
def _save(self, data: dict) -> None:
|
|
104
|
+
self.CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
|
|
105
|
+
self.CREDENTIALS_FILE.chmod(0o600)
|
|
106
|
+
|
|
107
|
+
def _add_profile(self, data: dict, entry: dict, email: str | None) -> None:
|
|
108
|
+
data.setdefault("profiles", {})[entry["org_id"]] = {
|
|
109
|
+
"api_key": entry["api_key"],
|
|
110
|
+
"key_id": entry.get("key_id"),
|
|
111
|
+
"org_name": entry.get("org_name") or entry["org_id"],
|
|
112
|
+
"email": email,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def profiles(self) -> dict[str, dict]:
|
|
116
|
+
return self._load().get("profiles", {})
|
|
117
|
+
|
|
118
|
+
def current_org(self) -> str | None:
|
|
119
|
+
return self._load().get("current")
|
|
120
|
+
|
|
121
|
+
def use(self, org_id: str) -> dict:
|
|
122
|
+
"""Make the stored profile for ``org_id`` current."""
|
|
123
|
+
data = self._load()
|
|
124
|
+
if org_id not in data.get("profiles", {}):
|
|
125
|
+
raise AuthenticationError(
|
|
126
|
+
f"No key for {org_id} on this machine. Run 'dataspring auth login' and approve {org_id}."
|
|
127
|
+
)
|
|
128
|
+
data["current"] = org_id
|
|
129
|
+
self._save(data)
|
|
130
|
+
return data["profiles"][org_id]
|
|
131
|
+
|
|
132
|
+
# -- login -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async def login(self, *, all_orgs: bool = False, open_browser: bool = True, timeout: float = 600.0) -> "UserContext":
|
|
135
|
+
"""The device flow: show a code, wait for approval in a browser, store the key(s)."""
|
|
136
|
+
verifier = secrets.token_urlsafe(48)
|
|
137
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
|
|
138
|
+
try:
|
|
139
|
+
r = httpx.post(f"{_host()}/api/device/code", json={
|
|
140
|
+
"code_challenge": challenge, "code_challenge_method": "S256",
|
|
141
|
+
"hostname": socket.gethostname(), "all_orgs": all_orgs,
|
|
142
|
+
}, timeout=15.0)
|
|
143
|
+
except httpx.HTTPError as e:
|
|
144
|
+
raise AuthenticationError(f"Could not reach DataSpring: {e}")
|
|
145
|
+
if r.status_code != 200:
|
|
146
|
+
raise AuthenticationError(f"Could not start the sign-in (HTTP {r.status_code})")
|
|
147
|
+
start = r.json()
|
|
148
|
+
# flush: an agent runs this with stdout redirected, where Python
|
|
149
|
+
# buffers it; unflushed, the code appeared only after the CLI exited,
|
|
150
|
+
# too late for anyone to approve it (tick 373).
|
|
151
|
+
print(f"To sign in, open {start['verification_uri']} and enter the code {start['user_code']}")
|
|
152
|
+
print(f" (or open {start['verification_uri_complete']})", flush=True)
|
|
153
|
+
if open_browser:
|
|
154
|
+
try:
|
|
155
|
+
webbrowser.open(start["verification_uri_complete"])
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # no browser on this machine: the URL above is enough
|
|
158
|
+
|
|
159
|
+
interval = float(start.get("interval", 5))
|
|
160
|
+
deadline = time.monotonic() + min(timeout, float(start.get("expires_in", 600)))
|
|
161
|
+
while time.monotonic() < deadline:
|
|
162
|
+
time.sleep(interval)
|
|
163
|
+
r = httpx.post(f"{_host()}/api/device/token", json={"device_code": start["device_code"], "code_verifier": verifier}, timeout=15.0)
|
|
164
|
+
if r.status_code == 200:
|
|
165
|
+
body = r.json()
|
|
166
|
+
data = self._load()
|
|
167
|
+
self._add_profile(data, body, body.get("email"))
|
|
168
|
+
for other in body.get("other_orgs") or []:
|
|
169
|
+
self._add_profile(data, other, body.get("email"))
|
|
170
|
+
data["current"] = body["org_id"]
|
|
171
|
+
self._save(data)
|
|
172
|
+
if body.get("email"):
|
|
173
|
+
self._save_config({"email": body["email"]})
|
|
174
|
+
return await self.get_user()
|
|
175
|
+
error = (r.json() or {}).get("error") if r.headers.get("content-type", "").startswith("application/json") else None
|
|
176
|
+
if error == "authorization_pending":
|
|
177
|
+
continue
|
|
178
|
+
if error == "slow_down":
|
|
179
|
+
interval += 5
|
|
180
|
+
continue
|
|
181
|
+
if error == "access_denied":
|
|
182
|
+
raise AuthenticationError("The sign-in was denied in the browser.")
|
|
183
|
+
raise AuthenticationError("The sign-in expired or failed. Run 'dataspring auth login' again.")
|
|
184
|
+
raise AuthenticationError("The sign-in timed out. Run 'dataspring auth login' again.")
|
|
185
|
+
|
|
186
|
+
async def store_key(self, key: str) -> "UserContext":
|
|
187
|
+
"""``--key``: keep a key you already have (CI). The server says which org it is for."""
|
|
188
|
+
me = await self._me(key)
|
|
189
|
+
data = self._load()
|
|
190
|
+
self._add_profile(data, {"api_key": key, "key_id": None, "org_id": me["org_id"], "org_name": me.get("org_name")}, me.get("email"))
|
|
191
|
+
data["current"] = me["org_id"]
|
|
192
|
+
self._save(data)
|
|
193
|
+
return self._context(me)
|
|
194
|
+
|
|
195
|
+
# -- using the key ---------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def get_access_token(self) -> str:
|
|
198
|
+
"""The key requests carry: ``DATASPRING_API_KEY`` if set, else the current profile's.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
AuthenticationError: If not logged in
|
|
202
|
+
"""
|
|
203
|
+
if os.environ.get(ENV_KEY):
|
|
204
|
+
return os.environ[ENV_KEY]
|
|
205
|
+
data = self._load()
|
|
206
|
+
profile = data.get("profiles", {}).get(data.get("current") or "")
|
|
207
|
+
if not profile:
|
|
208
|
+
raise AuthenticationError("Not logged in. Run 'dataspring auth login' first.")
|
|
209
|
+
return profile["api_key"]
|
|
210
|
+
|
|
211
|
+
async def _me(self, key: str) -> dict:
|
|
212
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
213
|
+
r = await client.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"})
|
|
214
|
+
if r.status_code == 401:
|
|
215
|
+
raise AuthenticationError("This key is unknown or revoked. Run 'dataspring auth login' again.")
|
|
216
|
+
if r.status_code == 403:
|
|
217
|
+
raise AuthenticationError(r.json().get("detail", "Access denied"))
|
|
218
|
+
if r.status_code != 200:
|
|
219
|
+
raise AuthenticationError(f"Failed to resolve user context (HTTP {r.status_code})")
|
|
220
|
+
return r.json()
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _context(me: dict) -> "UserContext":
|
|
224
|
+
org_id = me.get("org_id")
|
|
225
|
+
return UserContext(uid=me["uid"], email=me.get("email") or "", org_id=org_id,
|
|
226
|
+
org_name=me.get("org_name") or org_id, role=me.get("org_role"))
|
|
227
|
+
|
|
228
|
+
async def get_user(self) -> "UserContext":
|
|
229
|
+
"""The person and the key's org, as ``GET /api/me`` reports them."""
|
|
230
|
+
return self._context(await self._me(self.get_access_token()))
|
|
231
|
+
|
|
232
|
+
async def logout(self):
|
|
233
|
+
"""Revoke every stored key on the server, then forget them."""
|
|
234
|
+
for profile in self.profiles().values():
|
|
235
|
+
if not profile.get("key_id"):
|
|
236
|
+
continue # a --key key: not ours to revoke, just forgotten
|
|
237
|
+
try:
|
|
238
|
+
httpx.delete(f"{_host()}/api/keys/{profile['key_id']}",
|
|
239
|
+
headers={"Authorization": f"Bearer {profile['api_key']}"}, timeout=10.0)
|
|
240
|
+
except httpx.HTTPError:
|
|
241
|
+
pass # offline: revoke it from /api/keys later
|
|
242
|
+
if self.CREDENTIALS_FILE.exists():
|
|
243
|
+
self.CREDENTIALS_FILE.unlink()
|
|
244
|
+
if self.CONFIG_FILE.exists():
|
|
245
|
+
self.CONFIG_FILE.unlink()
|
|
246
|
+
|
|
247
|
+
def is_logged_in(self) -> bool:
|
|
248
|
+
if os.environ.get(ENV_KEY):
|
|
249
|
+
return True
|
|
250
|
+
data = self._load()
|
|
251
|
+
return bool(data.get("profiles", {}).get(data.get("current") or ""))
|
|
252
|
+
|
|
253
|
+
def get_stored_email(self) -> str | None:
|
|
254
|
+
"""Get stored email from config (doesn't validate the key)."""
|
|
255
|
+
return self._load_config().get("email")
|
|
256
|
+
|
|
257
|
+
def _save_config(self, data: dict):
|
|
258
|
+
"""Save config data (non-sensitive)."""
|
|
259
|
+
existing = {}
|
|
260
|
+
if self.CONFIG_FILE.exists():
|
|
261
|
+
try:
|
|
262
|
+
existing = json.loads(self.CONFIG_FILE.read_text())
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
pass
|
|
265
|
+
existing.update(data)
|
|
266
|
+
self.CONFIG_FILE.write_text(json.dumps(existing, indent=2))
|
|
267
|
+
|
|
268
|
+
def _load_config(self) -> dict:
|
|
269
|
+
"""Load config data."""
|
|
270
|
+
if not self.CONFIG_FILE.exists():
|
|
271
|
+
return {}
|
|
272
|
+
try:
|
|
273
|
+
return json.loads(self.CONFIG_FILE.read_text())
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
return {}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Global auth manager instance
|
|
279
|
+
auth_manager = CLIAuthManager()
|
|
@@ -6,17 +6,14 @@ 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 = "eacbe5d755f415a814b50b568eb105ff60693b92fe1ce44d30e39f71670d15d0"
|
|
10
10
|
TOOL_KEYS = (
|
|
11
|
-
"dashboard_edit",
|
|
12
|
-
"widget_edit",
|
|
13
|
-
"page_edit",
|
|
14
|
-
"section_edit",
|
|
15
11
|
"business_context_edit",
|
|
16
12
|
"semantic_model_edit",
|
|
17
13
|
"metric_edit",
|
|
18
14
|
"quick_metric_edit",
|
|
19
|
-
"
|
|
15
|
+
"briefing_edit",
|
|
16
|
+
"verify_numbers",
|
|
20
17
|
"learned_edit",
|
|
21
18
|
"verified_query_edit",
|
|
22
19
|
"warehouse_edit",
|
|
@@ -25,12 +22,13 @@ TOOL_KEYS = (
|
|
|
25
22
|
"update_context",
|
|
26
23
|
"export_data",
|
|
27
24
|
"import_manifest",
|
|
28
|
-
"render_dashboard",
|
|
29
|
-
"render_widget",
|
|
30
|
-
"switch_organization",
|
|
31
25
|
"submit_error_report",
|
|
32
26
|
"secret_edit",
|
|
33
27
|
"run_sql",
|
|
34
28
|
"datacore_edit",
|
|
35
29
|
"datacore_run",
|
|
30
|
+
"tenant_edit",
|
|
31
|
+
"board_edit",
|
|
32
|
+
"board_fetch",
|
|
33
|
+
"watch_edit",
|
|
36
34
|
)
|