13 KiB
CLAUDE.md
Commands
# Root commands (via Turborepo)
bun run dev # Start API, Vite dev, and Expo dev servers
bun run dev:docs # Start the fumadocs/Next.js dev server
bun run build # Production build (both apps)
bun run lint # Oxlint lint check
bun run format # Oxfmt format (auto-fix)
bun run check-types # TypeScript type check
bun run test # Run tests
bun run generate:openapi # Regenerate OpenAPI spec + docs API pages (run after contract/schema changes)
bun run i18n:extract # Run LingUI's string extraction
bun run i18n:claude # Prompt Claude Code to fill in untranslated strings
bun run changeset # Add a changeset describing a user-facing change (see Releases)
# Database commands (run from packages/db/)
cd packages/db && bun run db:push # Push schema changes to SQLite database
cd packages/db && bun run db:generate # Generate Drizzle migration files
cd packages/db && bun run db:migrate # Run Drizzle migrations
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
Use Bun, not Node.js — bun <file>, bun install, bun run <script>, bunx <pkg>. Bun auto-loads .env. Never run bun test directly — always use bun run test which runs Vitest via Turborepo.
CRITICAL: Pre-commit checks
Before declaring a task is complete, all four commands MUST pass with zero errors or warnings:
bun run lint
bun run format:check
bun run check-types
bun run test
If the change is user-facing, it also needs a changeset — see Releases.
Architecture
Sofa is a self-hosted movie & TV tracking app built as a Turborepo monorepo.
couch-potato/
├── apps/
│ ├── native/ # @sofa/native — Expo 55 React Native app (Expo Router, UniWind)
│ ├── public-api/ # @sofa/public-api — Hono microservice on Vercel (version check, telemetry)
│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
├── docs/ # sofa-docs — Fumadocs (Next.js) documentation site + OpenAPI reference
├── packages/
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
│ ├── auth/ # @sofa/auth — Better Auth server config (JIT)
│ ├── config/ # @sofa/config — Path constants + TMDB URLs from env (JIT)
│ ├── core/ # @sofa/core — Business logic services (JIT, DB-agnostic)
│ ├── db/ # @sofa/db — Drizzle schema, client, queries, migrations (JIT)
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
│ ├── test/ # @sofa/test — Vitest setup + in-memory DB test helpers
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
├── .changeset/ # Changesets config + pending changesets (see Releases)
├── .oxlintrc.json
├── .oxfmtrc.json
├── Dockerfile
├── package.json
└── turbo.json
All shared packages are JIT (raw TypeScript exports, no build step).
Apps
@sofa/server— Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. Dev: port 3001. Prod: serves SPA static files too, port 3000.@sofa/web— Vite SPA with TanStack Router (file-based routing). No SSR, no DB. All data via oRPC. Vite dev server proxies/api/*and/rpc/*to the API server.@sofa/native— Expo Router app with 4-tab layout (Home, Explore, Search, Settings). UniWind for styling,@better-auth/expowith SecureStore for auth, oRPC client for API calls. Dark-only cinema theme matching web.@sofa/public-api— Minimal Hono microservice deployed on Vercel. Endpoints:GET /v1/version(latest release from GitHub),POST /v1/telemetry(forwards instance stats to PostHog), and OAuth device-code proxy for Trakt/Simkl imports (/v1/import/:provider/device-code,/v1/import/:provider/poll). Dev: port 3002.sofa-docs— Fumadocs site (Next.js) with landing page, Markdown docs, and auto-generated OpenAPI API reference. Content lives indocs/content/docs/. API docs are generated fromdocs/openapi.jsonviafumadocs-openapi.
Stack
- Frontend: Vite 7 SPA, React 19, TanStack Router (file-based), Tailwind CSS v4, shadcn, Jotai
- Mobile: Expo 55, Expo Router, UniWind (Tailwind for RN),
@tabler/icons-react-native - API: Hono on Bun, oRPC (contract-first) with
@orpc/tanstack-query - Database: SQLite via
bun:sqlite+ Drizzle ORM (WAL mode, sync queries, auto-migrations) - Auth: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
- Monorepo: Turborepo with Bun workspaces
- Docs: Fumadocs (Next.js), fumadocs-openapi for API reference
- Testing: Vitest (unit tests on Node.js, browser component tests via Playwright)
- Linting: Oxlint + Oxfmt (2-space indent, organized imports, Tailwind class sorting)
- External API: TMDB (The Movie Database)
- Translation: LingUI + Crowdin
Package imports
Path aliases: @/* maps to src/ in both apps/web/ and apps/native/.
Cross-package imports:
@sofa/api/contract,@sofa/api/schemas— Contract and Zod types@sofa/db/queries/*— Query layer (e.g.,@sofa/db/queries/tracking,@sofa/db/queries/metadata)@sofa/db/client,@sofa/db/schema,@sofa/db/migrate@sofa/test/setup,@sofa/test/db— Test setup (Vitest) and DB helpers (in-memory SQLite, seed functions)@sofa/tmdb/client,@sofa/tmdb/image@sofa/core/metadata,@sofa/core/tracking,@sofa/core/imports, etc.@sofa/auth/server,@sofa/auth/config@sofa/config— Path constants (DATA_DIR,DATABASE_URL,CACHE_DIR,BACKUP_DIR,AVATAR_DIR) and TMDB URLs@sofa/logger—createLogger(name)for structured logging
Key patterns
Layered architecture — Strict separation: apps/server/ → @sofa/core/* → @sofa/db/queries/* → @sofa/db/client. Server procedures/routes call core services for all business logic. Core services call query functions for all DB access. Core must never import @sofa/db/client directly. Type-only imports from @sofa/db/schema are allowed in core (for $inferInsert/$inferSelect), but runtime imports are not.
Query layer — All Drizzle ORM queries live in packages/db/src/queries/, organized by domain (tracking, metadata, discovery, etc.). Plain exported functions, transactions hidden as implementation details. Wildcard export: "./queries/*": "./src/queries/*.ts".
oRPC queries use orpc.*.queryOptions() with TanStack Query. Mutations use client.*() directly. Route loaders prefetch via queryClient.ensureQueryData().
Auth guards — Web routes use beforeLoad + authClient.getSession(). API procedures use auth middleware that calls auth.api.getSession().
TMDB images — Only paths stored in DB. The API server resolves full URLs via tmdbImageUrl(). When IMAGE_CACHE_ENABLED (default), images are downloaded to disk and served via /images/:category/:filename.
Database IDs — All app tables use UUIDv7 text PKs via Bun.randomUUIDv7().
i18n (LingUI) — All user-facing strings are wrapped with LingUI macros. The @sofa/i18n shared package holds the i18n singleton, locale metadata, and Intl-based format utilities. Config lives at the repo root (lingui.config.ts). PO catalogs and compiled TS files live in packages/i18n/src/po/. Crowdin syncs via crowdin.yml with languages_mapping to map regional codes (es-ES→es, pt-PT→pt).
- JSX text →
<Trans>from@lingui/react/macro - Strings in hooks/components →
const { t } = useLingui()from@lingui/react/macro, thent`string` - Strings outside React (plain modules) →
import { msg } from "@lingui/core/macro"+import { i18n } from "@sofa/i18n", theni18n._(msg`string`) - Pluralization →
import { plural } from "@lingui/core/macro". Use standalone when it's the whole string:plural(count, { one: "# item", other: "# items" }). Nest insidetonly when there's surrounding text:t`You have ${plural(count, { one: "# item", other: "# items" })} in your cart` - DO NOT use
t(i18n)— it's deprecated in v5 and removed in v6. Usei18n._(msg...)instead. - Date/number formatting → use
formatDate,formatRelativeTime,formatNumber,formatBytesfrom@sofa/i18n/format(Intl-based, locale-aware). Never usedate-fnsin app code. - Native Intl polyfills →
@formatjs/intl-*polyfills loaded inapps/native/src/lib/intl-polyfills.ts(strict dependency order, with locale data for all 6 languages). - Error messages → Server throws
ORPCErrorwithdata: { code: AppErrorCode.XXX }. Clients map codes to localized strings via per-apperror-messages.ts. Never displayerror.messageto users. - After adding/changing strings, run
bun run i18n:extract. The Vite and Metro plugins compile.pocatalogs on the fly — no manual compile step needed.
Environment variables
Required: TMDB_API_READ_ACCESS_TOKEN, BETTER_AUTH_SECRET, BETTER_AUTH_URL.
Optional:
DATA_DIR— Root for DB + cache (default./data).DATABASE_URLandCACHE_DIRderived from it but overridable.TMDB_API_BASE_URL,TMDB_IMAGE_BASE_URL— Override TMDB endpoints.PUBLIC_API_URL— Base URL for centralized public API (default:https://public-api.sofa.watch). Used for update checks and OAuth import proxy.IMAGE_CACHE_ENABLED— Defaulttrue. Setfalsefor direct TMDB CDN URLs.LOG_LEVEL—error/warn/info/debug(default:info).- OIDC:
OIDC_CLIENT_ID,OIDC_CLIENT_SECRET,OIDC_ISSUER_URL,OIDC_PROVIDER_NAME,OIDC_AUTO_REGISTER,DISABLE_PASSWORD_LOGIN.
Testing
Vitest (not Bun's built-in test runner). Always run tests via bun run test (never bun test).
@sofa/test— Shared test package.@sofa/test/setupis the Vitest setup file (mocks@sofa/db/clientwith in-memory SQLite, polyfillsBun.randomUUIDv7).@sofa/test/dbexports the test DB instance, seed helpers (insertUser,insertTitle, etc.), andclearAllTables.packages/core/test/— Unit tests for business logic. Config:packages/core/vitest.config.ts.apps/web/— Browser component tests via@vitest/browser+ Playwright. Config:apps/web/vitest.config.ts.- To add tests to a new package: create a
vitest.config.tswithsetupFiles: ["@sofa/test/setup"], add"test": "vitest run"to scripts, and add the project path to the rootvitest.config.tsprojectsarray. Do not add@sofa/testto the package'sdevDependencies— it resolves via hoistednode_modules, and adding it would causeturbo pruneto pullbetter-sqlite3into Docker builds.
Docker
Single container, single process. Hono serves API + SPA on port 3000. API routes (/rpc/*, /api/*) mounted first; unmatched routes fall back to index.html.
Releases
Versions and changelogs are managed with Changesets. Every PR with a user-facing change needs a changeset: a Markdown file in .changeset/ naming the affected packages and their semver bump. Run bun run changeset, or write one by hand with any unique filename:
---
"@sofa/web": minor
---
Add a "Rewatch" button to title pages
- The summary is user-facing — it lands verbatim in
CHANGELOG.mdand the GitHub release notes, so write it for people running Sofa. Skip the changeset for changes users won't notice (refactors, tests, CI, internal docs). - Server release —
@sofa/server,@sofa/web, and everypackages/*library are a fixed group sharing one version: the Docker image tag and thevX.Y.ZGitHub release. Name the packages you changed; any bump moves the whole group. @sofa/nativeis left out of Changesets (ignorein the config): its version is the App Store / Play Store version, bumped by hand for store submissions. Never name it in a changeset — Changesets rejects a changeset that mixes it with other packages and silently skips one that names only it.@sofa/public-apiis versioned on its own and deploys continuously; a changeset is optional.- Bump types —
patchfor fixes,minorfor features and for breaking changes while on 0.x. Never usemajorunless asked: it would release 1.0.0.
.github/workflows/release.yml automates the rest. While changesets are pending, it keeps a "chore(release): version packages" PR up to date (bun run version-packages bumps versions, writes changelogs, and syncs bun.lock and the OpenAPI spec version). Merging that PR makes bun run release tag vX.Y.Z, publish the GitHub release, and dispatch the Docker workflow for the tag. Mobile builds still go through the EAS workflows.
Browser Automation
Use agent-browser for web automation (agent-browser --help for all commands). Auth credentials: demo@sofa.watch / password.