panel: Forgejo integration, ssh deploy key and per-app environment variables

Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
  from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
  /api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
  dropdown; private repos are cloned over https with the stored token, or
  over ssh with the deploy key when no token is connected. The app name and
  domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
  the provider ("Forgejo · main").

Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
  ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
  (BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
  kept; git errors now report the meaningful line instead of git's advice.

Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
  interpolation works; by default deploy/restart also generate a compose
  override listing the keys under every service's environment (values are
  read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
  import, hidden values, validation of names (reserved podman/compose vars
  rejected), hints for ${VAR}s the compose file uses but aren't set, and
  Save / Save & deploy. Removing an app deletes its variables.

The API still accepts the old source_type "github" / github_* fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
agent 2026-09-26 22:52:09 +00:00
parent 2d3b30d078
commit db46c5e793
5 changed files with 1258 additions and 100 deletions

View file

@ -4,10 +4,14 @@
import json
import os
import re
import shlex
import shutil
import socket
import subprocess
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -22,12 +26,38 @@ FRONTEND_DIR = os.environ.get(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
)
# Optional Forgejo instance (set from panel.nix). The API URL may be an internal
# address; the public URL is what repositories are cloned from and linked to.
FORGEJO_URL = os.environ.get("PANEL_FORGEJO_URL", "").rstrip("/")
FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).rstrip("/")
FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/")
FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or ""
PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel")
FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token")
SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh")
SSH_KEY = os.path.join(SSH_DIR, "id_ed25519")
ENV_DIR = os.path.join(BASE_DIR, "state", "env")
COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]
GIT_TIMEOUT = 300
# Manifest values are written into a file that panelctl sources with bash, so
# they must not contain anything that is special inside double quotes.
REPO_URL_RE = re.compile(r"^https?://[^\s\"'`$\\]+$")
_URL_CHARS = r"[^\s\"'`$\\]"
REPO_URL_RE = re.compile(
rf"^(?:https?://{_URL_CHARS}+" # https://host/owner/repo.git
rf"|ssh://{_URL_CHARS}+" # ssh://git@host:port/owner/repo.git
rf"|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:{_URL_CHARS}+)$" # git@host:owner/repo.git
)
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$")
FORGEJO_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Variables are set on the compose process itself, so anything that changes how
# podman/compose run (or where they look for state) is off limits.
RESERVED_ENV = {"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"}
RESERVED_ENV_PREFIXES = ("XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_",
"PODMAN_", "BUILDAH_", "LD_", "BASH_")
def is_safe_name(name):
@ -67,8 +97,9 @@ def busy_snapshot():
def redact_credentials(text, replacement="***@"):
"""Hide user:token@ credentials embedded in URLs."""
return re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@", r"\1" + replacement, text or "")
"""Hide user:token@ credentials embedded in http(s) URLs.
(ssh://git@host is a username, not a secret, and must be kept.)"""
return re.sub(r"(https?://)[^/@\s]+@", r"\1" + replacement, text or "")
def last_line(text):
@ -76,17 +107,74 @@ def last_line(text):
return lines[-1] if lines else ""
def git_error(stderr):
"""The informative line of a git failure (git ends with generic advice)."""
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
for line in lines:
if re.match(r"^(ssh|fatal|error|remote):", line, re.I) and "could not read from remote" not in line.lower():
return re.sub(r"^fatal:\s*", "", line)
return last_line(stderr)
def write_private_file(path, content):
"""Atomically write a file only the service user can read."""
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
tmp = f"{path}.tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
os.replace(tmp, path)
# ── SSH deploy key ──
# One key pair for the panel; add its public half as a (read-only) deploy key
# to repositories cloned over SSH.
def ssh_public_key(create=True):
pub = SSH_KEY + ".pub"
if not os.path.isfile(pub) and create:
keygen = shutil.which("ssh-keygen")
if not keygen:
return None
os.makedirs(SSH_DIR, mode=0o700, exist_ok=True)
subprocess.run(
[keygen, "-t", "ed25519", "-N", "", "-q", "-C", f"panel@{socket.gethostname()}", "-f", SSH_KEY],
capture_output=True, check=False, timeout=30,
)
try:
with open(pub, "r", encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return None
def is_ssh_url(url):
return not re.match(r"^https?://", url or "")
# ── Git helpers ──
def git_env():
# Never block on an interactive credential prompt.
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
if os.path.isfile(SSH_KEY):
env["GIT_SSH_COMMAND"] = " ".join([
"ssh", "-i", shlex.quote(SSH_KEY),
"-o", "IdentitiesOnly=yes",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=" + shlex.quote(os.path.join(SSH_DIR, "known_hosts")),
])
return env
def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
git_bin = shutil.which("git")
if not git_bin:
return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"}
cmd = [git_bin] + (["-C", cwd] if cwd else []) + args
# Never block on an interactive credential prompt.
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
proc = subprocess.run(cmd, capture_output=True, text=True, env=git_env(), timeout=timeout)
except subprocess.TimeoutExpired:
return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"}
return {
@ -98,14 +186,145 @@ def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
def clone_repo(url, branch, target_dir, token=""):
auth_url = url
if token:
if token and not is_ssh_url(url):
auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1)
elif is_ssh_url(url):
ssh_public_key() # make sure the deploy key exists before the first clone
args = ["clone"]
if branch:
args += ["--branch", branch]
return run_git(args + ["--", auth_url, target_dir])
def repo_host_and_path(url):
url = redact_credentials(url or "", "")
m = (re.match(r"^https?://([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
or re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
or re.match(r"^(?:[^@/]+@)?([^/:]+):(?!/)(.+?)(?:\.git)?/?$", url))
return (m.group(1), m.group(2)) if m else (None, None)
def repo_provider(url):
host, _ = repo_host_and_path(url)
if host and FORGEJO_HOST and host == FORGEJO_HOST:
return "forgejo"
if host in ("github.com", "www.github.com"):
return "github"
return "git"
def repo_web_url(url):
"""Browser URL of a repository, for commit / compare links."""
host, path = repo_host_and_path(url)
if not host:
return ""
if FORGEJO_HOST and host == FORGEJO_HOST:
return f"{FORGEJO_URL}/{path}"
m = re.match(r"^(https?)://", url or "")
return f"{m.group(1) if m else 'https'}://{host}/{path}"
# ── Forgejo ──
class ForgejoError(Exception):
pass
def forgejo_token():
try:
with open(FORGEJO_TOKEN_FILE, "r", encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return ""
def forgejo_api(path, token=None, timeout=10):
if not FORGEJO_API_URL:
raise ForgejoError("no Forgejo instance is configured")
token = forgejo_token() if token is None else token
req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"})
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
return json.loads(res.read().decode("utf-8") or "null")
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
raise ForgejoError("Forgejo rejected the token") from exc
if exc.code == 404:
raise ForgejoError("not found on Forgejo (or no access)") from exc
raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
raise ForgejoError(f"can't reach Forgejo: {getattr(exc, 'reason', exc)}") from exc
def is_forgejo_https_url(url):
return bool(FORGEJO_URL) and not is_ssh_url(url) and repo_provider(url) == "forgejo"
# ── Environment variables ──
# Stored per app in state/env/<app>.env as KEY=VALUE lines (0600). panelctl
# passes them to every compose command, so they work for ${VAR} interpolation
# and — unless disabled — are injected into every service.
def env_file_path(name):
return os.path.join(ENV_DIR, f"{name}.env")
def read_app_env(name):
items = []
try:
with open(env_file_path(name), "r", encoding="utf-8") as fh:
lines = fh.read().splitlines()
except OSError:
return items
for line in lines:
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
items.append({"key": key, "value": value})
return items
def validate_env(items):
if items is None:
return []
if not isinstance(items, list):
raise ValueError("env must be a list of {key, value}")
seen = set()
out = []
for item in items:
if not isinstance(item, dict):
raise ValueError("env must be a list of {key, value}")
key = str(item.get("key", "")).strip()
value = item.get("value", "")
value = "" if value is None else str(value)
if not key and not value:
continue
if not ENV_KEY_RE.match(key):
raise ValueError(f"'{key}' is not a valid variable name (letters, digits and _, not starting with a digit)")
if key in RESERVED_ENV or key.startswith(RESERVED_ENV_PREFIXES):
raise ValueError(f"'{key}' is reserved because it would change how podman/compose run")
if key in seen:
raise ValueError(f"'{key}' is set more than once")
if any(c in value for c in "\n\r\0"):
raise ValueError(f"the value of '{key}' must be a single line")
seen.add(key)
out.append({"key": key, "value": value})
return out
def write_app_env(name, items):
path = env_file_path(name)
if not items:
try:
os.remove(path)
except FileNotFoundError:
pass
return
write_private_file(path, "".join(f"{i['key']}={i['value']}\n" for i in items))
def repo_commit(repo_dir, ref="HEAD"):
result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15)
if not result["ok"] or not result["stdout"]:
@ -200,13 +419,18 @@ def load_app_summaries():
env = parse_env_blob(fh.read())
except OSError:
continue
repo_url = redact_credentials(env.get("APP_REPO_URL", ""), "")
apps.append({
"name": name,
"routes": manifest_routes(env),
"auth": env.get("APP_AUTH_PROTECTED", "true") == "true",
"compose_file": env.get("APP_COMPOSE_FILE", ""),
"repo_url": redact_credentials(env.get("APP_REPO_URL", ""), ""),
"repo_url": repo_url,
"repo_branch": env.get("APP_REPO_BRANCH", ""),
"repo_provider": repo_provider(repo_url) if repo_url else "",
"repo_web_url": repo_web_url(repo_url) if repo_url else "",
"env_count": len(read_app_env(name)),
"env_inject": env.get("APP_ENV_INJECT", "true") != "false",
})
return apps
@ -456,6 +680,76 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "service": "panel-api"})
return
# /integrations — Forgejo connection and the panel's SSH deploy key
if path == "/integrations":
token = forgejo_token()
forgejo = {
"configured": bool(FORGEJO_URL),
"url": FORGEJO_URL,
"ssh_url": FORGEJO_SSH_URL,
"has_token": bool(token),
"user": None,
}
if FORGEJO_URL and token:
try:
forgejo["user"] = (forgejo_api("/api/v1/user", timeout=5) or {}).get("login")
except ForgejoError as exc:
forgejo["error"] = str(exc)
self._json(200, {"ok": True, "forgejo": forgejo, "ssh": {"public_key": ssh_public_key()}})
return
# /forgejo/repos?q= — repositories visible to the stored token (public ones without)
if path == "/forgejo/repos":
q = query.get("q", [""])[0].strip()
try:
data = forgejo_api(f"/api/v1/repos/search?q={quote(q)}&limit=50&sort=updated&order=desc")
except ForgejoError as exc:
self._json(502, {"ok": False, "error": str(exc)})
return
repos = [{
"full_name": r.get("full_name", ""),
"description": r.get("description", ""),
"private": bool(r.get("private")),
"empty": bool(r.get("empty")),
"archived": bool(r.get("archived")),
"default_branch": r.get("default_branch", ""),
"clone_url": r.get("clone_url", ""),
"ssh_url": r.get("ssh_url", ""),
"html_url": r.get("html_url", ""),
"updated_at": r.get("updated_at", ""),
} for r in (data or {}).get("data", [])]
self._json(200, {"ok": True, "repos": repos, "authenticated": bool(forgejo_token())})
return
# /forgejo/branches?repo=owner/name
if path == "/forgejo/branches":
repo = query.get("repo", [""])[0].strip()
if not FORGEJO_REPO_RE.match(repo):
self._json(400, {"ok": False, "error": "repo must look like owner/name"})
return
try:
data = forgejo_api(f"/api/v1/repos/{repo}/branches?limit=100")
except ForgejoError as exc:
self._json(502, {"ok": False, "error": str(exc)})
return
self._json(200, {"ok": True, "branches": [b.get("name", "") for b in (data or [])]})
return
# /apps/<name>/env — environment variables used when deploying
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "env":
name = parts[1]
app, err = read_app_info(name)
if err is not None:
self._json(404, err)
return
self._json(200, {
"ok": True,
"name": name,
"vars": read_app_env(name),
"inject": app.get("APP_ENV_INJECT", "true") != "false",
})
return
# /status — every app with routes, container status and running operation.
# This is what the UI polls, so it is one request regardless of app count.
if path == "/status":
@ -625,9 +919,14 @@ class Handler(BaseHTTPRequestHandler):
"ok": True,
"name": name,
"url": redact_credentials(repo_url, ""),
"web_url": repo_web_url(repo_url),
"provider": repo_provider(repo_url),
"ssh": is_ssh_url(repo_url),
"branch": branch,
"cloned": os.path.isdir(os.path.join(repo_dir, ".git")),
}
if info["ssh"]:
info["public_key"] = ssh_public_key()
if info["cloned"]:
info["commit"] = repo_commit(repo_dir)
status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15)
@ -636,7 +935,7 @@ class Handler(BaseHTTPRequestHandler):
ref = branch or repo_current_branch(repo_dir)
fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir)
if not fetched["ok"]:
info["fetch_error"] = last_line(fetched["stderr"]) or "git fetch failed"
info["fetch_error"] = git_error(fetched["stderr"]) or "git fetch failed"
else:
info["remote"] = repo_commit(repo_dir, "FETCH_HEAD")
count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15)
@ -921,21 +1220,33 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
if source_type not in ["default", "raw", "github"]:
if source_type == "github": # older clients
source_type = "git"
if source_type not in ["default", "raw", "git"]:
self._json(400, {"ok": False, "error": "invalid source_type"})
return
# Validate git parameters before creating anything.
if source_type == "github":
repo_url = str(payload.get("github_url", "")).strip()
branch = str(payload.get("github_branch", "")).strip()
token = str(payload.get("github_pat", "")).strip()
if not REPO_URL_RE.match(repo_url):
self._json(400, {"ok": False, "error": "repository URL must be a plain http(s) URL"})
# Validate everything before creating anything.
try:
env_items = validate_env(payload.get("env"))
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
env_inject = payload.get("env_inject", True) is not False
if source_type == "git":
repo_url = str(payload.get("repo_url") or payload.get("github_url") or "").strip()
branch = str(payload.get("repo_branch") or payload.get("github_branch") or "").strip()
token = str(payload.get("repo_token") or payload.get("github_pat") or "").strip()
if not REPO_URL_RE.match(repo_url) or repo_url.startswith("-"):
self._json(400, {"ok": False, "error": "repository URL must be an https://, ssh:// or git@host:owner/repo URL"})
return
if branch and not BRANCH_RE.match(branch):
self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"})
return
# Clone a Forgejo repository with the panel's stored token.
if not token and payload.get("use_forgejo_token") and is_forgejo_https_url(repo_url):
token = forgejo_token()
try:
result = run_panelctl(["init", name, routes_str, auth])
@ -960,9 +1271,11 @@ class Handler(BaseHTTPRequestHandler):
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
return
elif source_type == "github":
# Any http(s) git host works (GitHub, Forgejo, ...). A token is
# embedded in the clone URL, so later syncs reuse it from .git/config.
summary = "initialized successfully"
if source_type == "git":
# Any git host works (Forgejo, GitHub, ...), over https or ssh.
# An https token is embedded in the clone URL, so later syncs
# reuse it from .git/config; ssh uses the panel's deploy key.
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
@ -972,7 +1285,7 @@ class Handler(BaseHTTPRequestHandler):
run_panelctl(["remove", name])
self._json(400, {
"ok": False,
"error": f"git clone failed: {last_line(cloned['stderr'])}",
"error": f"git clone failed: {git_error(cloned['stderr'])}",
"stderr": cloned["stderr"],
})
return
@ -997,19 +1310,78 @@ class Handler(BaseHTTPRequestHandler):
commit = repo_commit(target_dir)
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
self._json(200, {"ok": True, "code": 0, "stdout": summary})
return
self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
if env_items or not env_inject:
write_app_env(name, env_items)
update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"})
summary += f"\n{len(env_items)} environment variable(s) set"
self._json(200, {"ok": True, "code": 0, "stdout": summary})
except Exception as exc:
run_panelctl(["remove", name])
self._json(500, {"ok": False, "error": f"init failed: {exc}"})
return
# POST /integrations/forgejo {"token": "..."} — verify and store ("" clears it)
if path == "/integrations/forgejo":
if not FORGEJO_URL:
self._json(400, {"ok": False, "error": "no Forgejo instance is configured (PANEL_FORGEJO_URL)"})
return
try:
token = str(self._read_json().get("token", "")).strip()
except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
if not token:
try:
os.remove(FORGEJO_TOKEN_FILE)
except FileNotFoundError:
pass
self._json(200, {"ok": True, "has_token": False})
return
try:
user = (forgejo_api("/api/v1/user", token=token) or {}).get("login")
except ForgejoError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
write_private_file(FORGEJO_TOKEN_FILE, token + "\n")
self._json(200, {"ok": True, "has_token": True, "user": user})
return
if len(parts) >= 3 and parts[0] == "apps":
name = parts[1]
action = parts[2]
# POST /apps/<name>/env — replace environment variables, optionally redeploy
if action == "env":
app, err = read_app_info(name)
if err is not None:
self._json(404, err)
return
try:
payload = self._read_json()
items = validate_env(payload.get("vars", []))
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
inject = payload.get("inject", True) is not False
try:
write_app_env(name, items)
update_manifest(name, {"APP_ENV_INJECT": "true" if inject else "false"})
except (OSError, ValueError) as exc:
self._json(500, {"ok": False, "error": f"failed to save variables: {exc}"})
return
result = {"ok": True, "name": name, "count": len(items),
"stdout": f"saved {len(items)} environment variable(s)"}
if payload.get("deploy"):
deployed = run_panelctl(["deploy", name])
deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]]))
deployed["count"] = len(items)
self._json(200 if deployed["ok"] else 400, deployed)
return
self._json(200, result)
return
# POST /apps/<name>/compose — save compose file
if action == "compose":
app, err = read_app_info(name)
@ -1141,7 +1513,7 @@ class Handler(BaseHTTPRequestHandler):
if not fetched["ok"]:
self._json(400, {
"ok": False,
"error": f"git fetch failed: {last_line(fetched['stderr'])}",
"error": f"git fetch failed: {git_error(fetched['stderr'])}",
"stderr": fetched["stderr"],
})
return
@ -1149,7 +1521,7 @@ class Handler(BaseHTTPRequestHandler):
if not reset["ok"]:
self._json(400, {
"ok": False,
"error": f"git reset failed: {last_line(reset['stderr'])}",
"error": f"git reset failed: {git_error(reset['stderr'])}",
"stderr": reset["stderr"],
})
return
@ -1162,7 +1534,7 @@ class Handler(BaseHTTPRequestHandler):
if not cloned["ok"]:
self._json(400, {
"ok": False,
"error": f"git clone failed: {last_line(cloned['stderr'])}",
"error": f"git clone failed: {git_error(cloned['stderr'])}",
"stderr": cloned["stderr"],
})
return