Files
space 8b53698f29
Deploy / Build (pull_request) Successful in 40s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
Really huge mass update; Getting everything up-to-spec and implementing a wide range of features
2026-07-26 14:24:18 +02:00

17 KiB

CLAUDE.md

Guidance for AI agents (and humans) working in this repository. Read this before making changes.

This file is mirrored 1:1 to AGENTS.md. If you edit one, make the identical edit to the other. They must stay byte-for-byte identical.


What this project is

PR Previews (PP) is a self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP:

  1. Provisions an AWS EC2 instance in the user's own AWS account,
  2. Clones the PR branch, installs deps, builds, and runs the app over SSH,
  3. Posts a live preview URL (http://<ec2-ip>:<port>) back onto the PR as an in-place-edited comment.

It is built for teams and agent-driven workflows where reviewers want to see a running PR without pulling code locally. Gitea only — no GitHub/GitLab. One preview per PR; the EC2 instance is reused across pushes.

The canonical product/behavior spec is SPEC.md. When behavior is ambiguous, SPEC.md wins — this file describes how the code is built, SPEC.md describes what it must do.


Repository layout

This is a pnpm workspace monorepo (pnpm-workspace.yamlbackend, frontend).

./backend/            # Node.js API + workers (rjweb-server + Prisma). Compiled with esbuild.
./frontend/           # React 19 + Vite + Tailwind SPA.
./prisma/             # Prisma schema + migrations — LIVES AT REPO ROOT, not in backend/.
  schema.prisma
  migrations/
./Dockerfile          # Multi-stage build for the whole app (frontend + backend + prisma).
./docker-compose.yml  # Runs PP itself: pp-backend + pp-db (Postgres). NOT the preview EC2s.
./SPEC.md             # Product/behavior specification (source of truth for behavior).
./example.env         # Env var template.
./TODO.md             # Known follow-ups / rough edges (untracked scratch list).

Backend source map (backend/src/)

index.ts                     # Server bootstrap: builds rjweb Server, registers ALL routes, starts workers.
lib/
  env.ts                     # Zod-validated process.env. Loads backend/.env. Throws on invalid env at boot.
  db.ts                      # Prisma client singleton (`prisma`).
  encryption.ts              # AES-256-GCM encrypt()/decrypt() using ENCRYPTION_KEY.
  logger.ts                  # pino logger + createLogger("COMPONENT") child factory.
  errors.ts                  # ERROR_MESSAGES map (code + message constants).
  response.ts                # makeResponse()/endResponse() — the standard JSON envelope.
  adminSettings.ts           # getAdminSettings() — reads/creates the single AdminSettings row (id=1).
  middlewares/
    cors.ts                  # CORS (allows PP_BASE_URL origin; wide-open in development).
    main.ts                  # Handles OPTIONS preflight.
    auth.ts                  # authResolution (adds ctr.getAuth()) + authEnforcement middleware.
routes/
  auth.ts                    # login/logout/me/setup-status/first-user.
  webhook.ts                 # POST /webhook/{userId} — HMAC verify, enqueue jobs. The Gitea entrypoint.
  api/user.ts                # User settings: gitea creds, aws creds, webhook secret.
  api/repos.ts               # Repo discovery + enable/disable (registers/deletes Gitea webhooks).
  api/previews.ts            # List/get/stop previews + WebSocket log stream.
  api/admin.ts               # Admin: user CRUD, global settings, all-previews dashboard.
services/
  ec2.ts                     # AWS EC2/STS: key pairs, security groups, launch, terminate, AMI map, bootstrap script.
  ssh.ts                     # ssh2 wrapper: connectSsh(), exec with timeout, abort support.
  gitea.ts                   # Gitea REST client (axios): repos, webhooks, comments, permissions, PR comment body builder.
  deploy.ts                  # THE deploy engine: firstDeploy/redeploy/stopPreview, log streaming, abort signals.
  orphanCleanup.ts           # On boot: terminate EC2s tagged pp:managed that have no active Preview.
workers/
  jobWorker.ts               # Polls Job table, serializes per-preview, new DEPLOY cancels running DEPLOY.
  cronWorker.ts              # node-cron: inactivity check (30m) + daily cleanup (03:00).
types/node-cron.d.ts         # Ambient types.

Frontend source map (frontend/src/)

main.tsx / App.tsx           # Router (react-router-dom v7). ProtectedRoute + SetupCheck gating.
services/api.ts              # Typed fetch wrapper (credentials: include) + openLogsWs() WebSocket helper.
hooks/useAuth.ts             # Auth context/provider (calls /api/auth/me).
hooks/useTheme.ts            # Dark/light theme, persisted to localStorage.
pages/                       # Login, Dashboard, PreviewDetail, Repos, Settings, Admin, SetupWizard, Privacy.
components/                  # Layout, LogViewer, StatusBadge, ConfirmDialog.

Tech stack & versions

Layer Choice Notes
Runtime Node 24 (alpine in Docker) packageManager pins pnpm 11.5.2 (hash-verified via corepack).
Package manager pnpm workspaces Do not use npm/yarn. A stray package-lock.json exists but pnpm is authoritative.
Backend HTTP rjweb-server ^9.8.6 + @rjweb/runtime-node v9 API — see gotchas below.
Backend build esbuild → CJS to dist/ Not tsc for runtime; tsc is only used by the frontend build.
ORM / DB Prisma ^6 + PostgreSQL 18 Schema at ./prisma/schema.prisma (repo root).
Auth hashing bcryptjs NOT native bcrypt (Node 24 compat).
AWS @aws-sdk/client-ec2, @aws-sdk/client-sts v3
SSH ssh2 Connects to EC2 as user ubuntu.
HTTP client axios Gitea REST calls.
Validation zod Env schema; ad-hoc input checks in handlers.
Logging pino (+ pino-pretty in dev)
Scheduling node-cron
Frontend React 19, Vite 6, Tailwind 3, react-router-dom 7, motion, react-toastify

Commands

Run backend/frontend commands from their own package dir (cd backend / cd frontend). This is Windows/PowerShell — chain with ; (not &&) if needed, or just run the dedicated tool.

Local development

# 1. Start Postgres (compose, just the db)
docker compose up -d pp-db

# 2. Apply migrations (schema path is RELATIVE and points OUTSIDE backend/)
cd backend; npx prisma migrate dev --schema=../prisma/schema.prisma
#   or: pnpm --filter pp-backend migrate

# 3. Backend (esbuild → dist → node), serves API on :5000
cd backend; pnpm install; pnpm dev

# 4. Frontend dev server on :3000, proxies /api and /webhook → :5000
cd frontend; pnpm install; pnpm dev

Build / production

cd backend; pnpm build      # esbuild src → dist
cd backend; pnpm start      # node dist/index.js  (cwd must be dist)
cd backend; pnpm prod       # build + start
cd frontend; pnpm build     # tsc typecheck + vite build → frontend/dist

# Whole stack (build image, run backend + db, auto-runs `prisma migrate deploy` on start)
docker compose up -d

Prisma

cd backend; pnpm generate         # prisma generate --schema=../prisma/schema.prisma
cd backend; pnpm migrate          # migrate dev
cd backend; pnpm migrate:deploy   # migrate deploy (used by the Docker CMD)

⚠️ Every Prisma command needs --schema=../prisma/schema.prisma because the schema is at the repo root, not in backend/. The package.json scripts already include it — prefer them.

There are no automated tests (V1 decision). Verify changes by running the stack. Do not add a test runner unless asked.


Environment variables

Validated at boot by backend/src/lib/env.ts (zod). Backend loads backend/.env. Missing/invalid vars throw and prevent startup — this is intentional.

Var Required Purpose
DATABASE_URL yes Postgres connection string.
SESSION_SECRET yes (min 16) Session cookie value signing.
PP_BASE_URL yes Public URL of this PP instance. Used in webhook URLs, PR comment links, CORS allow-list. No trailing slash.
ENCRYPTION_KEY yes Exactly 64 hex chars (32-byte AES-256). Encrypts Gitea PAT, AWS creds, SSH keys at rest. Changing it invalidates all stored secrets.
PORT no (default 5000) Backend port.
LOG_LEVEL no (default info) trace/debug/info/warn/error.
NODE_ENV no (default development) development/production/test.

Generate secrets: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".


How the system works (data + control flow)

Request → deploy pipeline

  1. Gitea fires a webhook → POST /webhook/{userId} (routes/webhook.ts).
  2. Handler: rate-limits per user (in-memory), HMAC-SHA256 verifies the raw body against the user's WebhookToken.token, returns 200 OK immediately, then processes async via setImmediate.
  3. It creates/updates a Preview row and enqueues a Job (DEPLOY/STOP) in Postgres. Webhooks never do slow work inline.
  4. jobWorker.ts polls the Job table (~1s), enforces one active job per previewId, and — key rule — a new DEPLOY aborts a currently-running DEPLOY for the same preview (via signalAbort → SSH session .abort()).
  5. deploy.ts does the real work over SSH: firstDeploy (provision EC2, clone, setup, build, run) or redeploy (reuse instance, pull, rebuild, restart). Status transitions: PROVISIONING → BUILDING → RUNNING (or FAILED).
  6. Throughout, log output is appended to Preview.logs (capped at AdminSettings.logSizeLimitBytes, oldest lines truncated) and broadcast over WebSocket to PreviewDetail via subscribeToLogs.
  7. PR comment is edited in place through gitea.ts using Preview.giteaCommentId.

Background loops (cronWorker.ts)

  • Every 30 min: any RUNNING preview idle past RepoConfig.inactivityHours gets an INACTIVITY_STOP job (deduped).
  • Daily 03:00: purge STOPPED/FAILED previews older than AdminSettings.previewRetentionDays (+ their jobs).

On boot (index.ts.start() callback)

prisma.$connect() → seed AdminSettingsstartJobWorker() (resets stuck RUNNING jobs → PENDING) → startCronWorkers()runOrphanCleanup() (terminate pp:managed EC2s with no active Preview).

EC2 lifecycle (ec2.ts + deploy.ts)

  • Per preview: ephemeral RSA key pair (CreateKeyPair, name pp-preview-<id>) → security group pp-preview-<id> (opens 22 + app port) → launch Ubuntu 22.04 (per-region AMI map) with a UserData bootstrap script → poll for running + public IP.
  • Bootstrap installs only PP's base SSH/deploy dependencies. Repo-selected preinstall options add Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages over SSH.
  • Every instance is tagged pp:managed, pp:userId, pp:repo, pp:prNumber, pp:previewId.
  • On stop: terminate instance → delete key pair → delete SG (after ~30s delay for ENI detach) → null out sshPrivateKey/sshKeyName/instanceId.

PP commands (in PR comments)

Parsed in webhook.ts (handleIssueCommentEvent). A comment is a command if its first line starts with /pp . Only the PR author or a repo owner/admin may run them; PP ignores its own comments. Commands: /pp rebuild, /pp stop, /pp start, /pp logs, /pp ignore.


Conventions (match these)

Backend HTTP handlers

  • Handlers take a single ctr: any (rjweb context) and are registered in index.ts — every route is declared there, grouped by area. There is no file-based routing.
  • Return responses via makeResponse({ ctr, content: { code, message?, data? } }) (lib/response.ts). The envelope is:
    • success: { status: "OK", message?, data? }
    • failure (code ≥ 400): { status: "FAILED", message }
    • 5xx messages are replaced with a generic string automatically.
  • Auth inside a handler: const auth = ctr.getAuth?.(); then check auth?.success and use auth.user. Several route files define a local requireAuth(ctr) helper — reuse that pattern.
  • Read JSON body with await ctr.body(); raw body (webhook HMAC) with await ctr.$body().text().
  • Route/path params: ctr.params.get("name"). Query/headers/cookies: ctr.headers.get(...), ctr.cookies.get(...).

rjweb-server v9 gotchas (important)

  • path.http(METHOD, fullPath, ...) takes the FULL path, not one relative to the .path("/") prefix. All routes are registered under .path("/") with absolute paths like /api/repos/{owner}/{repo}/config.
  • URL params use {braces}, e.g. /webhook/{userId}, /api/previews/{id} — not :colon.
  • Static UI is registered LAST, after all API routes, and notFound hand-serves index.html for non-API/non-asset paths (SPA fallback).
  • Middleware order in the Server constructor matters: cors → main (OPTIONS) → authResolution → authEnforcement.

Secrets & security

  • Anything sensitive is encrypted at rest: Gitea PAT, AWS access key + secret, SSH private keys go through encrypt() before DB writes and decrypt() on read. Never store them in plaintext.
  • Never log or echo secrets. deploy.ts deliberately masks the PAT out of git output and passes it via git -c http.extraHeader rather than in the clone URL. Preserve this.
  • API responses never return raw secrets — they return "****", booleans like giteaPatSet, or omit the field (see auth.ts meHandler, and repoConfig responses strip giteaWebhookId).
  • Webhook signatures are compared with a constant-time equal. Keep it constant-time.

Database

  • Single Prisma client from lib/db.ts — import { prisma }, don't new PrismaClient().
  • AdminSettings is a singleton row id = 1 — always go through getAdminSettings() (creates it if missing).
  • Enums live in the schema: PreviewStatus, JobType, JobStatus. Reuse them; don't invent string statuses.

Logging

  • import { createLogger } from "../lib/logger" and const log = createLogger("COMPONENT"). pino style: log.info({ structuredFields }, "message").

Frontend

  • All API calls go through the api object in services/api.ts (adds credentials: "include"). Add new endpoints there rather than calling fetch directly in components.
  • Live logs use openLogsWs(previewId, onMessage) (auto-picks ws/wss).
  • Toasts via react-toastify; confirmations via components/ConfirmDialog. Theme via useTheme (localStorage).
  • Setup gating: ProtectedRoute (requires auth) wraps SetupCheck (redirects to /setup until user.setupComplete).

Style

  • TypeScript, 2-space indent, double quotes, semicolons — match the surrounding file. Handlers are terminal (return makeResponse(...)), services throw and let the deploy engine/worker catch.

Known sharp edges / decisions already made

Don't "fix" these without a reason — they were deliberate (see git history / project memory):

  • pnpm, not npm. Ignore package-lock.json.
  • bcryptjs, not bcrypt (Node 24).
  • CreateKeyPair, not ImportKeyPair — avoids OpenSSH wire-format encoding issues; AWS returns the PEM we store encrypted.
  • SSH as ubuntu, not root. Selected preinstall tools are installed from the deploy flow; Node commands are wrapped with NVM_PREFIX only when Node is selected. Recent commits specifically moved off root.
  • Prisma schema is at repo root (./prisma/schema.prisma), so every command needs --schema=../prisma/schema.prisma.
  • rjweb full paths in path.http() (see above).
  • Docker uses corepack + frozen lockfile with the pinned pnpm to keep supply-chain policy deterministic; workspace-level install so pnpm-workspace.yaml settings apply.
  • Webhook returns 200 before processing — all deploy work is async through the Job queue; keep it that way.

Current work

Active branch: v2/full-spec-implementation (recent commits are fix(v2): ... hardening the EC2 bootstrap/SSH/deploy path). main is the stable branch and the usual PR base. TODO.md tracks UI/UX polish items still open (theming, repo search/filtering, config-as-its-own-page, favicon/SEO, tag-input bugs).

Gitea webhooks are registered for pull_request, pull_request_sync (pushes to a PR branch — a separate Gitea event; without it previews never redeploy on push), and issue_comment, and are named PR Previews. On boot, services/webhookReconcile.ts backfills these onto existing webhooks. Any PATCH to a Gitea webhook MUST re-send the full events array — Gitea resets an omitted/empty events to push-only. Also: Gitea's PR-sync action string is synchronized (past tense), not GitHub's synchronizewebhook.ts accepts both.

Before committing: only commit when asked; branch off main if you're on it; end commit messages with the required Co-Authored-By trailer.


Where to look first

  • Changing deploy/build/run behavior on the EC2services/deploy.ts (+ services/ssh.ts, services/ec2.ts).
  • Webhook / PP command handlingroutes/webhook.ts.
  • Job scheduling / cancellationworkers/jobWorker.ts.
  • Gitea API calls / PR comment formatservices/gitea.ts.
  • Adding an API endpoint → write the handler in routes/…, then register it in index.ts.
  • Data model changeprisma/schema.prisma, then pnpm --filter pp-backend migrate.
  • What the product is supposed to doSPEC.md.