shelldeck 0.0.1__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 (45) hide show
  1. shelldeck/__init__.py +0 -0
  2. shelldeck/__main__.py +4 -0
  3. shelldeck/addons/__init__.py +1 -0
  4. shelldeck/addons/fast_context.py +1346 -0
  5. shelldeck/auth.py +207 -0
  6. shelldeck/cli.py +587 -0
  7. shelldeck/db.py +794 -0
  8. shelldeck/gitgraph.py +150 -0
  9. shelldeck/integration/bash.sh +27 -0
  10. shelldeck/integration/shelldeck.fish +5 -0
  11. shelldeck/integration/shelldeck.ps1 +35 -0
  12. shelldeck/integration/zsh/.zshenv +6 -0
  13. shelldeck/integration/zsh/.zshrc +10 -0
  14. shelldeck/pty.py +352 -0
  15. shelldeck/runner.py +75 -0
  16. shelldeck/scheduler.py +283 -0
  17. shelldeck/server.py +1216 -0
  18. shelldeck/shells.py +174 -0
  19. shelldeck/static/app.css +2288 -0
  20. shelldeck/static/app.js +1863 -0
  21. shelldeck/static/gitgraph.js +142 -0
  22. shelldeck/static/history.js +172 -0
  23. shelldeck/static/icon-192.png +0 -0
  24. shelldeck/static/icon-32.png +0 -0
  25. shelldeck/static/icon-512.png +0 -0
  26. shelldeck/static/icon.svg +1 -0
  27. shelldeck/static/index.html +102 -0
  28. shelldeck/static/manifest.webmanifest +1 -0
  29. shelldeck/static/monitor.js +143 -0
  30. shelldeck/static/ui.js +316 -0
  31. shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
  32. shelldeck/static/vendor/addon-fit.js +2 -0
  33. shelldeck/static/vendor/addon-search.js +2 -0
  34. shelldeck/static/vendor/addon-serialize.js +2 -0
  35. shelldeck/static/vendor/addon-web-links.js +2 -0
  36. shelldeck/static/vendor/addon-webgl.js +2 -0
  37. shelldeck/static/vendor/xterm.css +218 -0
  38. shelldeck/static/vendor/xterm.js +2 -0
  39. shelldeck/static/views.js +658 -0
  40. shelldeck/stats.py +128 -0
  41. shelldeck-0.0.1.dist-info/METADATA +254 -0
  42. shelldeck-0.0.1.dist-info/RECORD +45 -0
  43. shelldeck-0.0.1.dist-info/WHEEL +4 -0
  44. shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
  45. shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/server.py ADDED
