diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 306cc78..a48cee1 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -8,6 +8,9 @@ jobs: build-and-deploy: runs-on: docker steps: + - name: Install tools + run: apt-get update && apt-get install -y docker.io openssh-client + - name: Checkout code uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/apps/api/package.json b/apps/api/package.json index 60fd85a..155b859 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts", + "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/src/server.js", "test": "vitest" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..92135eb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,91 @@ +services: + caddy: + container_name: lila-caddy + image: caddy:2-alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + depends_on: + api: + condition: service_healthy + networks: + - lila-network + + api: + container_name: lila-api + build: + context: . + dockerfile: ./apps/api/Dockerfile + target: runner + env_file: + - .env + restart: unless-stopped + healthcheck: + test: + ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + depends_on: + database: + condition: service_healthy + networks: + - lila-network + + web: + container_name: lila-web + build: + context: . + dockerfile: ./apps/web/Dockerfile + target: production + args: + VITE_API_URL: https://api.lilastudy.com + restart: unless-stopped + networks: + - lila-network + + database: + container_name: lila-database + image: postgres:18.3-alpine3.23 + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data + volumes: + - lila-db:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - lila-network + + forgejo: + container_name: lila-forgejo + image: codeberg.org/forgejo/forgejo:11 + volumes: + - forgejo-data:/data + environment: + - USER_UID=1000 + - USER_GID=1000 + ports: + - "2222:22" + restart: unless-stopped + networks: + - lila-network + +networks: + lila-network: + +volumes: + lila-db: + caddy_data: + caddy_config: + forgejo-data: diff --git a/docker-compose.yml b/docker-compose.yml index b661975..5903fa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,12 +42,11 @@ services: volumes: - ./apps/api:/app/apps/api # Hot reload API code - ./packages/shared:/app/packages/shared # Hot reload shared - - ./packages/db:/app/packages/db - /app/node_modules restart: unless-stopped healthcheck: test: - ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/health || exit 1"] + ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 5 @@ -67,7 +66,6 @@ services: - "5173:5173" volumes: - ./apps/web:/app/apps/web # Hot reload: local edits reflect immediately - - ./packages/shared:/app/packages/shared - /app/node_modules # Protect container's node_modules from being overwritten environment: - VITE_API_URL=http://localhost:3000 diff --git a/documentation/deployment.md b/documentation/deployment.md index 5912d2f..f95fea4 100644 --- a/documentation/deployment.md +++ b/documentation/deployment.md @@ -225,9 +225,59 @@ Host git.lilastudy.com This allows standard git commands without specifying the port. +## CI/CD Pipeline + +Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers. + +### Components + +- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/` +- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64 +- **Workflow file** — `.forgejo/workflows/deploy.yml` + +### Pipeline Steps + +1. Install Docker CLI and SSH client in the job container +2. Checkout the repository +3. Login to the Forgejo container registry +4. Build API image (target: `runner`) +5. Build Web image (target: `production`, with `VITE_API_URL` baked in) +6. Push both images to `git.lilastudy.com` +7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images + +### Secrets (stored in Forgejo repo settings → Actions → Secrets) + +| Secret | Value | +|---|---| +| REGISTRY_USER | Forgejo username | +| REGISTRY_PASSWORD | Forgejo password | +| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS | +| SSH_HOST | VPS IP address | +| SSH_USER | `lila` | + +### Runner Configuration + +The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings: + +- `docker_host: "automount"` — mounts the host Docker socket into job containers +- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount +- `privileged: true` — required for Docker access from job containers +- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers + +The runner command must explicitly reference the config file: + +```yaml +command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"' +``` + +### Deploy Cycle + +Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required. + +To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button. + ## Known Issues and Future Work -- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation) - **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added - **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state - **Monitoring/logging**: No centralized logging or uptime monitoring configured diff --git a/documentation/game_modes.md b/documentation/game_modes.md deleted file mode 100644 index 22eff3d..0000000 --- a/documentation/game_modes.md +++ /dev/null @@ -1,83 +0,0 @@ -# Game Modes - -This document describes the planned game modes for lila. Each mode uses the same lobby system and vocabulary data but differs in how answers are submitted, scored, and how a winner is determined. - -The first multiplayer mode to implement is TBD. The lobby infrastructure (create, join, WebSocket connection) is mode-agnostic — adding a new mode means adding new game logic, not changing the lobby. - ---- - -## TV Quiz Show - -**Type:** Multiplayer -**Answer model:** Buzzer — first to press gets to answer -**Rounds:** Fixed (e.g. 10) - -A question appears for all players. The first player to buzz in gets to answer. If correct, they score a point. If wrong, other players may get a chance to answer (TBD: whether the question passes to the next buzzer or the round ends). The host or a timer controls the pace. - -Key difference from other modes: only one player answers per question. Speed of reaction matters as much as knowledge. - ---- - -## Race to the Top - -**Type:** Multiplayer -**Answer model:** Simultaneous — all players answer independently -**Rounds:** None — play until target score reached - -All players see the same question and answer independently. No fixed round count. The first player to reach a target number of correct answers wins (e.g. 20). Fast-paced and competitive. - -Open questions: what happens if two players hit the target on the same question? Tiebreaker by speed? Shared win? - ---- - -## Chain Link - -**Type:** Multiplayer -**Answer model:** Turn-based — one player at a time, in rotation -**Rounds:** None — play until a player fails - -Players answer in a fixed rotation: Player 1, Player 2, Player 3, then back to Player 1. Each player gets one question per turn. The game continues until a player answers incorrectly — that player is out (or the game ends). Last correct answerer wins, or the game simply ends on the first wrong answer. - -Key difference from other modes: turn-based, not simultaneous. Pressure builds as you wait for your turn. - -Open questions: does the player who answers wrong lose, or does the game just end? If the game continues, does it become elimination? - ---- - -## Elimination Round - -**Type:** Multiplayer -**Answer model:** Simultaneous — all players answer independently -**Rounds:** Continue until one player remains - -All players see the same question and answer simultaneously. Players who answer incorrectly are eliminated. Rounds continue until only one player is left standing. - -Open questions: what if everyone gets it wrong in the same round? Reset that round? Eliminate nobody? What if it comes down to two players and both get it wrong repeatedly? - ---- - -## Cooperative Challenge - -**Type:** Multiplayer -**Answer model:** TBD -**Rounds:** TBD - -Players work together rather than competing. Concept not yet defined. Possible ideas: shared team score with a target, each player contributes answers to a collective pool, or players take turns and the team survives as long as the chain doesn't break. - ---- - -## Single Player Extended - -**Type:** Singleplayer -**Answer model:** TBD -**Rounds:** TBD - -An expanded version of the current singleplayer quiz. Concept not yet defined. Possible ideas: longer sessions with increasing difficulty, mixed POS/language rounds, streak bonuses, progress tracking across sessions, or timed challenge mode. - ---- - -## Schema Impact - -The `lobbies` table includes a `game_mode` column (varchar) with values like `tv_quiz`, `race_to_top`, `chain_link`, `elimination`. Mode-specific settings (e.g. target score for Race to the Top) can be stored in a `settings` jsonb column if needed. - -The singleplayer modes (Single Player Extended) don't require a lobby — they extend the existing singleplayer flow. diff --git a/documentation/notes.md b/documentation/notes.md index c750683..998cabf 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -21,13 +21,18 @@ WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/c Configure a credential helper to remove this warning. See https://docs.docker.com/go/credential-store/ -### docker containers on startup? - -laptop: verify if docker containers run on startup (they shouldnt) - ### vps setup - monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary) +- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy + +### ~~cd/ci pipeline~~ ✅ RESOLVED + +Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `deployment.md`. + +### ~~postgres backups~~ ✅ RESOLVED + +Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`. ### try now option diff --git a/scripts/create-issues.sh b/scripts/create-issues.sh deleted file mode 100644 index fefb072..0000000 --- a/scripts/create-issues.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/bin/bash - -# Forgejo batch issue creator for lila -# Usage: FORGEJO_TOKEN=your_token ./create-issues.sh - -FORGEJO_URL="https://git.lilastudy.com" -OWNER="forgejo-lila" -REPO="lila" -TOKEN="${FORGEJO_TOKEN:?Set FORGEJO_TOKEN environment variable}" - -API="${FORGEJO_URL}/api/v1/repos/${OWNER}/${REPO}" - -# Helper: create a label (ignores if already exists) -create_label() { - local name="$1" color="$2" description="$3" - curl -s -X POST "${API}/labels" \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"name\":\"${name}\",\"color\":\"${color}\",\"description\":\"${description}\"}" > /dev/null - echo "Label: ${name}" -} - -# Helper: create an issue with labels -create_issue() { - local title="$1" body="$2" - shift 2 - local labels="$*" - - # Build labels JSON array - local label_ids="" - for label in $labels; do - local id - id=$(curl -s "${API}/labels" \ - -H "Authorization: token ${TOKEN}" | \ - python3 -c "import sys,json; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='${label}']") - if [ -n "$label_ids" ]; then - label_ids="${label_ids},${id}" - else - label_ids="${id}" - fi - done - - curl -s -X POST "${API}/issues" \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"title\":$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"body\":$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"labels\":[${label_ids}]}" > /dev/null - - echo "Issue: ${title}" -} - -echo "=== Creating labels ===" -create_label "feature" "#0075ca" "New user-facing functionality" -create_label "infra" "#e4e669" "Infrastructure, deployment, DevOps" -create_label "debt" "#d876e3" "Technical cleanup, refactoring" -create_label "security" "#b60205" "Security improvements" -create_label "ux" "#1d76db" "User experience, accessibility, polish" -create_label "multiplayer" "#0e8a16" "Multiplayer lobby and game features" - -echo "" -echo "=== Creating issues ===" - -# ── feature ── - -create_issue \ - "Add guest/try-now option — play without account" \ - "Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a 'Try without account' button on the login/landing page." \ - feature - -create_issue \ - "Add Apple login provider" \ - "Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration." \ - feature - -create_issue \ - "Add email+password login" \ - "Add traditional email and password authentication as an alternative to social login. Configure via Better Auth." \ - feature - -create_issue \ - "User stats endpoint + profile page" \ - "Add GET /users/me/stats endpoint returning games played, score history, etc. Build a frontend profile page displaying the stats." \ - feature - -# ── infra ── - -create_issue \ - "Google OAuth app verification and publishing" \ - "Currently only test users can log in via Google. Publish the OAuth consent screen so any Google user can sign in. Requires branding verification through Google Cloud Console." \ - infra - -create_issue \ - "Set up Docker credential helper on dev laptop" \ - "Docker credentials are stored unencrypted in ~/.docker/config.json. Set up a credential helper to store them securely. See https://docs.docker.com/go/credential-store/" \ - infra - -create_issue \ - "VPS monitoring and logging" \ - "Set up monitoring and centralized logging on the VPS. Options: chkrootkit/rkhunter for security, logwatch/monit for daily summaries, uptime monitoring for service health." \ - infra - -create_issue \ - "Move to offsite backup storage" \ - "Currently database backups live on the same VPS. Add offsite copies to Hetzner Object Storage or similar S3-compatible service to protect against VPS failure." \ - infra - -create_issue \ - "Replace in-memory game session store with Valkey" \ - "Add Valkey container to the production Docker stack. Implement ValkeyGameSessionStore using the existing GameSessionStore interface. Required before multiplayer." \ - infra - -create_issue \ - "Modern env management approach" \ - "Evaluate replacing .env files with a more robust approach (e.g. dotenvx, infisical, or similar). Current setup works but .env files are error-prone and not versioned." \ - infra - -create_issue \ - "Pin dependencies in package.json files" \ - "Pin all dependency versions in package.json files to exact versions to prevent unexpected updates from breaking builds." \ - infra - -# ── debt ── - -create_issue \ - "Rethink organization of datafiles and wordlists" \ - "The current layout of data-sources/, scripts/datafiles/, scripts/data-sources/, and packages/db/src/data/ is confusing with overlapping content. Consolidate into a clear structure." \ - debt - -create_issue \ - "Resolve eslint peer dependency warning" \ - "eslint-plugin-react-hooks 7.0.1 expects eslint ^3.0.0-^9.0.0 but found 10.0.3. Resolve the peer dependency mismatch." \ - debt - -# ── security ── - -create_issue \ - "Rate limiting on API endpoints" \ - "Add rate limiting to prevent abuse. At minimum: auth endpoints (brute force prevention), game endpoints (spam prevention). Consider express-rate-limit or similar." \ - security - -# ── ux ── - -create_issue \ - "404/redirect handling for unknown routes and subdomains" \ - "Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider Caddy fallback for unrecognized subdomains." \ - ux - -create_issue \ - "React error boundaries" \ - "Add error boundaries to catch and display runtime errors gracefully instead of crashing the entire app." \ - ux - -create_issue \ - "Accessibility pass" \ - "Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow." \ - ux - -create_issue \ - "Favicon, page titles, Open Graph meta" \ - "Add favicon, set proper page titles per route, add Open Graph meta tags for link previews when sharing." \ - ux - -# ── multiplayer ── - -create_issue \ - "Drizzle schema: lobbies, lobby_players + migration" \ - "Create lobbies table (id, code, host_user_id, status, is_private, game_mode, settings, created_at) and lobby_players table (lobby_id, user_id, score, joined_at). Run migration. See game-modes.md for game_mode values." \ - multiplayer - -create_issue \ - "REST endpoints: POST /lobbies, POST /lobbies/:code/join" \ - "Create lobby (generates short code, sets host) and join lobby (validates code, adds player, enforces max limit)." \ - multiplayer - -create_issue \ - "LobbyService: create lobby, join lobby, enforce player limit" \ - "Service layer for lobby management. Generate human-readable codes, validate join requests, track lobby state. Public lobbies are browsable, private lobbies require code." \ - multiplayer - -create_issue \ - "WebSocket server: attach ws upgrade to Express" \ - "Attach ws library upgrade handler to the existing Express HTTP server. Handle connection lifecycle." \ - multiplayer - -create_issue \ - "WS auth middleware: validate session on upgrade" \ - "Validate Better Auth session on WebSocket upgrade request. Reject unauthenticated connections." \ - multiplayer - -create_issue \ - "WS message router: dispatch by type" \ - "Route incoming WebSocket messages by their type field to the appropriate handler. Use Zod discriminated union for type safety." \ - multiplayer - -create_issue \ - "Lobby join/leave handlers + broadcast lobby state" \ - "Handle lobby:join and lobby:leave WebSocket events. Broadcast updated player list to all connected players in the lobby." \ - multiplayer - -create_issue \ - "Lobby state in Valkey (ephemeral) + PostgreSQL (durable)" \ - "Store live lobby state (connected players, current question, timer) in Valkey. Store durable records (who played, final scores) in PostgreSQL." \ - multiplayer - -create_issue \ - "WS event Zod schemas in packages/shared" \ - "Define all WebSocket message types as Zod discriminated unions in packages/shared. Covers lobby events (join, leave, start) and game events (question, answer, result, finished)." \ - multiplayer - -create_issue \ - "Frontend: lobby browser + create/join lobby" \ - "Lobby list showing public open lobbies. Create lobby form (game mode, public/private). Join-by-code input for private lobbies." \ - multiplayer - -create_issue \ - "Frontend: lobby view (player list, code, start game)" \ - "Show lobby code, connected players, game mode. Host sees Start Game button. Players see waiting state. Real-time updates via WebSocket." \ - multiplayer - -create_issue \ - "Frontend: WS client singleton with reconnect" \ - "WebSocket client that maintains a single connection, handles reconnection on disconnect, and dispatches incoming messages to the appropriate state handlers." \ - multiplayer - -create_issue \ - "GameService: question sequence + server timer" \ - "Generate question sequence for a lobby game. Enforce per-question timer (e.g. 15s). Timer logic varies by game mode — see game-modes.md." \ - multiplayer - -create_issue \ - "lobby:start WS handler — broadcast first question" \ - "When host starts the game, generate questions, change lobby status to in_progress, broadcast first question to all players." \ - multiplayer - -create_issue \ - "game:answer WS handler — collect answers" \ - "Receive player answers via WebSocket. Track who has answered. Behavior varies by game mode (simultaneous vs turn-based vs buzzer)." \ - multiplayer - -create_issue \ - "Answer evaluation + broadcast results" \ - "On all-answered or timeout: evaluate answers, calculate scores, broadcast game:answer_result to all players. Then send next question or end game." \ - multiplayer - -create_issue \ - "Game finished: broadcast results, update DB" \ - "After final round: broadcast game:finished with final scores and winner. Write game results to PostgreSQL (transactional). Change lobby status to finished." \ - multiplayer - -create_issue \ - "Frontend: multiplayer game route" \ - "Route for active multiplayer games. Receives questions and results via WebSocket. Reuses QuestionCard and OptionButton components." \ - multiplayer - -create_issue \ - "Frontend: countdown timer component" \ - "Visual countdown timer synchronized with server timer. Shows remaining seconds per question." \ - multiplayer - -create_issue \ - "Frontend: ScoreBoard component (live per-player scores)" \ - "Displays live scores for all players during a multiplayer game. Updates in real-time via WebSocket." \ - multiplayer - -create_issue \ - "Frontend: GameFinished screen" \ - "Winner highlight, final scores, play again option. Returns to lobby on play again." \ - multiplayer - -create_issue \ - "Multiplayer GameService unit tests" \ - "Unit tests for round evaluation, scoring, tie-breaking, timeout handling across different game modes." \ - multiplayer - -create_issue \ - "Graceful WS reconnect with exponential back-off" \ - "Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if game is still in progress." \ - multiplayer - -echo "" -echo "=== Done ==="