diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f3fede8e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Build context trimming for apps/node/Dockerfile. +# node_modules and dist are rebuilt inside the image; secrets must never enter it. + +node_modules +**/node_modules +dist +**/dist +.wrangler +**/.wrangler + +.git +.github +.changeset + +.dev.vars +.env +.env.* +!.env.docker.example + +*.log +npm-debug.log* +coverage +.vscode +.idea +.DS_Store + +test diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 00000000..3e1a3c0f --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,48 @@ +# Codra self-hosted (Docker) environment example. +# Copy to .env before running `docker compose up -d`: +# +# cp .env.docker.example .env +# +# Keep real secrets in .env only. It is gitignored and excluded from the image. + +# --- Database and queue --- +# These configure the bundled postgres service. Change POSTGRES_PASSWORD before +# deploying anywhere real; DATABASE_URL is built from these three values, so you +# do not have to keep a connection string in sync by hand. +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" +POSTGRES_DB="codra" + +# Set these two only to point at a database or Redis you host elsewhere. +# Left unset, they resolve to the bundled postgres and redis services. +# DATABASE_URL="postgres://user:password@host:5432/codra" +# REDIS_URL="redis://host:6379" + +# --- Application URLs --- +# Set both to your public URL when deploying behind a domain. +APP_URL="http://localhost:3000" +AUTH_CALLBACK_URL="http://localhost:3000/auth/github/callback" +PORT="3000" +ENVIRONMENT="production" + +# --- GitHub App (webhooks, checks, reviews) --- +GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID" +GITHUB_APP_SLUG="REPLACE_WITH_YOUR_APP_SLUG" +GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET" +APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" + +# --- GitHub OAuth (dashboard sign-in) --- +GITHUB_CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID" +GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" + +# Comma-separated GitHub usernames allowed into the dashboard. +# Set this to your own username, or nobody on your install can sign in. +DASHBOARD_ALLOWED_USERS="REPLACE_WITH_YOUR_GITHUB_USERNAME" + +# The bot account name Codra posts reviews as (usually "[bot]"). +BOT_USERNAME="REPLACE_WITH_YOUR_BOT_USERNAME" + +# --- LLM provider config encryption --- +# Encrypts dashboard-managed provider API keys at rest. Generate with: +# openssl rand -base64 48 +LLM_CONFIG_ENCRYPTION_KEY="REPLACE_WITH_A_LONG_RANDOM_ENCRYPTION_KEY" diff --git a/.gitignore b/.gitignore index e22ce89b..636bc968 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ web_modules/ .env.* !.env.example !.env.test.example +!.env.docker.example # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/apps/node/Dockerfile b/apps/node/Dockerfile new file mode 100644 index 00000000..46ab3937 --- /dev/null +++ b/apps/node/Dockerfile @@ -0,0 +1,85 @@ +# syntax=docker/dockerfile:1 + +# Production image for the Node deployment target (@codraoss/node-server). +# Build context is the repository root: docker build -f apps/node/Dockerfile . + +# ── Stage 1: workspace install ──────────────────────────────────────────────── +# Manifests are copied before the sources so npm ci stays cached until a +# dependency actually changes. +FROM node:22-alpine AS deps +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/api/package.json packages/api/ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/models/package.json packages/models/ +COPY packages/provider-github/package.json packages/provider-github/ +COPY packages/schema/package.json packages/schema/ +COPY packages/ui/package.json packages/ui/ +COPY apps/node/package.json apps/node/ +COPY apps/worker/package.json apps/worker/ + +RUN npm ci + +# ── Stage 2: build ──────────────────────────────────────────────────────────── +# Two artifacts: the dashboard bundle at dist/client (served by the Node app) +# and the server bundle at apps/node/dist. +# +# `npx vite build` rather than `npm run build`: the root build script chains +# cf-typegen, which shells out to wrangler and has no place in this image. +FROM deps AS builder +WORKDIR /app + +COPY . . + +RUN npx vite build +RUN npm run build --workspace=@codraoss/node-server + +# ── Stage 3: runtime dependencies ───────────────────────────────────────────── +# tsup bundles every @codraoss/* package into the server bundle, so only the +# node-server workspace's own runtime deps are installed here. +FROM node:22-alpine AS prod-deps +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/api/package.json packages/api/ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/models/package.json packages/models/ +COPY packages/provider-github/package.json packages/provider-github/ +COPY packages/schema/package.json packages/schema/ +COPY packages/ui/package.json packages/ui/ +COPY apps/node/package.json apps/node/ +COPY apps/worker/package.json apps/worker/ + +RUN npm ci --omit=dev --workspace=@codraoss/node-server + +# ── Stage 4: runner ─────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# tsup emits ESM. Node 22 only runs it unflagged because of module syntax +# auto-detection; this marks the bundle explicitly so an older base image or a +# stricter loader cannot turn startup into "Cannot use import statement outside +# a module". +RUN printf '{"type":"module"}\n' > package.json + +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=builder /app/apps/node/dist ./dist +COPY --from=builder /app/dist/client ./dist/client + +# The migrate service in docker-compose.yml runs from this same image; the +# migration runner is a plain script and is not part of the server bundle. +COPY --from=builder /app/packages/db/package.json ./packages/db/package.json +COPY --from=builder /app/packages/db/scripts ./packages/db/scripts +COPY --from=builder /app/packages/db/migrations ./packages/db/migrations + +USER node + +EXPOSE 3000 + +CMD ["node", "dist/index.js"] diff --git a/apps/node/README.md b/apps/node/README.md new file mode 100644 index 00000000..629683d4 --- /dev/null +++ b/apps/node/README.md @@ -0,0 +1,94 @@ +# @codraoss/node-server + +The Node.js deployment target for Codra. Runs the same API, dashboard, and review +engine as the Cloudflare Worker, backed by Postgres and Redis instead of +Hyperdrive, KV, and Cloudflare Queues. + +Use this when you want Codra on a VM, a VPS, or any platform that runs Docker +(Coolify, Railway, Render, Fly.io). + +## Run the stack with Docker + +From the repository root: + +```bash +cp .env.docker.example .env +# fill in the GitHub App and OAuth values, then: +docker compose up -d +``` + +The dashboard is at . + +`docker compose up` starts four things: + +| Service | What it does | +| ----------- | --------------------------------------------------------- | +| `postgres` | Database, on a named volume so data survives restarts | +| `redis` | Config cache and the review job queue | +| `migrate` | Applies `packages/db/migrations`, then exits | +| `codra-app` | The API, dashboard, and webhook receiver on port 3000 | + +The app waits for Postgres and Redis to report healthy and for `migrate` to +finish, so a first boot lands on a ready schema. + +Postgres and Redis are published on `127.0.0.1` only, so deploying this file to +a VPS does not expose them to the internet. They are still reachable by anything +else on the host, and they ship with development credentials, so change +`POSTGRES_PASSWORD` before you deploy and drop the `ports:` entries from +`docker-compose.yml` if you do not need to reach them from the host; the +services find each other over the compose network either way. + +Useful commands: + +```bash +docker compose logs -f codra-app # follow the app logs +docker compose ps # health of each service +docker compose down # stop (volumes are kept) +docker compose down -v # stop and delete the database +``` + +## Configuration + +Every value lives in `.env`; see `.env.docker.example` for the full list with +comments. The ones you must set before the app will boot: + +| Variable | Notes | +| --------------------------- | -------------------------------------------------- | +| `GITHUB_APP_ID` | From your GitHub App settings page | +| `GITHUB_APP_WEBHOOK_SECRET` | The webhook secret on that same page | +| `APP_PRIVATE_KEY` | The App private key, newlines written as `\n` | +| `GITHUB_CLIENT_ID` | OAuth client, for dashboard sign-in | +| `GITHUB_CLIENT_SECRET` | OAuth client secret | +| `DASHBOARD_ALLOWED_USERS` | Your GitHub username; nobody else can sign in | +| `LLM_CONFIG_ENCRYPTION_KEY` | `openssl rand -base64 48`, encrypts provider keys | +| `APP_URL` | Public URL; set with `AUTH_CALLBACK_URL` for a domain | + +`DATABASE_URL` and `REDIS_URL` already point at the bundled services. Point them +somewhere else to use a managed database or a hosted Redis. + +LLM provider API keys are not environment variables: add them from the dashboard +Settings page once you can sign in. + +## Running without Docker + +```bash +npm ci +npx vite build # builds the dashboard into dist/client +npm run dev --workspace=@codraoss/node-server +``` + +The server reads `.dev.vars` from the repository root, and expects Postgres and +Redis to be reachable at `DATABASE_URL` and `REDIS_URL`. + +## Current limitations + +Dashboard sessions are held in memory, not in Redis, so restarting or +redeploying the container signs every dashboard user out. They sign back in +through GitHub; nothing else is lost. + +Queued reviews are not processed yet. The webhook receiver, dashboard, and queue +producer all work, but the review runtime for Node is still being built +(issues [#106](https://github.com/devarshishimpi/codra/issues/106) and +[#107](https://github.com/devarshishimpi/codra/issues/107)), so jobs land in +Redis and wait there. Cloudflare Workers remains the deployment target that +completes reviews today. diff --git a/apps/node/src/index.ts b/apps/node/src/index.ts index c1dc488f..7bbfe288 100644 --- a/apps/node/src/index.ts +++ b/apps/node/src/index.ts @@ -58,9 +58,14 @@ app.onError((err, c) => { console.error('HONO ERROR:', err); return c.text('Custom Error: ' + err.message, 500); }); -app.use('/assets/*', serveStatic({ root: process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client' })); -app.use('/*.svg', serveStatic({ root: process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client' })); -app.use('/*.ico', serveStatic({ root: process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client' })); +const clientRoot = process.cwd().endsWith('node') ? '../../dist/client' : 'dist/client'; + +app.use('/assets/*', serveStatic({ root: clientRoot })); +// Hono has no suffix matching, so '/*.svg' and '/*.ico' never matched: the root-level +// icons vite copies out of public/ are mounted by their own paths instead. +app.use('/icons/*', serveStatic({ root: clientRoot })); +app.use('/favicon.ico', serveStatic({ root: clientRoot })); +app.use('/favicon.svg', serveStatic({ root: clientRoot })); const port = parseInt(process.env.PORT || '3000', 10); serve({ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4893e87a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +# Full Codra stack for self-hosting: Postgres, Redis, and the Node server. +# +# cp .env.docker.example .env +# docker compose up -d +# +# Postgres and Redis are published on the loopback interface only, so a VPS +# running this file does not expose its database to the internet. + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-codra} + ports: + - '127.0.0.1:5432:5432' + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-codra}'] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ['redis-server', '--appendonly', 'yes'] + ports: + - '127.0.0.1:6379:6379' + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 10 + + # Applies packages/db/migrations against an empty database and exits. The app + # waits on it, so a fresh `docker compose up` boots against a ready schema. + migrate: + build: + context: . + dockerfile: apps/node/Dockerfile + image: codra-node:latest + restart: 'no' + command: ['node', 'packages/db/scripts/migrate.mjs'] + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-codra}} + depends_on: + postgres: + condition: service_healthy + + codra-app: + build: + context: . + dockerfile: apps/node/Dockerfile + image: codra-node:latest + restart: unless-stopped + env_file: + - .env + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-codra}} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + PORT: '3000' + ports: + - '${PORT:-3000}:3000' + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + healthcheck: + test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://127.0.0.1:3000/healthz'] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + postgres-data: + redis-data: