274 lines
17 KiB
Markdown
274 lines
17 KiB
Markdown
# 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`](./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
|
|
```bash
|
|
# 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
|
|
```bash
|
|
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
|
|
```bash
|
|
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 `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`, 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 `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 in `index.ts`.
|
|
- **Data model change** → `prisma/schema.prisma`, then `pnpm --filter pp-backend migrate`.
|
|
- **What the product is supposed to do** → `SPEC.md`.
|