#!/usr/bin/env bash # setup_pyops.sh # One-shot installer for: # - PyOps FastAPI (with streaming pip logs) # - code-server (VS Code in the browser) # - systemd services (or fallback runner if systemd unavailable e.g., some WSL setups) # Safe for re-runs. Tested on Ubuntu 22.04+/24.04+ and WSL with systemd enabled. set -Eeuo pipefail ### ---- Helpers --------------------------------------------------------------- log() { printf "\n[INFO] %s\n" "$*"; } warn() { printf "\n[WARN] %s\n" "$*" >&2; } die() { printf "\n[ERR ] %s\n" "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } REAL_USER() { if [[ -n "${SUDO_USER-}" && "${SUDO_USER}" != "root" ]]; then printf "%s" "$SUDO_USER" else printf "%s" "$USER" fi } as_root() { # run a command with sudo if not root if [[ $EUID -ne 0 ]]; then sudo bash -c "$*" else bash -c "$*" fi } SYSTEMD_AVAILABLE() { # Consider systemd available if the directory exists and systemctl is usable [[ -d /run/systemd/system ]] && have systemctl } ### ---- Vars ------------------------------------------------------------------ TARGET_USER="$(REAL_USER)" USER_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" [[ -n "$USER_HOME" ]] || die "Could not resolve HOME for user $TARGET_USER" PYOPS_DIR="$USER_HOME/pyops" VENV_DIR="$USER_HOME/.venvs/pyops" PY_BIN="$VENV_DIR/bin/python" PIP_BIN="$VENV_DIR/bin/pip" API_HOST="127.0.0.1" API_PORT="8077" CODE_HOST="127.0.0.1" CODE_PORT="8080" ### ---- OS packages ----------------------------------------------------------- install_apt() { log "Installing system packages (Ubuntu)…" as_root "apt-get update -y" as_root "DEBIAN_FRONTEND=noninteractive apt-get install -y \ python3 python3-venv python3-pip \ curl ca-certificates psmisc build-essential jq" } install_code_server() { if have code-server; then log "code-server already installed." return 0 fi log "Installing code-server…" # Official installer curl -fsSL https://code-server.dev/install.sh | bash || { warn "code-server install script failed; attempting apt-based install." # Fallback apt method (if script unavailable) as_root "curl -fsSL https://code-server.dev/install.sh | sh" || { die "Failed to install code-server" } } } ### ---- App & venv ------------------------------------------------------------ write_app_py() { log "Writing $PYOPS_DIR/app.py …" mkdir -p "$PYOPS_DIR" # Write the FastAPI app with protected 'pyops' deletion + streaming endpoints cat >"$PYOPS_DIR/app.py" <<'PYOPS_APP' from __future__ import annotations import os import shlex import shutil import subprocess import tempfile import pathlib from pathlib import Path from typing import Iterator from fastapi import FastAPI, HTTPException, Form, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ( HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse, ) from pydantic import BaseModel, constr # ------------------------------------------------------------------- # FastAPI app (must exist before any @app.* decorators) # ------------------------------------------------------------------- app = FastAPI() # CORS for local extension/UI app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) # ------------------------------------------------------------------- # Paths / config # ------------------------------------------------------------------- BASE = pathlib.Path.home() PRIMARY = BASE / "envs" # default create target ROOTS = [PRIMARY, BASE / ".venvs", BASE / ".virtualenvs"] PRIMARY.mkdir(exist_ok=True) NameStr = constr(pattern=r"^[A-Za-z0-9_-]{1,32}$") # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- def _safe(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">") def _env_dir(name: str) -> pathlib.Path | None: for root in ROOTS: p = root / name if p.is_dir(): return p return None def list_envs() -> list[str]: names = set() for root in ROOTS: if root.exists(): for p in root.iterdir(): if p.is_dir(): names.add(p.name) return sorted(names) def _py_path(env: str) -> pathlib.Path: d = _env_dir(env) or (PRIMARY / env) return d / "bin" / "python" def _pip_path(env: str) -> pathlib.Path: d = _env_dir(env) or (PRIMARY / env) return d / "bin" / "pip" def _pip_install_reqs(env: str, reqs_path: pathlib.Path) -> subprocess.CompletedProcess: out = subprocess.run( [str(_pip_path(env)), "install", "-r", str(reqs_path)], capture_output=True, text=True, ) return out def _delete_env(name: str) -> tuple[bool, str]: # Safety: never delete the working "pyops" venv if name == "pyops": return False, "refusing to delete protected environment: pyops" d = _env_dir(name) or (PRIMARY / name) try: if not d.exists(): return False, f"{name}: not found" if not d.is_dir(): return False, f"{name}: not a directory" if d.is_symlink(): return False, f"{name}: symlink not allowed" if not any(d.resolve().is_relative_to(r.resolve()) for r in ROOTS): return False, "refuses to delete: outside allowed roots" shutil.rmtree(d) return True, f"deleted: {name}" except Exception as e: # pragma: no cover return False, f"error deleting {name}: {e}" def _stream_cmd(args: list[str], cwd: str | None = None) -> Iterator[str]: """Run a command and yield combined stdout/stderr line-by-line.""" env = os.environ.copy() # make pip chatty and line-oriented when not a TTY env["PYTHONUNBUFFERED"] = "1" env["PYTHONIOENCODING"] = "utf-8" env["PIP_PROGRESS_BAR"] = "off" env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" quoted = " ".join(shlex.quote(a) for a in args) yield f"$ {quoted}\n" proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, # line-buffered cwd=cwd, env=env, ) assert proc.stdout is not None for line in proc.stdout: # ensure every chunk ends with newline for the client appender yield line if line.endswith("\n") else (line + "\n") rc = proc.wait() yield f"\n[exit {rc}]\n" # ------------------------------------------------------------------- # Models # ------------------------------------------------------------------- class Snippet(BaseModel): env: str code: str args: str | None = "" class VenvReq(BaseModel): name: NameStr class PipReq(BaseModel): env: NameStr pkgs: list[str] | str class RunReq(BaseModel): env: NameStr path: str args: list[str] | str | None = None # ------------------------------------------------------------------- # Basic routes / UI # ------------------------------------------------------------------- HTML_HEAD = """
Tip: if both file and path are supplied, the uploaded file wins.
Env {_safe(name)}
already exists ({_safe(str(tgt))}).
{_safe(out.stdout+out.stderr)}" ) @app.post("/pip-install", response_class=HTMLResponse) def pip_install(env: str = Form(...), pkgs: str = Form(...)): out = subprocess.run( [str(_pip_path(env)), "install", *shlex.split(pkgs)], capture_output=True, text=True, ) return ( HTML_HEAD + f"
{_safe(out.stdout+out.stderr)}" ) @app.post("/pip-install-reqs", response_class=HTMLResponse) def pip_install_reqs_html( env: str = Form(...), file: UploadFile = File(None), path: str = Form(None) ): if file is not None: with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp: tmp.write(file.file.read()) tmp_path = pathlib.Path(tmp.name) out = _pip_install_reqs(env, tmp_path) try: tmp_path.unlink(missing_ok=True) except Exception: pass else: if not path: return HTML_HEAD + "
No file or path provided.
" req = (BASE / path).expanduser() out = _pip_install_reqs(env, req) return ( HTML_HEAD + f"{_safe(out.stdout+out.stderr)}" ) @app.post("/run", response_class=HTMLResponse) def run(env: str = Form(...), path: str = Form(...), args: str = Form("")): script = (BASE / path).expanduser() out = subprocess.run( [str(_py_path(env)), str(script), *shlex.split(args or "")], capture_output=True, text=True, cwd=str(script.parent), ) return ( HTML_HEAD + f"
{_safe(out.stdout+out.stderr)}" ) @app.post("/delete-venv", response_class=HTMLResponse) def delete_venv_html(name: str = Form(...)): ok, msg = _delete_env(name) return HTML_HEAD + f"
{_safe(msg)}" # ---------------- JSON API ---------------- @app.get("/api/envs") def api_list_envs(): return {"envs": list_envs(), "roots": [str(r) for r in ROOTS]} @app.post("/api/create-venv") def api_create_venv(req: VenvReq): tgt = PRIMARY / req.name tgt.parent.mkdir(parents=True, exist_ok=True) if tgt.exists(): return {"ok": True, "note": "exists", "env": req.name, "path": str(tgt)} out = subprocess.run( ["/usr/bin/python3", "-m", "venv", str(tgt)], capture_output=True, text=True, ) return { "ok": out.returncode == 0, "env": req.name, "path": str(tgt), "stdout": out.stdout, "stderr": out.stderr, } @app.post("/api/pip-install") def api_pip_install(req: PipReq): pkgs = req.pkgs if isinstance(req.pkgs, list) else shlex.split(req.pkgs) out = subprocess.run( [str(_pip_path(req.env)), "install", *pkgs], capture_output=True, text=True ) return {"ok": out.returncode == 0, "stdout": out.stdout, "stderr": out.stderr} @app.post("/api/pip-install-reqs") async def api_pip_install_reqs( env: NameStr = Form(...), file: UploadFile = File(None), path: str = Form(None) ): if file is not None: with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp: b = await file.read() tmp.write(b) tmp_path = pathlib.Path(tmp.name) out = _pip_install_reqs(env, tmp_path) try: tmp_path.unlink(missing_ok=True) except Exception: pass else: if not path: return JSONResponse({"ok": False, "stderr": "no file or path"}) req = (BASE / path).expanduser() out = _pip_install_reqs(env, req) return {"ok": out.returncode == 0, "stdout": out.stdout, "stderr": out.stderr} @app.post("/api/run") def api_run(req: RunReq): script = (BASE / req.path).expanduser() args = ( req.args if isinstance(req.args, list) else (shlex.split(req.args) if req.args else []) ) out = subprocess.run( [str(_py_path(req.env)), str(script), *args], capture_output=True, text=True, cwd=str(script.parent), ) return { "ok": out.returncode == 0, "code": out.returncode, "stdout": out.stdout, "stderr": out.stderr, } @app.post("/api/delete-venv") def api_delete_venv(req: VenvReq): ok, msg = _delete_env(req.name) return JSONResponse({"ok": ok, "message": msg}) # ---------------- STREAMING API (for live install output) ---------------- @app.post("/api/pip-install-stream") def api_pip_install_stream(req: PipReq): env_dir = _env_dir(req.env) if not env_dir or not (_pip_path(req.env)).exists(): raise HTTPException(status_code=400, detail=f"env not found: {req.env}") pkgs = req.pkgs if isinstance(req.pkgs, list) else shlex.split(req.pkgs) args = [str(_pip_path(req.env)), "install", *pkgs] return StreamingResponse( _stream_cmd(args), media_type="text/plain", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.post("/api/pip-install-reqs-stream") async def api_pip_install_reqs_stream( env: NameStr = Form(...), file: UploadFile = File(None), path: str = Form(None) ): env_dir = _env_dir(env) if not env_dir or not (_pip_path(env)).exists(): raise HTTPException(status_code=400, detail=f"env not found: {env}") req_path: pathlib.Path | None = None if file is not None: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".txt") tmp.write(await file.read()) tmp.flush() tmp.close() req_path = pathlib.Path(tmp.name) elif path: req_path = (BASE / path).expanduser() else: raise HTTPException(status_code=400, detail="no file or path provided") args = [str(_pip_path(env)), "install", "-r", str(req_path)] def gen(): try: yield from _stream_cmd(args) finally: if file is not None: try: req_path.unlink(missing_ok=True) # type: ignore[union-attr] except Exception: pass return StreamingResponse( gen(), media_type="text/plain", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) PYOPS_APP chmod 644 "$PYOPS_DIR/app.py" } ensure_venv() { log "Creating venv at $VENV_DIR …" mkdir -p "$(dirname "$VENV_DIR")" if [[ ! -x "$PY_BIN" ]]; then /usr/bin/python3 -m venv "$VENV_DIR" fi log "Upgrading pip/setuptools/wheel …" "$PIP_BIN" install -U pip setuptools wheel >/dev/null } install_pydeps() { log "Installing Python deps (fastapi, uvicorn[standard], python-multipart)…" "$PIP_BIN" install -U "fastapi>=0.114" "uvicorn[standard]>=0.30" "python-multipart>=0.0.9" >/dev/null } ### ---- systemd units (fixed paths, no WorkingDirectory) ---------------------- write_units() { log "Writing systemd service: /etc/systemd/system/pyops@.service" as_root "tee /etc/systemd/system/pyops@.service >/dev/null" <<'EOF' [Unit] Description=PyOps (venv/pip/run web UI) for user %i After=network.target StartLimitIntervalSec=0 [Service] Type=simple User=%i # Avoid CHDIR problems: don't set WorkingDirectory Environment=PYTHONUNBUFFERED=1 Environment=PYTHONIOENCODING=utf-8 ExecStartPre=-/usr/bin/fuser -k 8077/tcp # Use explicit /home/%i paths; --app-dir points at the app without chdir ExecStart=/home/%i/.venvs/pyops/bin/python -m uvicorn app:app --app-dir /home/%i/pyops --host 127.0.0.1 --port 8077 Restart=on-failure RestartSec=2 [Install] WantedBy=multi-user.target EOF log "Writing systemd service: /etc/systemd/system/code-server@.service" as_root "tee /etc/systemd/system/code-server@.service >/dev/null" <<'EOF' [Unit] Description=code-server (VS Code in browser) for user %i After=network.target [Service] Type=simple User=%i # Avoid CHDIR; give code-server a starting folder explicitly ExecStart=/usr/bin/code-server --bind-addr 127.0.0.1:8080 --auth none --disable-telemetry /home/%i Restart=on-failure RestartSec=2 [Install] WantedBy=multi-user.target EOF } enable_services() { if SYSTEMD_AVAILABLE; then log "Reloading systemd and enabling services at boot…" as_root "systemctl daemon-reload" as_root "systemctl enable --now pyops@${TARGET_USER}" as_root "systemctl enable --now code-server@${TARGET_USER}" else warn "systemd not available (common on some WSL setups without systemd). Using fallback launcher." fallback_launch fi } fallback_launch() { # Start processes in the background for this session (no autostart) log "Starting PyOps API in background (fallback)…" nohup "$PY_BIN" -m uvicorn app:app --app-dir "$PYOPS_DIR" --host "$API_HOST" --port "$API_PORT" \ >"$PYOPS_DIR/pyops.log" 2>&1 & disown || warn "Failed to start PyOps in fallback mode." if have code-server; then log "Starting code-server in background (fallback)…" nohup /usr/bin/code-server --bind-addr "$CODE_HOST:$CODE_PORT" --auth none --disable-telemetry "$USER_HOME" \ >"$USER_HOME/.code-server.log" 2>&1 & disown || warn "Failed to start code-server in fallback mode." else warn "code-server not installed; skipping fallback start." fi warn "Autostart is not configured without systemd. On WSL, enable systemd in /etc/wsl.conf and restart the distro." } ### ---- Health checks --------------------------------------------------------- wait_for_api() { log "Checking API health…" local tries=40 local url="http://${API_HOST}:${API_PORT}/api/health" until curl -fsS "$url" >/dev/null 2>&1; do ((tries--)) || { warn "PyOps API not responding yet. Check logs."; return 1; } sleep 0.5 done curl -fsS "$url" && echo return 0 } ### ---- Main ------------------------------------------------------------------ main() { install_apt install_code_server write_app_py ensure_venv install_pydeps # Permissions are important if script was run with sudo chown -R "$TARGET_USER":"$TARGET_USER" "$USER_HOME/.venvs" "$PYOPS_DIR" 2>/dev/null || true write_units enable_services wait_for_api || { warn "PyOps API not ready. Tail logs with:" echo " sudo journalctl -u pyops@${TARGET_USER} -f" } log "code-server should be at http://${CODE_HOST}:${CODE_PORT}/ (auth disabled, loopback-only)." log "Done. Defaults match your DevDock extension:" printf " API: http://%s:%s\n" "$API_HOST" "$API_PORT" printf " Code: http://%s:%s\n\n" "$CODE_HOST" "$CODE_PORT" cat <