Module spec
The module.json contract, generated files and ownership rules (docs/module-spec.md)
Every optional thing lives in modules/<category>/<option>/ with module.json, files/ (a mirror of repo-root paths), and docs.md.
bun setup copies selected files/** to the root byte-for-byte, deletes files owned only by non-selected modules, generates the files below, patches package/app/tsconfig, strips the demo content (unless --with-examples), drops the readynative:contract-lint entry from eslint.config.js (unless --keep-modules), finalizes the tree (see "Finalize" below, unless --keep-modules) and (unless --keep-modules) removes modules/.
Invariant: the committed root tree == setup --preset default --with-examples --keep-modules output (diff empty except lockfile and .readynative.json). The drift check for the dev loop is therefore:
bun run setup --dry-run --preset default --with-examples --keep-modules --yes # must report only .readynative.json to writeThe root keeps the examples and the lint contract on purpose: every UI stack in modules/ has to compile the shipped screens, which is what the no-restricted-imports entry marked // readynative:contract-lint enforces. Buyers get neither - a default setup run removes both.
Finalize (the buyer's tree)
After the strip/ESLint step, and only when --keep-modules is absent, setup runs
scripts/lib/finalize.ts (planFinalize → moves / deletes / rewrites / packageJson, then the
same reporting writer as the rest of apply). verify-modules.sh always passes
--keep-modules, so finalize never runs there and the dev loop keeps the product tree.
- F1 - components.
src/ui/<active stack>/*→src/components/ui/*(flat), plussrc/ui/theme-mode.tsandsrc/ui/__tests__/.src/ui/contract.tsbecomessrc/components/ui/types.ts- everything after its// readynative:uikitmarker (theUiKittype) is cut, the prop types stay because 20 components import them. The stack'sindex.tslosesimport type { UiKit }, theconst kit = { … } satisfies UiKitobject and its default export, and gains the re-exports the generatedsrc/ui/index.tscarried (./theme-mode,./types). Every@/ui…reference insrc/**,scripts/**,jest.setup.ts,.maestro/**and the root*.ts(x)/jsfiles (comments included) is rewritten:@/ui→@/components/ui,@/ui/contract→@/components/ui/types,@/ui/<stack>/x→@/components/ui/x.jest.setup.tsloses its// readynative:ui-jest-setupblock in favour of a plainrequire("./src/components/ui/jest-setup"). - F2 - tooling.
scripts/keepsdoctor,gen-assets,gen-links,gen-theme,e2e(+lib/{args,format,doctor,gen-assets,gen-links,gen-theme,e2e,readynative-state}.tsand thegen-theme-<gen id>.tsemitter of the active stack) and the tests of the kept scripts that do not needscripts/test-utils/fixture.ts.scripts/lib/gen-theme.tskeeps only the// readynative:gen <id>lines of the active stack, so its renderer map is a direct call.docs/,examples/,tools/and.github/workflows/docs.ymlare deleted,ci.ymlis replaced by a typecheck/lint/test job,README.md/AGENTS.mdare rendered fromscripts/lib/templates/, and package.json losesgen/clean/docs/release/verifyand@clack/prompts(setupstays as a one-line "already applied" echo so a secondbun run setupexits 0).bun installruns after all of this, sobun.lockis refreshed.
Nothing outside scripts/lib/finalize.ts knows about the buyer's layout: the scripts that
survive read .readynative.json through scripts/lib/readynative-state.ts, which imports no part
of the module system (catalog / registry / schema / resolve).
Categories / options (first = default)
ui nativewind4·nativewind5·tamagui·unistyles·stylesheet | data react-query·apollo·swr·none | state zustand·jotai·none |
storage kv-store·mmkv·async-storage | forms react-hook-form·none | i18n i18next·lingui·none | auth supabase·clerk·better-auth·none |
payments revenuecat·adapty·stripe·none | analytics posthog·amplitude·none | crash sentry·none | push expo-notifications·none |
backend api-routes·none | onboarding on·off | testing jest·none
Presets: minimal (ui nativewind4 + storage kv-store, everything else none/off), default (nativewind4, react-query, zustand, kv-store, react-hook-form, i18next, onboarding on, testing jest, others none), saas (default + supabase, revenuecat, posthog, sentry, expo-notifications, api-routes).
Phase 2 ships only the modules the default and minimal presets need (plus none/off modules for every category). Unknown options in a preset → setup error.
module.json
{
"id": "react-query",
"category": "data",
"label": "TanStack Query",
"hint": "server state + cache · Expo Go OK",
"expoGo": true, // false → doctor requires a dev build
"recommended": false, // true → docs flag it as "most people pick this"
"dependencies": { "@tanstack/react-query": "^5.102.0" },
"devDependencies": {},
"expoInstall": [], // passed to `bunx expo install`
"package": { "scripts": {}, "overrides": {}, "lint-staged": {} }, // shallow-merge into package.json (per top-level key)
"app": { "expo": { "plugins": [] } }, // JSON merge patch onto app.config extras - see "app patch" below
"tsconfig": { "include": [] }, // append-unique
"files": ["src/lib/query.tsx"], // root-relative; must exist under files/
"removeFiles": [], // root paths deleted when this module is selected
"providers": [{ "import": "QueryProvider", "from": "@/lib/query", "order": 20 }],
"env": [
{
"key": "EXPO_PUBLIC_API_URL",
"example": "https://api.example.com",
"required": false, // doctor-only: true → `bun doctor` fails when the key is unset (see "env")
"docsUrl": "",
"server": false, // true → server-only secret: no EXPO_PUBLIC_ prefix, never in src/lib/env.ts
},
],
"requires": { "storage": ["kv-store", "mmkv", "async-storage"] }, // category → allowed options
"recommends": {},
"conflicts": [], // conflicts: ["<category>/<option>"]
"gen": [], // gen-theme targets, ui only: ["nativewind5"]
"doctor": [{ "type": "env", "keys": ["EXPO_PUBLIC_API_URL"] }, { "type": "devBuild" }],
"postSetup": ["..."],
"privacy": {
// what this module's SDK collects; composed by setup, see "privacy"
"dataTypes": [
{
"type": "NSPrivacyCollectedDataTypeEmailAddress",
"linked": true,
"purposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"],
},
],
"accessedApis": [{ "type": "NSPrivacyAccessedAPICategoryUserDefaults", "reasons": ["CA92.1"] }],
"tracking": false,
"trackingDomains": [],
"playDataSafety": {
"sdk": "X (cat/opt)",
"collects": ["..."],
"sharedWith": "X (processor)",
"disclosureUrl": "https://...",
},
},
}env
requirednever changes the generatedsrc/lib/env.ts(every key isz.string().optional()): a missing key must degrade the module ("Configure X"), not crash the app at import time. It only drives doctor: everyrequired: truekey is an implicit{ "type": "env" }check, so a module lists a key indoctoronly when it is optional-but-worth-checking (e.g.EXPO_PUBLIC_API_URL). Explicit + implicit keys are merged per module and deduped.server: true(e.g.BETTER_AUTH_SECRET,STRIPE_SECRET_KEY): read viaprocess.env.Xinsrc/app/api/**/src/server/**; listed in.env.exampleunder the trailing# serversection; checked by doctor like any other key; never rendered intosrc/lib/env.ts, so it cannot leak into the bundle. Keys withserver: truemust not start withEXPO_PUBLIC_.- A key declared by several selected modules (typically
EXPO_PUBLIC_API_URL) is deduped first-wins in copy order (uifirst, then catalog order): the first module'sexample+docsUrlland in.env.exampleand in the doctor check;requiredis OR-ed.
Core dependencies
CORE_DEPENDENCIES in scripts/lib/catalog.ts = every root dependencies/devDependencies key that no default-preset module declares (expo, expo-constants, expo-device, react-native, zod, …). removeDependencies never touches them, so a module may redeclare a core package it imports (push/expo-notifications → expo-device, expo-constants) without turning it off for every selection that lacks the module. A unit test asserts the constant ⊆ root package.json; regenerate the list when core deps change.
none/off modules: { id, category, label, hint, expoGo: true, recommended: false, files: [], providers: [], env: [] } - everything else optional. Schema is zod in scripts/lib/schema.ts; every field except id/category/label has a default.
privacy
Declared once per module, composed by resolvePlan (composePrivacy): dataTypes dedupe by type (linked/tracking OR-ed, purposes unioned), accessedApis dedupe by category (reasons unioned), tracking OR-ed, domains unioned. The result is patched into app.expo.ios.privacyManifests (so app.config.ts ships an Apple privacy manifest without a static block) and the playDataSafety rows are stored in .readynative.json → modules.privacy.dataSafety, which bun run doctor --store prints as the Play Data safety draft - a finalized tree has no modules/ to read. Storage modules declare the UserDefaults required-reason API (they are the callers); service modules declare their data types and a Data safety row; none options declare nothing.
app patch
app.config.ts reads .readynative.json → modules.app (merged patch of every selected module's app.expo) and applies it: plugins append-unique by plugin id (string or [id, opts]), other keys deep-merged. So app.config.ts stays hand-written and modules never edit it.
Generated files (owned by setup, never by modules)
src/ui/index.ts:/** * THE swap point: `bun setup` rewrites this file to re-export the active stack. * Screens, components and routes import UI only from `@/ui`. */ export * from "./contract"; export * from "./theme-mode"; export * from "./<ui option>";src/providers.tsx: header comment (the 10..90 order table as today), then imports sorted byorderthenimportname, oneimport { X } from "from";line each (ThemeProvider as UiThemeProviderfor the ui provider - the ui module declares{ "import": "ThemeProvider", "as": "UiThemeProvider", "from": "@/ui", "order": 50 }-asis an optional field), andToastHostfrom@/ui(always, order 90, rendered after children). Body: nested JSX from lowest order outward,{children}innermost,<ToastHost />right after{children}inside the innermost provider. Prettier-formatted (run prettier on the output string via its API).src/lib/env.ts: same structure as today; onez.string().optional()line per public (non-server) env key across selected modules (APP_VARIANTalways first;requiredis doctor-only, see "env"),rawobject listingoptional(process.env.EXPO_PUBLIC_<KEY>)per key. Keys sorted, deduped..env.example: header,APP_VARIANT=dev,EXPO_PUBLIC_APP_VARIANT=dev, then per module a# <label>comment +KEY=examplelines (+# docs: url) for its public keys (a key shared by two modules appears once, first wins), then one# server (never EXPO_PUBLIC)section with theserver: truekeys grouped per module..readynative.json:{ "version": 1, "preset": "default" | null, "selection": { "<category>": "<option>" }, "applied": true, "modules": { "app": {...merged app patch...}, "postSetup": [...] , "doctor": [...] }, "keepModules": true|false }.package.jsonpatches: add moduledependencies/devDependencies(remove deps that belong ONLY to non-selected modules and are not inCORE_DEPENDENCIES), mergepackageblock; sub-keys of object blocks (e.g.scripts.test) andtsconfig.includeentries that belong ONLY to non-selected modules are removed the same way. Keys sorted like bun does. Top-level scalarpackagekeys (e.g.main) are set by the selected module that declares them; when only non-selected modules declare one, the core default fromPACKAGE_CORE_DEFAULTS(main: "expo-router/entry") is restored, or the key is deleted if it has no core default. JSON rewrites (package.json, tsconfig.json) are prettier-formatted and only happen when the parsed value changes.- Core tooling deps stay in the root
package.jsoneven when a module would also bring them (e.g.@types/node, needed byapp.config.tsandscripts/**; with TS 6 the tsconfig lists"types": ["node"]explicitly). Engine unit tests (scripts/__tests__/**,scripts/test-utils/**) andjest.config.jsare owned bytesting/jest.
Ownership
Core (never removed): everything not listed in any module.json.files. A file listed by several modules is removed only if none of its owners is selected. A selected module's removeFiles beats every owner, selected or not - the path is not copied and is deleted. Copy order: ui first, then category order above.
Tests and mocks
- Module-owned tests live in the module's
files/(e.g.modules/storage/mmkv/files/src/lib/__tests__/storage-mmkv.test.ts,modules/data/swr/files/src/hooks/__tests__/use-example-query.test.tsx). Two options of one category may own the same test path (src/stores/__tests__/example-store.test.tsis owned by bothstate/zustandandstate/jotai); the selected one's copy wins. - Every shared test path must also be listed in
testing/none.removeFiles(the per-stacksrc/ui/<stack>/jest-setup.tsincluded), so--testing noneleaves no__tests__behind regardless of which options own them - selected owners too, sinceremoveFileswins overfiles. When you add a test to a module, add its path there too (bunx jest scripts-registry.test.ts- checksfiles[]entries exist; the--testing noneverify run is what catches a missingremoveFilesentry). - Storage modules mock their native package with jest's
__mocks__/auto-mock convention: a root-level__mocks__/<package-name>.ts(modules/storage/mmkv/files/__mocks__/react-native-nitro-modules.ts,modules/storage/async-storage/files/__mocks__/@react-native-async-storage/async-storage.ts) is picked up automatically for that node module - nojest.mock()call injest.setup.ts. The corejest.setup.tsmocksexpo-sqlite/kv-storewith{ virtual: true }so it stays valid whenexpo-sqliteis not installed (storage=mmkv|async-storage). Core tests never import a storage package directly; they go through@/lib/storage(storage.getItem/setItem, async) so they pass on every adapter.
Scripts (bun run scripts/<x>.ts)
setup [--preset p] [--<category> <option>]… [--yes] [--dry-run] [--no-install] [--keep-modules] [--with-examples] [--root <dir>]- interactive via @clack/prompts when a TTY and not--yes. Idempotent: same selection → no diff, exit 0. Whenmodules/is gone (a previous run without--keep-modules) and.readynative.json.appliedis set: same selection → "Already applied", exit 0; different selection → error pointing at--keep-modules.gen-theme [--check] [--root <dir>]- tokens (+ brand) → one artefact set per ui modulegentarget, written undermodules/ui/<stack>/files/and, for the active ui (.readynative.json, default nativewind4), at the root:nativewind5→src/global.css;nativewind4→src/global.css+tailwind.config.js;tamagui→tamagui.config.ts. Output is prettier-formatted.--checkexits 1 on drift in any target.doctor [--root <dir>]- reads.readynative.json; checks env keys (doctorentries + everyrequired: truekey,.envthen process env, with docsUrl), placeholders inreadynative.config.ts(com.acme.app, empty urls/store ids),eas whoami(skip if eas-cli absent), Xcode (xcode-select -p) / Android SDK (ANDROID_HOME) when any selected module isexpoGo:false, bun/node versions. Non-zero on failures, checklist output.gen screen <name>|gen module <category> <option>- scaffolders.clean [--no-examples]- the same stripsetupruns: the examples tab (src/app/examples,src/screens/examples,src/examples.ts, the example hook/store and their tests) and its Settings row,_catalogand its Settings row, the weather demo (the Cities tab and its<NativeTabs.Trigger>insrc/app/(tabs)/_layout.tsx,src/screens/weather,src/lib/weather,src/stores/weather.ts,src/hooks/use-forecast.ts), sample locales (keepsen, rewritessrc/lib/i18n.ts), placeholder assets,tools/; the home screen becomes a plain welcome and the Weather tab turns back into Home. Every anchored edit is a no-op once applied and warns (never fails) when it cannot find its anchor.--no-exampleskeeps the weather demo and the examples tab. All scripts take--rootso unit tests run them against a fixture tree in a temp dir. Logic lives inscripts/lib/*.ts;scripts/<x>.tsare thin CLIs.
Service modules (auth · payments · analytics · crash · push · backend)
- Shim ownership.
src/lib/{auth,analytics,crash,payments,push,location,widgets}.tsare owned by the<category>/nonemodule and are one-line re-exports ofsrc/lib/shims/<category>.ts(same pattern asi18n/none). A real module lists the samesrc/lib/<category>.tsinfiles[]and ships its own implementation of the shim's exported interface (Auth,Payments,Analytics,Crash,Push,Location,Widgets) with the same export names - core only ever imports@/lib/<category>.backend/noneowns nothing (src/app/api/**andsrc/server/**areapi-routesfiles). src/hooks/use-auth-redirect.tsis owned byauth/none(no-op) and by every auth module. The root layout callsuseOnboardingRedirect()thenuseAuthRedirect(); the contract (gate onuseRootNavigationState().key, no-op whileloading,unauthenticatedoutside(auth)→router.replace("/(auth)/sign-in"),authenticatedinside(auth)→router.replace("/"), onboarding wins first) is documented in the hook itself. Auth modules ship thesrc/app/(auth)/group (sign-in/up/reset).- Core hooks already wired:
_layout.tsxcallsanalytics.screen(pathname)on every pathname change; the Settings screen renders an "Account" card withauth.signOut()only whenauth.useSession().status === "authenticated". - Examples slugs reserved in
src/examples.ts(the module ownssrc/app/examples/<slug>.tsx):auth(auth),paywall(payments),analytics(analytics),crash(crash),push(push),api(backend). Without the module the[slug]fallback renders "Module not installed". module.jsonrequirements for every service module: everyenv[]entry has a non-emptydocsUrl; the keys the module cannot work without arerequired: true(doctor checks them implicitly; an explicit{ "type": "env", "keys": [...] }entry is for optional keys still worth flagging, e.g.EXPO_PUBLIC_API_URL); server secrets areserver: true;{ "type": "devBuild" }whenexpoGo: false;postSetupnames the dashboard steps (create the project, paste keys, register bundle ids / webhooks);docs.mdcontains a "Manual E2E checklist" section (sandbox account → real device flow), since CI only proves the module builds.