"""Local backend process skeleton (U-007, extended in U-022): pure stdlib `http.server`, no third-party deps (05 §5.7) -- this process is also where the Graph Engine scheduler will live later (05 §5.4.1), but that's a different Unit. MVP scope: process start/stop, `c_pick_folder` (03-C §3.-1, now all three platforms -- U-007 shipped macOS only via `osascript`; U-022 adds Windows via a `powershell` FolderBrowserDialog script and Linux via `zenity`/ `kdialog`, whichever is installed), `c_project_create` (03-C §3.0, wired to the U-002 `project_scaffold.create_project()` function that had no HTTP route until now) for `v_project_setup`'s submit flow, and serving the U-008 frontend shell as static files (no build step, 05 §5.7: whitelisted by exact path, not a generic directory walker, so there's no path-traversal surface to reason about). """ import json import os import platform import subprocess import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from src import paths, project_scaffold, schema _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") # U-008: exact-path whitelist, not a generic static-file server -- the # frontend is 3 fixed files until PH3 adds real pages. _STATIC_FILES = { "/": ("index.html", "text/html; charset=utf-8"), "/index.html": ("index.html", "text/html; charset=utf-8"), "/app.js": ("app.js", "text/javascript; charset=utf-8"), "/style.css": ("style.css", "text/css; charset=utf-8"), } class DialogUnavailableError(Exception): """DIALOG_UNAVAILABLE -- 03-C §3.-1: no native-dialog capability here.""" def pick_folder(): """c_pick_folder (03-C §3.-1), all three platforms. Returns {"selected_path": str} on success, {"selected_path": None} on user cancel (not an error). Raises DialogUnavailableError if the platform's native dialog tool itself fails or isn't installed.""" system = platform.system() if system == "Darwin": return _pick_folder_macos() if system == "Windows": return _pick_folder_windows() return _pick_folder_linux() def _pick_folder_macos(): script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")' result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) if result.returncode != 0: if "User canceled" in result.stderr or "(-128)" in result.stderr: return {"selected_path": None} raise DialogUnavailableError(result.stderr.strip()) return {"selected_path": result.stdout.strip()} def _pick_folder_windows(): # .NET FolderBrowserDialog via a one-shot PowerShell script -- no # cancel-vs-error signal other than empty stdout on a clean exit. script = ( "Add-Type -AssemblyName System.Windows.Forms; " "$f = New-Object System.Windows.Forms.FolderBrowserDialog; " "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }" ) try: result = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], capture_output=True, text=True, ) except FileNotFoundError as e: raise DialogUnavailableError(str(e)) if result.returncode != 0: raise DialogUnavailableError(result.stderr.strip()) return {"selected_path": result.stdout.strip() or None} def _pick_folder_linux(): # zenity (GNOME) and kdialog (KDE) are the two dialogs actually present # across mainstream Linux desktops; try whichever exists. Both exit # non-zero on user cancel, indistinguishable here from "no folder # chosen" -- both cases map to selected_path=None, not an error. for cmd in ( ["zenity", "--file-selection", "--directory"], ["kdialog", "--getexistingdirectory", os.path.expanduser("~")], ): try: result = subprocess.run(cmd, capture_output=True, text=True) except FileNotFoundError: continue if result.returncode != 0: return {"selected_path": None} return {"selected_path": result.stdout.strip()} raise DialogUnavailableError("neither zenity nor kdialog is installed") class _Handler(BaseHTTPRequestHandler): def do_GET(self): entry = _STATIC_FILES.get(self.path) if entry is None: self._send_json(404, {"error": "NOT_FOUND"}) return filename, content_type = entry with open(os.path.join(_FRONTEND_DIR, filename), "rb") as f: body = f.read() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_POST(self): if self.path == "/api/system/pick-folder": self._handle_pick_folder() elif self.path == "/api/projects": self._handle_project_create() else: self._send_json(404, {"error": "NOT_FOUND"}) def _handle_pick_folder(self): try: self._send_json(200, pick_folder()) except DialogUnavailableError as e: self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)}) def _handle_project_create(self): length = int(self.headers.get("Content-Length", 0)) try: payload = json.loads(self.rfile.read(length) or b"{}") except json.JSONDecodeError: self._send_json(400, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"}) return target_folder_path = payload.get("target_folder_path") if not target_folder_path: self._send_json(400, {"error": "INVALID_INPUT", "detail": "target_folder_path is required"}) return try: with self.server.conn_lock: result = project_scaffold.create_project( self.server.conn, target_folder_path, name=payload.get("name"), init_git_if_missing=bool(payload.get("init_git_if_missing", False)), ) except project_scaffold.PathNotFoundError as e: self._send_json(404, {"error": "PATH_NOT_FOUND", "detail": str(e)}) except project_scaffold.PathNotWritableError as e: self._send_json(403, {"error": "PATH_NOT_WRITABLE", "detail": str(e)}) except project_scaffold.GitInitDeclinedError as e: self._send_json(409, {"error": "GIT_INIT_DECLINED", "detail": str(e)}) else: self._send_json(200, result) def _send_json(self, status, obj): body = json.dumps(obj).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, format, *args): pass # quiets test output; real request logging is a later Unit's concern def _ensure_schema(conn): exists = conn.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'project'" ).fetchone() if exists is None: schema.create_schema(conn) class _Server(ThreadingHTTPServer): def server_close(self): super().server_close() self.conn.close() def create_server(host="127.0.0.1", port=0, db_path=None): """port=0 lets the OS assign a free port -- avoids collisions in tests and lets multiple graphlemon instances run side by side if ever needed. db_path defaults to paths.db_path() (U-003's platform_home, so it never lives inside a target project's own repo); tests pass ":memory:" or a tempfile so runs don't touch the real developer's ~/.graphlemon/.""" conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False) _ensure_schema(conn) server = _Server((host, port), _Handler) server.conn = conn server.conn_lock = threading.Lock() return server