gitroster 0.2.0__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.
gitroster/server.py ADDED
@@ -0,0 +1,234 @@
1
+ """Local HTTP server: JSON API + one static page. Binds to 127.0.0.1 only."""
2
+ import json
3
+ import os
4
+ import shlex
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from urllib.parse import parse_qs, urlparse
12
+ from pathlib import Path
13
+
14
+ from . import __version__, agent, export, opener, procs, repoinfo, scanner
15
+ from .store import Store
16
+
17
+ STATIC = Path(__file__).parent / "static"
18
+ READ = {"graph", "overview", "tree", "file", "commit", "search"}
19
+ TYPES = {".html": "text/html; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png"}
20
+
21
+
22
+ class App:
23
+ def __init__(self, roots, port, store=None, agent_cmd=None, demo=False):
24
+ self.roots, self.port, self.agent_cmd, self.demo = roots, port, agent_cmd, demo
25
+ self.store = store or Store()
26
+ self.scan = self.store.cached_scan()
27
+ self.scanning = False
28
+ self.lock = threading.Lock()
29
+ threading.Thread(target=opener.detect, daemon=True).start() # warm the editor/terminal cache
30
+
31
+ def settings(self):
32
+ return {"settings": self.store.settings(), **opener.detect(), "agents": agent.catalog(), "version": __version__,
33
+ "agent_override": bool(self.agent_cmd or os.environ.get("GITROSTER_AGENT"))}
34
+
35
+ def summarize(self, path):
36
+ repo = self.repo(path)
37
+ if not repo:
38
+ raise ValueError("unknown repo")
39
+ summary = agent.summarize(repo, self.store.settings(), self.agent_cmd)
40
+ return self.store.update_meta(path, summary=summary)
41
+
42
+ def repo(self, path):
43
+ return next((r for r in self.scan.get("repos", []) if r["path"] == path), None)
44
+
45
+ def do_scan(self):
46
+ with self.lock:
47
+ if self.scanning: # a scan is already running: don't start a second one
48
+ return self.state()
49
+ self.scanning = True
50
+ try:
51
+ result = scanner.scan(self.roots)
52
+ self.scan = result
53
+ self.store.save_scan(result)
54
+ finally:
55
+ self.scanning = False
56
+ return self.state()
57
+
58
+ def live_ports(self):
59
+ ports, containers = procs.listening(), procs.docker_containers()
60
+ procs.link(ports, containers, self.scan.get("repos", []), self.scan.get("envs", []))
61
+ return ports, containers
62
+
63
+ def state(self):
64
+ s = dict(self.scan)
65
+ s["meta"] = self.store.meta()
66
+ s["scanning"] = self.scanning
67
+ s["platform"] = {"win32": "windows", "darwin": "macos"}.get(sys.platform, "linux")
68
+ s["home"] = str(self.store.config.parent) if self.demo else str(Path.home()) # demo paths read as ~/projects/...
69
+ s["demo"] = self.demo
70
+ return s
71
+
72
+ def start(self, path, cmd):
73
+ repo = self.repo(path)
74
+ if not repo or cmd not in repo["run"]:
75
+ raise ValueError("command is not a detected run command for this repo")
76
+ log = self.store.log_path(path)
77
+ with open(log, "ab") as f:
78
+ f.write(f"\n$ {cmd} [{time.ctime()}]\n".encode())
79
+ if sys.platform == "win32": # cmd comes from the detected list, never from the request
80
+ proc = subprocess.Popen(cmd, cwd=path, stdout=f, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
81
+ shell=True, creationflags=0x08000200) # new process group, no console window (DETACHED_PROCESS loses the log)
82
+ else:
83
+ proc = subprocess.Popen(shlex.split(cmd), cwd=path, stdout=f, stderr=subprocess.STDOUT,
84
+ stdin=subprocess.DEVNULL, start_new_session=True)
85
+ threading.Thread(target=proc.wait, daemon=True).start() # reap it when it exits
86
+ return {"pid": proc.pid, "log": str(log)}
87
+
88
+ def tail(self, path, n=4000):
89
+ log = self.store.log_path(path)
90
+ try:
91
+ with open(log, "rb") as f:
92
+ f.seek(0, 2)
93
+ f.seek(max(0, f.tell() - n))
94
+ return f.read().decode(errors="replace")
95
+ except OSError:
96
+ return ""
97
+
98
+
99
+ def make_handler(app):
100
+ hosts = {f"127.0.0.1:{app.port}", f"localhost:{app.port}"}
101
+
102
+ class Handler(BaseHTTPRequestHandler):
103
+ def log_message(self, *a):
104
+ pass
105
+
106
+ def send(self, code, body, ctype="application/json"):
107
+ data = body if isinstance(body, bytes) else body.encode()
108
+ self.send_response(code)
109
+ self.send_header("Content-Type", ctype)
110
+ self.send_header("Content-Length", str(len(data)))
111
+ self.send_header("Cache-Control", "no-store")
112
+ self.end_headers()
113
+ self.wfile.write(data)
114
+
115
+ def ok(self, obj):
116
+ self.send(200, json.dumps(obj))
117
+
118
+ def err(self, code, msg):
119
+ self.send(code, json.dumps({"error": msg}))
120
+
121
+ def read_api(self, name):
122
+ q = {k: v[0] for k, v in parse_qs(urlparse(self.path).query).items()}
123
+ repos = [r for r in app.scan.get("repos", []) if not app.store.meta().get(r["path"], {}).get("ignored")]
124
+ try:
125
+ if name == "search":
126
+ scope = tuple(x for x in q.get("scope", "code,files,commits").split(",") if x in ("code", "files", "commits"))
127
+ return self.ok({"results": repoinfo.search(repos, q.get("q", ""), scope, q.get("regex") == "1")})
128
+ p = q.get("path", "")
129
+ if not app.repo(p):
130
+ return self.err(404, "unknown repo")
131
+ if name == "graph":
132
+ return self.ok({"commits": scanner.graph(p, min(int(q.get("n", 300)), 1000))})
133
+ if name == "overview":
134
+ return self.ok(repoinfo.overview(p))
135
+ if name == "tree":
136
+ return self.ok({"entries": repoinfo.tree(p, q.get("ref"), q.get("dir"))})
137
+ if name == "file":
138
+ return self.ok(repoinfo.read_file(p, q.get("ref"), q.get("file")))
139
+ if name == "commit":
140
+ return self.ok(repoinfo.commit(p, q.get("hash")))
141
+ except ValueError as e:
142
+ return self.err(400, str(e))
143
+ self.err(404, "not found")
144
+
145
+ def do_GET(self):
146
+ if self.headers.get("Host") not in hosts: # DNS rebinding guard
147
+ return self.err(403, "bad host")
148
+ path = self.path.split("?")[0]
149
+ if path in ("/", "/index.html"):
150
+ return self.send(200, (STATIC / "index.html").read_bytes(), TYPES[".html"])
151
+ if path in ("/icon.svg", "/favicon.svg", "/favicon.ico"):
152
+ return self.send(200, (STATIC / "icon.svg").read_bytes(), TYPES[".svg"])
153
+ if path == "/api/state":
154
+ return self.ok(app.state())
155
+ if path.startswith("/api/") and path.split("/")[2] in READ:
156
+ return self.read_api(path.split("/")[2])
157
+ if path == "/api/settings":
158
+ return self.ok(app.settings())
159
+ if path == "/api/export.md":
160
+ return self.send(200, export.markdown(app.scan, app.store.meta()), "text/markdown; charset=utf-8")
161
+ if path == "/api/ports":
162
+ ports, containers = app.live_ports()
163
+ return self.ok({"ports": ports, "containers": containers})
164
+ if path == "/api/log":
165
+ q = dict(x.split("=", 1) for x in self.path.partition("?")[2].split("&") if "=" in x)
166
+ from urllib.parse import unquote
167
+ p = unquote(q.get("path", ""))
168
+ return self.ok({"log": app.tail(p) if app.repo(p) else ""})
169
+ self.err(404, "not found")
170
+
171
+ def do_POST(self):
172
+ origin = self.headers.get("Origin", "").split("//")[-1]
173
+ if self.headers.get("Host") not in hosts or origin not in hosts:
174
+ return self.err(403, "forbidden")
175
+ try:
176
+ length = int(self.headers.get("Content-Length", 0))
177
+ if not 0 <= length <= 1_000_000:
178
+ return self.err(413, "body too large")
179
+ body = json.loads(self.rfile.read(length) or b"{}")
180
+ except ValueError:
181
+ return self.err(400, "bad json")
182
+ path = self.path
183
+ if app.demo and path in ("/api/kill", "/api/start", "/api/open", "/api/summarize"):
184
+ return self.err(403, "disabled in demo mode")
185
+ try:
186
+ if path == "/api/scan": # POST only: a page on another site must not be able to trigger a scan
187
+ return self.ok(app.do_scan())
188
+ if path == "/api/kill":
189
+ pid = int(body["pid"])
190
+ if pid not in {p["pid"] for p in procs.listening()}:
191
+ return self.err(404, "not a listening process")
192
+ os.kill(pid, signal.SIGTERM)
193
+ return self.ok({"ok": True})
194
+ if path == "/api/settings":
195
+ fields = {k: str(body[k])[:300] for k in ("editor", "editor_custom", "terminal", "theme", "agent", "agent_custom") if k in body}
196
+ if fields.get("theme") not in (None, "system", "light", "dark"):
197
+ return self.err(400, "theme must be system, light or dark")
198
+ app.store.update_settings(**fields)
199
+ return self.ok(app.settings())
200
+ if path == "/api/open":
201
+ if not app.repo(body["path"]):
202
+ return self.err(404, "unknown repo")
203
+ return self.ok({"with": opener.open_repo(body["path"], body["target"], app.store.settings())})
204
+ if path == "/api/start":
205
+ return self.ok(app.start(body["path"], body["cmd"]))
206
+ if path == "/api/meta":
207
+ if not app.repo(body["path"]):
208
+ return self.err(404, "unknown repo")
209
+ fields = {k: body[k] for k in ("note", "pinned", "ignored") if k in body}
210
+ if "note" in fields:
211
+ fields["note"] = str(fields["note"])[:4000]
212
+ return self.ok(app.store.update_meta(body["path"], **fields))
213
+ if path == "/api/summarize":
214
+ return self.ok(app.summarize(body["path"]))
215
+ except (KeyError, ValueError, TypeError) as e:
216
+ return self.err(400, f"bad request: {e}")
217
+ except RuntimeError as e:
218
+ return self.err(500, str(e))
219
+ except OSError as e:
220
+ return self.err(500, str(e))
221
+ self.err(404, "not found")
222
+
223
+ return Handler
224
+
225
+
226
+ def serve(app):
227
+ opener.warm()
228
+ srv = ThreadingHTTPServer(("127.0.0.1", app.port), make_handler(app))
229
+ try:
230
+ srv.serve_forever()
231
+ except KeyboardInterrupt:
232
+ pass
233
+ finally:
234
+ srv.server_close()
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect width="64" height="64" rx="14" fill="#111"/>
3
+ <!-- git history line -->
4
+ <path d="M17 14v36" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".5"/>
5
+ <!-- roster rows: one commit dot + one entry per repo -->
6
+ <circle cx="17" cy="18" r="4.2" fill="#fff"/>
7
+ <rect x="28" y="15" width="26" height="6" rx="3" fill="#fff"/>
8
+ <circle cx="17" cy="32" r="4.2" fill="#fff"/>
9
+ <rect x="28" y="29" width="20" height="6" rx="3" fill="#fff" opacity=".7"/>
10
+ <circle cx="17" cy="46" r="4.2" fill="#fff"/>
11
+ <rect x="28" y="43" width="23" height="6" rx="3" fill="#fff" opacity=".45"/>
12
+ </svg>