A recipe management app for iOS, Android, and web. Store, scale, import, and improve recipes with AI assistance.
| Layer | Technology |
|---|---|
| Mobile app | React Native, NativeWind v5, Tailwind CSS v4, React Navigation v7 |
| Web app | Vite 6, React 19, React Router 7, Tailwind CSS v4, Vitest, MSW |
| Shared client | @eciperay/client — API, auth, query keys, recipe utils |
| Shared theme | @eciperay/theme — Tailwind v4 design tokens |
| Backend | Node.js, Express v5, TypeScript |
| Database | Supabase Postgres (raw SQL via pg) |
| File storage | Supabase Storage (recipe-images bucket for recipe photos) |
| Auth | Supabase Auth |
| AI | Anthropic Claude (Haiku + Sonnet) |
eciperay/
├── packages/
│ ├── app/ # React Native app (iOS + Android)
│ ├── web/ # Vite web app
│ ├── client/ # Shared API client, auth, utils
│ ├── theme/ # Shared Tailwind v4 tokens
│ ├── server/ # Express API server
│ └── types/ # Shared TypeScript types
├── eslint.config.ts
├── tsconfig.base.json
└── package.json
- Node.js ^22.22.2, ^24.15.0, or >=26.0.0 (see
enginesin package.json) - npm 10+
- React Native environment (for mobile)
- A Supabase project
- An Anthropic API key
npm installCopy the example env to the repo root (used by server, Metro, and Vite):
cp .env.example .envFill in Supabase, Anthropic, and URL values. Web requires VITE_* prefixed
vars; the mobile app uses API_BASE_URL and SUPABASE_* via the Metro env shim
(.env for Metro debug; release APKs are built in GitHub Actions).
Add these redirect URLs in your Supabase project's Authentication → URL configuration:
| URL | Purpose |
|---|---|
http://localhost:8081/** |
Local web auth (password reset, etc.) |
https://www.eciperay.com/** |
Production web auth |
https://eciperay.com/** |
Production web auth (apex) |
com.eciperay://auth/callback |
Android/iOS release auth email links |
com.eciperay.dev://auth/callback |
Android/iOS debug/dev auth email links (com.eciperay.dev package) |
Debug mobile builds install as Eciperay Dev (com.eciperay.dev on Android
and iOS) so they can sit alongside the Play Store / TestFlight build. Release
builds keep com.eciperay and the Eciperay display name.
Set Site URL to your production web origin (for example
https://www.eciperay.com).
npm run migrate --workspace=packages/serverThis also creates the public recipe-images Supabase Storage bucket and RLS
policies (migration 003_recipe_images_storage.sql). Recipe photos upload
directly from the browser to {supabase-user-id}/{recipe-id}; only the public
URL is stored in recipes.image_url.
# API + Metro (mobile)
npm run dev
# API + Vite (web) — http://localhost:8081
npm run dev:web
# API + Metro + iOS simulator
npm run dev:ios
# API + Metro + physical Android phone (USB)
npm run dev:androidnpm run build
npm run startThe server serves the API under /api/* and the compiled Vite app for all other
routes. Set VITE_API_BASE_URL=/api when building the web app (default in
.env.example). Android release APKs are built via GitHub Actions (see below).
One Fly app serves API + static web from the root Dockerfile.
1. Install the CLI and log in
brew install flyctl # or https://fly.io/docs/flyctl/install/
fly auth login2. Create the app (once)
fly apps create eciperay # or edit `app` in fly.toml3. Set secrets (runtime — not baked into the web bundle)
fly secrets set \
DATABASE_URL='postgresql://...' \
SUPABASE_URL='https://your-project.supabase.co' \
SUPABASE_SECRET_KEY='your-service-role-key' \
ANTHROPIC_API_KEY='your-anthropic-key' \
OPENAI_API_KEY='your-openai-key' \
POSTHOG_KEY='your-posthog-project-api-key' \
POSTHOG_HOST='https://a.eciperay.com' \
CORS_ORIGINS='https://eciperay.com,https://www.eciperay.com'CORS_ORIGINS is mainly for non-browser clients; same-origin web does not need
it.
4. Deploy
VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY from your local .env are
passed as Docker build args (public anon key — expected in the client bundle):
./scripts/fly-deploy.shOr manually:
fly deploy \
--build-arg VITE_API_BASE_URL=/api \
--build-arg VITE_SUPABASE_URL=https://your-project.supabase.co \
--build-arg VITE_SUPABASE_ANON_KEY=your-anon-keyGitHub Actions: Fly deploys run from the Release workflow
(.github/workflows/release.yml) when a Pending Release PR merges and the paths
changed since the previous tag resolve to the Fly target (shared packages expand
to their dependents). You can also trigger Deploy manually from the Actions
tab.
Add these repository secrets (Settings → Secrets and variables → Actions):
| Secret | Purpose |
|---|---|
FLY_API_TOKEN |
Fly deploy token (fly tokens create deploy) |
VITE_SUPABASE_URL |
Docker build arg for the web bundle |
VITE_SUPABASE_ANON_KEY |
Docker build arg for the web bundle |
VITE_POSTHOG_KEY |
Docker build arg for web analytics |
VITE_POSTHOG_HOST |
Docker build arg for PostHog ingest (reverse proxy) |
POSTHOG_API_KEY |
Personal API key for web source map upload |
POSTHOG_PROJECT_ID |
PostHog project ID for source map upload |
Source map upload uses https://us.posthog.com in CI (not the ingest proxy).
POSTHOG_KEY and POSTHOG_HOST for server-side exception capture are Fly
runtime secrets (see step 3 above), not GitHub secrets.
Runtime secrets (DATABASE_URL, SUPABASE_SECRET_KEY, API keys, etc.) stay on
Fly via fly secrets set — they are not needed in GitHub.
Optional: create a production environment in GitHub to require approval before deploys.
PR review apps
Every pull request gets an ephemeral preview deployment on Fly.io via
.github/workflows/fly-review.yml (Fly's
review-apps blueprint), at
https://pr-<number>-spudly-eciperay.fly.dev. The app is updated on each push
and destroyed when the PR closes. Two one-time setup steps:
- Org token. The workflow uses the
FLY_API_TOKENrepository secret, but creating apps requires an org token — the app-scoped deploy token (fly tokens create deploy) is not enough. Create one withfly tokens create organd store it asFLY_API_TOKEN; org tokens can also deploy the production app, sofly-deploy.ymlkeeps working. - Runtime secrets. Review apps run against the Supabase QA dataset
(issue #234), not production: the workflow feeds the app from the five
PREVIEW_*repository secrets —DATABASE_URL←PREVIEW_DATABASE_URL,SUPABASE_URL←PREVIEW_SUPABASE_URL,SUPABASE_SECRET_KEY←PREVIEW_SUPABASE_SECRET_KEY, and the web build'sVITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY←PREVIEW_VITE_SUPABASE_URL/PREVIEW_VITE_SUPABASE_ANON_KEY— plus the sharedANTHROPIC_API_KEY,OPENAI_API_KEY,PADDLE_API_KEY, andPADDLE_WEBHOOK_SECRETsecrets. The fivePREVIEW_*secrets are required: the server creates its Supabase client at import time and will not start without them, so the workflow validates them before deploying. Paddle is pinned to the sandbox environment in previews, and Paddle webhooks still point at production — previews cannot exercise real billing webhooks.
Safety notes: fly.review.toml has no release command, so PR code never
runs database migrations; the persistent preview environment (eciperay-next)
owns migrating the shared QA dataset. Review apps are isolated from production —
every action in a review app, including account deletion, can only affect QA
data. All review apps and the persistent preview share the one QA dataset, so
data created while testing is visible across them: use QA — name prefixes and
clean up what you create.
5. Custom domain
fly certs add eciperay.com
fly certs add www.eciperay.comPoint DNS at the addresses fly certs add prints (usually A + AAAA to Fly).
6. Post-deploy
- Supabase → Authentication → URL configuration:
- Site URL:
https://www.eciperay.com - Additional redirect URLs:
https://www.eciperay.com/**,https://eciperay.com/**,com.eciperay://auth/callback
- Site URL:
- Android: run the Android Deploy workflow (see below) and upload the AAB to Play Console
Migrations run automatically on each deploy via release_command in fly.toml.
The default machine config uses 512MB RAM with one machine always
running (min_machines_running = 1) so web and mobile API requests avoid cold
starts.
fly logs
fly status
curl https://www.eciperay.com/api/health- Enable Developer options and USB debugging on your phone
- Connect via USB and accept the debugging prompt
- Verify:
adb devices(should show your phone serial, notemulator-XXXX) - Run
npm run dev:android
The setup script forwards ports over USB so the phone can reach Metro (8081)
and the API (8080). Keep API_BASE_URL=http://localhost:8080/api in .env.
If multiple phones are connected, set ANDROID_SERIAL to the device id from
adb devices.
Release builds bundle production API and Supabase config at build time via
the Metro @env shim (packages/app/scripts/envShim.cjs). Debug/Metro builds
keep using .env (localhost).
packages/app/android/app/build.gradle configures signingConfigs.release from
ANDROID_* environment variables (CI) or ECIPERAY_UPLOAD_* Gradle properties
(local). The upload keystore is gitignored — store it in a password manager and
in GitHub secrets for CI.
Android permissions, cleartext HTTP policy, and runtime rationale dialogs are
documented in
packages/app/android/PERMISSIONS.md.
Android versionCode / versionName and the Play Store release process are
documented in
packages/app/android/VERSIONING.md. Root
package.json version is the source of truth; the changesets
pending-release flow versions all workspace packages together — merging the
Pending Release PR stamps the new version everywhere (the publish step syncs the
root manifest to the package group's version).
Releases run on the changesets pending-release flow (issue #234). A PR that
changes user-facing behavior carries a changeset (see Commits
below); changesets/action in the Release workflow
(.github/workflows/release.yml) keeps a Pending Release PR open with the
accumulated version bump and changelog. Merging that PR is the release: the
publish step syncs the root package.json to the new version, tags vX.Y.Z,
creates the GitHub Release, and deploys what changed since the previous tag.
Deploy targets resolve from the changed file paths in the release range — not commit scopes — expanded through the package dependents graph:
| Changed package | Deploy |
|---|---|
web, server |
Fly (API + web) |
app |
Android AAB attached to GitHub Release |
client |
Fly + Android |
types, icons |
Fly + Android (all consumers) |
| anything else | Fly + Android (global change) |
Ordinary merges to master do not deploy production — they update the
persistent preview environment (eciperay-next) instead. Play Store upload is
still manual: Actions → Android Deploy → Run workflow and pick a Play track.
| Track | Play Console destination |
|---|---|
build-only |
Skip Play upload; download the AAB from Artifacts |
internal |
Internal testing |
alpha |
Closed testing |
beta |
Open testing |
production |
Production |
internalsharing |
Internal app sharing (temporary download link) |
Add these repository secrets
| Secret | Purpose |
|---|---|
VITE_SUPABASE_URL |
Production Supabase URL (same as web deploy) |
VITE_SUPABASE_ANON_KEY |
Production Supabase anon key (same as web deploy) |
VITE_POSTHOG_KEY |
PostHog project API key (app runtime) |
VITE_POSTHOG_HOST |
PostHog ingest host (reverse proxy) |
POSTHOG_CLI_API_KEY |
Personal API key for mobile source map upload |
POSTHOG_CLI_PROJECT_ID |
PostHog project ID for mobile source map upload |
ANDROID_KEYSTORE_BASE64 |
Base64-encoded release .jks / .keystore file |
ANDROID_KEYSTORE_PASSWORD |
Keystore password |
ANDROID_KEY_ALIAS |
Key alias |
ANDROID_KEY_PASSWORD |
Key password |
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON |
Play Console API service account JSON (for upload) |
API_BASE_URL is set to https://www.eciperay.com/api in the workflow.
Google Play service account (one-time): enable the
Google Play Android Developer API,
create a service account in Google Cloud, download its JSON key, and invite that
account under Play Console → Users and permissions with release permissions
for com.eciperay. Store the JSON contents in
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON.
Generate a keystore locally (once):
keytool -genkeypair -v -storetype PKCS12 \
-keystore upload-keystore.jks -alias eciperay \
-keyalg RSA -keysize 2048 -validity 10000
base64 -i upload-keystore.jks | pbcopy # paste into ANDROID_KEYSTORE_BASE64Download the AAB from the workflow run’s Artifacts tab
(eciperay-android-<run-number>) when using build-only, or check Play Console
for the release on the track you selected.
From packages/app/android, export production env and signing vars, then build.
Avoid ./gradlew clean — it wipes native-module prefab outputs under
node_modules. Use --rerun-tasks on the bundle step when env vars change.
cd packages/app/android
export APP_ENV=production
export API_BASE_URL=https://www.eciperay.com/api
export SUPABASE_URL=https://your-project.supabase.co
export SUPABASE_ANON_KEY=your-anon-key
export ANDROID_KEYSTORE_PATH=upload-keystore.jks
export ANDROID_KEYSTORE_PASSWORD=...
export ANDROID_KEY_ALIAS=eciperay
export ANDROID_KEY_PASSWORD=...
# Play Store upload (AAB) — set VERSION_CODE higher than the last Play upload
VERSION_CODE=123 ./scripts/android-release.sh
# Or manually:
./gradlew :app:createBundleReleaseJsAndAssets --rerun-tasks
./gradlew bundleRelease -PVERSION_CODE=123
# Sideload onto a device (APK — same signing config)
./gradlew assembleRelease
adb install --no-streaming -r app/build/outputs/apk/release/app-release.apkVerify the production API URL is baked into the bundle before installing:
strings app/build/generated/assets/react/release/index.android.bundle \
| grep 'www.eciperay.com/api'- Create the app in Play Console with package name
com.eciperay. - Set the Privacy policy URL to
https://www.eciperay.com/privacyunder App content (required for apps with auth and user content). - Build a release AAB signed with your upload keystore (
bundleReleaseabove). - Upload to Internal testing (or another track). Play App Signing enrolls
on first upload — your
upload-keystore.jksbecomes the upload key; Google holds the app signing key used for Play-distributed APKs. - Do not use “Change signing key” on first setup — that flow is only for replacing a lost or rotated upload key.
Optional: create a production environment in GitHub to require approval before Android deploys (same as Fly web deploy).
| Command | Description |
|---|---|
npm run build |
Build web app into packages/web/dist |
npm run start |
Production server (API + static web) |
./scripts/fly-deploy.sh |
Deploy API + web to Fly.io |
npm run dev |
API server + Metro bundler |
npm run dev:web |
API server + Vite web app |
npm run dev:ios |
Server + Metro + iOS simulator |
npm run dev:android |
Server + Metro + physical Android device |
npm run android:device |
Build/install app on connected phone only |
npm run typecheck |
TypeScript check all packages |
npm run lint |
ESLint across all packages |
npm run ci |
Full CI (typecheck, lint, tests) |
npm test |
Run all package tests |
npm run test:coverage |
All packages with 100% coverage threshold |
All code packages (client, web, app, server) enforce 100% test
coverage. Run npm run ci before opening a PR.
| Package | Runner | Why |
|---|---|---|
packages/server |
Jest | Node/Express integration tests with supertest and nock |
packages/client |
Jest | Shared TypeScript library; same ESM Jest setup as server |
packages/app |
Jest | React Native preset and native-module mocks |
packages/web |
Vitest | Shares Vite's transform pipeline, aliases, and ESM config (see below) |
Why Vitest for web, not Jest? packages/web is a Vite 6 ESM app. Vitest
runs on the same config as dev and build (vitest.config.ts reuses Vite
plugins, path aliases, and envDir), so tests resolve modules the same way the
app does. Jest would need a parallel transform/alias setup (e.g. vite-jest or
custom moduleNameMapper) on top of the Jest 29/30 configs already used by app,
server, and client. Vitest also fits the web stack (vi mocks, jsdom, MSW,
@vitest/coverage-v8) without extra bridging. We keep Jest where it is the
natural fit (Node API, React Native); we use Vitest only for the Vite web
package.
-
npm run cipasses -
npm run dev:web— sign in, list recipes, import, edit, delete - iOS/Android — auth persistence, add flow, recipe detail tabs
Commit messages use Conventional Commits with a required scope matching the monorepo package you changed:
feat(web): add recipe import drag target
fix(server): validate image mime type
feat(client): add shared query key
- Format rules and deploy impact:
.cursor/skills/eciperay-commits/SKILL.md - Enforced locally by Husky
commit-msg+commitlint.config.cjs - If you squash-merge PRs on GitHub, use the same format for the PR title
Allowed scopes: app, web, server, client, types, android, deps,
ci, release
- Import flows save a draft recipe server-side, then open ImportReview for edit or discard (delete)
- AI drafts from chat/cleanup require explicit approve/reject
- Recipe scaling is client-side only and never persisted
- Tags are managed per user; preset tags are read-only