repr-cli 0.2.16__py3-none-any.whl → 0.2.18__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.
Files changed (49) hide show
  1. repr/__init__.py +1 -1
  2. repr/api.py +363 -62
  3. repr/auth.py +47 -38
  4. repr/change_synthesis.py +478 -0
  5. repr/cli.py +4306 -364
  6. repr/config.py +119 -11
  7. repr/configure.py +889 -0
  8. repr/cron.py +419 -0
  9. repr/dashboard/__init__.py +9 -0
  10. repr/dashboard/build.py +126 -0
  11. repr/dashboard/dist/assets/index-B-aCjaCw.js +384 -0
  12. repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
  13. repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
  14. repr/dashboard/dist/assets/index-C7Gzxc4f.js +384 -0
  15. repr/dashboard/dist/assets/index-CQdMXo6g.js +391 -0
  16. repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
  17. repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
  18. repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
  19. repr/dashboard/dist/assets/index-Cs8ofFGd.js +384 -0
  20. repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
  21. repr/dashboard/dist/assets/index-DwN0SeMc.css +1 -0
  22. repr/dashboard/dist/assets/index-YFch_e0S.js +384 -0
  23. repr/dashboard/dist/favicon.svg +4 -0
  24. repr/dashboard/dist/index.html +14 -0
  25. repr/dashboard/manager.py +234 -0
  26. repr/dashboard/server.py +1489 -0
  27. repr/db.py +980 -0
  28. repr/hooks.py +3 -2
  29. repr/loaders/__init__.py +22 -0
  30. repr/loaders/base.py +156 -0
  31. repr/loaders/claude_code.py +287 -0
  32. repr/loaders/clawdbot.py +313 -0
  33. repr/loaders/gemini_antigravity.py +381 -0
  34. repr/mcp_server.py +1196 -0
  35. repr/models.py +503 -0
  36. repr/openai_analysis.py +25 -0
  37. repr/session_extractor.py +481 -0
  38. repr/storage.py +328 -0
  39. repr/story_synthesis.py +1296 -0
  40. repr/templates.py +68 -4
  41. repr/timeline.py +710 -0
  42. repr/tools.py +17 -8
  43. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/METADATA +48 -10
  44. repr_cli-0.2.18.dist-info/RECORD +58 -0
  45. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/WHEEL +1 -1
  46. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/entry_points.txt +1 -0
  47. repr_cli-0.2.16.dist-info/RECORD +0 -26
  48. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/licenses/LICENSE +0 -0
  49. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1489 @@
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
+ "token": None,
235
+ }
236
+
237
+ auth = get_auth()
238
+ return {
239
+ "authenticated": True,
240
+ "user": {
241
+ "user_id": auth.get("user_id"),
242
+ "email": auth.get("email"),
243
+ "username": auth.get("username"),
244
+ "authenticated_at": auth.get("authenticated_at"),
245
+ },
246
+ "token": auth.get("access_token"),
247
+ }
248
+
249
+
250
+ def _start_login_flow() -> dict:
251
+ """Start device code login flow."""
252
+ global _active_login_flow
253
+
254
+ import asyncio
255
+ from ..auth import request_device_code, AuthError
256
+
257
+ with _login_flow_lock:
258
+ # Check if already logged in
259
+ from ..config import is_authenticated
260
+ if is_authenticated():
261
+ return {"error": "already_authenticated", "message": "Already logged in"}
262
+
263
+ # Check if flow already active
264
+ if _active_login_flow is not None:
265
+ # Return existing flow info
266
+ return {
267
+ "status": "pending",
268
+ "user_code": _active_login_flow["user_code"],
269
+ "verification_url": _active_login_flow["verification_url"],
270
+ "expires_in": _active_login_flow["expires_in"],
271
+ }
272
+
273
+ try:
274
+ # Request device code
275
+ loop = asyncio.new_event_loop()
276
+ asyncio.set_event_loop(loop)
277
+ try:
278
+ device_code_response = loop.run_until_complete(request_device_code())
279
+ finally:
280
+ loop.close()
281
+
282
+ # Store flow state
283
+ _active_login_flow = {
284
+ "device_code": device_code_response.device_code,
285
+ "user_code": device_code_response.user_code,
286
+ "verification_url": device_code_response.verification_url,
287
+ "expires_in": device_code_response.expires_in,
288
+ "interval": device_code_response.interval,
289
+ }
290
+
291
+ return {
292
+ "status": "pending",
293
+ "user_code": device_code_response.user_code,
294
+ "verification_url": device_code_response.verification_url,
295
+ "expires_in": device_code_response.expires_in,
296
+ }
297
+
298
+ except AuthError as e:
299
+ return {"error": "auth_error", "message": str(e)}
300
+ except Exception as e:
301
+ return {"error": "unknown_error", "message": str(e)}
302
+
303
+
304
+ def _poll_login_status() -> dict:
305
+ """Poll for login completion."""
306
+ global _active_login_flow
307
+
308
+ import httpx
309
+ from ..config import get_api_base, is_authenticated
310
+ from ..auth import save_token, TokenResponse
311
+ from ..telemetry import get_device_id
312
+ import platform
313
+ import socket
314
+
315
+ with _login_flow_lock:
316
+ # Check if already logged in
317
+ if is_authenticated():
318
+ _active_login_flow = None
319
+ return {"status": "completed", "authenticated": True}
320
+
321
+ # Check if flow is active
322
+ if _active_login_flow is None:
323
+ return {"status": "no_flow", "message": "No login flow active"}
324
+
325
+ device_code = _active_login_flow["device_code"]
326
+
327
+ try:
328
+ # Get device name
329
+ hostname = socket.gethostname()
330
+ system = platform.system()
331
+ device_name = f"{hostname} ({system})"
332
+
333
+ # Poll the token endpoint
334
+ token_url = f"{get_api_base()}/token"
335
+ print(f"[DEBUG] Polling token URL: {token_url}")
336
+
337
+ with httpx.Client() as client:
338
+ response = client.post(
339
+ token_url,
340
+ json={
341
+ "device_code": device_code,
342
+ "client_id": "repr-cli",
343
+ "device_id": get_device_id(),
344
+ "device_name": device_name,
345
+ },
346
+ timeout=30,
347
+ )
348
+
349
+ if response.status_code == 200:
350
+ data = response.json()
351
+ user_data = data.get("user", {})
352
+ token = TokenResponse(
353
+ access_token=data["access_token"],
354
+ user_id=user_data.get("id", ""),
355
+ email=user_data.get("email", ""),
356
+ username=user_data.get("username"),
357
+ litellm_api_key=data.get("litellm_api_key"),
358
+ )
359
+ save_token(token)
360
+
361
+ # Clear flow
362
+ _active_login_flow = None
363
+
364
+ return {
365
+ "status": "completed",
366
+ "authenticated": True,
367
+ "user": {
368
+ "user_id": token.user_id,
369
+ "email": token.email,
370
+ "username": token.username,
371
+ },
372
+ }
373
+
374
+ if response.status_code == 400:
375
+ data = response.json()
376
+ error = data.get("error", "unknown")
377
+
378
+ if error == "authorization_pending":
379
+ return {"status": "pending", "message": "Waiting for authorization"}
380
+ elif error == "slow_down":
381
+ return {"status": "pending", "message": "Slow down, polling too fast"}
382
+ elif error == "expired_token":
383
+ _active_login_flow = None
384
+ return {"status": "expired", "message": "Device code expired"}
385
+ elif error == "access_denied":
386
+ _active_login_flow = None
387
+ return {"status": "denied", "message": "Authorization denied"}
388
+ else:
389
+ _active_login_flow = None
390
+ return {"status": "error", "message": f"Authorization failed: {error}"}
391
+
392
+ return {"status": "error", "message": f"Unexpected status: {response.status_code}"}
393
+
394
+ except httpx.RequestError as e:
395
+ return {"status": "error", "message": f"Network error: {str(e)}"}
396
+ except Exception as e:
397
+ return {"status": "error", "message": str(e)}
398
+
399
+
400
+ def _cancel_login_flow() -> dict:
401
+ """Cancel active login flow."""
402
+ global _active_login_flow
403
+
404
+ with _login_flow_lock:
405
+ if _active_login_flow is None:
406
+ return {"success": True, "message": "No active flow"}
407
+
408
+ _active_login_flow = None
409
+ return {"success": True, "message": "Login flow cancelled"}
410
+
411
+
412
+ def _logout() -> dict:
413
+ """Logout current user."""
414
+ global _active_login_flow
415
+
416
+ from ..auth import logout
417
+ from ..config import is_authenticated
418
+
419
+ with _login_flow_lock:
420
+ _active_login_flow = None
421
+
422
+ if not is_authenticated():
423
+ return {"success": True, "message": "Already logged out"}
424
+
425
+ logout()
426
+ return {"success": True, "message": "Logged out successfully"}
427
+
428
+
429
+ def _save_auth_token(token_data: dict) -> dict:
430
+ """Save auth token received from frontend direct auth with api.repr.dev."""
431
+ from ..auth import save_token, TokenResponse
432
+
433
+ try:
434
+ user = token_data.get("user", {})
435
+ token = TokenResponse(
436
+ access_token=token_data["access_token"],
437
+ user_id=user.get("id", ""),
438
+ email=user.get("email", ""),
439
+ username=user.get("username"),
440
+ litellm_api_key=token_data.get("litellm_api_key"),
441
+ )
442
+ save_token(token)
443
+ return {"success": True, "message": "Token saved successfully"}
444
+ except Exception as e:
445
+ return {"success": False, "error": str(e)}
446
+
447
+
448
+ # ============================================================================
449
+ # Username API helpers
450
+ # ============================================================================
451
+
452
+ def _get_username_info() -> dict:
453
+ """Get current username info (local + remote)."""
454
+ from ..config import get_auth, get_profile_config, is_authenticated
455
+
456
+ profile = get_profile_config()
457
+ local_username = profile.get("username")
458
+ claimed = profile.get("claimed", False)
459
+
460
+ result = {
461
+ "local_username": local_username,
462
+ "claimed": claimed,
463
+ "remote_username": None,
464
+ }
465
+
466
+ # If authenticated, get remote username
467
+ if is_authenticated():
468
+ auth = get_auth()
469
+ result["remote_username"] = auth.get("username")
470
+ result["user_id"] = auth.get("user_id")
471
+ result["email"] = auth.get("email")
472
+
473
+ return result
474
+
475
+
476
+ def _set_local_username(username: str) -> dict:
477
+ """Set local username (without claiming on server)."""
478
+ from ..config import set_profile_config
479
+
480
+ if not username or not username.strip():
481
+ return {"success": False, "error": "Username cannot be empty"}
482
+
483
+ username = username.strip().lower()
484
+
485
+ # Basic validation
486
+ if len(username) < 3:
487
+ return {"success": False, "error": "Username must be at least 3 characters"}
488
+ if len(username) > 30:
489
+ return {"success": False, "error": "Username must be at most 30 characters"}
490
+
491
+ set_profile_config(username=username, claimed=False)
492
+ return {"success": True, "username": username}
493
+
494
+
495
+ def _check_username_availability(username: str) -> dict:
496
+ """Check if username is available on the server."""
497
+ import httpx
498
+ from ..config import get_api_base
499
+
500
+ if not username or not username.strip():
501
+ return {"available": False, "reason": "Username cannot be empty"}
502
+
503
+ username = username.strip().lower()
504
+
505
+ try:
506
+ url = f"{get_api_base()}/username/check/{username}"
507
+ with httpx.Client() as client:
508
+ response = client.get(url, timeout=30)
509
+ if response.status_code == 200:
510
+ return response.json()
511
+ return {"available": False, "reason": f"Server error: {response.status_code}"}
512
+ except httpx.RequestError as e:
513
+ return {"available": False, "reason": f"Network error: {str(e)}"}
514
+ except Exception as e:
515
+ return {"available": False, "reason": str(e)}
516
+
517
+
518
+ def _claim_username(username: str) -> dict:
519
+ """Claim username on the server."""
520
+ import httpx
521
+ from ..config import get_api_base, get_access_token, set_profile_config, is_authenticated
522
+
523
+ if not is_authenticated():
524
+ return {"success": False, "error": "Not authenticated. Please login first."}
525
+
526
+ if not username or not username.strip():
527
+ return {"success": False, "error": "Username cannot be empty"}
528
+
529
+ username = username.strip().lower()
530
+ token = get_access_token()
531
+
532
+ try:
533
+ url = f"{get_api_base()}/username/claim"
534
+ with httpx.Client() as client:
535
+ response = client.post(
536
+ url,
537
+ json={"username": username},
538
+ headers={"Authorization": f"Bearer {token}"},
539
+ timeout=30,
540
+ )
541
+ if response.status_code == 200:
542
+ data = response.json()
543
+ if data.get("success"):
544
+ # Update local config
545
+ set_profile_config(username=username, claimed=True)
546
+ return data
547
+ return {"success": False, "error": f"Server error: {response.status_code}"}
548
+ except httpx.RequestError as e:
549
+ return {"success": False, "error": f"Network error: {str(e)}"}
550
+ except Exception as e:
551
+ return {"success": False, "error": str(e)}
552
+
553
+
554
+ # ============================================================================
555
+ # Visibility API helpers
556
+ # ============================================================================
557
+
558
+ def _get_visibility_settings() -> dict:
559
+ """Get visibility settings from config, with backend fetch if authenticated."""
560
+ from ..config import load_config, is_authenticated, get_access_token, get_api_base
561
+ import httpx
562
+
563
+ config = load_config()
564
+ privacy = config.get("privacy", {})
565
+
566
+ # Local defaults (all private by default)
567
+ local_settings = {
568
+ "profile": privacy.get("profile_visibility", "private"),
569
+ "repos_default": privacy.get("repos_default_visibility", "private"),
570
+ "stories_default": privacy.get("stories_default_visibility", "private"),
571
+ }
572
+
573
+ # Try to fetch from backend if authenticated
574
+ if is_authenticated():
575
+ try:
576
+ token = get_access_token()
577
+ url = f"{get_api_base()}/visibility"
578
+ with httpx.Client() as client:
579
+ response = client.get(
580
+ url,
581
+ headers={"Authorization": f"Bearer {token}"},
582
+ timeout=10,
583
+ )
584
+ if response.status_code == 200:
585
+ backend_settings = response.json()
586
+ # Merge backend settings (they take precedence)
587
+ return {
588
+ "profile": backend_settings.get("profile", local_settings["profile"]),
589
+ "repos_default": backend_settings.get("repos_default", local_settings["repos_default"]),
590
+ "stories_default": backend_settings.get("stories_default", local_settings["stories_default"]),
591
+ }
592
+ except Exception:
593
+ pass # Fall back to local settings
594
+
595
+ return local_settings
596
+
597
+
598
+ def _set_visibility_settings(settings: dict) -> dict:
599
+ """Set visibility settings in config and sync to backend if authenticated."""
600
+ from ..config import load_config, save_config, is_authenticated, get_access_token, get_api_base
601
+ import httpx
602
+
603
+ config = load_config()
604
+ if "privacy" not in config:
605
+ config["privacy"] = {}
606
+
607
+ valid_values = {"public", "private", "connections"}
608
+ update_data = {}
609
+
610
+ if "profile" in settings and settings["profile"] in valid_values:
611
+ config["privacy"]["profile_visibility"] = settings["profile"]
612
+ update_data["profile"] = settings["profile"]
613
+ if "repos_default" in settings and settings["repos_default"] in valid_values:
614
+ config["privacy"]["repos_default_visibility"] = settings["repos_default"]
615
+ update_data["repos_default"] = settings["repos_default"]
616
+ if "stories_default" in settings and settings["stories_default"] in valid_values:
617
+ config["privacy"]["stories_default_visibility"] = settings["stories_default"]
618
+ update_data["stories_default"] = settings["stories_default"]
619
+
620
+ save_config(config)
621
+
622
+ # Sync to backend if authenticated
623
+ backend_synced = False
624
+ if is_authenticated() and update_data:
625
+ try:
626
+ token = get_access_token()
627
+ url = f"{get_api_base()}/visibility"
628
+ with httpx.Client() as client:
629
+ response = client.patch(
630
+ url,
631
+ json=update_data,
632
+ headers={"Authorization": f"Bearer {token}"},
633
+ timeout=30,
634
+ )
635
+ backend_synced = response.status_code == 200
636
+ except Exception:
637
+ pass # Fail silently, local config is still saved
638
+
639
+ return {"success": True, "backend_synced": backend_synced}
640
+
641
+
642
+ def _set_story_visibility(story_id: str, visibility: str) -> dict:
643
+ """Set visibility for a specific story."""
644
+ from ..db import get_db
645
+
646
+ valid_values = {"public", "private", "connections"}
647
+ if visibility not in valid_values:
648
+ return {"success": False, "error": f"Invalid visibility: {visibility}"}
649
+
650
+ db = get_db()
651
+ story = db.get_story(story_id)
652
+ if not story:
653
+ return {"success": False, "error": "Story not found"}
654
+
655
+ # Update the story visibility
656
+ db.update_story_visibility(story_id, visibility)
657
+ return {"success": True, "story_id": story_id, "visibility": visibility}
658
+
659
+
660
+ # ============================================================================
661
+ # Publish API helpers
662
+ # ============================================================================
663
+
664
+ def _get_stories_to_publish(
665
+ scope: str,
666
+ repo_name: str | None = None,
667
+ story_id: str | None = None,
668
+ include_pushed: bool = False,
669
+ ) -> list:
670
+ """Get stories to publish based on scope."""
671
+ from ..db import get_db
672
+
673
+ db = get_db()
674
+
675
+ if scope == "story" and story_id:
676
+ story = db.get_story(story_id)
677
+ if story:
678
+ return [story]
679
+ return []
680
+
681
+ elif scope == "repo" and repo_name:
682
+ projects = db.list_projects()
683
+ project_ids = [p["id"] for p in projects if p["name"] == repo_name]
684
+ if not project_ids:
685
+ return []
686
+ return [s for s in db.list_stories(limit=10000) if s.project_id in project_ids]
687
+
688
+ else: # scope == "all"
689
+ return db.list_stories(limit=10000)
690
+
691
+
692
+ def _publish_preview(data: dict) -> dict:
693
+ """Preview what would be published."""
694
+ scope = data.get("scope", "all")
695
+ repo_name = data.get("repo_name")
696
+ story_id = data.get("story_id")
697
+ include_pushed = data.get("include_pushed", False)
698
+
699
+ stories = _get_stories_to_publish(scope, repo_name, story_id, include_pushed)
700
+
701
+ return {
702
+ "count": len(stories),
703
+ "stories": [
704
+ {
705
+ "id": s.id,
706
+ "title": s.title,
707
+ "visibility": s.visibility or "private",
708
+ "repo_name": getattr(s, "repo_name", None),
709
+ }
710
+ for s in stories[:100] # Limit preview to 100 stories
711
+ ],
712
+ }
713
+
714
+
715
+ def _publish_stories(data: dict) -> dict:
716
+ """Publish stories to repr.dev."""
717
+ import asyncio
718
+ from ..api import push_stories_batch, APIError, AuthError
719
+ from ..config import is_authenticated, get_access_token
720
+
721
+ # Check authentication
722
+ if not is_authenticated():
723
+ return {"success": False, "error": "Not authenticated. Please login first."}
724
+
725
+ scope = data.get("scope", "all")
726
+ repo_name = data.get("repo_name")
727
+ story_id = data.get("story_id")
728
+ visibility_override = data.get("visibility")
729
+ include_pushed = data.get("include_pushed", False)
730
+
731
+ stories = _get_stories_to_publish(scope, repo_name, story_id, include_pushed)
732
+
733
+ if not stories:
734
+ return {"success": True, "published_count": 0, "failed_count": 0, "message": "No stories to publish"}
735
+
736
+ # Build batch payload
737
+ stories_payload = []
738
+ for story in stories:
739
+ payload = story.model_dump(mode="json")
740
+ # Use override visibility or story's current visibility
741
+ payload["visibility"] = visibility_override or story.visibility or "private"
742
+ payload["client_id"] = story.id
743
+ stories_payload.append(payload)
744
+
745
+ try:
746
+ result = asyncio.run(push_stories_batch(stories_payload))
747
+ pushed = result.get("pushed", 0)
748
+ failed = result.get("failed", 0)
749
+
750
+ return {
751
+ "success": True,
752
+ "published_count": pushed,
753
+ "failed_count": failed,
754
+ }
755
+
756
+ except AuthError as e:
757
+ return {"success": False, "error": f"Authentication error: {str(e)}"}
758
+ except APIError as e:
759
+ return {"success": False, "error": f"API error: {str(e)}"}
760
+ except Exception as e:
761
+ return {"success": False, "error": str(e)}
762
+
763
+
764
+ class TimelineHandler(http.server.BaseHTTPRequestHandler):
765
+ """HTTP handler for story dashboard."""
766
+
767
+ def log_message(self, format: str, *args) -> None:
768
+ pass
769
+
770
+ def do_GET(self):
771
+ if self.path.startswith("/api/"):
772
+ if self.path == "/api/stories":
773
+ self.serve_stories()
774
+ elif self.path.startswith("/api/diff"):
775
+ self.serve_diff()
776
+ elif self.path == "/api/status":
777
+ self.serve_status()
778
+ elif self.path == "/api/config":
779
+ self.serve_config()
780
+ elif self.path == "/api/repos":
781
+ self.serve_repos()
782
+ elif self.path == "/api/cron":
783
+ self.serve_cron()
784
+ elif self.path == "/api/auth":
785
+ self.serve_auth_status()
786
+ elif self.path == "/api/auth/login/status":
787
+ self.serve_login_poll()
788
+ elif self.path == "/api/username":
789
+ self.serve_username_info()
790
+ elif self.path.startswith("/api/username/check/"):
791
+ self.check_username()
792
+ elif self.path == "/api/visibility":
793
+ self.serve_visibility_settings()
794
+ elif self.path.startswith("/api/publish/preview"):
795
+ self.serve_publish_preview()
796
+ else:
797
+ self.send_error(404, "API Endpoint Not Found")
798
+ elif "." in self.path.split("/")[-1]:
799
+ # Serve static files if path looks like a file
800
+ self.serve_static()
801
+ else:
802
+ # SPA fallback - serve index.html for all other routes
803
+ self.serve_dashboard()
804
+
805
+ def do_PUT(self):
806
+ if self.path == "/api/config":
807
+ self.update_config()
808
+ else:
809
+ self.send_error(404, "API Endpoint Not Found")
810
+
811
+ def do_POST(self):
812
+ if self.path == "/api/repos/add":
813
+ self.add_repo()
814
+ elif self.path == "/api/repos/remove":
815
+ self.remove_repo()
816
+ elif self.path == "/api/repos/pause":
817
+ self.pause_repo()
818
+ elif self.path == "/api/repos/resume":
819
+ self.resume_repo()
820
+ elif self.path == "/api/repos/rename":
821
+ self.rename_repo()
822
+ elif self.path == "/api/generate":
823
+ self.trigger_generation()
824
+ elif self.path == "/api/auth/login":
825
+ self.start_login()
826
+ elif self.path == "/api/auth/login/cancel":
827
+ self.cancel_login()
828
+ elif self.path == "/api/auth/save":
829
+ self.save_auth_token()
830
+ elif self.path == "/api/auth/logout":
831
+ self.do_logout()
832
+ elif self.path == "/api/username/set":
833
+ self.set_username()
834
+ elif self.path == "/api/username/claim":
835
+ self.claim_username()
836
+ elif self.path == "/api/visibility":
837
+ self.update_visibility_settings()
838
+ elif self.path.startswith("/api/stories/") and self.path.endswith("/visibility"):
839
+ self.update_story_visibility()
840
+ elif self.path == "/api/publish":
841
+ self.do_publish()
842
+ elif self.path == "/api/publish/mark-pushed":
843
+ self.mark_stories_pushed()
844
+ else:
845
+ self.send_error(404, "API Endpoint Not Found")
846
+
847
+ def do_OPTIONS(self):
848
+ """Handle CORS preflight."""
849
+ self.send_response(200)
850
+ self.send_header("Access-Control-Allow-Origin", "*")
851
+ self.send_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
852
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
853
+ self.end_headers()
854
+
855
+ def serve_dashboard(self):
856
+ try:
857
+ dashboard_dir = _get_dashboard_dir()
858
+ index_path = dashboard_dir / "index.html"
859
+
860
+ if index_path.exists():
861
+ content = index_path.read_text(encoding="utf-8")
862
+ self.send_response(200)
863
+ self.send_header("Content-Type", "text/html; charset=utf-8")
864
+ self.send_header("Content-Length", len(content.encode("utf-8")))
865
+ self.end_headers()
866
+ self.wfile.write(content.encode("utf-8"))
867
+ else:
868
+ self.send_error(404, f"Dashboard index.html not found at {index_path}")
869
+ except Exception as e:
870
+ self.send_error(500, str(e))
871
+
872
+ def serve_static(self):
873
+ """Serve static files from dashboard directory."""
874
+ try:
875
+ dashboard_dir = _get_dashboard_dir()
876
+ clean_path = self.path.lstrip("/")
877
+ file_path = (dashboard_dir / clean_path).resolve()
878
+
879
+ # Security check: ensure path is within dashboard dir
880
+ if not str(file_path).startswith(str(dashboard_dir.resolve())):
881
+ self.send_error(403, "Access denied")
882
+ return
883
+
884
+ # Block sensitive files
885
+ if file_path.suffix == ".py" or file_path.name.startswith("."):
886
+ self.send_error(403, "Access denied")
887
+ return
888
+
889
+ if not file_path.exists() or not file_path.is_file():
890
+ self.send_error(404, "File not found")
891
+ return
892
+
893
+ content_type, _ = mimetypes.guess_type(file_path)
894
+ content_type = content_type or "application/octet-stream"
895
+
896
+ content = file_path.read_bytes()
897
+ self.send_response(200)
898
+ self.send_header("Content-Type", content_type)
899
+ self.send_header("Cache-Control", "max-age=31536000, immutable") # Cache static assets
900
+ self.send_header("Content-Length", len(content))
901
+ self.end_headers()
902
+ self.wfile.write(content)
903
+ except Exception as e:
904
+ self.send_error(500, str(e))
905
+
906
+ def serve_stories(self):
907
+ try:
908
+ stories = _get_stories_from_db()
909
+ response = {"stories": stories}
910
+ body = json.dumps(response)
911
+ self.send_response(200)
912
+ self.send_header("Content-Type", "application/json")
913
+ self.send_header("Access-Control-Allow-Origin", "*")
914
+ self.send_header("Content-Length", len(body.encode()))
915
+ self.end_headers()
916
+ self.wfile.write(body.encode())
917
+ except Exception as e:
918
+ self.send_error(500, str(e))
919
+
920
+ def serve_diff(self):
921
+ """Serve diff for a story."""
922
+ from urllib.parse import urlparse, parse_qs
923
+ from ..db import get_db
924
+ from ..tools import get_commits_by_shas
925
+
926
+ try:
927
+ query = parse_qs(urlparse(self.path).query)
928
+ story_id = query.get("story_id", [None])[0]
929
+
930
+ if not story_id:
931
+ self.send_error(400, "Missing story_id")
932
+ return
933
+
934
+ db = get_db()
935
+ story = db.get_story(story_id)
936
+ if not story:
937
+ self.send_error(404, "Story not found")
938
+ return
939
+
940
+ project = db.get_project_by_id(story.project_id)
941
+ if not project:
942
+ self.send_error(404, "Project not found")
943
+ return
944
+
945
+ project_path = Path(project["path"])
946
+ if not project_path.exists():
947
+ self.send_error(404, "Repository path not found")
948
+ return
949
+
950
+ commit_shas = story.commit_shas
951
+ commits = get_commits_by_shas(project_path, commit_shas)
952
+
953
+ body = json.dumps({"commits": commits}, default=str)
954
+ self.send_response(200)
955
+ self.send_header("Content-Type", "application/json")
956
+ self.send_header("Access-Control-Allow-Origin", "*")
957
+ self.send_header("Content-Length", len(body.encode()))
958
+ self.end_headers()
959
+ self.wfile.write(body.encode())
960
+ except Exception as e:
961
+ self.send_error(500, str(e))
962
+
963
+ def serve_status(self):
964
+ try:
965
+ stats = _get_stats_from_db()
966
+ body = json.dumps(stats)
967
+ self.send_response(200)
968
+ self.send_header("Content-Type", "application/json")
969
+ self.send_header("Content-Length", len(body.encode()))
970
+ self.end_headers()
971
+ self.wfile.write(body.encode())
972
+ except Exception:
973
+ self.send_error(500, "Error loading stats")
974
+
975
+ def serve_config(self):
976
+ """Serve current configuration."""
977
+ try:
978
+ config = _get_config()
979
+ body = json.dumps(config)
980
+ self.send_response(200)
981
+ self.send_header("Content-Type", "application/json")
982
+ self.send_header("Access-Control-Allow-Origin", "*")
983
+ self.send_header("Content-Length", len(body.encode()))
984
+ self.end_headers()
985
+ self.wfile.write(body.encode())
986
+ except Exception as e:
987
+ self.send_error(500, str(e))
988
+
989
+ def update_config(self):
990
+ """Update configuration."""
991
+ try:
992
+ content_length = int(self.headers.get("Content-Length", 0))
993
+ body = self.rfile.read(content_length)
994
+ new_config = json.loads(body.decode())
995
+ result = _save_config(new_config)
996
+ response = json.dumps(result)
997
+ self.send_response(200)
998
+ self.send_header("Content-Type", "application/json")
999
+ self.send_header("Access-Control-Allow-Origin", "*")
1000
+ self.send_header("Content-Length", len(response.encode()))
1001
+ self.end_headers()
1002
+ self.wfile.write(response.encode())
1003
+ except json.JSONDecodeError:
1004
+ self.send_error(400, "Invalid JSON")
1005
+ except Exception as e:
1006
+ self.send_error(500, str(e))
1007
+
1008
+ def serve_repos(self):
1009
+ """Serve tracked repositories."""
1010
+ try:
1011
+ repos = _get_tracked_repos()
1012
+ body = json.dumps({"repos": repos})
1013
+ self.send_response(200)
1014
+ self.send_header("Content-Type", "application/json")
1015
+ self.send_header("Access-Control-Allow-Origin", "*")
1016
+ self.send_header("Content-Length", len(body.encode()))
1017
+ self.end_headers()
1018
+ self.wfile.write(body.encode())
1019
+ except Exception as e:
1020
+ self.send_error(500, str(e))
1021
+
1022
+ def add_repo(self):
1023
+ """Add a repository to tracking."""
1024
+ try:
1025
+ content_length = int(self.headers.get("Content-Length", 0))
1026
+ body = self.rfile.read(content_length)
1027
+ data = json.loads(body.decode())
1028
+ result = _add_tracked_repo(data.get("path", ""))
1029
+ response = json.dumps(result)
1030
+ self.send_response(200)
1031
+ self.send_header("Content-Type", "application/json")
1032
+ self.send_header("Access-Control-Allow-Origin", "*")
1033
+ self.send_header("Content-Length", len(response.encode()))
1034
+ self.end_headers()
1035
+ self.wfile.write(response.encode())
1036
+ except Exception as e:
1037
+ self.send_error(500, str(e))
1038
+
1039
+ def remove_repo(self):
1040
+ """Remove a repository from tracking."""
1041
+ try:
1042
+ content_length = int(self.headers.get("Content-Length", 0))
1043
+ body = self.rfile.read(content_length)
1044
+ data = json.loads(body.decode())
1045
+ result = _remove_tracked_repo(data.get("path", ""))
1046
+ response = json.dumps(result)
1047
+ self.send_response(200)
1048
+ self.send_header("Content-Type", "application/json")
1049
+ self.send_header("Access-Control-Allow-Origin", "*")
1050
+ self.send_header("Content-Length", len(response.encode()))
1051
+ self.end_headers()
1052
+ self.wfile.write(response.encode())
1053
+ except Exception as e:
1054
+ self.send_error(500, str(e))
1055
+
1056
+ def pause_repo(self):
1057
+ """Pause a repository."""
1058
+ try:
1059
+ content_length = int(self.headers.get("Content-Length", 0))
1060
+ body = self.rfile.read(content_length)
1061
+ data = json.loads(body.decode())
1062
+ result = _set_repo_paused(data.get("path", ""), True)
1063
+ response = json.dumps(result)
1064
+ self.send_response(200)
1065
+ self.send_header("Content-Type", "application/json")
1066
+ self.send_header("Access-Control-Allow-Origin", "*")
1067
+ self.send_header("Content-Length", len(response.encode()))
1068
+ self.end_headers()
1069
+ self.wfile.write(response.encode())
1070
+ except Exception as e:
1071
+ self.send_error(500, str(e))
1072
+
1073
+ def resume_repo(self):
1074
+ """Resume a repository."""
1075
+ try:
1076
+ content_length = int(self.headers.get("Content-Length", 0))
1077
+ body = self.rfile.read(content_length)
1078
+ data = json.loads(body.decode())
1079
+ result = _set_repo_paused(data.get("path", ""), False)
1080
+ response = 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(response.encode()))
1085
+ self.end_headers()
1086
+ self.wfile.write(response.encode())
1087
+ except Exception as e:
1088
+ self.send_error(500, str(e))
1089
+
1090
+ def rename_repo(self):
1091
+ """Rename a repository's project."""
1092
+ try:
1093
+ content_length = int(self.headers.get("Content-Length", 0))
1094
+ body = self.rfile.read(content_length)
1095
+ data = json.loads(body.decode())
1096
+ result = _rename_repo_project(data.get("path", ""), data.get("name", ""))
1097
+ response = json.dumps(result)
1098
+ self.send_response(200)
1099
+ self.send_header("Content-Type", "application/json")
1100
+ self.send_header("Access-Control-Allow-Origin", "*")
1101
+ self.send_header("Content-Length", len(response.encode()))
1102
+ self.end_headers()
1103
+ self.wfile.write(response.encode())
1104
+ except Exception as e:
1105
+ self.send_error(500, str(e))
1106
+
1107
+ def serve_cron(self):
1108
+ """Serve cron status."""
1109
+ try:
1110
+ status = _get_cron_status()
1111
+ body = json.dumps(status)
1112
+ self.send_response(200)
1113
+ self.send_header("Content-Type", "application/json")
1114
+ self.send_header("Access-Control-Allow-Origin", "*")
1115
+ self.send_header("Content-Length", len(body.encode()))
1116
+ self.end_headers()
1117
+ self.wfile.write(body.encode())
1118
+ except Exception as e:
1119
+ self.send_error(500, str(e))
1120
+
1121
+ def trigger_generation(self):
1122
+ """Trigger story generation background process."""
1123
+ import subprocess
1124
+ import sys
1125
+
1126
+ try:
1127
+ # We use Popen to run it in background so we can return immediately
1128
+ # Using same python executable
1129
+ cmd = [sys.executable, "-m", "repr", "generate"]
1130
+
1131
+ # Check config for cloud mode preferences, but default to safe (local) if unsure
1132
+ config = _get_config()
1133
+ # If default mode is cloud AND user is allowed, we could add --cloud
1134
+ # But safer to just let CLI logic handle defaults (it defaults to local if not auth)
1135
+ # Maybe add --batch-size from config?
1136
+ if config.get("generation", {}).get("batch_size"):
1137
+ cmd.extend(["--batch-size", str(config["generation"]["batch_size"])])
1138
+
1139
+ subprocess.Popen(
1140
+ cmd,
1141
+ stdout=subprocess.DEVNULL,
1142
+ stderr=subprocess.DEVNULL,
1143
+ start_new_session=True
1144
+ )
1145
+
1146
+ response = json.dumps({"success": True, "message": "Generation started in background"})
1147
+ self.send_response(200)
1148
+ self.send_header("Content-Type", "application/json")
1149
+ self.send_header("Access-Control-Allow-Origin", "*")
1150
+ self.send_header("Content-Length", len(response.encode()))
1151
+ self.end_headers()
1152
+ self.wfile.write(response.encode())
1153
+ except Exception as e:
1154
+ self.send_error(500, str(e))
1155
+
1156
+ # =========================================================================
1157
+ # Auth endpoints
1158
+ # =========================================================================
1159
+
1160
+ def serve_auth_status(self):
1161
+ """Serve current authentication status."""
1162
+ try:
1163
+ status = _get_auth_status()
1164
+ body = json.dumps(status)
1165
+ self.send_response(200)
1166
+ self.send_header("Content-Type", "application/json")
1167
+ self.send_header("Access-Control-Allow-Origin", "*")
1168
+ self.send_header("Content-Length", len(body.encode()))
1169
+ self.end_headers()
1170
+ self.wfile.write(body.encode())
1171
+ except Exception as e:
1172
+ self.send_error(500, str(e))
1173
+
1174
+ def start_login(self):
1175
+ """Start device code login flow."""
1176
+ try:
1177
+ result = _start_login_flow()
1178
+ body = json.dumps(result)
1179
+ self.send_response(200)
1180
+ self.send_header("Content-Type", "application/json")
1181
+ self.send_header("Access-Control-Allow-Origin", "*")
1182
+ self.send_header("Content-Length", len(body.encode()))
1183
+ self.end_headers()
1184
+ self.wfile.write(body.encode())
1185
+ except Exception as e:
1186
+ self.send_error(500, str(e))
1187
+
1188
+ def serve_login_poll(self):
1189
+ """Poll for login completion."""
1190
+ try:
1191
+ result = _poll_login_status()
1192
+ body = json.dumps(result)
1193
+ self.send_response(200)
1194
+ self.send_header("Content-Type", "application/json")
1195
+ self.send_header("Access-Control-Allow-Origin", "*")
1196
+ self.send_header("Content-Length", len(body.encode()))
1197
+ self.end_headers()
1198
+ self.wfile.write(body.encode())
1199
+ except Exception as e:
1200
+ self.send_error(500, str(e))
1201
+
1202
+ def cancel_login(self):
1203
+ """Cancel active login flow."""
1204
+ try:
1205
+ result = _cancel_login_flow()
1206
+ body = json.dumps(result)
1207
+ self.send_response(200)
1208
+ self.send_header("Content-Type", "application/json")
1209
+ self.send_header("Access-Control-Allow-Origin", "*")
1210
+ self.send_header("Content-Length", len(body.encode()))
1211
+ self.end_headers()
1212
+ self.wfile.write(body.encode())
1213
+ except Exception as e:
1214
+ self.send_error(500, str(e))
1215
+
1216
+ def save_auth_token(self):
1217
+ """Save auth token from frontend direct auth."""
1218
+ try:
1219
+ content_length = int(self.headers.get("Content-Length", 0))
1220
+ body = self.rfile.read(content_length)
1221
+ data = json.loads(body) if body else {}
1222
+
1223
+ result = _save_auth_token(data)
1224
+ response_body = json.dumps(result)
1225
+ self.send_response(200)
1226
+ self.send_header("Content-Type", "application/json")
1227
+ self.send_header("Access-Control-Allow-Origin", "*")
1228
+ self.send_header("Content-Length", len(response_body.encode()))
1229
+ self.end_headers()
1230
+ self.wfile.write(response_body.encode())
1231
+ except Exception as e:
1232
+ self.send_error(500, str(e))
1233
+
1234
+ def do_logout(self):
1235
+ """Logout current user."""
1236
+ try:
1237
+ result = _logout()
1238
+ body = json.dumps(result)
1239
+ self.send_response(200)
1240
+ self.send_header("Content-Type", "application/json")
1241
+ self.send_header("Access-Control-Allow-Origin", "*")
1242
+ self.send_header("Content-Length", len(body.encode()))
1243
+ self.end_headers()
1244
+ self.wfile.write(body.encode())
1245
+ except Exception as e:
1246
+ self.send_error(500, str(e))
1247
+
1248
+ # =========================================================================
1249
+ # Username endpoints
1250
+ # =========================================================================
1251
+
1252
+ def serve_username_info(self):
1253
+ """Serve current username info."""
1254
+ try:
1255
+ info = _get_username_info()
1256
+ body = json.dumps(info)
1257
+ self.send_response(200)
1258
+ self.send_header("Content-Type", "application/json")
1259
+ self.send_header("Access-Control-Allow-Origin", "*")
1260
+ self.send_header("Content-Length", len(body.encode()))
1261
+ self.end_headers()
1262
+ self.wfile.write(body.encode())
1263
+ except Exception as e:
1264
+ self.send_error(500, str(e))
1265
+
1266
+ def check_username(self):
1267
+ """Check username availability."""
1268
+ try:
1269
+ # Extract username from path: /api/username/check/{username}
1270
+ parts = self.path.split("/")
1271
+ username = parts[-1] if len(parts) > 4 else ""
1272
+
1273
+ result = _check_username_availability(username)
1274
+ body = json.dumps(result)
1275
+ self.send_response(200)
1276
+ self.send_header("Content-Type", "application/json")
1277
+ self.send_header("Access-Control-Allow-Origin", "*")
1278
+ self.send_header("Content-Length", len(body.encode()))
1279
+ self.end_headers()
1280
+ self.wfile.write(body.encode())
1281
+ except Exception as e:
1282
+ self.send_error(500, str(e))
1283
+
1284
+ def set_username(self):
1285
+ """Set local username."""
1286
+ try:
1287
+ content_length = int(self.headers.get("Content-Length", 0))
1288
+ body = self.rfile.read(content_length)
1289
+ data = json.loads(body.decode())
1290
+ result = _set_local_username(data.get("username", ""))
1291
+ response = json.dumps(result)
1292
+ self.send_response(200)
1293
+ self.send_header("Content-Type", "application/json")
1294
+ self.send_header("Access-Control-Allow-Origin", "*")
1295
+ self.send_header("Content-Length", len(response.encode()))
1296
+ self.end_headers()
1297
+ self.wfile.write(response.encode())
1298
+ except json.JSONDecodeError:
1299
+ self.send_error(400, "Invalid JSON")
1300
+ except Exception as e:
1301
+ self.send_error(500, str(e))
1302
+
1303
+ def claim_username(self):
1304
+ """Claim username on server."""
1305
+ try:
1306
+ content_length = int(self.headers.get("Content-Length", 0))
1307
+ body = self.rfile.read(content_length)
1308
+ data = json.loads(body.decode())
1309
+ result = _claim_username(data.get("username", ""))
1310
+ response = json.dumps(result)
1311
+ self.send_response(200)
1312
+ self.send_header("Content-Type", "application/json")
1313
+ self.send_header("Access-Control-Allow-Origin", "*")
1314
+ self.send_header("Content-Length", len(response.encode()))
1315
+ self.end_headers()
1316
+ self.wfile.write(response.encode())
1317
+ except json.JSONDecodeError:
1318
+ self.send_error(400, "Invalid JSON")
1319
+ except Exception as e:
1320
+ self.send_error(500, str(e))
1321
+
1322
+ # =========================================================================
1323
+ # Visibility endpoints
1324
+ # =========================================================================
1325
+
1326
+ def serve_visibility_settings(self):
1327
+ """Serve visibility settings."""
1328
+ try:
1329
+ settings = _get_visibility_settings()
1330
+ body = json.dumps(settings)
1331
+ self.send_response(200)
1332
+ self.send_header("Content-Type", "application/json")
1333
+ self.send_header("Access-Control-Allow-Origin", "*")
1334
+ self.send_header("Content-Length", len(body.encode()))
1335
+ self.end_headers()
1336
+ self.wfile.write(body.encode())
1337
+ except Exception as e:
1338
+ self.send_error(500, str(e))
1339
+
1340
+ def update_visibility_settings(self):
1341
+ """Update visibility settings."""
1342
+ try:
1343
+ content_length = int(self.headers.get("Content-Length", 0))
1344
+ body = self.rfile.read(content_length)
1345
+ data = json.loads(body.decode())
1346
+ result = _set_visibility_settings(data)
1347
+ response = json.dumps(result)
1348
+ self.send_response(200)
1349
+ self.send_header("Content-Type", "application/json")
1350
+ self.send_header("Access-Control-Allow-Origin", "*")
1351
+ self.send_header("Content-Length", len(response.encode()))
1352
+ self.end_headers()
1353
+ self.wfile.write(response.encode())
1354
+ except json.JSONDecodeError:
1355
+ self.send_error(400, "Invalid JSON")
1356
+ except Exception as e:
1357
+ self.send_error(500, str(e))
1358
+
1359
+ def update_story_visibility(self):
1360
+ """Update visibility for a specific story."""
1361
+ try:
1362
+ # Extract story_id from path: /api/stories/{story_id}/visibility
1363
+ parts = self.path.split("/")
1364
+ story_id = parts[3] if len(parts) > 4 else ""
1365
+
1366
+ content_length = int(self.headers.get("Content-Length", 0))
1367
+ body = self.rfile.read(content_length)
1368
+ data = json.loads(body.decode())
1369
+ result = _set_story_visibility(story_id, data.get("visibility", ""))
1370
+ response = json.dumps(result)
1371
+ self.send_response(200)
1372
+ self.send_header("Content-Type", "application/json")
1373
+ self.send_header("Access-Control-Allow-Origin", "*")
1374
+ self.send_header("Content-Length", len(response.encode()))
1375
+ self.end_headers()
1376
+ self.wfile.write(response.encode())
1377
+ except json.JSONDecodeError:
1378
+ self.send_error(400, "Invalid JSON")
1379
+ except Exception as e:
1380
+ self.send_error(500, str(e))
1381
+
1382
+ # =========================================================================
1383
+ # Publish endpoints
1384
+ # =========================================================================
1385
+
1386
+ def serve_publish_preview(self):
1387
+ """Preview what would be published."""
1388
+ from urllib.parse import urlparse, parse_qs
1389
+
1390
+ try:
1391
+ query = parse_qs(urlparse(self.path).query)
1392
+ data = {
1393
+ "scope": query.get("scope", ["all"])[0],
1394
+ "repo_name": query.get("repo_name", [None])[0],
1395
+ "story_id": query.get("story_id", [None])[0],
1396
+ "include_pushed": query.get("include_pushed", ["false"])[0] == "true",
1397
+ }
1398
+
1399
+ result = _publish_preview(data)
1400
+ body = json.dumps(result)
1401
+ self.send_response(200)
1402
+ self.send_header("Content-Type", "application/json")
1403
+ self.send_header("Access-Control-Allow-Origin", "*")
1404
+ self.send_header("Content-Length", len(body.encode()))
1405
+ self.end_headers()
1406
+ self.wfile.write(body.encode())
1407
+ except Exception as e:
1408
+ self.send_error(500, str(e))
1409
+
1410
+ def do_publish(self):
1411
+ """Publish stories to repr.dev."""
1412
+ try:
1413
+ content_length = int(self.headers.get("Content-Length", 0))
1414
+ body = self.rfile.read(content_length)
1415
+ data = json.loads(body.decode()) if body else {}
1416
+
1417
+ result = _publish_stories(data)
1418
+ response_body = json.dumps(result)
1419
+ self.send_response(200)
1420
+ self.send_header("Content-Type", "application/json")
1421
+ self.send_header("Access-Control-Allow-Origin", "*")
1422
+ self.send_header("Content-Length", len(response_body.encode()))
1423
+ self.end_headers()
1424
+ self.wfile.write(response_body.encode())
1425
+ except json.JSONDecodeError:
1426
+ self.send_error(400, "Invalid JSON")
1427
+ except Exception as e:
1428
+ self.send_error(500, str(e))
1429
+
1430
+ def mark_stories_pushed(self):
1431
+ """Mark stories as pushed in local DB."""
1432
+ try:
1433
+ content_length = int(self.headers.get("Content-Length", 0))
1434
+ body = self.rfile.read(content_length)
1435
+ data = json.loads(body.decode()) if body else {}
1436
+
1437
+ story_ids = data.get("story_ids", [])
1438
+ if story_ids:
1439
+ from datetime import datetime
1440
+ now = datetime.utcnow().isoformat()
1441
+ db = _get_db()
1442
+ placeholders = ",".join("?" * len(story_ids))
1443
+ db.execute(
1444
+ f"UPDATE stories SET pushed_at = ? WHERE id IN ({placeholders})",
1445
+ [now] + story_ids
1446
+ )
1447
+ db.commit()
1448
+
1449
+ response_body = json.dumps({"success": True, "marked": len(story_ids)})
1450
+ self.send_response(200)
1451
+ self.send_header("Content-Type", "application/json")
1452
+ self.send_header("Access-Control-Allow-Origin", "*")
1453
+ self.send_header("Content-Length", len(response_body.encode()))
1454
+ self.end_headers()
1455
+ self.wfile.write(response_body.encode())
1456
+ except json.JSONDecodeError:
1457
+ self.send_error(400, "Invalid JSON")
1458
+ except Exception as e:
1459
+ self.send_error(500, str(e))
1460
+
1461
+
1462
+ def run_server(port: int, host: str, skip_update_check: bool = False) -> None:
1463
+ """
1464
+ Start the dashboard HTTP server.
1465
+
1466
+ Args:
1467
+ port: Port to listen on
1468
+ host: Host to bind to
1469
+ skip_update_check: If True, skip checking for dashboard updates
1470
+ """
1471
+ global _dashboard_dir
1472
+
1473
+ # Check for updates (non-blocking, best-effort)
1474
+ if not skip_update_check:
1475
+ try:
1476
+ check_for_updates(quiet=True)
1477
+ except Exception:
1478
+ pass # Don't fail startup if update check fails
1479
+
1480
+ # Ensure dashboard is available
1481
+ from .manager import ensure_dashboard
1482
+ #_dashboard_dir = ensure_dashboard()
1483
+
1484
+ handler = TimelineHandler
1485
+ with socketserver.TCPServer((host, port), handler) as server:
1486
+ try:
1487
+ server.serve_forever()
1488
+ except KeyboardInterrupt:
1489
+ pass