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.
- shelldeck/__init__.py +0 -0
- shelldeck/__main__.py +4 -0
- shelldeck/addons/__init__.py +1 -0
- shelldeck/addons/fast_context.py +1346 -0
- shelldeck/auth.py +207 -0
- shelldeck/cli.py +587 -0
- shelldeck/db.py +794 -0
- shelldeck/gitgraph.py +150 -0
- shelldeck/integration/bash.sh +27 -0
- shelldeck/integration/shelldeck.fish +5 -0
- shelldeck/integration/shelldeck.ps1 +35 -0
- shelldeck/integration/zsh/.zshenv +6 -0
- shelldeck/integration/zsh/.zshrc +10 -0
- shelldeck/pty.py +352 -0
- shelldeck/runner.py +75 -0
- shelldeck/scheduler.py +283 -0
- shelldeck/server.py +1216 -0
- shelldeck/shells.py +174 -0
- shelldeck/static/app.css +2288 -0
- shelldeck/static/app.js +1863 -0
- shelldeck/static/gitgraph.js +142 -0
- shelldeck/static/history.js +172 -0
- shelldeck/static/icon-192.png +0 -0
- shelldeck/static/icon-32.png +0 -0
- shelldeck/static/icon-512.png +0 -0
- shelldeck/static/icon.svg +1 -0
- shelldeck/static/index.html +102 -0
- shelldeck/static/manifest.webmanifest +1 -0
- shelldeck/static/monitor.js +143 -0
- shelldeck/static/ui.js +316 -0
- shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
- shelldeck/static/vendor/addon-fit.js +2 -0
- shelldeck/static/vendor/addon-search.js +2 -0
- shelldeck/static/vendor/addon-serialize.js +2 -0
- shelldeck/static/vendor/addon-web-links.js +2 -0
- shelldeck/static/vendor/addon-webgl.js +2 -0
- shelldeck/static/vendor/xterm.css +218 -0
- shelldeck/static/vendor/xterm.js +2 -0
- shelldeck/static/views.js +658 -0
- shelldeck/stats.py +128 -0
- shelldeck-0.0.1.dist-info/METADATA +254 -0
- shelldeck-0.0.1.dist-info/RECORD +45 -0
- shelldeck-0.0.1.dist-info/WHEEL +4 -0
- shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
- 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()
|