repr-cli 0.2.16__py3-none-any.whl → 0.2.17__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.
- repr/__init__.py +1 -1
- repr/api.py +363 -62
- repr/auth.py +47 -38
- repr/change_synthesis.py +478 -0
- repr/cli.py +4099 -280
- repr/config.py +119 -11
- repr/configure.py +889 -0
- repr/cron.py +419 -0
- repr/dashboard/__init__.py +9 -0
- repr/dashboard/build.py +126 -0
- repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
- repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
- repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
- repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
- repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
- repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
- repr/dashboard/dist/favicon.svg +4 -0
- repr/dashboard/dist/index.html +14 -0
- repr/dashboard/manager.py +234 -0
- repr/dashboard/server.py +1298 -0
- repr/db.py +980 -0
- repr/hooks.py +3 -2
- repr/loaders/__init__.py +22 -0
- repr/loaders/base.py +156 -0
- repr/loaders/claude_code.py +287 -0
- repr/loaders/clawdbot.py +313 -0
- repr/loaders/gemini_antigravity.py +381 -0
- repr/mcp_server.py +1196 -0
- repr/models.py +503 -0
- repr/openai_analysis.py +25 -0
- repr/session_extractor.py +481 -0
- repr/storage.py +328 -0
- repr/story_synthesis.py +1296 -0
- repr/templates.py +68 -4
- repr/timeline.py +710 -0
- repr/tools.py +17 -8
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/METADATA +48 -10
- repr_cli-0.2.17.dist-info/RECORD +52 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
- repr_cli-0.2.16.dist-info/RECORD +0 -26
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
repr/dashboard/server.py
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP server for repr story dashboard.
|
|
3
|
+
|
|
4
|
+
Serves the Vue dashboard from either:
|
|
5
|
+
1. User-installed dashboard (~/.repr/dashboard/) - downloaded from GitHub
|
|
6
|
+
2. Bundled dashboard (repr/dashboard/dist/) - ships with CLI
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import http.server
|
|
10
|
+
import json
|
|
11
|
+
import mimetypes
|
|
12
|
+
import socketserver
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .manager import get_dashboard_path, check_for_updates
|
|
17
|
+
|
|
18
|
+
# Dashboard directory - resolved at runtime
|
|
19
|
+
_dashboard_dir: Path | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_dashboard_dir() -> Path:
|
|
23
|
+
"""Get the dashboard directory, caching the result."""
|
|
24
|
+
global _dashboard_dir
|
|
25
|
+
if _dashboard_dir is None:
|
|
26
|
+
_dashboard_dir = get_dashboard_path()
|
|
27
|
+
if _dashboard_dir is None:
|
|
28
|
+
raise RuntimeError("No dashboard available")
|
|
29
|
+
return _dashboard_dir
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_stories_from_db() -> list[dict]:
|
|
33
|
+
"""Get stories from SQLite database."""
|
|
34
|
+
from ..db import get_db
|
|
35
|
+
|
|
36
|
+
db = get_db()
|
|
37
|
+
# Create project mapping
|
|
38
|
+
projects = db.list_projects()
|
|
39
|
+
project_map = {p["id"]: p["name"] for p in projects}
|
|
40
|
+
|
|
41
|
+
stories = db.list_stories(limit=500)
|
|
42
|
+
|
|
43
|
+
result = []
|
|
44
|
+
for story in stories:
|
|
45
|
+
story_dict = story.model_dump()
|
|
46
|
+
# Enrich with repo name
|
|
47
|
+
story_dict["repo_name"] = project_map.get(story_dict.get("project_id"), "unknown")
|
|
48
|
+
|
|
49
|
+
# author_name is already stored in the database, no git operations needed
|
|
50
|
+
|
|
51
|
+
# Convert datetime objects to ISO strings
|
|
52
|
+
for key in ["created_at", "updated_at", "started_at", "ended_at"]:
|
|
53
|
+
if story_dict.get(key):
|
|
54
|
+
story_dict[key] = story_dict[key].isoformat()
|
|
55
|
+
result.append(story_dict)
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_stats_from_db() -> dict:
|
|
61
|
+
"""Get stats from SQLite database."""
|
|
62
|
+
from ..db import get_db
|
|
63
|
+
|
|
64
|
+
db = get_db()
|
|
65
|
+
stats = db.get_stats()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"count": stats["story_count"],
|
|
69
|
+
"last_updated": None,
|
|
70
|
+
"categories": stats["categories"],
|
|
71
|
+
"files": stats["unique_files"],
|
|
72
|
+
"repos": stats["project_count"],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_config() -> dict:
|
|
77
|
+
"""Get current configuration."""
|
|
78
|
+
from ..config import load_config
|
|
79
|
+
return load_config()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _save_config(config: dict) -> dict:
|
|
83
|
+
"""Save configuration."""
|
|
84
|
+
from ..config import save_config
|
|
85
|
+
save_config(config)
|
|
86
|
+
return {"success": True}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_git_origin(repo_path: Path) -> str | None:
|
|
90
|
+
"""Get git remote origin URL for a repository."""
|
|
91
|
+
import subprocess
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["git", "-C", str(repo_path), "remote", "get-url", "origin"],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=5,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode == 0:
|
|
100
|
+
return result.stdout.strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_repo_name_from_origin(origin: str | None) -> str | None:
|
|
107
|
+
"""Extract username/repo from git origin URL, stripping .git suffix."""
|
|
108
|
+
if not origin:
|
|
109
|
+
return None
|
|
110
|
+
# Handle SSH format: git@github.com:user/repo.git -> user/repo
|
|
111
|
+
if origin.startswith("git@"):
|
|
112
|
+
parts = origin.split(":")
|
|
113
|
+
if len(parts) == 2:
|
|
114
|
+
path = parts[1]
|
|
115
|
+
return path.removesuffix(".git")
|
|
116
|
+
# Handle HTTPS format: https://github.com/user/repo.git -> user/repo
|
|
117
|
+
elif "://" in origin:
|
|
118
|
+
# Split by / and get last two parts (user/repo)
|
|
119
|
+
parts = origin.rstrip("/").split("/")
|
|
120
|
+
if len(parts) >= 2:
|
|
121
|
+
user_repo = "/".join(parts[-2:])
|
|
122
|
+
return user_repo.removesuffix(".git")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_tracked_repos() -> list[dict]:
|
|
127
|
+
"""Get tracked repositories with status, origin, and project info."""
|
|
128
|
+
from ..config import get_tracked_repos
|
|
129
|
+
from ..db import get_db
|
|
130
|
+
|
|
131
|
+
repos = get_tracked_repos()
|
|
132
|
+
db = get_db()
|
|
133
|
+
|
|
134
|
+
result = []
|
|
135
|
+
for repo in repos:
|
|
136
|
+
repo_info = dict(repo)
|
|
137
|
+
repo_path = Path(repo["path"])
|
|
138
|
+
|
|
139
|
+
# Check if path exists
|
|
140
|
+
repo_info["exists"] = repo_path.exists()
|
|
141
|
+
|
|
142
|
+
# Get git origin URL
|
|
143
|
+
origin = _get_git_origin(repo_path) if repo_info["exists"] else None
|
|
144
|
+
repo_info["origin"] = origin
|
|
145
|
+
|
|
146
|
+
# Extract repo name from origin (without .git)
|
|
147
|
+
origin_name = _extract_repo_name_from_origin(origin)
|
|
148
|
+
repo_info["origin_name"] = origin_name
|
|
149
|
+
|
|
150
|
+
# Get associated project info from database
|
|
151
|
+
project = db.get_project_by_path(repo_path)
|
|
152
|
+
if project:
|
|
153
|
+
repo_info["project"] = {
|
|
154
|
+
"id": project["id"],
|
|
155
|
+
"name": project["name"],
|
|
156
|
+
"last_generated": project.get("last_generated"),
|
|
157
|
+
"last_commit_sha": project.get("last_commit_sha"),
|
|
158
|
+
}
|
|
159
|
+
else:
|
|
160
|
+
repo_info["project"] = None
|
|
161
|
+
|
|
162
|
+
result.append(repo_info)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _add_tracked_repo(path: str) -> dict:
|
|
167
|
+
"""Add a repository to tracking."""
|
|
168
|
+
from ..config import add_tracked_repo
|
|
169
|
+
repo_path = Path(path).expanduser().resolve()
|
|
170
|
+
if not (repo_path / ".git").exists():
|
|
171
|
+
return {"success": False, "error": "Not a git repository"}
|
|
172
|
+
add_tracked_repo(str(repo_path))
|
|
173
|
+
return {"success": True}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _remove_tracked_repo(path: str) -> dict:
|
|
177
|
+
"""Remove a repository from tracking."""
|
|
178
|
+
from ..config import remove_tracked_repo
|
|
179
|
+
remove_tracked_repo(path)
|
|
180
|
+
return {"success": True}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _set_repo_paused(path: str, paused: bool) -> dict:
|
|
184
|
+
"""Pause or resume a repository."""
|
|
185
|
+
from ..config import set_repo_paused
|
|
186
|
+
set_repo_paused(path, paused)
|
|
187
|
+
return {"success": True}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _rename_repo_project(path: str, name: str) -> dict:
|
|
191
|
+
"""Rename a repository's project."""
|
|
192
|
+
from pathlib import Path as PathlibPath
|
|
193
|
+
from ..db import get_db
|
|
194
|
+
|
|
195
|
+
if not name or not name.strip():
|
|
196
|
+
return {"success": False, "error": "Name cannot be empty"}
|
|
197
|
+
|
|
198
|
+
db = get_db()
|
|
199
|
+
repo_path = PathlibPath(path).expanduser().resolve()
|
|
200
|
+
db.register_project(repo_path, name.strip())
|
|
201
|
+
return {"success": True}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _get_cron_status() -> dict:
|
|
205
|
+
"""Get cron job status."""
|
|
206
|
+
from ..config import load_config
|
|
207
|
+
config = load_config()
|
|
208
|
+
cron_config = config.get("cron", {})
|
|
209
|
+
return {
|
|
210
|
+
"installed": cron_config.get("installed", False),
|
|
211
|
+
"paused": cron_config.get("paused", False),
|
|
212
|
+
"interval_hours": cron_config.get("interval_hours"),
|
|
213
|
+
"min_commits": cron_config.get("min_commits"),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ============================================================================
|
|
218
|
+
# Auth API helpers
|
|
219
|
+
# ============================================================================
|
|
220
|
+
|
|
221
|
+
# Global state for active login flow (only one at a time per dashboard)
|
|
222
|
+
_active_login_flow: dict | None = None
|
|
223
|
+
_login_flow_lock = threading.Lock()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _get_auth_status() -> dict:
|
|
227
|
+
"""Get current authentication status."""
|
|
228
|
+
from ..config import get_auth, is_authenticated
|
|
229
|
+
|
|
230
|
+
if not is_authenticated():
|
|
231
|
+
return {
|
|
232
|
+
"authenticated": False,
|
|
233
|
+
"user": None,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
auth = get_auth()
|
|
237
|
+
return {
|
|
238
|
+
"authenticated": True,
|
|
239
|
+
"user": {
|
|
240
|
+
"user_id": auth.get("user_id"),
|
|
241
|
+
"email": auth.get("email"),
|
|
242
|
+
"username": auth.get("username"),
|
|
243
|
+
"authenticated_at": auth.get("authenticated_at"),
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _start_login_flow() -> dict:
|
|
249
|
+
"""Start device code login flow."""
|
|
250
|
+
global _active_login_flow
|
|
251
|
+
|
|
252
|
+
import asyncio
|
|
253
|
+
from ..auth import request_device_code, AuthError
|
|
254
|
+
|
|
255
|
+
with _login_flow_lock:
|
|
256
|
+
# Check if already logged in
|
|
257
|
+
from ..config import is_authenticated
|
|
258
|
+
if is_authenticated():
|
|
259
|
+
return {"error": "already_authenticated", "message": "Already logged in"}
|
|
260
|
+
|
|
261
|
+
# Check if flow already active
|
|
262
|
+
if _active_login_flow is not None:
|
|
263
|
+
# Return existing flow info
|
|
264
|
+
return {
|
|
265
|
+
"status": "pending",
|
|
266
|
+
"user_code": _active_login_flow["user_code"],
|
|
267
|
+
"verification_url": _active_login_flow["verification_url"],
|
|
268
|
+
"expires_in": _active_login_flow["expires_in"],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
# Request device code
|
|
273
|
+
loop = asyncio.new_event_loop()
|
|
274
|
+
asyncio.set_event_loop(loop)
|
|
275
|
+
try:
|
|
276
|
+
device_code_response = loop.run_until_complete(request_device_code())
|
|
277
|
+
finally:
|
|
278
|
+
loop.close()
|
|
279
|
+
|
|
280
|
+
# Store flow state
|
|
281
|
+
_active_login_flow = {
|
|
282
|
+
"device_code": device_code_response.device_code,
|
|
283
|
+
"user_code": device_code_response.user_code,
|
|
284
|
+
"verification_url": device_code_response.verification_url,
|
|
285
|
+
"expires_in": device_code_response.expires_in,
|
|
286
|
+
"interval": device_code_response.interval,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
"status": "pending",
|
|
291
|
+
"user_code": device_code_response.user_code,
|
|
292
|
+
"verification_url": device_code_response.verification_url,
|
|
293
|
+
"expires_in": device_code_response.expires_in,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
except AuthError as e:
|
|
297
|
+
return {"error": "auth_error", "message": str(e)}
|
|
298
|
+
except Exception as e:
|
|
299
|
+
return {"error": "unknown_error", "message": str(e)}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _poll_login_status() -> dict:
|
|
303
|
+
"""Poll for login completion."""
|
|
304
|
+
global _active_login_flow
|
|
305
|
+
|
|
306
|
+
import httpx
|
|
307
|
+
from ..config import get_api_base, is_authenticated
|
|
308
|
+
from ..auth import save_token, TokenResponse
|
|
309
|
+
from ..telemetry import get_device_id
|
|
310
|
+
import platform
|
|
311
|
+
import socket
|
|
312
|
+
|
|
313
|
+
with _login_flow_lock:
|
|
314
|
+
# Check if already logged in
|
|
315
|
+
if is_authenticated():
|
|
316
|
+
_active_login_flow = None
|
|
317
|
+
return {"status": "completed", "authenticated": True}
|
|
318
|
+
|
|
319
|
+
# Check if flow is active
|
|
320
|
+
if _active_login_flow is None:
|
|
321
|
+
return {"status": "no_flow", "message": "No login flow active"}
|
|
322
|
+
|
|
323
|
+
device_code = _active_login_flow["device_code"]
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
# Get device name
|
|
327
|
+
hostname = socket.gethostname()
|
|
328
|
+
system = platform.system()
|
|
329
|
+
device_name = f"{hostname} ({system})"
|
|
330
|
+
|
|
331
|
+
# Poll the token endpoint
|
|
332
|
+
token_url = f"{get_api_base()}/token"
|
|
333
|
+
print(f"[DEBUG] Polling token URL: {token_url}")
|
|
334
|
+
|
|
335
|
+
with httpx.Client() as client:
|
|
336
|
+
response = client.post(
|
|
337
|
+
token_url,
|
|
338
|
+
json={
|
|
339
|
+
"device_code": device_code,
|
|
340
|
+
"client_id": "repr-cli",
|
|
341
|
+
"device_id": get_device_id(),
|
|
342
|
+
"device_name": device_name,
|
|
343
|
+
},
|
|
344
|
+
timeout=30,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if response.status_code == 200:
|
|
348
|
+
data = response.json()
|
|
349
|
+
user_data = data.get("user", {})
|
|
350
|
+
token = TokenResponse(
|
|
351
|
+
access_token=data["access_token"],
|
|
352
|
+
user_id=user_data.get("id", ""),
|
|
353
|
+
email=user_data.get("email", ""),
|
|
354
|
+
username=user_data.get("username"),
|
|
355
|
+
litellm_api_key=data.get("litellm_api_key"),
|
|
356
|
+
)
|
|
357
|
+
save_token(token)
|
|
358
|
+
|
|
359
|
+
# Clear flow
|
|
360
|
+
_active_login_flow = None
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
"status": "completed",
|
|
364
|
+
"authenticated": True,
|
|
365
|
+
"user": {
|
|
366
|
+
"user_id": token.user_id,
|
|
367
|
+
"email": token.email,
|
|
368
|
+
"username": token.username,
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if response.status_code == 400:
|
|
373
|
+
data = response.json()
|
|
374
|
+
error = data.get("error", "unknown")
|
|
375
|
+
|
|
376
|
+
if error == "authorization_pending":
|
|
377
|
+
return {"status": "pending", "message": "Waiting for authorization"}
|
|
378
|
+
elif error == "slow_down":
|
|
379
|
+
return {"status": "pending", "message": "Slow down, polling too fast"}
|
|
380
|
+
elif error == "expired_token":
|
|
381
|
+
_active_login_flow = None
|
|
382
|
+
return {"status": "expired", "message": "Device code expired"}
|
|
383
|
+
elif error == "access_denied":
|
|
384
|
+
_active_login_flow = None
|
|
385
|
+
return {"status": "denied", "message": "Authorization denied"}
|
|
386
|
+
else:
|
|
387
|
+
_active_login_flow = None
|
|
388
|
+
return {"status": "error", "message": f"Authorization failed: {error}"}
|
|
389
|
+
|
|
390
|
+
return {"status": "error", "message": f"Unexpected status: {response.status_code}"}
|
|
391
|
+
|
|
392
|
+
except httpx.RequestError as e:
|
|
393
|
+
return {"status": "error", "message": f"Network error: {str(e)}"}
|
|
394
|
+
except Exception as e:
|
|
395
|
+
return {"status": "error", "message": str(e)}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _cancel_login_flow() -> dict:
|
|
399
|
+
"""Cancel active login flow."""
|
|
400
|
+
global _active_login_flow
|
|
401
|
+
|
|
402
|
+
with _login_flow_lock:
|
|
403
|
+
if _active_login_flow is None:
|
|
404
|
+
return {"success": True, "message": "No active flow"}
|
|
405
|
+
|
|
406
|
+
_active_login_flow = None
|
|
407
|
+
return {"success": True, "message": "Login flow cancelled"}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _logout() -> dict:
|
|
411
|
+
"""Logout current user."""
|
|
412
|
+
global _active_login_flow
|
|
413
|
+
|
|
414
|
+
from ..auth import logout
|
|
415
|
+
from ..config import is_authenticated
|
|
416
|
+
|
|
417
|
+
with _login_flow_lock:
|
|
418
|
+
_active_login_flow = None
|
|
419
|
+
|
|
420
|
+
if not is_authenticated():
|
|
421
|
+
return {"success": True, "message": "Already logged out"}
|
|
422
|
+
|
|
423
|
+
logout()
|
|
424
|
+
return {"success": True, "message": "Logged out successfully"}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _save_auth_token(token_data: dict) -> dict:
|
|
428
|
+
"""Save auth token received from frontend direct auth with api.repr.dev."""
|
|
429
|
+
from ..auth import save_token, TokenResponse
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
user = token_data.get("user", {})
|
|
433
|
+
token = TokenResponse(
|
|
434
|
+
access_token=token_data["access_token"],
|
|
435
|
+
user_id=user.get("id", ""),
|
|
436
|
+
email=user.get("email", ""),
|
|
437
|
+
username=user.get("username"),
|
|
438
|
+
litellm_api_key=token_data.get("litellm_api_key"),
|
|
439
|
+
)
|
|
440
|
+
save_token(token)
|
|
441
|
+
return {"success": True, "message": "Token saved successfully"}
|
|
442
|
+
except Exception as e:
|
|
443
|
+
return {"success": False, "error": str(e)}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ============================================================================
|
|
447
|
+
# Username API helpers
|
|
448
|
+
# ============================================================================
|
|
449
|
+
|
|
450
|
+
def _get_username_info() -> dict:
|
|
451
|
+
"""Get current username info (local + remote)."""
|
|
452
|
+
from ..config import get_auth, get_profile_config, is_authenticated
|
|
453
|
+
|
|
454
|
+
profile = get_profile_config()
|
|
455
|
+
local_username = profile.get("username")
|
|
456
|
+
claimed = profile.get("claimed", False)
|
|
457
|
+
|
|
458
|
+
result = {
|
|
459
|
+
"local_username": local_username,
|
|
460
|
+
"claimed": claimed,
|
|
461
|
+
"remote_username": None,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
# If authenticated, get remote username
|
|
465
|
+
if is_authenticated():
|
|
466
|
+
auth = get_auth()
|
|
467
|
+
result["remote_username"] = auth.get("username")
|
|
468
|
+
result["user_id"] = auth.get("user_id")
|
|
469
|
+
result["email"] = auth.get("email")
|
|
470
|
+
|
|
471
|
+
return result
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _set_local_username(username: str) -> dict:
|
|
475
|
+
"""Set local username (without claiming on server)."""
|
|
476
|
+
from ..config import set_profile_config
|
|
477
|
+
|
|
478
|
+
if not username or not username.strip():
|
|
479
|
+
return {"success": False, "error": "Username cannot be empty"}
|
|
480
|
+
|
|
481
|
+
username = username.strip().lower()
|
|
482
|
+
|
|
483
|
+
# Basic validation
|
|
484
|
+
if len(username) < 3:
|
|
485
|
+
return {"success": False, "error": "Username must be at least 3 characters"}
|
|
486
|
+
if len(username) > 30:
|
|
487
|
+
return {"success": False, "error": "Username must be at most 30 characters"}
|
|
488
|
+
|
|
489
|
+
set_profile_config(username=username, claimed=False)
|
|
490
|
+
return {"success": True, "username": username}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _check_username_availability(username: str) -> dict:
|
|
494
|
+
"""Check if username is available on the server."""
|
|
495
|
+
import httpx
|
|
496
|
+
from ..config import get_api_base
|
|
497
|
+
|
|
498
|
+
if not username or not username.strip():
|
|
499
|
+
return {"available": False, "reason": "Username cannot be empty"}
|
|
500
|
+
|
|
501
|
+
username = username.strip().lower()
|
|
502
|
+
|
|
503
|
+
try:
|
|
504
|
+
url = f"{get_api_base()}/username/check/{username}"
|
|
505
|
+
with httpx.Client() as client:
|
|
506
|
+
response = client.get(url, timeout=30)
|
|
507
|
+
if response.status_code == 200:
|
|
508
|
+
return response.json()
|
|
509
|
+
return {"available": False, "reason": f"Server error: {response.status_code}"}
|
|
510
|
+
except httpx.RequestError as e:
|
|
511
|
+
return {"available": False, "reason": f"Network error: {str(e)}"}
|
|
512
|
+
except Exception as e:
|
|
513
|
+
return {"available": False, "reason": str(e)}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _claim_username(username: str) -> dict:
|
|
517
|
+
"""Claim username on the server."""
|
|
518
|
+
import httpx
|
|
519
|
+
from ..config import get_api_base, get_access_token, set_profile_config, is_authenticated
|
|
520
|
+
|
|
521
|
+
if not is_authenticated():
|
|
522
|
+
return {"success": False, "error": "Not authenticated. Please login first."}
|
|
523
|
+
|
|
524
|
+
if not username or not username.strip():
|
|
525
|
+
return {"success": False, "error": "Username cannot be empty"}
|
|
526
|
+
|
|
527
|
+
username = username.strip().lower()
|
|
528
|
+
token = get_access_token()
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
url = f"{get_api_base()}/username/claim"
|
|
532
|
+
with httpx.Client() as client:
|
|
533
|
+
response = client.post(
|
|
534
|
+
url,
|
|
535
|
+
json={"username": username},
|
|
536
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
537
|
+
timeout=30,
|
|
538
|
+
)
|
|
539
|
+
if response.status_code == 200:
|
|
540
|
+
data = response.json()
|
|
541
|
+
if data.get("success"):
|
|
542
|
+
# Update local config
|
|
543
|
+
set_profile_config(username=username, claimed=True)
|
|
544
|
+
return data
|
|
545
|
+
return {"success": False, "error": f"Server error: {response.status_code}"}
|
|
546
|
+
except httpx.RequestError as e:
|
|
547
|
+
return {"success": False, "error": f"Network error: {str(e)}"}
|
|
548
|
+
except Exception as e:
|
|
549
|
+
return {"success": False, "error": str(e)}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ============================================================================
|
|
553
|
+
# Visibility API helpers
|
|
554
|
+
# ============================================================================
|
|
555
|
+
|
|
556
|
+
def _get_visibility_settings() -> dict:
|
|
557
|
+
"""Get visibility settings from config, with backend fetch if authenticated."""
|
|
558
|
+
from ..config import load_config, is_authenticated, get_access_token, get_api_base
|
|
559
|
+
import httpx
|
|
560
|
+
|
|
561
|
+
config = load_config()
|
|
562
|
+
privacy = config.get("privacy", {})
|
|
563
|
+
|
|
564
|
+
# Local defaults (all private by default)
|
|
565
|
+
local_settings = {
|
|
566
|
+
"profile": privacy.get("profile_visibility", "private"),
|
|
567
|
+
"repos_default": privacy.get("repos_default_visibility", "private"),
|
|
568
|
+
"stories_default": privacy.get("stories_default_visibility", "private"),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
# Try to fetch from backend if authenticated
|
|
572
|
+
if is_authenticated():
|
|
573
|
+
try:
|
|
574
|
+
token = get_access_token()
|
|
575
|
+
url = f"{get_api_base()}/visibility"
|
|
576
|
+
with httpx.Client() as client:
|
|
577
|
+
response = client.get(
|
|
578
|
+
url,
|
|
579
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
580
|
+
timeout=10,
|
|
581
|
+
)
|
|
582
|
+
if response.status_code == 200:
|
|
583
|
+
backend_settings = response.json()
|
|
584
|
+
# Merge backend settings (they take precedence)
|
|
585
|
+
return {
|
|
586
|
+
"profile": backend_settings.get("profile", local_settings["profile"]),
|
|
587
|
+
"repos_default": backend_settings.get("repos_default", local_settings["repos_default"]),
|
|
588
|
+
"stories_default": backend_settings.get("stories_default", local_settings["stories_default"]),
|
|
589
|
+
}
|
|
590
|
+
except Exception:
|
|
591
|
+
pass # Fall back to local settings
|
|
592
|
+
|
|
593
|
+
return local_settings
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _set_visibility_settings(settings: dict) -> dict:
|
|
597
|
+
"""Set visibility settings in config and sync to backend if authenticated."""
|
|
598
|
+
from ..config import load_config, save_config, is_authenticated, get_access_token, get_api_base
|
|
599
|
+
import httpx
|
|
600
|
+
|
|
601
|
+
config = load_config()
|
|
602
|
+
if "privacy" not in config:
|
|
603
|
+
config["privacy"] = {}
|
|
604
|
+
|
|
605
|
+
valid_values = {"public", "private", "connections"}
|
|
606
|
+
update_data = {}
|
|
607
|
+
|
|
608
|
+
if "profile" in settings and settings["profile"] in valid_values:
|
|
609
|
+
config["privacy"]["profile_visibility"] = settings["profile"]
|
|
610
|
+
update_data["profile"] = settings["profile"]
|
|
611
|
+
if "repos_default" in settings and settings["repos_default"] in valid_values:
|
|
612
|
+
config["privacy"]["repos_default_visibility"] = settings["repos_default"]
|
|
613
|
+
update_data["repos_default"] = settings["repos_default"]
|
|
614
|
+
if "stories_default" in settings and settings["stories_default"] in valid_values:
|
|
615
|
+
config["privacy"]["stories_default_visibility"] = settings["stories_default"]
|
|
616
|
+
update_data["stories_default"] = settings["stories_default"]
|
|
617
|
+
|
|
618
|
+
save_config(config)
|
|
619
|
+
|
|
620
|
+
# Sync to backend if authenticated
|
|
621
|
+
backend_synced = False
|
|
622
|
+
if is_authenticated() and update_data:
|
|
623
|
+
try:
|
|
624
|
+
token = get_access_token()
|
|
625
|
+
url = f"{get_api_base()}/visibility"
|
|
626
|
+
with httpx.Client() as client:
|
|
627
|
+
response = client.patch(
|
|
628
|
+
url,
|
|
629
|
+
json=update_data,
|
|
630
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
631
|
+
timeout=30,
|
|
632
|
+
)
|
|
633
|
+
backend_synced = response.status_code == 200
|
|
634
|
+
except Exception:
|
|
635
|
+
pass # Fail silently, local config is still saved
|
|
636
|
+
|
|
637
|
+
return {"success": True, "backend_synced": backend_synced}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _set_story_visibility(story_id: str, visibility: str) -> dict:
|
|
641
|
+
"""Set visibility for a specific story."""
|
|
642
|
+
from ..db import get_db
|
|
643
|
+
|
|
644
|
+
valid_values = {"public", "private", "connections"}
|
|
645
|
+
if visibility not in valid_values:
|
|
646
|
+
return {"success": False, "error": f"Invalid visibility: {visibility}"}
|
|
647
|
+
|
|
648
|
+
db = get_db()
|
|
649
|
+
story = db.get_story(story_id)
|
|
650
|
+
if not story:
|
|
651
|
+
return {"success": False, "error": "Story not found"}
|
|
652
|
+
|
|
653
|
+
# Update the story visibility
|
|
654
|
+
db.update_story_visibility(story_id, visibility)
|
|
655
|
+
return {"success": True, "story_id": story_id, "visibility": visibility}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class TimelineHandler(http.server.BaseHTTPRequestHandler):
|
|
659
|
+
"""HTTP handler for story dashboard."""
|
|
660
|
+
|
|
661
|
+
def log_message(self, format: str, *args) -> None:
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
def do_GET(self):
|
|
665
|
+
if self.path.startswith("/api/"):
|
|
666
|
+
if self.path == "/api/stories":
|
|
667
|
+
self.serve_stories()
|
|
668
|
+
elif self.path.startswith("/api/diff"):
|
|
669
|
+
self.serve_diff()
|
|
670
|
+
elif self.path == "/api/status":
|
|
671
|
+
self.serve_status()
|
|
672
|
+
elif self.path == "/api/config":
|
|
673
|
+
self.serve_config()
|
|
674
|
+
elif self.path == "/api/repos":
|
|
675
|
+
self.serve_repos()
|
|
676
|
+
elif self.path == "/api/cron":
|
|
677
|
+
self.serve_cron()
|
|
678
|
+
elif self.path == "/api/auth":
|
|
679
|
+
self.serve_auth_status()
|
|
680
|
+
elif self.path == "/api/auth/login/status":
|
|
681
|
+
self.serve_login_poll()
|
|
682
|
+
elif self.path == "/api/username":
|
|
683
|
+
self.serve_username_info()
|
|
684
|
+
elif self.path.startswith("/api/username/check/"):
|
|
685
|
+
self.check_username()
|
|
686
|
+
elif self.path == "/api/visibility":
|
|
687
|
+
self.serve_visibility_settings()
|
|
688
|
+
else:
|
|
689
|
+
self.send_error(404, "API Endpoint Not Found")
|
|
690
|
+
elif "." in self.path.split("/")[-1]:
|
|
691
|
+
# Serve static files if path looks like a file
|
|
692
|
+
self.serve_static()
|
|
693
|
+
else:
|
|
694
|
+
# SPA fallback - serve index.html for all other routes
|
|
695
|
+
self.serve_dashboard()
|
|
696
|
+
|
|
697
|
+
def do_PUT(self):
|
|
698
|
+
if self.path == "/api/config":
|
|
699
|
+
self.update_config()
|
|
700
|
+
else:
|
|
701
|
+
self.send_error(404, "API Endpoint Not Found")
|
|
702
|
+
|
|
703
|
+
def do_POST(self):
|
|
704
|
+
if self.path == "/api/repos/add":
|
|
705
|
+
self.add_repo()
|
|
706
|
+
elif self.path == "/api/repos/remove":
|
|
707
|
+
self.remove_repo()
|
|
708
|
+
elif self.path == "/api/repos/pause":
|
|
709
|
+
self.pause_repo()
|
|
710
|
+
elif self.path == "/api/repos/resume":
|
|
711
|
+
self.resume_repo()
|
|
712
|
+
elif self.path == "/api/repos/rename":
|
|
713
|
+
self.rename_repo()
|
|
714
|
+
elif self.path == "/api/generate":
|
|
715
|
+
self.trigger_generation()
|
|
716
|
+
elif self.path == "/api/auth/login":
|
|
717
|
+
self.start_login()
|
|
718
|
+
elif self.path == "/api/auth/login/cancel":
|
|
719
|
+
self.cancel_login()
|
|
720
|
+
elif self.path == "/api/auth/save":
|
|
721
|
+
self.save_auth_token()
|
|
722
|
+
elif self.path == "/api/auth/logout":
|
|
723
|
+
self.do_logout()
|
|
724
|
+
elif self.path == "/api/username/set":
|
|
725
|
+
self.set_username()
|
|
726
|
+
elif self.path == "/api/username/claim":
|
|
727
|
+
self.claim_username()
|
|
728
|
+
elif self.path == "/api/visibility":
|
|
729
|
+
self.update_visibility_settings()
|
|
730
|
+
elif self.path.startswith("/api/stories/") and self.path.endswith("/visibility"):
|
|
731
|
+
self.update_story_visibility()
|
|
732
|
+
else:
|
|
733
|
+
self.send_error(404, "API Endpoint Not Found")
|
|
734
|
+
|
|
735
|
+
def do_OPTIONS(self):
|
|
736
|
+
"""Handle CORS preflight."""
|
|
737
|
+
self.send_response(200)
|
|
738
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
739
|
+
self.send_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
|
|
740
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
741
|
+
self.end_headers()
|
|
742
|
+
|
|
743
|
+
def serve_dashboard(self):
|
|
744
|
+
try:
|
|
745
|
+
dashboard_dir = _get_dashboard_dir()
|
|
746
|
+
index_path = dashboard_dir / "index.html"
|
|
747
|
+
|
|
748
|
+
if index_path.exists():
|
|
749
|
+
content = index_path.read_text(encoding="utf-8")
|
|
750
|
+
self.send_response(200)
|
|
751
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
752
|
+
self.send_header("Content-Length", len(content.encode("utf-8")))
|
|
753
|
+
self.end_headers()
|
|
754
|
+
self.wfile.write(content.encode("utf-8"))
|
|
755
|
+
else:
|
|
756
|
+
self.send_error(404, f"Dashboard index.html not found at {index_path}")
|
|
757
|
+
except Exception as e:
|
|
758
|
+
self.send_error(500, str(e))
|
|
759
|
+
|
|
760
|
+
def serve_static(self):
|
|
761
|
+
"""Serve static files from dashboard directory."""
|
|
762
|
+
try:
|
|
763
|
+
dashboard_dir = _get_dashboard_dir()
|
|
764
|
+
clean_path = self.path.lstrip("/")
|
|
765
|
+
file_path = (dashboard_dir / clean_path).resolve()
|
|
766
|
+
|
|
767
|
+
# Security check: ensure path is within dashboard dir
|
|
768
|
+
if not str(file_path).startswith(str(dashboard_dir.resolve())):
|
|
769
|
+
self.send_error(403, "Access denied")
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
# Block sensitive files
|
|
773
|
+
if file_path.suffix == ".py" or file_path.name.startswith("."):
|
|
774
|
+
self.send_error(403, "Access denied")
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
if not file_path.exists() or not file_path.is_file():
|
|
778
|
+
self.send_error(404, "File not found")
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
content_type, _ = mimetypes.guess_type(file_path)
|
|
782
|
+
content_type = content_type or "application/octet-stream"
|
|
783
|
+
|
|
784
|
+
content = file_path.read_bytes()
|
|
785
|
+
self.send_response(200)
|
|
786
|
+
self.send_header("Content-Type", content_type)
|
|
787
|
+
self.send_header("Cache-Control", "max-age=31536000, immutable") # Cache static assets
|
|
788
|
+
self.send_header("Content-Length", len(content))
|
|
789
|
+
self.end_headers()
|
|
790
|
+
self.wfile.write(content)
|
|
791
|
+
except Exception as e:
|
|
792
|
+
self.send_error(500, str(e))
|
|
793
|
+
|
|
794
|
+
def serve_stories(self):
|
|
795
|
+
try:
|
|
796
|
+
stories = _get_stories_from_db()
|
|
797
|
+
response = {"stories": stories}
|
|
798
|
+
body = json.dumps(response)
|
|
799
|
+
self.send_response(200)
|
|
800
|
+
self.send_header("Content-Type", "application/json")
|
|
801
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
802
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
803
|
+
self.end_headers()
|
|
804
|
+
self.wfile.write(body.encode())
|
|
805
|
+
except Exception as e:
|
|
806
|
+
self.send_error(500, str(e))
|
|
807
|
+
|
|
808
|
+
def serve_diff(self):
|
|
809
|
+
"""Serve diff for a story."""
|
|
810
|
+
from urllib.parse import urlparse, parse_qs
|
|
811
|
+
from ..db import get_db
|
|
812
|
+
from ..tools import get_commits_by_shas
|
|
813
|
+
|
|
814
|
+
try:
|
|
815
|
+
query = parse_qs(urlparse(self.path).query)
|
|
816
|
+
story_id = query.get("story_id", [None])[0]
|
|
817
|
+
|
|
818
|
+
if not story_id:
|
|
819
|
+
self.send_error(400, "Missing story_id")
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
db = get_db()
|
|
823
|
+
story = db.get_story(story_id)
|
|
824
|
+
if not story:
|
|
825
|
+
self.send_error(404, "Story not found")
|
|
826
|
+
return
|
|
827
|
+
|
|
828
|
+
project = db.get_project_by_id(story.project_id)
|
|
829
|
+
if not project:
|
|
830
|
+
self.send_error(404, "Project not found")
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
project_path = Path(project["path"])
|
|
834
|
+
if not project_path.exists():
|
|
835
|
+
self.send_error(404, "Repository path not found")
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
commit_shas = story.commit_shas
|
|
839
|
+
commits = get_commits_by_shas(project_path, commit_shas)
|
|
840
|
+
|
|
841
|
+
body = json.dumps({"commits": commits}, default=str)
|
|
842
|
+
self.send_response(200)
|
|
843
|
+
self.send_header("Content-Type", "application/json")
|
|
844
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
845
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
846
|
+
self.end_headers()
|
|
847
|
+
self.wfile.write(body.encode())
|
|
848
|
+
except Exception as e:
|
|
849
|
+
self.send_error(500, str(e))
|
|
850
|
+
|
|
851
|
+
def serve_status(self):
|
|
852
|
+
try:
|
|
853
|
+
stats = _get_stats_from_db()
|
|
854
|
+
body = json.dumps(stats)
|
|
855
|
+
self.send_response(200)
|
|
856
|
+
self.send_header("Content-Type", "application/json")
|
|
857
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
858
|
+
self.end_headers()
|
|
859
|
+
self.wfile.write(body.encode())
|
|
860
|
+
except Exception:
|
|
861
|
+
self.send_error(500, "Error loading stats")
|
|
862
|
+
|
|
863
|
+
def serve_config(self):
|
|
864
|
+
"""Serve current configuration."""
|
|
865
|
+
try:
|
|
866
|
+
config = _get_config()
|
|
867
|
+
body = json.dumps(config)
|
|
868
|
+
self.send_response(200)
|
|
869
|
+
self.send_header("Content-Type", "application/json")
|
|
870
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
871
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
872
|
+
self.end_headers()
|
|
873
|
+
self.wfile.write(body.encode())
|
|
874
|
+
except Exception as e:
|
|
875
|
+
self.send_error(500, str(e))
|
|
876
|
+
|
|
877
|
+
def update_config(self):
|
|
878
|
+
"""Update configuration."""
|
|
879
|
+
try:
|
|
880
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
881
|
+
body = self.rfile.read(content_length)
|
|
882
|
+
new_config = json.loads(body.decode())
|
|
883
|
+
result = _save_config(new_config)
|
|
884
|
+
response = json.dumps(result)
|
|
885
|
+
self.send_response(200)
|
|
886
|
+
self.send_header("Content-Type", "application/json")
|
|
887
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
888
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
889
|
+
self.end_headers()
|
|
890
|
+
self.wfile.write(response.encode())
|
|
891
|
+
except json.JSONDecodeError:
|
|
892
|
+
self.send_error(400, "Invalid JSON")
|
|
893
|
+
except Exception as e:
|
|
894
|
+
self.send_error(500, str(e))
|
|
895
|
+
|
|
896
|
+
def serve_repos(self):
|
|
897
|
+
"""Serve tracked repositories."""
|
|
898
|
+
try:
|
|
899
|
+
repos = _get_tracked_repos()
|
|
900
|
+
body = json.dumps({"repos": repos})
|
|
901
|
+
self.send_response(200)
|
|
902
|
+
self.send_header("Content-Type", "application/json")
|
|
903
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
904
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
905
|
+
self.end_headers()
|
|
906
|
+
self.wfile.write(body.encode())
|
|
907
|
+
except Exception as e:
|
|
908
|
+
self.send_error(500, str(e))
|
|
909
|
+
|
|
910
|
+
def add_repo(self):
|
|
911
|
+
"""Add a repository to tracking."""
|
|
912
|
+
try:
|
|
913
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
914
|
+
body = self.rfile.read(content_length)
|
|
915
|
+
data = json.loads(body.decode())
|
|
916
|
+
result = _add_tracked_repo(data.get("path", ""))
|
|
917
|
+
response = json.dumps(result)
|
|
918
|
+
self.send_response(200)
|
|
919
|
+
self.send_header("Content-Type", "application/json")
|
|
920
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
921
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
922
|
+
self.end_headers()
|
|
923
|
+
self.wfile.write(response.encode())
|
|
924
|
+
except Exception as e:
|
|
925
|
+
self.send_error(500, str(e))
|
|
926
|
+
|
|
927
|
+
def remove_repo(self):
|
|
928
|
+
"""Remove a repository from tracking."""
|
|
929
|
+
try:
|
|
930
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
931
|
+
body = self.rfile.read(content_length)
|
|
932
|
+
data = json.loads(body.decode())
|
|
933
|
+
result = _remove_tracked_repo(data.get("path", ""))
|
|
934
|
+
response = json.dumps(result)
|
|
935
|
+
self.send_response(200)
|
|
936
|
+
self.send_header("Content-Type", "application/json")
|
|
937
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
938
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
939
|
+
self.end_headers()
|
|
940
|
+
self.wfile.write(response.encode())
|
|
941
|
+
except Exception as e:
|
|
942
|
+
self.send_error(500, str(e))
|
|
943
|
+
|
|
944
|
+
def pause_repo(self):
|
|
945
|
+
"""Pause a repository."""
|
|
946
|
+
try:
|
|
947
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
948
|
+
body = self.rfile.read(content_length)
|
|
949
|
+
data = json.loads(body.decode())
|
|
950
|
+
result = _set_repo_paused(data.get("path", ""), True)
|
|
951
|
+
response = json.dumps(result)
|
|
952
|
+
self.send_response(200)
|
|
953
|
+
self.send_header("Content-Type", "application/json")
|
|
954
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
955
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
956
|
+
self.end_headers()
|
|
957
|
+
self.wfile.write(response.encode())
|
|
958
|
+
except Exception as e:
|
|
959
|
+
self.send_error(500, str(e))
|
|
960
|
+
|
|
961
|
+
def resume_repo(self):
|
|
962
|
+
"""Resume a repository."""
|
|
963
|
+
try:
|
|
964
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
965
|
+
body = self.rfile.read(content_length)
|
|
966
|
+
data = json.loads(body.decode())
|
|
967
|
+
result = _set_repo_paused(data.get("path", ""), False)
|
|
968
|
+
response = json.dumps(result)
|
|
969
|
+
self.send_response(200)
|
|
970
|
+
self.send_header("Content-Type", "application/json")
|
|
971
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
972
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
973
|
+
self.end_headers()
|
|
974
|
+
self.wfile.write(response.encode())
|
|
975
|
+
except Exception as e:
|
|
976
|
+
self.send_error(500, str(e))
|
|
977
|
+
|
|
978
|
+
def rename_repo(self):
|
|
979
|
+
"""Rename a repository's project."""
|
|
980
|
+
try:
|
|
981
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
982
|
+
body = self.rfile.read(content_length)
|
|
983
|
+
data = json.loads(body.decode())
|
|
984
|
+
result = _rename_repo_project(data.get("path", ""), data.get("name", ""))
|
|
985
|
+
response = json.dumps(result)
|
|
986
|
+
self.send_response(200)
|
|
987
|
+
self.send_header("Content-Type", "application/json")
|
|
988
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
989
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
990
|
+
self.end_headers()
|
|
991
|
+
self.wfile.write(response.encode())
|
|
992
|
+
except Exception as e:
|
|
993
|
+
self.send_error(500, str(e))
|
|
994
|
+
|
|
995
|
+
def serve_cron(self):
|
|
996
|
+
"""Serve cron status."""
|
|
997
|
+
try:
|
|
998
|
+
status = _get_cron_status()
|
|
999
|
+
body = json.dumps(status)
|
|
1000
|
+
self.send_response(200)
|
|
1001
|
+
self.send_header("Content-Type", "application/json")
|
|
1002
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1003
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1004
|
+
self.end_headers()
|
|
1005
|
+
self.wfile.write(body.encode())
|
|
1006
|
+
except Exception as e:
|
|
1007
|
+
self.send_error(500, str(e))
|
|
1008
|
+
|
|
1009
|
+
def trigger_generation(self):
|
|
1010
|
+
"""Trigger story generation background process."""
|
|
1011
|
+
import subprocess
|
|
1012
|
+
import sys
|
|
1013
|
+
|
|
1014
|
+
try:
|
|
1015
|
+
# We use Popen to run it in background so we can return immediately
|
|
1016
|
+
# Using same python executable
|
|
1017
|
+
cmd = [sys.executable, "-m", "repr", "generate"]
|
|
1018
|
+
|
|
1019
|
+
# Check config for cloud mode preferences, but default to safe (local) if unsure
|
|
1020
|
+
config = _get_config()
|
|
1021
|
+
# If default mode is cloud AND user is allowed, we could add --cloud
|
|
1022
|
+
# But safer to just let CLI logic handle defaults (it defaults to local if not auth)
|
|
1023
|
+
# Maybe add --batch-size from config?
|
|
1024
|
+
if config.get("generation", {}).get("batch_size"):
|
|
1025
|
+
cmd.extend(["--batch-size", str(config["generation"]["batch_size"])])
|
|
1026
|
+
|
|
1027
|
+
subprocess.Popen(
|
|
1028
|
+
cmd,
|
|
1029
|
+
stdout=subprocess.DEVNULL,
|
|
1030
|
+
stderr=subprocess.DEVNULL,
|
|
1031
|
+
start_new_session=True
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
response = json.dumps({"success": True, "message": "Generation started in background"})
|
|
1035
|
+
self.send_response(200)
|
|
1036
|
+
self.send_header("Content-Type", "application/json")
|
|
1037
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1038
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
1039
|
+
self.end_headers()
|
|
1040
|
+
self.wfile.write(response.encode())
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
self.send_error(500, str(e))
|
|
1043
|
+
|
|
1044
|
+
# =========================================================================
|
|
1045
|
+
# Auth endpoints
|
|
1046
|
+
# =========================================================================
|
|
1047
|
+
|
|
1048
|
+
def serve_auth_status(self):
|
|
1049
|
+
"""Serve current authentication status."""
|
|
1050
|
+
try:
|
|
1051
|
+
status = _get_auth_status()
|
|
1052
|
+
body = json.dumps(status)
|
|
1053
|
+
self.send_response(200)
|
|
1054
|
+
self.send_header("Content-Type", "application/json")
|
|
1055
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1056
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1057
|
+
self.end_headers()
|
|
1058
|
+
self.wfile.write(body.encode())
|
|
1059
|
+
except Exception as e:
|
|
1060
|
+
self.send_error(500, str(e))
|
|
1061
|
+
|
|
1062
|
+
def start_login(self):
|
|
1063
|
+
"""Start device code login flow."""
|
|
1064
|
+
try:
|
|
1065
|
+
result = _start_login_flow()
|
|
1066
|
+
body = json.dumps(result)
|
|
1067
|
+
self.send_response(200)
|
|
1068
|
+
self.send_header("Content-Type", "application/json")
|
|
1069
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1070
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1071
|
+
self.end_headers()
|
|
1072
|
+
self.wfile.write(body.encode())
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
self.send_error(500, str(e))
|
|
1075
|
+
|
|
1076
|
+
def serve_login_poll(self):
|
|
1077
|
+
"""Poll for login completion."""
|
|
1078
|
+
try:
|
|
1079
|
+
result = _poll_login_status()
|
|
1080
|
+
body = json.dumps(result)
|
|
1081
|
+
self.send_response(200)
|
|
1082
|
+
self.send_header("Content-Type", "application/json")
|
|
1083
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1084
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1085
|
+
self.end_headers()
|
|
1086
|
+
self.wfile.write(body.encode())
|
|
1087
|
+
except Exception as e:
|
|
1088
|
+
self.send_error(500, str(e))
|
|
1089
|
+
|
|
1090
|
+
def cancel_login(self):
|
|
1091
|
+
"""Cancel active login flow."""
|
|
1092
|
+
try:
|
|
1093
|
+
result = _cancel_login_flow()
|
|
1094
|
+
body = json.dumps(result)
|
|
1095
|
+
self.send_response(200)
|
|
1096
|
+
self.send_header("Content-Type", "application/json")
|
|
1097
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1098
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1099
|
+
self.end_headers()
|
|
1100
|
+
self.wfile.write(body.encode())
|
|
1101
|
+
except Exception as e:
|
|
1102
|
+
self.send_error(500, str(e))
|
|
1103
|
+
|
|
1104
|
+
def save_auth_token(self):
|
|
1105
|
+
"""Save auth token from frontend direct auth."""
|
|
1106
|
+
try:
|
|
1107
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1108
|
+
body = self.rfile.read(content_length)
|
|
1109
|
+
data = json.loads(body) if body else {}
|
|
1110
|
+
|
|
1111
|
+
result = _save_auth_token(data)
|
|
1112
|
+
response_body = json.dumps(result)
|
|
1113
|
+
self.send_response(200)
|
|
1114
|
+
self.send_header("Content-Type", "application/json")
|
|
1115
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1116
|
+
self.send_header("Content-Length", len(response_body.encode()))
|
|
1117
|
+
self.end_headers()
|
|
1118
|
+
self.wfile.write(response_body.encode())
|
|
1119
|
+
except Exception as e:
|
|
1120
|
+
self.send_error(500, str(e))
|
|
1121
|
+
|
|
1122
|
+
def do_logout(self):
|
|
1123
|
+
"""Logout current user."""
|
|
1124
|
+
try:
|
|
1125
|
+
result = _logout()
|
|
1126
|
+
body = json.dumps(result)
|
|
1127
|
+
self.send_response(200)
|
|
1128
|
+
self.send_header("Content-Type", "application/json")
|
|
1129
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1130
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1131
|
+
self.end_headers()
|
|
1132
|
+
self.wfile.write(body.encode())
|
|
1133
|
+
except Exception as e:
|
|
1134
|
+
self.send_error(500, str(e))
|
|
1135
|
+
|
|
1136
|
+
# =========================================================================
|
|
1137
|
+
# Username endpoints
|
|
1138
|
+
# =========================================================================
|
|
1139
|
+
|
|
1140
|
+
def serve_username_info(self):
|
|
1141
|
+
"""Serve current username info."""
|
|
1142
|
+
try:
|
|
1143
|
+
info = _get_username_info()
|
|
1144
|
+
body = json.dumps(info)
|
|
1145
|
+
self.send_response(200)
|
|
1146
|
+
self.send_header("Content-Type", "application/json")
|
|
1147
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1148
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1149
|
+
self.end_headers()
|
|
1150
|
+
self.wfile.write(body.encode())
|
|
1151
|
+
except Exception as e:
|
|
1152
|
+
self.send_error(500, str(e))
|
|
1153
|
+
|
|
1154
|
+
def check_username(self):
|
|
1155
|
+
"""Check username availability."""
|
|
1156
|
+
try:
|
|
1157
|
+
# Extract username from path: /api/username/check/{username}
|
|
1158
|
+
parts = self.path.split("/")
|
|
1159
|
+
username = parts[-1] if len(parts) > 4 else ""
|
|
1160
|
+
|
|
1161
|
+
result = _check_username_availability(username)
|
|
1162
|
+
body = json.dumps(result)
|
|
1163
|
+
self.send_response(200)
|
|
1164
|
+
self.send_header("Content-Type", "application/json")
|
|
1165
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1166
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1167
|
+
self.end_headers()
|
|
1168
|
+
self.wfile.write(body.encode())
|
|
1169
|
+
except Exception as e:
|
|
1170
|
+
self.send_error(500, str(e))
|
|
1171
|
+
|
|
1172
|
+
def set_username(self):
|
|
1173
|
+
"""Set local username."""
|
|
1174
|
+
try:
|
|
1175
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1176
|
+
body = self.rfile.read(content_length)
|
|
1177
|
+
data = json.loads(body.decode())
|
|
1178
|
+
result = _set_local_username(data.get("username", ""))
|
|
1179
|
+
response = json.dumps(result)
|
|
1180
|
+
self.send_response(200)
|
|
1181
|
+
self.send_header("Content-Type", "application/json")
|
|
1182
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1183
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
1184
|
+
self.end_headers()
|
|
1185
|
+
self.wfile.write(response.encode())
|
|
1186
|
+
except json.JSONDecodeError:
|
|
1187
|
+
self.send_error(400, "Invalid JSON")
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
self.send_error(500, str(e))
|
|
1190
|
+
|
|
1191
|
+
def claim_username(self):
|
|
1192
|
+
"""Claim username on server."""
|
|
1193
|
+
try:
|
|
1194
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1195
|
+
body = self.rfile.read(content_length)
|
|
1196
|
+
data = json.loads(body.decode())
|
|
1197
|
+
result = _claim_username(data.get("username", ""))
|
|
1198
|
+
response = json.dumps(result)
|
|
1199
|
+
self.send_response(200)
|
|
1200
|
+
self.send_header("Content-Type", "application/json")
|
|
1201
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1202
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
1203
|
+
self.end_headers()
|
|
1204
|
+
self.wfile.write(response.encode())
|
|
1205
|
+
except json.JSONDecodeError:
|
|
1206
|
+
self.send_error(400, "Invalid JSON")
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
self.send_error(500, str(e))
|
|
1209
|
+
|
|
1210
|
+
# =========================================================================
|
|
1211
|
+
# Visibility endpoints
|
|
1212
|
+
# =========================================================================
|
|
1213
|
+
|
|
1214
|
+
def serve_visibility_settings(self):
|
|
1215
|
+
"""Serve visibility settings."""
|
|
1216
|
+
try:
|
|
1217
|
+
settings = _get_visibility_settings()
|
|
1218
|
+
body = json.dumps(settings)
|
|
1219
|
+
self.send_response(200)
|
|
1220
|
+
self.send_header("Content-Type", "application/json")
|
|
1221
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1222
|
+
self.send_header("Content-Length", len(body.encode()))
|
|
1223
|
+
self.end_headers()
|
|
1224
|
+
self.wfile.write(body.encode())
|
|
1225
|
+
except Exception as e:
|
|
1226
|
+
self.send_error(500, str(e))
|
|
1227
|
+
|
|
1228
|
+
def update_visibility_settings(self):
|
|
1229
|
+
"""Update visibility settings."""
|
|
1230
|
+
try:
|
|
1231
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1232
|
+
body = self.rfile.read(content_length)
|
|
1233
|
+
data = json.loads(body.decode())
|
|
1234
|
+
result = _set_visibility_settings(data)
|
|
1235
|
+
response = json.dumps(result)
|
|
1236
|
+
self.send_response(200)
|
|
1237
|
+
self.send_header("Content-Type", "application/json")
|
|
1238
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1239
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
1240
|
+
self.end_headers()
|
|
1241
|
+
self.wfile.write(response.encode())
|
|
1242
|
+
except json.JSONDecodeError:
|
|
1243
|
+
self.send_error(400, "Invalid JSON")
|
|
1244
|
+
except Exception as e:
|
|
1245
|
+
self.send_error(500, str(e))
|
|
1246
|
+
|
|
1247
|
+
def update_story_visibility(self):
|
|
1248
|
+
"""Update visibility for a specific story."""
|
|
1249
|
+
try:
|
|
1250
|
+
# Extract story_id from path: /api/stories/{story_id}/visibility
|
|
1251
|
+
parts = self.path.split("/")
|
|
1252
|
+
story_id = parts[3] if len(parts) > 4 else ""
|
|
1253
|
+
|
|
1254
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
1255
|
+
body = self.rfile.read(content_length)
|
|
1256
|
+
data = json.loads(body.decode())
|
|
1257
|
+
result = _set_story_visibility(story_id, data.get("visibility", ""))
|
|
1258
|
+
response = json.dumps(result)
|
|
1259
|
+
self.send_response(200)
|
|
1260
|
+
self.send_header("Content-Type", "application/json")
|
|
1261
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1262
|
+
self.send_header("Content-Length", len(response.encode()))
|
|
1263
|
+
self.end_headers()
|
|
1264
|
+
self.wfile.write(response.encode())
|
|
1265
|
+
except json.JSONDecodeError:
|
|
1266
|
+
self.send_error(400, "Invalid JSON")
|
|
1267
|
+
except Exception as e:
|
|
1268
|
+
self.send_error(500, str(e))
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def run_server(port: int, host: str, skip_update_check: bool = False) -> None:
|
|
1272
|
+
"""
|
|
1273
|
+
Start the dashboard HTTP server.
|
|
1274
|
+
|
|
1275
|
+
Args:
|
|
1276
|
+
port: Port to listen on
|
|
1277
|
+
host: Host to bind to
|
|
1278
|
+
skip_update_check: If True, skip checking for dashboard updates
|
|
1279
|
+
"""
|
|
1280
|
+
global _dashboard_dir
|
|
1281
|
+
|
|
1282
|
+
# Check for updates (non-blocking, best-effort)
|
|
1283
|
+
if not skip_update_check:
|
|
1284
|
+
try:
|
|
1285
|
+
check_for_updates(quiet=True)
|
|
1286
|
+
except Exception:
|
|
1287
|
+
pass # Don't fail startup if update check fails
|
|
1288
|
+
|
|
1289
|
+
# Ensure dashboard is available
|
|
1290
|
+
from .manager import ensure_dashboard
|
|
1291
|
+
#_dashboard_dir = ensure_dashboard()
|
|
1292
|
+
|
|
1293
|
+
handler = TimelineHandler
|
|
1294
|
+
with socketserver.TCPServer((host, port), handler) as server:
|
|
1295
|
+
try:
|
|
1296
|
+
server.serve_forever()
|
|
1297
|
+
except KeyboardInterrupt:
|
|
1298
|
+
pass
|