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:
- Provisions an AWS EC2 instance in the user's own AWS account,
- Clones the PR branch, installs deps, builds, and runs the app over SSH,
- 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.yaml → backend, 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.prismabecause the schema is at the repo root, not inbackend/. 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
- Gitea fires a webhook →
POST /webhook/{userId}(routes/webhook.ts). - Handler: rate-limits per user (in-memory), HMAC-SHA256 verifies the raw body against the user's
WebhookToken.token, returns200 OKimmediately, then processes async viasetImmediate. - It creates/updates a
Previewrow and enqueues aJob(DEPLOY/STOP) in Postgres. Webhooks never do slow work inline. jobWorker.tspolls theJobtable (~1s), enforces one active job perpreviewId, and — key rule — a newDEPLOYaborts a currently-runningDEPLOYfor the same preview (viasignalAbort→ SSH session.abort()).deploy.tsdoes the real work over SSH:firstDeploy(provision EC2, clone, setup, build, run) orredeploy(reuse instance, pull, rebuild, restart). Status transitions:PROVISIONING → BUILDING → RUNNING(orFAILED).- Throughout, log output is appended to
Preview.logs(capped atAdminSettings.logSizeLimitBytes, oldest lines truncated) and broadcast over WebSocket toPreviewDetailviasubscribeToLogs. - PR comment is edited in place through
gitea.tsusingPreview.giteaCommentId.
Background loops (cronWorker.ts)
- Every 30 min: any
RUNNINGpreview idle pastRepoConfig.inactivityHoursgets anINACTIVITY_STOPjob (deduped). - Daily 03:00: purge
STOPPED/FAILEDpreviews older thanAdminSettings.previewRetentionDays(+ their jobs).
On boot (index.ts → .start() callback)
prisma.$connect() → seed AdminSettings → startJobWorker() (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, namepp-preview-<id>) → security grouppp-preview-<id>(opens 22 + app port) → launch Ubuntu 22.04 (per-region AMI map) with aUserDatabootstrap script → poll forrunning+ 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 inindex.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.
- success:
- Auth inside a handler:
const auth = ctr.getAuth?.();then checkauth?.successand useauth.user. Several route files define a localrequireAuth(ctr)helper — reuse that pattern. - Read JSON body with
await ctr.body(); raw body (webhook HMAC) withawait 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
notFoundhand-servesindex.htmlfor non-API/non-asset paths (SPA fallback). - Middleware order in the
Serverconstructor 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 anddecrypt()on read. Never store them in plaintext. - Never log or echo secrets.
deploy.tsdeliberately masks the PAT out of git output and passes it viagit -c http.extraHeaderrather than in the clone URL. Preserve this. - API responses never return raw secrets — they return
"****", booleans likegiteaPatSet, or omit the field (seeauth.tsmeHandler, andrepoConfigresponses stripgiteaWebhookId). - Webhook signatures are compared with a constant-time equal. Keep it constant-time.
Database
- Single Prisma client from
lib/db.ts— import{ prisma }, don'tnew PrismaClient(). AdminSettingsis a singleton rowid = 1— always go throughgetAdminSettings()(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"andconst log = createLogger("COMPONENT"). pino style:log.info({ structuredFields }, "message").
Frontend
- All API calls go through the
apiobject inservices/api.ts(addscredentials: "include"). Add new endpoints there rather than callingfetchdirectly in components. - Live logs use
openLogsWs(previewId, onMessage)(auto-picks ws/wss). - Toasts via
react-toastify; confirmations viacomponents/ConfirmDialog. Theme viauseTheme(localStorage). - Setup gating:
ProtectedRoute(requires auth) wrapsSetupCheck(redirects to/setupuntiluser.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, notImportKeyPair— avoids OpenSSH wire-format encoding issues; AWS returns the PEM we store encrypted.- SSH as
ubuntu, notroot. Selected preinstall tools are installed from the deploy flow; Node commands are wrapped withNVM_PREFIXonly 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.yamlsettings 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 synchronize — webhook.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 EC2 →
services/deploy.ts(+services/ssh.ts,services/ec2.ts). - Webhook / PP command handling →
routes/webhook.ts. - Job scheduling / cancellation →
workers/jobWorker.ts. - Gitea API calls / PR comment format →
services/gitea.ts. - Adding an API endpoint → write the handler in
routes/…, then register it inindex.ts. - Data model change →
prisma/schema.prisma, thenpnpm --filter pp-backend migrate. - What the product is supposed to do →
SPEC.md.