@@ -0,0 +1,1216 @@
1
+ import asyncio
2
+ import hmac
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import secrets
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from contextlib import asynccontextmanager
12
+ from datetime import datetime, timezone
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from logging.handlers import RotatingFileHandler
15
+ from pathlib import Path
16
+ from urllib.parse import quote, urlsplit
17
+
18
+ import uvicorn
19
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
20
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+
23
+ from . import auth, db, gitgraph, shells, stats
24
+ from . import scheduler as sched
25
+ from .pty import PtyManager
26
+
27
+ log = logging.getLogger("shelldeck")
28
+
29
+ try:
30
+ VERSION = version("shelldeck")
31
+ except PackageNotFoundError:
32
+ VERSION = "dev"
33
+
34
+ STATIC = Path(__file__).parent / "static"
35
+
36
+ # Requests must target one of these hosts (blocks DNS rebinding). None = any host.
37
+ ALLOWED_HOSTS: set[str] | None = {"127.0.0.1", "localhost", "[::1]"}
38
+
39
+ SETTINGS_DEFAULTS = {
40
+ "default_shell": shells.default_kind(),
41
+ "wsl_distro": "",
42
+ "font_size": "13",
43
+ "theme": "dark",
44
+ "layout_mode": "tiled",
45
+ "project_tint": "on",
46
+ "terminal_theme": "default",
47
+ "font_family": "",
48
+ "editor": "vscode",
49
+ }
50
+ # keep in sync with TERMINAL_THEMES in static/app.js
51
+ TERMINAL_THEMES = ("default", "dracula", "one-dark", "nord", "gruvbox-dark", "solarized-dark", "solarized-light", "github-light")
52
+ FONT_FAMILY = re.compile(r"[\w ,.'\"-]{0,120}")
53
+
54
+
55
+ manager = PtyManager()
56
+ sockets: dict[str, set[WebSocket]] = {}
57
+ readers: dict[str, asyncio.Task] = {}
58
+ alarm_sockets: set[WebSocket] = set()
59
+ clipboard = {"data": ""}
60
+ socket_owner: dict[WebSocket, str | None] = {} # open socket -> login session hash
61
+ last_cwd: dict[str, str] = {}
62
+ # shell integration reports the working directory (see shelldeck/integration)
63
+ CWD_REPORT = re.compile(r"\x1b\]633;P;Cwd=([^\x07\x1b]+)(?:\x07|\x1b\\)")
64
+
65
+
66
+ def err(code: str, status: int = 400) -> JSONResponse:
67
+ return JSONResponse({"error": code}, status_code=status)
68
+
69
+
70
+ def _trusted(headers) -> bool:
71
+ """Same-origin check. Browsers always send Origin cross-site; the CLI sends none."""
72
+ host = headers.get("host", "")
73
+ if ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
74
+ return False
75
+ origin = headers.get("origin")
76
+ return not origin or urlsplit(origin).netloc == host
77
+
78
+
79
+ def _to_utc(iso: str | None) -> str | None:
80
+ if not iso:
81
+ return None
82
+ try:
83
+ return datetime.fromisoformat(iso).astimezone(timezone.utc).isoformat()
84
+ except ValueError:
85
+ return iso
86
+
87
+
88
+ def get_settings() -> dict:
89
+ s = {k: db.get_setting(k, v) for k, v in SETTINGS_DEFAULTS.items()}
90
+ if s["default_shell"] not in shells.kinds(): # e.g. a Windows db opened on Linux
91
+ s["default_shell"] = shells.default_kind()
92
+ return s
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # App
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ @asynccontextmanager
101
+ async def lifespan(app: FastAPI):
102
+ db.init_db()
103
+ if code := auth.init_auth():
104
+ log.warning("no password yet; remote setup code: %s", code)
105
+ await sched.start()
106
+ sched.set_alarm_callback(_broadcast_alarm)
107
+ await sched.reminder_service.sync_all()
108
+ manager.store = db.config_dir() / "scrollback"
109
+ manager.prune({s["id"] for s in db.list_sessions()})
110
+ saver = asyncio.create_task(_save_scrollback())
111
+ log.info("shelldeck %s started", VERSION)
112
+ yield
113
+ saver.cancel()
114
+ stats.stop()
115
+ sched.shutdown()
116
+ for task in list(readers.values()):
117
+ task.cancel()
118
+ manager.save()
119
+ for sid in list(manager.procs):
120
+ manager.terminate(sid)
121
+
122
+
123
+ async def _save_scrollback() -> None:
124
+ # ponytail: periodic flush; a hard power-off loses at most the last 15s of output
125
+ while True:
126
+ await asyncio.sleep(15)
127
+ try:
128
+ await asyncio.to_thread(manager.save)
129
+ except OSError:
130
+ log.warning("saving scrollback failed", exc_info=True)
131
+
132
+
133
+ class RevalidatedStaticFiles(StaticFiles):
134
+ """Browsers must revalidate (a cheap 304), or they keep running old JS after an upgrade."""
135
+
136
+ def file_response(self, *args, **kwargs):
137
+ response = super().file_response(*args, **kwargs)
138
+ response.headers["Cache-Control"] = "no-cache"
139
+ return response
140
+
141
+
142
+ app = FastAPI(lifespan=lifespan, title="shelldeck", version=VERSION)
143
+ app.mount("/static", RevalidatedStaticFiles(directory=STATIC), name="static")
144
+
145
+ AUTH_EXEMPT = ("/api/auth/", "/api/health")
146
+ LOOPBACK = {"127.0.0.1", "::1", "localhost", "testclient"}
147
+
148
+
149
+ def _client(conn) -> str:
150
+ return conn.client.host if conn.client else "?"
151
+
152
+
153
+ def _is_local(conn) -> bool:
154
+ """A request from the host itself (not through a reverse proxy)."""
155
+ return _client(conn) in LOOPBACK and "x-forwarded-for" not in conn.headers
156
+
157
+
158
+ def _who(conn) -> tuple[str, str | None] | None:
159
+ """("cli", None) for the host CLI, ("browser", session hash) for a login, else None."""
160
+ if auth.cli_ok(conn.headers.get(auth.TOKEN_HEADER)):
161
+ return ("cli", None)
162
+ h = auth.session_hash(conn.cookies.get(auth.COOKIE))
163
+ return ("browser", h) if h else None
164
+
165
+
166
+ def _denied() -> JSONResponse:
167
+ return err("locked" if auth.has_password() else "setup_required", 401)
168
+
169
+
170
+ @app.middleware("http")
171
+ async def guard(request: Request, call_next):
172
+ if not _trusted(request.headers):
173
+ return err("forbidden_origin", 403)
174
+ path = request.url.path
175
+ request.state.session = None
176
+ if path.startswith("/api/") and not path.startswith(AUTH_EXEMPT):
177
+ who = _who(request)
178
+ if not who:
179
+ return _denied()
180
+ request.state.session = who[1]
181
+ if who[1] and request.method != "GET": # polling GETs are not activity
182
+ auth.touch(who[1])
183
+ return await call_next(request)
184
+
185
+
186
+ @app.exception_handler(Exception)
187
+ async def unhandled(request: Request, exc: Exception):
188
+ log.exception("unhandled error on %s %s", request.method, request.url.path)
189
+ return err("internal_error", 500)
190
+
191
+
192
+ @app.get("/")
193
+ async def index():
194
+ return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
195
+
196
+
197
+ @app.get("/api/health")
198
+ async def health():
199
+ return {"app": "shelldeck", "version": VERSION}
200
+
201
+
202
+ _server: uvicorn.Server | None = None
203
+
204
+
205
+ @app.post("/api/shutdown")
206
+ async def shutdown():
207
+ if _server:
208
+ _server.should_exit = True
209
+ return {"status": "stopping"}
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Auth
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _login_response(request: Request, body: dict, res=None):
218
+ """Start a login session: cookie on `res` (JSON body by default)."""
219
+ token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}")
220
+ res = res or JSONResponse(body)
221
+ res.set_cookie(
222
+ auth.COOKIE, token, httponly=True, samesite="strict", path="/",
223
+ secure=request.url.scheme == "https", max_age=30 * 24 * 3600,
224
+ )
225
+ return res
226
+
227
+
228
+ def _too_many(request: Request) -> JSONResponse | None:
229
+ wait = auth.blocked_for(_client(request))
230
+ if wait:
231
+ return JSONResponse({"error": "too_many_attempts", "retry_after": wait}, status_code=429, headers={"Retry-After": str(wait)})
232
+ return None
233
+
234
+
235
+ @app.get("/api/auth/status")
236
+ async def auth_status(request: Request):
237
+ who = _who(request)
238
+ if who and who[1]:
239
+ auth.touch(who[1])
240
+ return {
241
+ "has_password": auth.has_password(),
242
+ "setup_code_required": not auth.has_password() and not _is_local(request),
243
+ "authenticated": bool(who),
244
+ "idle_timeout": auth.LOCK_TIMEOUT_SECONDS,
245
+ "min_length": auth.MIN_PASSWORD,
246
+ }
247
+
248
+
249
+ @app.post("/api/auth/setup")
250
+ async def auth_setup(request: Request, payload: dict):
251
+ """First run: create the password. Remote clients also need the code printed on the host."""
252
+ if auth.has_password():
253
+ return err("password_already_set", 409)
254
+ if blocked := _too_many(request):
255
+ return blocked
256
+ if not _is_local(request):
257
+ ok = hmac.compare_digest(str(payload.get("code", "")).strip().lower(), (auth.setup_code() or "").lower())
258
+ auth.record_attempt(_client(request), ok)
259
+ if not ok:
260
+ return err("invalid_setup_code", 403)
261
+ password, confirm = str(payload.get("password", "")), str(payload.get("confirm", ""))
262
+ if problem := auth.validate_new(password, confirm):
263
+ return err(problem)
264
+ auth.set_password(password)
265
+ log.info("password created from %s", _client(request))
266
+ return _login_response(request, {"status": "ok"})
267
+
268
+
269
+ @app.post("/api/auth/login")
270
+ async def auth_login(request: Request, payload: dict):
271
+ if not auth.has_password():
272
+ return err("setup_required", 401)
273
+ if blocked := _too_many(request):
274
+ return blocked
275
+ ok = await asyncio.to_thread(auth.check_password, str(payload.get("password", "")))
276
+ auth.record_attempt(_client(request), ok)
277
+ if not ok:
278
+ log.warning("failed login from %s", _client(request))
279
+ return err("invalid_password", 401)
280
+ return _login_response(request, {"status": "ok"})
281
+
282
+
283
+ _login_links: dict[str, float] = {} # one-time code -> expiry
284
+
285
+
286
+ @app.post("/api/auth/login-link")
287
+ async def auth_login_link(request: Request):
288
+ """Host CLI only: a one-time URL that logs a browser in (emergency unlock)."""
289
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
290
+ return err("host_cli_only", 403)
291
+ code = secrets.token_urlsafe(24)
292
+ _login_links[code] = time.time() + 300
293
+ return {"path": f"/api/auth/link/{code}"}
294
+
295
+
296
+ @app.get("/api/auth/link/{code}")
297
+ async def auth_use_link(request: Request, code: str):
298
+ expiry = _login_links.pop(code, 0)
299
+ if expiry < time.time() or not auth.has_password():
300
+ return err("link_expired", 403)
301
+ return _login_response(request, {}, RedirectResponse("/", status_code=303))
302
+
303
+
304
+ @app.post("/api/auth/logout")
305
+ async def auth_logout(request: Request):
306
+ """Lock this browser: end its session and close its sockets."""
307
+ h = auth.session_hash(request.cookies.get(auth.COOKIE))
308
+ if h:
309
+ auth.end_session(h)
310
+ await _close_session_sockets(lambda owner: owner == h)
311
+ res = JSONResponse({"status": "locked"})
312
+ res.delete_cookie(auth.COOKIE, path="/")
313
+ return res
314
+
315
+
316
+ @app.put("/api/auth/password")
317
+ async def auth_password(request: Request, payload: dict):
318
+ """Change the password (logged-in browser only). Signs out every other browser."""
319
+ h = auth.session_hash(request.cookies.get(auth.COOKIE))
320
+ if not h:
321
+ return _denied()
322
+ if blocked := _too_many(request):
323
+ return blocked
324
+ ok = await asyncio.to_thread(auth.check_password, str(payload.get("current", "")))
325
+ auth.record_attempt(_client(request), ok)
326
+ if not ok:
327
+ return err("wrong_current_password", 403)
328
+ password, confirm = str(payload.get("password", "")), str(payload.get("confirm", ""))
329
+ if problem := auth.validate_new(password, confirm):
330
+ return err(problem)
331
+ auth.set_password(password)
332
+ auth.end_other_sessions(keep=h)
333
+ await _close_session_sockets(lambda owner: owner != h)
334
+ log.info("password changed from %s", _client(request))
335
+ return {"status": "ok"}
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Settings
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ @app.get("/api/settings")
344
+ async def read_settings():
345
+ return get_settings()
346
+
347
+
348
+ @app.put("/api/settings")
349
+ async def write_settings(payload: dict):
350
+ for key, value in payload.items():
351
+ if key not in SETTINGS_DEFAULTS:
352
+ return err(f"unknown_setting:{key}")
353
+ value = str(value).strip()
354
+ if key == "default_shell" and value not in shells.kinds():
355
+ return err("invalid_shell")
356
+ if key == "font_size" and not (value.isdigit() and 8 <= int(value) <= 32):
357
+ return err("invalid_font_size")
358
+ if key == "theme" and value not in ("dark", "light", "system"):
359
+ return err("invalid_theme")
360
+ if key == "layout_mode" and value not in ("tiled", "free"):
361
+ return err("invalid_layout_mode")
362
+ if key == "project_tint" and value not in ("on", "off"):
363
+ return err("invalid_project_tint")
364
+ if key == "terminal_theme" and value not in TERMINAL_THEMES:
365
+ return err("invalid_terminal_theme")
366
+ if key == "font_family" and not FONT_FAMILY.fullmatch(value):
367
+ return err("invalid_font_family")
368
+ if key == "editor" and value not in ("vscode", "system"):
369
+ return err("invalid_editor")
370
+ for key, value in payload.items():
371
+ db.set_setting(key, str(value).strip())
372
+ return get_settings()
373
+
374
+
375
+ @app.get("/api/stats")
376
+ async def get_stats():
377
+ """System CPU/RAM/GPU plus usage per running terminal (shell and its child processes)."""
378
+ pids = {sid: proc.pid for sid, proc in list(manager.procs.items()) if proc.isalive()}
379
+ return await asyncio.to_thread(stats.collect, pids)
380
+
381
+
382
+ @app.get("/api/shells")
383
+ async def list_shells():
384
+ return {
385
+ "kinds": shells.kinds(),
386
+ "labels": {k: shells.label(k) for k in shells.kinds()},
387
+ "default": shells.default_kind(),
388
+ "available": shells.available(),
389
+ "wsl_distros": await asyncio.to_thread(shells.wsl_distros),
390
+ }
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # Projects + filesystem
395
+ # ---------------------------------------------------------------------------
396
+
397
+
398
+ @app.get("/api/projects")
399
+ async def list_projects():
400
+ alive = set(manager.list_alive())
401
+ projects = db.list_projects()
402
+ for p in projects:
403
+ p["exists"] = Path(p["path"]).is_dir()
404
+ p["has_git"] = p["exists"] and gitgraph.has_git(p["path"])
405
+ p["sessions"] = db.list_sessions(p["id"])
406
+ for s in p["sessions"]:
407
+ s["alive"] = s["id"] in alive
408
+ return {"projects": projects}
409
+
410
+
411
+ @app.get("/api/projects/{project_id}/git/log")
412
+ async def project_git_log(project_id: str, limit: int = 300):
413
+ project = db.get_project(project_id)
414
+ if not project:
415
+ return err("project_not_found", 404)
416
+ try:
417
+ return await asyncio.to_thread(gitgraph.get_log, project["path"], max(1, min(limit, 5000)))
418
+ except gitgraph.GitError as e:
419
+ return err(f"git_log_failed:{e}")
420
+
421
+
422
+ @app.post("/api/projects/{project_id}/git/checkout")
423
+ async def project_git_checkout(project_id: str, payload: dict):
424
+ project = db.get_project(project_id)
425
+ if not project:
426
+ return err("project_not_found", 404)
427
+ ref = str(payload.get("ref") or "").strip()
428
+ if not ref:
429
+ return err("ref_required")
430
+ ok, message = await asyncio.to_thread(gitgraph.checkout, project["path"], ref)
431
+ if not ok:
432
+ return err(f"git_checkout_failed:{message}")
433
+ return {"status": "ok", "message": message}
434
+
435
+
436
+ @app.post("/api/projects")
437
+ async def add_project(payload: dict):
438
+ raw = str(payload.get("path") or "").strip()
439
+ if not raw:
440
+ return err("path_required")
441
+ path = Path(raw).expanduser()
442
+ if not path.is_dir():
443
+ return err("not_a_directory")
444
+ return db.ensure_project(str(path))
445
+
446
+
447
+ @app.patch("/api/projects/{project_id}")
448
+ async def rename_project(project_id: str, payload: dict):
449
+ name = str(payload.get("name") or "").strip()
450
+ if not name:
451
+ return err("name_required")
452
+ return db.rename_project(project_id, name) or err("project_not_found", 404)
453
+
454
+
455
+ @app.delete("/api/projects/{project_id}")
456
+ async def delete_project(project_id: str):
457
+ if not db.get_project(project_id):
458
+ return err("project_not_found", 404)
459
+ for s in db.list_sessions(project_id):
460
+ await _end_session(s["id"])
461
+ for j in db.list_jobs():
462
+ if j["project_id"] == project_id:
463
+ sched.scheduler_service.remove(j["id"])
464
+ db.delete_project(project_id)
465
+ return {"status": "ok"}
466
+
467
+
468
+ @app.get("/api/fs/dirs")
469
+ async def list_dirs(path: str = ""):
470
+ """Folder browser for the add-project dialog. Empty path = home + drives."""
471
+ if not path:
472
+ roots = [str(Path.home())]
473
+ roots += os.listdrives() if hasattr(os, "listdrives") else ["/"]
474
+ return {"path": "", "parent": None, "dirs": roots, "sep": os.sep}
475
+ p = Path(path).expanduser()
476
+ if not p.is_dir():
477
+ return err("not_a_directory")
478
+ try:
479
+ dirs = sorted(
480
+ (e.name for e in os.scandir(p) if e.is_dir() and not e.name.startswith(("$", "."))),
481
+ key=str.lower,
482
+ )
483
+ except OSError:
484
+ return err("permission_denied", 403)
485
+ p = p.resolve()
486
+ return {"path": str(p), "parent": str(p.parent) if p.parent != p else "", "dirs": dirs, "sep": os.sep}
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Sessions
491
+ # ---------------------------------------------------------------------------
492
+
493
+
494
+ @app.get("/api/sessions")
495
+ async def list_sessions():
496
+ alive = set(manager.list_alive())
497
+ sessions = db.list_sessions_with_project()
498
+ for s in sessions:
499
+ s["alive"] = s["id"] in alive
500
+ return {"sessions": sessions}
501
+
502
+
503
+ @app.get("/api/sessions/{session_id}")
504
+ async def get_session(session_id: str):
505
+ session = db.get_session(session_id)
506
+ if not session:
507
+ return err("session_not_found", 404)
508
+ session["project"] = db.get_project(session["project_id"]) or {}
509
+ session["alive"] = manager.get(session_id) is not None
510
+ return {"session": session}
511
+
512
+
513
+ @app.post("/api/sessions")
514
+ async def create_session(payload: dict):
515
+ project_id = payload.get("project_id")
516
+ if project_id:
517
+ project = db.get_project(project_id)
518
+ if not project:
519
+ return err("project_not_found", 404)
520
+ else:
521
+ cwd = str(payload.get("cwd") or "").strip()
522
+ if not cwd or not Path(cwd).is_dir():
523
+ return err("not_a_directory")
524
+ project = db.ensure_project(cwd)
525
+ shell = str(payload.get("shell") or "").strip()
526
+ if shell and shell not in shells.kinds():
527
+ return err("invalid_shell")
528
+ cwd = str(payload.get("cwd") or project["path"])
529
+ name = str(payload.get("name") or "").strip()
530
+ if not name:
531
+ kind = shell or get_settings()["default_shell"]
532
+ same = [x for x in db.list_sessions(project["id"]) if (x["shell"] or get_settings()["default_shell"]) == kind]
533
+ name = f"{shells.label(kind)} {len(same) + 1}"
534
+ session = db.add_session(project["id"], cwd=cwd, shell=shell, name=name)
535
+ session["project_path"] = project["path"]
536
+ return session
537
+
538
+
539
+ @app.patch("/api/sessions/{session_id}")
540
+ async def rename_session(session_id: str, payload: dict):
541
+ name = str(payload.get("name") or "").strip()
542
+ if not name:
543
+ return err("name_required")
544
+ return db.rename_session(session_id, name) or err("session_not_found", 404)
545
+
546
+
547
+ def _record_command(session_id: str, msg: dict) -> None:
548
+ command = str(msg.get("cmd") or "").strip()[:4000]
549
+ started = str(msg.get("at") or "")[:40]
550
+ if not command or not started:
551
+ return
552
+ exit_code = msg.get("exit")
553
+ duration = msg.get("ms")
554
+ session = db.get_session(session_id) or {}
555
+ db.add_command(
556
+ session_id, session.get("project_id"), session.get("cwd"), command,
557
+ exit_code if isinstance(exit_code, int) else None,
558
+ duration if isinstance(duration, int) and duration >= 0 else None,
559
+ started,
560
+ )
561
+
562
+
563
+ @app.get("/api/history")
564
+ async def history(q: str = "", project_id: str = "", failed: bool = False, limit: int = 300):
565
+ return {"commands": db.list_commands(q.strip(), project_id or None, failed, max(1, min(limit, 2000)))}
566
+
567
+
568
+ @app.delete("/api/history")
569
+ async def clear_history():
570
+ db.clear_commands()
571
+ return {"status": "ok"}
572
+
573
+
574
+ # strip terminal control sequences so saved output can be searched as text
575
+ ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]|[\x00-\x08\x0b-\x1f\x7f]")
576
+
577
+
578
+ def _search(q: str, per_session: int = 20, total: int = 400) -> list[dict]:
579
+ needle = q.casefold()
580
+ results = []
581
+ for s in db.list_sessions_with_project():
582
+ text = ANSI.sub("", manager.searchable(s["id"]).replace("\r\n", "\n"))
583
+ hits = [{"line": no, "text": line.strip()[:300]} for no, line in enumerate(text.split("\n")) if needle in line.casefold()]
584
+ if hits:
585
+ results.append({"session_id": s["id"], "count": len(hits), "hits": hits[-per_session:]})
586
+ total -= min(len(hits), per_session)
587
+ if total <= 0:
588
+ break
589
+ return results
590
+
591
+
592
+ @app.get("/api/search")
593
+ async def search_terminals(q: str = ""):
594
+ """Search the saved output of every terminal (live or not)."""
595
+ q = q.strip()
596
+ if len(q) < 2:
597
+ return {"results": []}
598
+ return {"results": await asyncio.to_thread(_search, q[:200])}
599
+
600
+
601
+ def _resolve(session_id: str, raw: str) -> Path | None:
602
+ """A path from terminal output, relative to the session's current folder; None unless it is a file."""
603
+ raw = raw.strip().strip("'\"")
604
+ if not raw or len(raw) > 1000:
605
+ return None
606
+ try:
607
+ path = Path(raw).expanduser()
608
+ if not path.is_absolute():
609
+ path = Path((db.get_session(session_id) or {}).get("cwd") or ".") / path
610
+ path = path.resolve()
611
+ return path if path.is_file() else None
612
+ except (OSError, ValueError):
613
+ return None
614
+
615
+
616
+ @app.post("/api/fs/check")
617
+ async def check_paths(payload: dict):
618
+ """Which of these path candidates are real files (drives clickable paths in terminal output)."""
619
+ sid = str(payload.get("session_id") or "")
620
+ raws = [str(x) for x in (payload.get("paths") or [])][:50]
621
+ found = await asyncio.to_thread(lambda: [r for r in raws if _resolve(sid, r)])
622
+ return {"files": found}
623
+
624
+
625
+ # the default app for these would run them rather than show them
626
+ EXECUTABLE = {".exe", ".bat", ".cmd", ".com", ".ps1", ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".msi",
627
+ ".msc", ".scr", ".hta", ".lnk", ".url", ".reg", ".cpl", ".jar", ".sh", ".app", ".desktop", ".appimage"}
628
+
629
+
630
+ def _editor_target(path: Path, line: int, col: int) -> str | None:
631
+ if get_settings()["editor"] == "vscode":
632
+ url = "vscode://file/" + quote(path.as_posix().lstrip("/"), safe="/:")
633
+ return url + (f":{line}" + (f":{col}" if col else "") if line else "")
634
+ if path.suffix.lower() in EXECUTABLE or (sys.platform != "win32" and os.access(path, os.X_OK)):
635
+ return None
636
+ return str(path)
637
+
638
+
639
+ @app.post("/api/open")
640
+ async def open_file(payload: dict):
641
+ """Open a file from terminal output in VS Code (at the line) or the system's default app."""
642
+ path = await asyncio.to_thread(_resolve, str(payload.get("session_id") or ""), str(payload.get("path") or ""))
643
+ if not path:
644
+ return err("file_not_found", 404)
645
+ target = _editor_target(path, _clamp(payload.get("line"), 0, 10_000_000, 0), _clamp(payload.get("col"), 0, 100_000, 0))
646
+ if not target:
647
+ return err("refusing_to_run_executable")
648
+ try:
649
+ if sys.platform == "win32":
650
+ os.startfile(target) # a file path or vscode:// URL, never a command line
651
+ else:
652
+ subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", target], start_new_session=True)
653
+ except OSError as e:
654
+ return err(f"open_failed:{e.strerror or e}")
655
+ return {"status": "ok", "path": str(path)}
656
+
657
+
658
+ @app.delete("/api/sessions/{session_id}")
659
+ async def delete_session(session_id: str):
660
+ await _end_session(session_id)
661
+ db.delete_session(session_id)
662
+ manager.forget(session_id)
663
+ return {"status": "ok"}
664
+
665
+
666
+ async def _end_session(session_id: str) -> None:
667
+ task = readers.pop(session_id, None)
668
+ if task:
669
+ task.cancel()
670
+ manager.terminate(session_id)
671
+ for ws in list(sockets.pop(session_id, ())):
672
+ await _close(ws)
673
+
674
+
675
+ async def _close(ws: WebSocket, code: int = 1000) -> None:
676
+ try:
677
+ await ws.close(code=code)
678
+ except (RuntimeError, WebSocketDisconnect):
679
+ pass
680
+
681
+
682
+ async def _close_session_sockets(match) -> None:
683
+ """Close sockets whose login (session hash; None for the CLI) matches."""
684
+ for ws, owner in list(socket_owner.items()):
685
+ if owner and match(owner):
686
+ await _close(ws, 1008)
687
+
688
+
689
+ # ---------------------------------------------------------------------------
690
+ # Clipboard + bookmarks
691
+ # ---------------------------------------------------------------------------
692
+
693
+
694
+ @app.post("/api/clipboard")
695
+ async def set_clipboard(payload: dict):
696
+ clipboard["data"] = str(payload.get("data", ""))
697
+ return {"status": "ok"}
698
+
699
+
700
+ @app.get("/api/clipboard")
701
+ async def get_clipboard():
702
+ return clipboard
703
+
704
+
705
+ @app.get("/api/bookmarks")
706
+ async def list_bookmarks():
707
+ return {"bookmarks": db.list_bookmarks()}
708
+
709
+
710
+ def _bookmark_fields(payload: dict) -> tuple[str, str, str | None] | JSONResponse:
711
+ name = str(payload.get("name") or "").strip()
712
+ command = str(payload.get("command") or "").strip()
713
+ project_id = payload.get("project_id") or None
714
+ if not name or not command:
715
+ return err("name_and_command_required")
716
+ if project_id and not db.get_project(project_id):
717
+ return err("project_not_found", 404)
718
+ return name, command, project_id
719
+
720
+
721
+ @app.post("/api/bookmarks")
722
+ async def create_bookmark(payload: dict):
723
+ fields = _bookmark_fields(payload)
724
+ if isinstance(fields, JSONResponse):
725
+ return fields
726
+ return db.add_bookmark(*fields)
727
+
728
+
729
+ @app.put("/api/bookmarks/{bookmark_id}")
730
+ async def update_bookmark(bookmark_id: str, payload: dict):
731
+ if not db.get_bookmark(bookmark_id):
732
+ return err("bookmark_not_found", 404)
733
+ fields = _bookmark_fields(payload)
734
+ if isinstance(fields, JSONResponse):
735
+ return fields
736
+ return db.update_bookmark(bookmark_id, *fields)
737
+
738
+
739
+ @app.delete("/api/bookmarks/{bookmark_id}")
740
+ async def delete_bookmark(bookmark_id: str):
741
+ if not db.get_bookmark(bookmark_id):
742
+ return err("bookmark_not_found", 404)
743
+ db.delete_bookmark(bookmark_id)
744
+ return {"status": "ok"}
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # WebSockets
749
+ # ---------------------------------------------------------------------------
750
+
751
+
752
+ async def _send_all(session_id: str, message: dict) -> None:
753
+ text = json.dumps(message)
754
+ for ws in list(sockets.get(session_id, ())):
755
+ try:
756
+ await ws.send_text(text)
757
+ except (RuntimeError, WebSocketDisconnect):
758
+ sockets.get(session_id, set()).discard(ws)
759
+
760
+
761
+ async def _pump(session_id: str) -> None:
762
+ """Single reader per session; fans output out to every attached socket."""
763
+ queue: asyncio.Queue[str | None] = asyncio.Queue()
764
+ loop = asyncio.get_running_loop()
765
+
766
+ def emit(data: str | None) -> None: # called from the reader thread
767
+ try:
768
+ loop.call_soon_threadsafe(queue.put_nowait, data)
769
+ except RuntimeError: # loop closed at shutdown
770
+ pass
771
+
772
+ manager.stream(session_id, emit)
773
+ try:
774
+ done = False
775
+ while not done:
776
+ data = await queue.get()
777
+ if data is None:
778
+ break
779
+ # merge chunks that queued up while we were sending: fewer, larger messages
780
+ while not queue.empty():
781
+ more = queue.get_nowait()
782
+ if more is None:
783
+ done = True
784
+ break
785
+ data += more
786
+ manager.record(session_id, data)
787
+ if (m := CWD_REPORT.findall(data)) and m[-1] != last_cwd.get(session_id):
788
+ last_cwd[session_id] = m[-1]
789
+ db.update_session_cwd(session_id, m[-1])
790
+ await _send_all(session_id, {"type": "output", "data": data})
791
+ log.info("session %s exited", session_id)
792
+ await _send_all(session_id, {"type": "exit"})
793
+ for ws in list(sockets.pop(session_id, ())):
794
+ await _close(ws)
795
+ finally:
796
+ readers.pop(session_id, None)
797
+ manager.terminate(session_id)
798
+
799
+
800
+ def _clamp(value, lo: int, hi: int, default: int) -> int:
801
+ try:
802
+ return max(lo, min(hi, int(value)))
803
+ except (TypeError, ValueError):
804
+ return default
805
+
806
+
807
+ def _attach(session: dict, rows: int, cols: int) -> None:
808
+ sid = session["id"]
809
+ if manager.get(sid):
810
+ manager.resize(sid, rows, cols)
811
+ else:
812
+ settings = get_settings()
813
+ manager.create(
814
+ sid,
815
+ cwd=session.get("cwd"),
816
+ shell=session.get("shell") or settings["default_shell"],
817
+ rows=rows,
818
+ cols=cols,
819
+ distro=settings["wsl_distro"] or None,
820
+ )
821
+ if sid not in readers:
822
+ readers[sid] = asyncio.create_task(_pump(sid))
823
+ db.update_session_size(sid, cols, rows)
824
+
825
+
826
+ @app.websocket("/ws/alarms")
827
+ async def alarms_ws(ws: WebSocket):
828
+ who = _who(ws) if _trusted(ws.headers) else None
829
+ if not who:
830
+ await ws.close(code=1008)
831
+ return
832
+ await ws.accept()
833
+ alarm_sockets.add(ws)
834
+ socket_owner[ws] = who[1]
835
+ try:
836
+ await _send_alarm_snapshot(ws)
837
+ while True:
838
+ if (await ws.receive_text()) == '{"type":"ping"}':
839
+ await ws.send_text('{"type":"pong"}')
840
+ except WebSocketDisconnect:
841
+ pass
842
+ finally:
843
+ alarm_sockets.discard(ws)
844
+ socket_owner.pop(ws, None)
845
+
846
+
847
+ @app.websocket("/ws/{session_id}")
848
+ async def terminal_ws(ws: WebSocket, session_id: str):
849
+ who = _who(ws) if _trusted(ws.headers) else None
850
+ if not who:
851
+ await ws.close(code=1008)
852
+ return
853
+ login = who[1]
854
+ session = db.get_session(session_id)
855
+ if not session:
856
+ await ws.close(code=4404)
857
+ return
858
+ await ws.accept()
859
+ sockets.setdefault(session_id, set()).add(ws)
860
+ socket_owner[ws] = login
861
+ try:
862
+ if history := manager.history(session_id):
863
+ await ws.send_text(json.dumps({"type": "output", "data": history, "replay": True}))
864
+ while True:
865
+ raw = await ws.receive_text()
866
+ if login and not auth.session_hash(ws.cookies.get(auth.COOKIE)):
867
+ await _close(ws, 1008) # logged out, idle too long, or password changed elsewhere
868
+ break
869
+ try:
870
+ msg = json.loads(raw)
871
+ except json.JSONDecodeError:
872
+ continue
873
+ mtype = msg.get("type")
874
+ if mtype == "ping": # client heartbeat; not user activity
875
+ await ws.send_text('{"type":"pong"}')
876
+ continue
877
+ if login and mtype == "input":
878
+ auth.touch(login)
879
+ if mtype == "resize":
880
+ rows = _clamp(msg.get("rows"), 2, 500, 24)
881
+ cols = _clamp(msg.get("cols"), 2, 1000, 120)
882
+ try:
883
+ _attach(session, rows, cols)
884
+ except Exception: # noqa: BLE001 - spawn failures come from winpty/OS
885
+ log.exception("spawn failed for session %s", session_id)
886
+ await ws.send_text(json.dumps({"type": "error", "message": "failed to start shell"}))
887
+ elif mtype == "input":
888
+ manager.write(session_id, str(msg.get("data", "")))
889
+ elif mtype == "snapshot":
890
+ manager.snapshot(session_id, str(msg.get("data", "")))
891
+ elif mtype == "command":
892
+ _record_command(session_id, msg)
893
+ elif mtype == "clipboard_set":
894
+ clipboard["data"] = str(msg.get("data", ""))
895
+ elif mtype == "clipboard_get":
896
+ await ws.send_text(json.dumps({"type": "clipboard", "data": clipboard["data"]}))
897
+ except WebSocketDisconnect:
898
+ pass
899
+ finally:
900
+ sockets.get(session_id, set()).discard(ws)
901
+ socket_owner.pop(ws, None)
902
+
903
+
904
+ async def _broadcast_alarm(task: dict) -> None:
905
+ data = json.dumps({"type": "alarm", "task": _serialize_task(task)})
906
+ for ws in list(alarm_sockets):
907
+ try:
908
+ await ws.send_text(data)
909
+ except (RuntimeError, WebSocketDisconnect):
910
+ alarm_sockets.discard(ws)
911
+
912
+
913
+ async def _send_alarm_snapshot(ws: WebSocket) -> None:
914
+ alarms = [_serialize_task(t) for t in db.list_active_alarms()]
915
+ await ws.send_text(json.dumps({"type": "alarm_snapshot", "alarms": alarms}))
916
+
917
+
918
+ # ---------------------------------------------------------------------------
919
+ # Scheduled jobs
920
+ # ---------------------------------------------------------------------------
921
+
922
+
923
+ def _serialize_job(job: dict) -> dict:
924
+ return {
925
+ "id": job["id"],
926
+ "name": job["name"],
927
+ "project_id": job["project_id"],
928
+ "command": job["command"],
929
+ "cron": job["cron"],
930
+ "timezone": job.get("timezone", "UTC"),
931
+ "enabled": bool(job.get("enabled")),
932
+ "timeout_seconds": job.get("timeout_seconds", 60),
933
+ "next_run_on": job.get("next_run_on"),
934
+ "last_run_on": job.get("last_run_on"),
935
+ "last_status": job.get("last_status", "never_run"),
936
+ "last_error": job.get("last_error"),
937
+ "last_duration_ms": job.get("last_duration_ms"),
938
+ "total_runs": job.get("total_runs", 0),
939
+ "successful_runs": job.get("successful_runs", 0),
940
+ "failed_runs": job.get("failed_runs", 0),
941
+ }
942
+
943
+
944
+ @app.get("/api/schedule/jobs")
945
+ async def list_schedule_jobs():
946
+ return {"jobs": [_serialize_job(j) for j in db.list_jobs()]}
947
+
948
+
949
+ @app.post("/api/schedule/jobs")
950
+ async def create_schedule_job(payload: dict):
951
+ name = (payload.get("name") or "").strip()
952
+ project_id = payload.get("project_id") or ""
953
+ command = (payload.get("command") or "").strip()
954
+ cron = (payload.get("cron") or "").strip()
955
+ timezone = (payload.get("timezone") or "UTC").strip()
956
+ enabled = payload.get("enabled", True)
957
+ timeout = int(payload.get("timeout_seconds", 60) or 60)
958
+
959
+ if not name or not command or not cron:
960
+ return err("name, command, and cron are required")
961
+ if not db.get_project(project_id):
962
+ return err("project_not_found", 404)
963
+ try:
964
+ sched.cron_trigger(cron, timezone)
965
+ except ValueError as e:
966
+ return err(str(e))
967
+
968
+ job = db.add_job(name, project_id, command, cron, timezone, bool(enabled), timeout)
969
+ await sched.scheduler_service.sync_job(job)
970
+ return _serialize_job(db.get_job(job["id"]))
971
+
972
+
973
+ @app.get("/api/schedule/jobs/{job_id}")
974
+ async def get_schedule_job(job_id: str):
975
+ job = db.get_job(job_id)
976
+ if not job:
977
+ return err("job_not_found", 404)
978
+ return _serialize_job(job)
979
+
980
+
981
+ @app.put("/api/schedule/jobs/{job_id}")
982
+ async def update_schedule_job(job_id: str, payload: dict):
983
+ job = db.get_job(job_id)
984
+ if not job:
985
+ return err("job_not_found", 404)
986
+
987
+ name = payload.get("name")
988
+ project_id = payload.get("project_id")
989
+ command = payload.get("command")
990
+ cron = payload.get("cron")
991
+ timezone = payload.get("timezone")
992
+ enabled = payload.get("enabled")
993
+ timeout = payload.get("timeout_seconds")
994
+
995
+ if cron is not None or timezone is not None:
996
+ cron = (cron or job["cron"]).strip()
997
+ tz = (timezone or job.get("timezone", "UTC")).strip()
998
+ try:
999
+ sched.cron_trigger(cron, tz)
1000
+ except ValueError as e:
1001
+ return err(str(e))
1002
+
1003
+ if project_id is not None and not db.get_project(project_id):
1004
+ return err("project_not_found", 404)
1005
+
1006
+ kwargs = {}
1007
+ if name is not None:
1008
+ kwargs["name"] = name.strip()
1009
+ if project_id is not None:
1010
+ kwargs["project_id"] = project_id
1011
+ if command is not None:
1012
+ kwargs["command"] = command.strip()
1013
+ if cron is not None:
1014
+ kwargs["cron"] = cron
1015
+ if timezone is not None:
1016
+ kwargs["timezone"] = timezone.strip()
1017
+ if enabled is not None:
1018
+ kwargs["enabled"] = bool(enabled)
1019
+ if timeout is not None:
1020
+ kwargs["timeout_seconds"] = int(timeout or 60)
1021
+
1022
+ updated = db.update_job(job_id, **kwargs)
1023
+ if not updated:
1024
+ return err("job_not_found", 404)
1025
+ await sched.scheduler_service.sync_job(updated)
1026
+ return _serialize_job(db.get_job(job_id))
1027
+
1028
+
1029
+ @app.delete("/api/schedule/jobs/{job_id}")
1030
+ async def delete_schedule_job(job_id: str):
1031
+ job = db.get_job(job_id)
1032
+ if not job:
1033
+ return err("job_not_found", 404)
1034
+ sched.scheduler_service.remove(job_id)
1035
+ db.delete_job(job_id)
1036
+ return {"status": "ok"}
1037
+
1038
+
1039
+ @app.post("/api/schedule/jobs/{job_id}/run")
1040
+ async def run_schedule_job(job_id: str):
1041
+ job = db.get_job(job_id)
1042
+ if not job:
1043
+ return err("job_not_found", 404)
1044
+ sched.scheduler_service.run_now(job_id)
1045
+ return {"status": "queued", "job_id": job_id}
1046
+
1047
+
1048
+ @app.post("/api/schedule/jobs/{job_id}/toggle")
1049
+ async def toggle_schedule_job(job_id: str):
1050
+ job = db.get_job(job_id)
1051
+ if not job:
1052
+ return err("job_not_found", 404)
1053
+ updated = db.toggle_job(job_id)
1054
+ if not updated:
1055
+ return err("job_not_found", 404)
1056
+ await sched.scheduler_service.sync_job(updated)
1057
+ return _serialize_job(db.get_job(job_id))
1058
+
1059
+
1060
+ @app.get("/api/schedule/jobs/{job_id}/runs")
1061
+ async def list_schedule_job_runs(job_id: str, limit: int = 50):
1062
+ job = db.get_job(job_id)
1063
+ if not job:
1064
+ return err("job_not_found", 404)
1065
+ return {"runs": db.list_job_runs(job_id, limit)}
1066
+
1067
+
1068
+ # ---------------------------------------------------------------------------
1069
+ # Tasks (Kanban)
1070
+ # ---------------------------------------------------------------------------
1071
+
1072
+
1073
+ def _serialize_task(task: dict) -> dict:
1074
+ return {
1075
+ "id": task["id"],
1076
+ "title": task["title"],
1077
+ "description": task.get("description", ""),
1078
+ "column": task.get("board_column", "backlog"),
1079
+ "priority": task.get("priority", "medium"),
1080
+ "due_at": task.get("due_at"),
1081
+ "reminder_at": task.get("reminder_at"),
1082
+ "reminder_acknowledged": bool(task.get("reminder_acknowledged")),
1083
+ "tags": task.get("tags", ""),
1084
+ "created_at": task.get("created_at"),
1085
+ "updated_at": task.get("updated_at"),
1086
+ }
1087
+
1088
+
1089
+ @app.get("/api/tasks/alarms")
1090
+ async def list_task_alarms():
1091
+ return {"alarms": [_serialize_task(t) for t in db.list_active_alarms()]}
1092
+
1093
+
1094
+ @app.get("/api/tasks")
1095
+ async def list_tasks():
1096
+ return {"tasks": [_serialize_task(t) for t in db.list_tasks()]}
1097
+
1098
+
1099
+ @app.post("/api/tasks")
1100
+ async def create_task(payload: dict):
1101
+ title = (payload.get("title") or "").strip()
1102
+ if not title:
1103
+ return err("title_required")
1104
+ task = db.add_task(
1105
+ title=title,
1106
+ description=(payload.get("description") or "").strip(),
1107
+ board_column=(payload.get("column") or "backlog").strip(),
1108
+ priority=(payload.get("priority") or "medium").strip(),
1109
+ due_at=_to_utc(payload.get("due_at")),
1110
+ reminder_at=_to_utc(payload.get("reminder_at")),
1111
+ tags=(payload.get("tags") or "").strip(),
1112
+ )
1113
+ sched.reminder_service.sync_task(task["id"])
1114
+ return _serialize_task(task)
1115
+
1116
+
1117
+ @app.get("/api/tasks/{task_id}")
1118
+ async def get_task(task_id: str):
1119
+ task = db.get_task(task_id)
1120
+ if not task:
1121
+ return err("task_not_found", 404)
1122
+ return _serialize_task(task)
1123
+
1124
+
1125
+ @app.put("/api/tasks/{task_id}")
1126
+ async def update_task(task_id: str, payload: dict):
1127
+ task = db.get_task(task_id)
1128
+ if not task:
1129
+ return err("task_not_found", 404)
1130
+
1131
+ updated = db.update_task(
1132
+ task_id,
1133
+ title=payload.get("title"),
1134
+ description=payload.get("description"),
1135
+ board_column=payload.get("column"),
1136
+ priority=payload.get("priority"),
1137
+ due_at=_to_utc(payload.get("due_at")),
1138
+ reminder_at=_to_utc(payload.get("reminder_at")),
1139
+ reminder_acknowledged=payload.get("reminder_acknowledged"),
1140
+ tags=payload.get("tags"),
1141
+ )
1142
+ if not updated:
1143
+ return err("task_not_found", 404)
1144
+ sched.reminder_service.sync_task(task_id)
1145
+ return _serialize_task(updated)
1146
+
1147
+
1148
+ @app.post("/api/tasks/{task_id}/move")
1149
+ async def move_task(task_id: str, payload: dict):
1150
+ column = (payload.get("column") or "").strip()
1151
+ if not column:
1152
+ return err("column_required")
1153
+ updated = db.move_task(task_id, column)
1154
+ if not updated:
1155
+ return err("task_not_found", 404)
1156
+ sched.reminder_service.sync_task(task_id)
1157
+ return _serialize_task(updated)
1158
+
1159
+
1160
+ @app.post("/api/tasks/{task_id}/ack")
1161
+ async def ack_task_reminder(task_id: str):
1162
+ updated = db.ack_reminder(task_id)
1163
+ if not updated:
1164
+ return err("task_not_found", 404)
1165
+ sched.reminder_service.sync_task(task_id)
1166
+ return _serialize_task(updated)
1167
+
1168
+
1169
+ @app.post("/api/tasks/{task_id}/snooze")
1170
+ async def snooze_task_reminder(task_id: str, payload: dict):
1171
+ minutes = int(payload.get("minutes", 15) or 15)
1172
+ updated = db.snooze_reminder(task_id, minutes)
1173
+ if not updated:
1174
+ return err("task_not_found", 404)
1175
+ sched.reminder_service.sync_task(task_id)
1176
+ return _serialize_task(updated)
1177
+
1178
+
1179
+ @app.delete("/api/tasks/{task_id}")
1180
+ async def delete_task(task_id: str):
1181
+ task = db.get_task(task_id)
1182
+ if not task:
1183
+ return err("task_not_found", 404)
1184
+ sched.reminder_service.remove_task(task_id)
1185
+ db.delete_task(task_id)
1186
+ return {"status": "ok"}
1187
+
1188
+
1189
+ def _setup_logging() -> None:
1190
+ handler = RotatingFileHandler(
1191
+ db.config_dir() / "shelldeck.log", maxBytes=1_000_000, backupCount=3, encoding="utf-8"
1192
+ )
1193
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
1194
+ logging.getLogger().addHandler(handler)
1195
+ logging.getLogger().setLevel(logging.INFO)
1196
+
1197
+
1198
+ def run(host: str = "127.0.0.1", port: int = 5455, certfile: str | None = None, keyfile: str | None = None) -> None:
1199
+ global ALLOWED_HOSTS, _server
1200
+ if host in ("0.0.0.0", "::"):
1201
+ ALLOWED_HOSTS = None
1202
+ elif ALLOWED_HOSTS is not None:
1203
+ ALLOWED_HOSTS.add(host)
1204
+ _setup_logging()
1205
+ scheme = "https" if certfile else "http"
1206
+ (db.config_dir() / "server.json").write_text(json.dumps({"port": port, "scheme": scheme, "host": host}), encoding="utf-8")
1207
+ if code := (None if auth.has_password() else auth.setup_code()):
1208
+ where = "this machine's address" if host in ("0.0.0.0", "::") else host
1209
+ print(f"shelldeck: no password set yet. Open {scheme}://{where}:{port} to create one.", flush=True)
1210
+ print(f"shelldeck: remote setup code (only needed from another machine): {code}", flush=True)
1211
+ _server = uvicorn.Server(uvicorn.Config(
1212
+ app, host=host, port=port, log_level="warning",
1213
+ ssl_certfile=certfile, ssl_keyfile=keyfile,
1214
+ ws_ping_interval=20, ws_ping_timeout=20, # drop dead connections; the UI reconnects
1215
+ ))
1216
+ _server.run()