- fix(db): migration 0012 column order mismatch causing NOT NULL constraint failure on existing databases; use explicit column mapping instead of SELECT * - fix(db): disable FK checks during migrations to allow SQLite table-recreation pattern (DROP + RENAME) - fix(security): filter cookie_secret and instance_id from settings API response for non-admin users - fix(lint): resolve all 7 API lint warnings (noParameterAssign, noImplicitAnyLet) in compose, image-enhancement, and workspace - fix(docs): correct permission count from 16 to 14 in CLAUDE.md - fix(e2e): resolve 44 Playwright test failures across 8 spec files including locator specificity, compress mode defaults, format count, restore-photo UI drift, stitch image count, GIF animated fixtures, submit button timing, and processing timeouts
9.7 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What Is SnapOtter
Open-source, self-hostable image manipulation suite (51 tools). Single Docker container, no external services. Dual-licensed AGPLv3 / commercial.
Monorepo Layout
apps/web/ # Main React SPA (port 1349)
apps/api/ # Fastify API server (port 13490)
apps/landing/ # Marketing site (Vite + React, deployed to Cloudflare Pages)
apps/docs/ # VitePress documentation site (deployed to Cloudflare Pages)
packages/shared/ # Constants, types, i18n, permissions -- consumed by all apps
packages/image-engine/ # Sharp-based image processing pipeline
packages/ai/ # Python sidecar bridge for ML models
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite 6, Tailwind CSS 4, Zustand, react-router-dom v7 |
| Backend | Fastify 5, tsx (no compile step in dev), Sharp |
| Database | SQLite via Drizzle ORM (better-sqlite3) |
| AI/ML | Python sidecar (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa) |
| Docs | VitePress |
| Testing | Vitest (unit/integration), Playwright (e2e) |
| CI/CD | GitHub Actions, semantic-release, Docker multi-arch |
| Linting | Biome (format + lint in one pass) |
Commands
pnpm dev # Start all dev servers (web on :1349, api on :13490)
pnpm build # Build all workspaces (turbo, packages first)
pnpm typecheck # TypeScript check across monorepo
pnpm lint # Biome lint + format check
pnpm lint:fix # Biome auto-fix
pnpm test # All Vitest tests (unit + integration)
pnpm test:unit # Unit tests only
pnpm test:integration # Integration tests only (full API)
pnpm test:e2e # Playwright e2e tests (main app)
pnpm test:e2e:landing # Playwright e2e tests (landing site)
pnpm test:e2e:docs # Playwright e2e tests (docs site)
pnpm test:coverage # Tests with V8 coverage report
# Run a single test file
pnpm vitest run tests/unit/my-test.test.ts
pnpm vitest run tests/integration/my-test.test.ts
# Run a single e2e spec
pnpm playwright test tests/e2e/my-test.spec.ts
# Database migrations (run from apps/api/)
cd apps/api && npx drizzle-kit generate # Generate from schema changes
cd apps/api && npx drizzle-kit migrate # Apply pending migrations
Key Conventions
- Simplicity over complexity -- do not over-engineer
- Double quotes, semicolons, 2-space indent (enforced by Biome)
- ES modules in all workspaces (
"type": "module") - Conventional commits for semantic-release (
feat:,fix:,refactor:,docs:,test:,chore:) - Zod for all API input validation
- i18n strings in
packages/shared/src/i18n/en.ts(reference locale, ~1500 keys) - All user-facing strings use
useTranslation()hook fromapps/web/src/contexts/i18n-context.tsx - Use
t.section.keyfor string references,format(t.key, { var })for interpolation,plural(n, one, other)for pluralization - Tailwind uses logical properties for RTL support (
ms-notml-,text-startnottext-left) - 21 supported languages. Adding a new locale: create
packages/shared/src/i18n/<code>.tstyped asTranslationKeys, add toSUPPORTED_LOCALESinindex.ts
Architecture: How a Tool Works
A "tool" is defined in three places that share the same toolId string:
-
Shared metadata (
packages/shared/src/constants.ts) --TOOLS[]array with id, name, description, category, icon, route. Single source of truth for tool metadata consumed by both frontend and backend. -
API route (
apps/api/src/routes/tools/<toolId>.ts) -- Processing logic. Most tools use thecreateToolRoutefactory (apps/api/src/routes/tool-factory.ts), which handles multipart parsing, file validation, format decoding (HEIC/RAW/PSD), SVG sanitization, EXIF auto-orientation, Zod settings validation, output saving, preview generation, file versioning, and analytics. A simple tool is ~30 lines: a Zod schema + a process function that calls@snapotter/image-engine. Routes are registered inapps/api/src/routes/tools/index.ts. -
Frontend settings (
apps/web/src/components/tools/<toolId>-settings.tsx) -- UI component for the tool's settings panel. Registered inapps/web/src/lib/tool-registry.tsxwith adisplayMode(side-by-side, before-after, live-preview, interactive-crop, interactive-eraser, no-dropzone, custom-results).
Adding a New Tool
- Add tool definition to
TOOLS[]inpackages/shared/src/constants.ts - Create
apps/api/src/routes/tools/<toolId>.ts-- export aregister<Tool>(app)function that callscreateToolRoute(app, { toolId, settingsSchema, process }) - Add to the registration array in
apps/api/src/routes/tools/index.ts - Create
apps/web/src/components/tools/<toolId>-settings.tsx - Add lazy import + registry entry in
apps/web/src/lib/tool-registry.tsx - Add i18n strings in
packages/shared/src/i18n/en.ts
Request Lifecycle
Standard tools: Frontend sends POST /api/v1/tools/:toolId (multipart with file + JSON settings) via XHR with upload progress. The createToolRoute factory processes and returns {jobId, downloadUrl, originalSize, processedSize}.
AI tools: Do NOT use createToolRoute for their HTTP route. They return 202 Accepted immediately, process asynchronously via the Python sidecar, and stream progress via SSE (EventSource on /api/v1/jobs/:jobId/progress). They still register a sync wrapper via registerToolProcessFn() for pipeline/batch reuse.
Pipeline System
Pipelines chain tools: each step's output buffer feeds the next step's input. The tool registry (toolRegistry Map) enables pipelines and batch processing to call any registered tool's process function without duplicating logic. CRUD stored in pipelines DB table as JSON. Batch processing uses p-queue concurrency and returns a ZIP via archiver.
AI Sidecar (packages/ai/)
Two-tier Python execution:
-
Persistent dispatcher (primary): Long-lived Python process (
packages/ai/python/dispatcher.py) communicating via JSON over stdio. Node sends{id, script, args}on stdin, Python responds{id, stdout, exitCode}on stdout. Progress events stream on stderr as{progress, stage}. Pre-imports heavy ML libraries at startup to eliminate cold-start latency. Auto-restarts after 50 requests to prevent memory leaks. -
Per-request fallback: Fresh Python process per request if dispatcher is unavailable. Exponential backoff with crash recovery (5 crashes in 60s = permanent disable).
AI tools require model bundles defined in packages/shared/src/features.ts (FEATURE_BUNDLES). The API checks isToolInstalled() before processing; the frontend shows an install prompt if not installed.
Image Engine (packages/image-engine/)
processImage(input, operations[], outputFormat?) pipes a buffer through named operations. Each operation is a pure function (Sharp, options) => Promise<Sharp>. The OPERATION_MAP in engine.ts dispatches by string key. Individual operations are also exported for direct use by API routes.
Auth and Permissions
Session-based auth with scrypt password hashing. Three built-in roles (admin, editor, user) with 14 granular permissions defined in packages/shared/src/permissions.ts. Custom roles stored in roles table. API keys (prefixed si_) carry optional scoped permissions that intersect with user role permissions. Auth can be disabled entirely (synthetic anonymous user with user role).
Frontend State
14 Zustand stores in apps/web/src/stores/. The central one is file-store.ts managing FileEntry[] with blob URLs, processing status, and batch ZIP. Key hooks: use-tool-processor.ts (single-file XHR + batch + SSE progress) and use-pipeline-processor.ts.
All page components are lazy()-loaded. The key route is /:toolId rendering ToolPage (apps/web/src/pages/tool-page.tsx), which looks up metadata from shared constants and the frontend tool registry.
Database
SQLite via Drizzle ORM. Schema: apps/api/src/db/schema.ts. Migrations in apps/api/drizzle/.
Tables: users, teams, sessions, settings (key-value), jobs, apiKeys, pipelines, auditLog, roles, userFiles.
Testing
tests/unit/-- Vitest unit teststests/integration/-- Vitest integration tests (one per tool/feature, full API)tests/e2e/-- Playwright specs (Chromium primary, Firefox/WebKit for cross-browser)tests/fixtures/-- Small test images in various formats
Vitest uses single-fork pool (shared SQLite connection), 30s timeouts, and injects test env vars. Playwright auth setup project logs in and saves storage state; spins up both dev servers with a fresh test DB.
Pre-commit Hooks
Husky + lint-staged runs biome check --write on staged *.{ts,tsx,js,jsx,json} files before every commit. If a commit fails, fix the lint issues rather than bypassing the hook.
Do Not Modify Config Files
Biome, TypeScript, and editor config files are protected by hooks. Fix the code to satisfy the linter/compiler, not the other way around. This prevents a common AI failure mode where rules get weakened instead of code getting fixed.
Model Routing for Subagents
Always use Opus 4.7, Max Effort, 1M context for every agent and subagent. No exceptions. No model downgrading.
Strategic Compaction
When context gets large, compact at logical phase boundaries:
- Good times to compact: After research and before planning. After debugging and before implementing the fix. After completing a major feature.
- Bad times to compact: Mid-implementation. While actively debugging. During a multi-step refactor.
- Survives compaction: This CLAUDE.md, active tasks, git state, memory files
- Lost on compaction: Intermediate reasoning, file contents previously read, conversation flow