ermes-cli 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,386 @@
1
+ """`ermes login` / `ermes logout` — Auth0 device-flow auth + project discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import socket
7
+
8
+ import keyring
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from ermes_cli import admin_api, auth0, keyring_store
14
+ from ermes_cli.config import (
15
+ DEFAULT_BASE_URL,
16
+ ConfigError,
17
+ clear_projects_file,
18
+ get_active_project_id,
19
+ get_base_url,
20
+ load_projects_file,
21
+ save_projects_file,
22
+ )
23
+
24
+ console = Console(stderr=True)
25
+
26
+
27
+ def _on_user_code(code: auth0.DeviceCode) -> None:
28
+ """Show the Auth0 device code prompt before opening the browser."""
29
+ console.print()
30
+ console.print("[bold cyan]Sign in to Ermes via Auth0[/bold cyan]")
31
+ console.print(f" user code: [bold]{code.user_code}[/bold]")
32
+ console.print(f" verification URL: {code.verification_uri_complete}")
33
+ console.print(
34
+ " [dim]Opening your browser… if it doesn't open, paste the URL above.[/dim]"
35
+ )
36
+ console.print()
37
+
38
+
39
+ def _bootstrap_projects(
40
+ base_url: str, ermes_jwt: str, admin_email: str | None = None
41
+ ) -> tuple[list[admin_api.ProjectSummary], dict[int, admin_api.ProjectKey]]:
42
+ """Enumerate all projects; keep already-cached per-admin keys.
43
+
44
+ Login no longer mints a key on every project (that left 9 hosts holding
45
+ admin keys to all 240 prod projects — DEV-1136). Keys are provisioned
46
+ lazily by `ensure_project_key()` on first use. Cached keys survive a
47
+ re-sync, except legacy shared `cli-managed-<host>` keys, which are
48
+ dropped so the next use mints a per-admin key. Cached keys are also
49
+ dropped wholesale when the identity or host changed: a key minted for
50
+ another admin, or against another Ermes host, must not survive a login.
51
+ """
52
+ projects = admin_api.list_all_projects(base_url, ermes_jwt)
53
+ stored = load_projects_file()
54
+ same_identity = (
55
+ (admin_email is None or stored.get("auth0_email") in (None, admin_email))
56
+ and str(stored.get("base_url") or base_url).rstrip("/") == base_url.rstrip("/")
57
+ )
58
+ # Read the cache from the same dict the identity check used, not from disk again.
59
+ cached = {int(k): v for k, v in (stored.get("projects") or {}).items()} if same_identity else {}
60
+ keys: dict[int, admin_api.ProjectKey] = {}
61
+ for project in projects:
62
+ prev = cached.get(project.id) or {}
63
+ name = str(prev.get("api_key_name") or "")
64
+ if prev.get("api_key") and not name.startswith("cli-managed-"):
65
+ keys[project.id] = admin_api.ProjectKey(
66
+ id=int(prev.get("api_key_id") or 0),
67
+ name=name,
68
+ value=str(prev["api_key"]),
69
+ scope="secret",
70
+ role=str(prev.get("role") or ""),
71
+ )
72
+ return projects, keys
73
+
74
+
75
+ def _hostname() -> str:
76
+ """Server accepts ^[A-Za-z0-9._-]{1,64}$ for the key name suffix."""
77
+ return re.sub(r"[^A-Za-z0-9._-]", "-", socket.gethostname())[:64] or "unknown-host"
78
+
79
+
80
+ def ensure_project_key(project_id: int) -> str:
81
+ """Return the cached CLI key for `project_id`, minting it on first use.
82
+
83
+ Raises `ConfigError` (never SystemExit — the MCP server calls this too)
84
+ when there is no Auth0 session or the server refuses.
85
+ """
86
+ payload = load_projects_file()
87
+ projects = payload.get("projects") or {}
88
+ # pop both spellings so a str-keyed YAML entry never leaves a duplicate
89
+ proj = projects.pop(project_id, None) or projects.pop(str(project_id), None) or {}
90
+ if proj.get("api_key"):
91
+ return str(proj["api_key"])
92
+ try:
93
+ api_base, result = _ensure_admin_session()
94
+ key = admin_api.create_cli_key(
95
+ api_base, result.ermes_access_token, project_id, _hostname()
96
+ )
97
+ except typer.Exit:
98
+ raise ConfigError("Not logged in. Run `ermes login` first.")
99
+ except admin_api.AdminAPIError as exc:
100
+ raise ConfigError(f"Could not provision a CLI key for project {project_id}: {exc}")
101
+ proj.update({"api_key": key.value, "api_key_id": key.id, "api_key_name": key.name})
102
+ projects[project_id] = proj
103
+ payload["projects"] = projects
104
+ try:
105
+ save_projects_file(payload)
106
+ except OSError as exc:
107
+ raise ConfigError(f"Could not write {exc.filename or 'projects.yml'}: {exc.strerror}")
108
+ console.print(f"[green]+[/green] Provisioned CLI key `{key.name}` for project {project_id}.")
109
+ return key.value
110
+
111
+
112
+ def _write_projects_file(
113
+ base_url: str,
114
+ auth0_email: str | None,
115
+ organization_id: int,
116
+ projects: list[admin_api.ProjectSummary],
117
+ keys: dict[int, admin_api.ProjectKey],
118
+ active_project_id: int | None,
119
+ ) -> None:
120
+ """Persist projects.yml. Preserve the prior active project if it still exists."""
121
+ payload: dict = {
122
+ "auth0_email": auth0_email,
123
+ "base_url": base_url,
124
+ "organization_id": organization_id,
125
+ "active_project": active_project_id,
126
+ "projects": {},
127
+ }
128
+ for project in projects:
129
+ key = keys.get(project.id)
130
+ payload["projects"][project.id] = {
131
+ "name": project.name,
132
+ "role": project.role,
133
+ "organization_id": organization_id,
134
+ "api_key": key.value if key else None,
135
+ "api_key_id": key.id if key else None,
136
+ "api_key_name": key.name if key else None,
137
+ }
138
+ save_projects_file(payload)
139
+
140
+
141
+ def _ensure_admin_session(
142
+ base_url: str | None = None,
143
+ ) -> tuple[str, admin_api.ExchangeResult]:
144
+ """Return `(api_base, exchange_result)` using cached Auth0 tokens.
145
+
146
+ Loads the keychain bundle, transparently refreshes the Auth0 access token
147
+ via the cached refresh_token when it has expired, then exchanges it for an
148
+ Ermes admin JWT via `/api/cli/exchange`. Exits (code 1) with an actionable
149
+ message when the user isn't logged in, the refresh token is dead, or the
150
+ exchange fails. `base_url` overrides projects.yml's cached base URL.
151
+ """
152
+ api_base = base_url or get_base_url()
153
+ _require_https(api_base)
154
+ bundle = keyring_store.load()
155
+ if bundle is None:
156
+ console.print("[red]Not logged in.[/red] Run `ermes login` first.")
157
+ raise typer.Exit(code=1)
158
+ if bundle.base_url and bundle.base_url.rstrip("/") != api_base.rstrip("/"):
159
+ # The id_token would otherwise be handed to whichever host --base-url names.
160
+ console.print(
161
+ f"[red]Logged in to {bundle.base_url}, not {api_base}.[/red] "
162
+ f"Run `ermes login --base-url {api_base}` to use this host."
163
+ )
164
+ raise typer.Exit(code=1)
165
+
166
+ if bundle.is_expired():
167
+ if not bundle.refresh_token:
168
+ console.print(
169
+ "[red]Auth0 token expired and no refresh token cached.[/red] "
170
+ "Run `ermes login` again."
171
+ )
172
+ raise typer.Exit(code=1)
173
+ try:
174
+ refreshed = auth0.refresh_access_token(bundle.refresh_token)
175
+ refreshed.base_url = bundle.base_url
176
+ bundle = refreshed
177
+ keyring_store.save(bundle)
178
+ console.print("[green]✓[/green] Refreshed Auth0 access token.")
179
+ except auth0.AuthError as exc:
180
+ console.print(
181
+ f"[red]Refresh failed:[/red] {exc}. "
182
+ "Run `ermes login` to re-authenticate."
183
+ )
184
+ raise typer.Exit(code=1)
185
+
186
+ try:
187
+ result = admin_api.exchange_auth0_token(
188
+ api_base, bundle.access_token, id_token=bundle.id_token or None
189
+ )
190
+ except admin_api.AdminAPIError as exc:
191
+ console.print(f"[red]Ermes exchange failed:[/red] {exc}")
192
+ raise typer.Exit(code=1)
193
+
194
+ return api_base, result
195
+
196
+
197
+ def _require_https(base_url: str) -> None:
198
+ """Auth0 id_tokens and the minted sk_ keys travel to this host; refuse plaintext
199
+ except for a local dev stack."""
200
+ from urllib.parse import urlparse
201
+
202
+ parsed = urlparse(base_url)
203
+ # urllib3 reads `http://evil.example\\@localhost` as host evil.example while
204
+ # urlparse says localhost; no Ermes URL carries userinfo, so refuse both outright.
205
+ if "@" in parsed.netloc or "\\" in base_url:
206
+ console.print(f"[red]--base-url must not contain credentials or backslashes:[/red] {base_url}")
207
+ raise typer.Exit(code=1)
208
+ if parsed.scheme == "https" or (
209
+ parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")
210
+ ):
211
+ return
212
+ console.print(f"[red]--base-url must use https:[/red] {base_url}")
213
+ raise typer.Exit(code=1)
214
+
215
+
216
+ def login(
217
+ base_url: str = typer.Option(
218
+ DEFAULT_BASE_URL,
219
+ "--base-url",
220
+ help="Ermes API base URL. Defaults to production.",
221
+ show_default=True,
222
+ ),
223
+ open_browser: bool = typer.Option(
224
+ True,
225
+ "--open-browser/--no-open-browser",
226
+ help="Open the Auth0 verification URL in the default browser.",
227
+ ),
228
+ ) -> None:
229
+ """Authenticate via Auth0 device flow + auto-discover your Ermes projects.
230
+
231
+ What this does:
232
+ 1. Starts an Auth0 device flow against the shared `dev-hqiwr-re` tenant.
233
+ 2. Stores the resulting Auth0 tokens in your OS keychain (`ermes-cli` service).
234
+ 3. Trades the Auth0 access token for an Ermes admin JWT via /api/cli/exchange.
235
+ 4. Calls /api/admin/projects/all to enumerate every project you can see.
236
+ 5. Caches the list in `~/.ermes/projects.yml` (chmod 600).
237
+
238
+ A per-admin, per-host `sk_` key is provisioned for a project the first
239
+ time you use it (`ermes use`, `ERMES_PROJECT=…`) — never for every project.
240
+ """
241
+ _require_https(base_url)
242
+ try:
243
+ bundle = auth0.device_login(
244
+ open_browser=open_browser,
245
+ on_user_code=_on_user_code,
246
+ )
247
+ except auth0.AuthError as exc:
248
+ console.print(f"[red]Auth0 login failed:[/red] {exc}")
249
+ raise typer.Exit(code=1)
250
+
251
+ bundle.base_url = base_url
252
+ keyring_store.save(bundle)
253
+ console.print("[green]✓[/green] Auth0 tokens stored in OS keychain.")
254
+
255
+ try:
256
+ result = admin_api.exchange_auth0_token(
257
+ base_url, bundle.access_token, id_token=bundle.id_token or None
258
+ )
259
+ except admin_api.AdminAPIError as exc:
260
+ console.print(f"[red]Ermes exchange failed:[/red] {exc}")
261
+ raise typer.Exit(code=1)
262
+
263
+ console.print(
264
+ f"[green]✓[/green] Logged in as [bold]{result.admin_email}[/bold] "
265
+ f"(org {result.organization_id}, role {result.role})."
266
+ )
267
+ stored = load_projects_file()
268
+ if stored and (
269
+ stored.get("auth0_email") != result.admin_email
270
+ or str(stored.get("base_url") or "").rstrip("/") != base_url.rstrip("/")
271
+ ):
272
+ # The keyring now holds this admin's tokens; the previous admin's cached
273
+ # keys must not outlive the login even if discovery stops early.
274
+ clear_projects_file()
275
+ console.print()
276
+ console.print("[bold]Discovering projects…[/bold]")
277
+
278
+ try:
279
+ projects, keys = _bootstrap_projects(base_url, result.ermes_access_token, result.admin_email)
280
+ except admin_api.AdminAPIError as exc:
281
+ console.print(f"[red]Project discovery failed:[/red] {exc}")
282
+ raise typer.Exit(code=1)
283
+
284
+ prior_active = get_active_project_id()
285
+ active_id = (
286
+ prior_active if prior_active in {p.id for p in projects} else (projects[0].id if projects else None)
287
+ )
288
+
289
+ # Written even when empty: a stale projects.yml must not outlive a login
290
+ # that no longer grants those projects.
291
+ _write_projects_file(
292
+ base_url=base_url,
293
+ auth0_email=result.admin_email,
294
+ organization_id=result.organization_id,
295
+ projects=projects,
296
+ keys=keys,
297
+ active_project_id=active_id,
298
+ )
299
+ if not projects:
300
+ console.print(
301
+ "[yellow]No projects found for this admin.[/yellow] "
302
+ "Visit the Ermes UI to create one, then re-run `ermes login`."
303
+ )
304
+ raise typer.Exit(code=0)
305
+
306
+ console.print()
307
+ table = Table(title=f"{len(projects)} project(s) cached at ~/.ermes/projects.yml")
308
+ table.add_column("ID", justify="right")
309
+ table.add_column("Name")
310
+ table.add_column("Role")
311
+ table.add_column("Active")
312
+ for p in projects:
313
+ table.add_row(str(p.id), p.name, p.role, "★" if p.id == active_id else "")
314
+ console.print(table)
315
+ console.print(
316
+ f"\nActive project: [bold]{next(p.name for p in projects if p.id == active_id)}[/bold] "
317
+ f"(id={active_id}). Switch with `ermes use <name-or-id>`. "
318
+ "Keys are provisioned per project on first use."
319
+ )
320
+
321
+
322
+ def logout() -> None:
323
+ """Forget Auth0 tokens + cached projects. Legacy ~/.ermes/config.yml is preserved."""
324
+ bundle = keyring_store.load()
325
+ if bundle is not None and bundle.refresh_token and not auth0.revoke_refresh_token(bundle.refresh_token):
326
+ console.print("[yellow]Could not revoke the refresh token at Auth0; it expires on its own.[/yellow]")
327
+ try:
328
+ keyring_store.clear()
329
+ except keyring.errors.KeyringError as exc:
330
+ console.print(f"[red]Could not remove the Auth0 tokens from the keyring:[/red] {exc}")
331
+ raise typer.Exit(code=1)
332
+ finally:
333
+ clear_projects_file()
334
+ console.print("[green]✓[/green] Cleared Auth0 tokens + projects.yml.")
335
+ console.print(
336
+ "[dim]Legacy ~/.ermes/config.yml profiles are unchanged. "
337
+ "Remove them with `ermes config remove <name>` if desired.[/dim]"
338
+ )
339
+
340
+
341
+ def refresh_projects(
342
+ base_url: str = typer.Option(
343
+ None,
344
+ "--base-url",
345
+ help="Override the API base URL (defaults to projects.yml's value).",
346
+ ),
347
+ ) -> None:
348
+ """Re-sync projects.yml from the API using the cached Auth0 token.
349
+
350
+ Useful when an admin has added you to a new project or revoked an
351
+ existing key. If the Auth0 access token has expired, this command
352
+ automatically refreshes it via the cached refresh_token. If the
353
+ refresh_token itself has expired or been revoked, you'll be told to
354
+ re-run `ermes login`.
355
+ """
356
+ api_base, result = _ensure_admin_session(base_url)
357
+
358
+ console.print(f"[green]✓[/green] Authenticated as {result.admin_email}.")
359
+ console.print("[bold]Re-syncing projects…[/bold]")
360
+
361
+ try:
362
+ projects, keys = _bootstrap_projects(api_base, result.ermes_access_token, result.admin_email)
363
+ except admin_api.AdminAPIError as exc:
364
+ console.print(f"[red]Project discovery failed:[/red] {exc}")
365
+ raise typer.Exit(code=1)
366
+
367
+ prior_active = get_active_project_id()
368
+ active_id = (
369
+ prior_active if prior_active in {p.id for p in projects} else (projects[0].id if projects else None)
370
+ )
371
+
372
+ _write_projects_file(
373
+ base_url=api_base,
374
+ auth0_email=result.admin_email,
375
+ organization_id=result.organization_id,
376
+ projects=projects,
377
+ keys=keys,
378
+ active_project_id=active_id,
379
+ )
380
+ if not projects:
381
+ console.print("[yellow]No projects found for this admin.[/yellow]")
382
+ raise typer.Exit(code=0)
383
+ console.print(
384
+ f"[green]✓[/green] Cached {len(projects)} project(s). Active: "
385
+ f"{next(p.name for p in projects if p.id == active_id)} (id={active_id})."
386
+ )