Files
sofa/AGENTS.md

185 lines
13 KiB
Markdown

# CLAUDE.md
## Commands
```bash
# 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:
```bash
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](#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/expo` with 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 in `docs/content/docs/`. API docs are generated from `docs/openapi.json` via `fumadocs-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`, then `` t`string` ``
- **Strings outside React** (plain modules) → `import { msg } from "@lingui/core/macro"` + `import { i18n } from "@sofa/i18n"`, then ``i18n._(msg`string`)``
- **Pluralization** → `import { plural } from "@lingui/core/macro"`. Use standalone when it's the whole string: `plural(count, { one: "# item", other: "# items" })`. Nest inside `t` only 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. Use `i18n._(msg`...`)` instead.
- **Date/number formatting** → use `formatDate`, `formatRelativeTime`, `formatNumber`, `formatBytes` from `@sofa/i18n/format` (Intl-based, locale-aware). Never use `date-fns` in app code.
- **Native Intl polyfills** → `@formatjs/intl-*` polyfills loaded in `apps/native/src/lib/intl-polyfills.ts` (strict dependency order, with locale data for all 6 languages).
- **Error messages** → Server throws `ORPCError` with `data: { code: AppErrorCode.XXX }`. Clients map codes to localized strings via per-app `error-messages.ts`. Never display `error.message` to users.
- After adding/changing strings, run `bun run i18n:extract`. The Vite and Metro plugins compile `.po` catalogs 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_URL` and `CACHE_DIR` derived 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` — Default `true`. Set `false` for 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/setup` is the Vitest setup file (mocks `@sofa/db/client` with in-memory SQLite, polyfills `Bun.randomUUIDv7`). `@sofa/test/db` exports the test DB instance, seed helpers (`insertUser`, `insertTitle`, etc.), and `clearAllTables`.
- **`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.ts` with `setupFiles: ["@sofa/test/setup"]`, add `"test": "vitest run"` to scripts, and add the project path to the root `vitest.config.ts` `projects` array. Do **not** add `@sofa/test` to the package's `devDependencies` — it resolves via hoisted `node_modules`, and adding it would cause `turbo prune` to pull `better-sqlite3` into 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](https://changesets.dev). **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:
```md
---
"@sofa/web": minor
---
Add a "Rewatch" button to title pages
```
- **The summary is user-facing** — it lands verbatim in `CHANGELOG.md` and 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 every `packages/*` library are a fixed group sharing one version: the Docker image tag and the `vX.Y.Z` GitHub release. Name the packages you changed; any bump moves the whole group.
- **`@sofa/native`** is left out of Changesets (`ignore` in 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-api`** is versioned on its own and deploys continuously; a changeset is optional.
- **Bump types** — `patch` for fixes, `minor` for features and for breaking changes while on 0.x. Never use `major` unless 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`.