Skip to content

Commit aad5d2f

Browse files
authored
Merge pull request #16 from lerim-dev/feat/logging-and-dashboard
Feat/logging and dashboard
2 parents 5893c4f + b034745 commit aad5d2f

11 files changed

Lines changed: 1405 additions & 348 deletions

File tree

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
repos:
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
rev: v0.15.7
4+
hooks:
5+
- id: ruff
6+
args: [src/, tests/]
7+
pass_filenames: false
8+
always_run: true
9+
stages: [pre-push]

pyproject.toml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = { text = "BSL-1.1" }
1212
dependencies = [
13-
"pydantic>=2.0",
14-
"eval_type_backport>=0.2.0; python_version < '3.13'",
13+
"pydantic==2.12.5",
14+
"eval_type_backport==0.3.1; python_version < '3.13'",
1515
"tomli>=2.0; python_version < '3.11'",
16-
"python-dotenv>=1.2.1",
17-
"PyYAML>=6.0",
18-
"loguru>=0.7.3",
19-
"dspy>=3.1.3",
20-
"python-frontmatter>=1.1.0",
21-
"openrouter>=0.6.0",
22-
"pydantic-ai>=1.61.0",
23-
"logfire[dspy]>=4.25.0",
24-
"litellm>=1.81.13",
25-
"rich>=13.0",
16+
"python-dotenv==1.2.2",
17+
"PyYAML==6.0.3",
18+
"loguru==0.7.3",
19+
"dspy==3.1.3",
20+
"python-frontmatter==1.1.0",
21+
"openrouter==0.7.11",
22+
"pydantic-ai==1.70.0",
23+
"logfire[dspy]==4.30.0",
24+
"litellm==1.81.13",
25+
"rich==14.3.3",
2626
]
2727

2828
[project.urls]

src/lerim/app/auth.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Auth commands for Lerim Cloud: login, status, logout.
2+
3+
Implements browser-based OAuth callback flow and manual token entry.
4+
The token is persisted in ~/.lerim/config.toml under [cloud].
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import json
11+
import random
12+
import socket
13+
import sys
14+
import threading
15+
import urllib.error
16+
import urllib.request
17+
import webbrowser
18+
from http.server import BaseHTTPRequestHandler, HTTPServer
19+
from typing import Any
20+
21+
from lerim.config.settings import get_config, save_config_patch
22+
23+
24+
def _emit(message: object = "", *, file: Any | None = None) -> None:
25+
"""Write one CLI output line to stdout or a provided file-like target."""
26+
target = file if file is not None else sys.stdout
27+
target.write(f"{message}\n")
28+
29+
30+
# ---------------------------------------------------------------------------
31+
# Callback server
32+
# ---------------------------------------------------------------------------
33+
34+
_TOKEN_RESULT: str | None = None
35+
"""Module-level holder for the token received via localhost callback."""
36+
37+
38+
class _CallbackHandler(BaseHTTPRequestHandler):
39+
"""Single-use HTTP handler that captures a token from ``/callback?token=...``."""
40+
41+
def do_GET(self) -> None: # noqa: N802
42+
from urllib.parse import parse_qs, urlparse
43+
44+
global _TOKEN_RESULT
45+
46+
parsed = urlparse(self.path)
47+
if parsed.path != "/callback":
48+
self.send_response(404)
49+
self.end_headers()
50+
self.wfile.write(b"Not found")
51+
return
52+
53+
params = parse_qs(parsed.query)
54+
token_list = params.get("token", [])
55+
if not token_list or not token_list[0].strip():
56+
self.send_response(400)
57+
self.end_headers()
58+
self.wfile.write(b"Missing token parameter")
59+
return
60+
61+
_TOKEN_RESULT = token_list[0].strip()
62+
self.send_response(200)
63+
self.send_header("Content-Type", "text/html; charset=utf-8")
64+
self.end_headers()
65+
self.wfile.write(
66+
b"<html><body><h2>Authenticated successfully.</h2>"
67+
b"<p>You can close this tab and return to the terminal.</p>"
68+
b"</body></html>"
69+
)
70+
71+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
72+
"""Suppress default stderr logging from BaseHTTPRequestHandler."""
73+
74+
75+
def _find_available_port() -> int:
76+
"""Find an available port in the 9876-9899 range, falling back to OS assignment."""
77+
ports = list(range(9876, 9900))
78+
random.shuffle(ports)
79+
for port in ports:
80+
try:
81+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
82+
sock.bind(("127.0.0.1", port))
83+
return port
84+
except OSError:
85+
continue
86+
# Fallback: let OS pick
87+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
88+
sock.bind(("127.0.0.1", 0))
89+
return sock.getsockname()[1]
90+
91+
92+
def _run_browser_flow(endpoint: str, timeout_seconds: int = 120) -> str | None:
93+
"""Start a localhost callback server, open the browser, and wait for a token.
94+
95+
Returns the token string on success, or None on timeout.
96+
"""
97+
global _TOKEN_RESULT
98+
_TOKEN_RESULT = None
99+
100+
port = _find_available_port()
101+
server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
102+
server.timeout = 1.0
103+
104+
callback_url = f"http://localhost:{port}/callback"
105+
auth_url = f"{endpoint}/auth/cli?callback={callback_url}"
106+
107+
_emit(f"Opening browser to: {auth_url}")
108+
webbrowser.open(auth_url)
109+
_emit("Waiting for authentication callback...")
110+
111+
# Run the server in a thread so we can enforce a timeout
112+
stop = threading.Event()
113+
114+
def _serve() -> None:
115+
import time
116+
117+
deadline = time.monotonic() + timeout_seconds
118+
while not stop.is_set() and time.monotonic() < deadline:
119+
server.handle_request()
120+
if _TOKEN_RESULT is not None:
121+
break
122+
123+
thread = threading.Thread(target=_serve, daemon=True)
124+
thread.start()
125+
thread.join(timeout=timeout_seconds)
126+
stop.set()
127+
server.server_close()
128+
129+
return _TOKEN_RESULT
130+
131+
132+
# ---------------------------------------------------------------------------
133+
# CLI command handlers
134+
# ---------------------------------------------------------------------------
135+
136+
137+
def cmd_auth(args: argparse.Namespace) -> int:
138+
"""Handle ``lerim auth`` (login) — browser flow or manual --token."""
139+
config = get_config()
140+
endpoint = config.cloud_endpoint.rstrip("/")
141+
142+
# Manual token entry
143+
manual_token: str | None = getattr(args, "token", None)
144+
if manual_token:
145+
manual_token = manual_token.strip()
146+
if not manual_token:
147+
_emit("Token cannot be empty.", file=sys.stderr)
148+
return 1
149+
save_config_patch({"cloud": {"token": manual_token}})
150+
_emit("Authenticated successfully.")
151+
return 0
152+
153+
# Browser-based flow
154+
token = _run_browser_flow(endpoint)
155+
if token is None:
156+
_emit(
157+
"Authentication timed out. No callback received within 120 seconds.",
158+
file=sys.stderr,
159+
)
160+
_emit(
161+
"You can authenticate manually with: lerim auth --token <token>",
162+
file=sys.stderr,
163+
)
164+
return 1
165+
166+
save_config_patch({"cloud": {"token": token}})
167+
_emit("Authenticated successfully.")
168+
return 0
169+
170+
171+
def cmd_auth_status(args: argparse.Namespace) -> int:
172+
"""Handle ``lerim auth status`` — check token and verify with cloud."""
173+
config = get_config()
174+
token = config.cloud_token
175+
176+
if not token:
177+
_emit("Not authenticated. Run `lerim auth` to log in.")
178+
return 0
179+
180+
endpoint = config.cloud_endpoint.rstrip("/")
181+
verify_url = f"{endpoint}/api/v1/auth/me"
182+
183+
try:
184+
req = urllib.request.Request(
185+
verify_url,
186+
method="GET",
187+
headers={"Authorization": f"Bearer {token}"},
188+
)
189+
with urllib.request.urlopen(req, timeout=10) as resp:
190+
data = json.loads(resp.read().decode())
191+
display = data.get("email") or data.get("name") or "unknown"
192+
_emit(f"Authenticated as {display}")
193+
return 0
194+
except urllib.error.HTTPError as exc:
195+
if exc.code == 401:
196+
_emit("Token found but could not verify (cloud may be unreachable)")
197+
return 0
198+
_emit("Token found but could not verify (cloud may be unreachable)")
199+
return 0
200+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
201+
_emit("Token found but could not verify (cloud may be unreachable)")
202+
return 0
203+
204+
205+
def cmd_auth_logout(args: argparse.Namespace) -> int:
206+
"""Handle ``lerim auth logout`` — remove token from config."""
207+
save_config_patch({"cloud": {"token": ""}})
208+
_emit("Logged out successfully.")
209+
return 0

0 commit comments

Comments
 (0)