create-four-app
field manual

An opinionated CLI that scaffolds a TanStack + Drizzle pnpm monorepo. Composable, idempotent.

pnpm dlx create-four-app

primary action: run command

or: npx create-four-app

02

choices

declared stack

  1. Drizzle, not Prisma.

    Type-safe SQL with no runtime client. Migrations are plain TypeScript files; the schema is the source of truth.

    choice 01

  2. Oxlint, not ESLint.

    Same rules, written in Rust. About 100× faster on cold starts, no plugin tax, no config bikeshedding.

    choice 02

  3. Vitest browser mode, not Jest + jsdom.

    Tests run in real browsers via Playwright. No emulated DOM, no surprises between CI and production.

    choice 03

  4. TanStack Start, not Next.js.

    Isomorphic loaders, type-safe routing, readable SSR. No framework runtime owns your code.

    choice 04

  5. pnpm workspaces, not npm or yarn.

    Strict isolation, fast installs, content-addressable store. Designed for monorepos from day one.

    choice 05

03

configure

compose modules

project-name

kebab-case, no spaces

framework

core modules

db

worker

requires db

auth providers

requires db

ops modules

config.json

{
  "projectName": "my-app",
  "framework": "tanstack-start",
  "modules": {}
}

command

create-four-app my-app --non-interactive --json-config config.json

04

modules

table

Eleven modules. Each is file-additive, never edits arbitrary user code. Click any row to see what an installer drops in.

files added:

  • apps/web/src/styles/globals.css
  • apps/web/vite.config.ts (plugin registration)

files added:

  • apps/web/src/components/ui/button.tsx
  • apps/web/src/components/ui/input.tsx
  • apps/web/src/lib/utils.ts

files added:

  • apps/web/src/i18n/index.ts
  • apps/web/src/i18n/locales/en.json
  • apps/web/src/providers/fragments/i18n.tsx

files added:

  • apps/web/src/stores/counter.ts
  • apps/web/src/stores/index.ts

files added:

  • apps/web/package.json (dependency additions)
  • apps/web/src/providers/fragments/query.tsx

files added:

  • packages/db/package.json
  • packages/db/src/client.ts
  • packages/db/src/schema/index.ts
  • packages/db/drizzle.config.ts
  • packages/config/env/fragments/db.ts

files added:

  • apps/worker/package.json
  • apps/worker/src/index.ts
  • apps/worker/src/jobs/example.ts

files added:

  • apps/web/src/server/auth/index.ts
  • apps/web/src/server/auth/providers/email-password.ts
  • packages/db/src/schema/auth.ts
  • apps/web/src/providers/fragments/auth.tsx

files added:

  • Dockerfile
  • docker-compose.yml
  • .dockerignore

files added:

  • wrangler.jsonc
  • apps/web/src/server/hyperdrive.ts
  • packages/config/env/fragments/wrangler.ts

files added:

  • .github/workflows/ci.yml

05

seams

composition

Installers never edit your code. They drop files into glob patterns the runtime reads at startup. Layer modules in any order; the seams resolve.

packages/db/src/schema/**/*.ts
Drizzle schema files. New auth tables, app tables, anything land here.
packages/config/env/fragments/*.ts
Zod env fragments. Each module drops one; the root config composes them.
apps/web/src/server/auth/providers/*.ts
better-auth providers. Add a file, the runtime picks it up.
apps/web/src/providers/fragments/*.tsx
React context providers, ordered. Lower order = outer wrapper.

06

tree

generated structure

What lands on disk after running every module against a fresh project. Real output, not a sketch.

my-app/
├── apps/
│   ├── web/
│   │   ├── src/
│   │   │   ├── components/ui/
│   │   │   │   ├── button.tsx
│   │   │   │   └── input.tsx
│   │   │   ├── i18n/
│   │   │   │   ├── locales/en.json
│   │   │   │   └── index.ts
│   │   │   ├── providers/
│   │   │   │   ├── fragments/
│   │   │   │   │   ├── auth.tsx
│   │   │   │   │   ├── i18n.tsx
│   │   │   │   │   ├── query.tsx
│   │   │   │   │   └── zustand.tsx
│   │   │   │   └── index.tsx
│   │   │   ├── routes/
│   │   │   │   ├── __root.tsx
│   │   │   │   └── index.tsx
│   │   │   ├── server/
│   │   │   │   ├── auth/
│   │   │   │   │   ├── providers/
│   │   │   │   │   │   ├── email-password.ts
│   │   │   │   │   │   └── github.ts
│   │   │   │   │   └── index.ts
│   │   │   │   └── hyperdrive.ts
│   │   │   ├── stores/counter.ts
│   │   │   └── styles/globals.css
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   ├── vite.config.ts
│   │   └── vitest.config.ts
│   └── worker/
│       ├── src/
│       │   ├── jobs/example.ts
│       │   └── index.ts
│       └── package.json
├── packages/
│   ├── config/
│   │   ├── env/
│   │   │   ├── fragments/
│   │   │   │   ├── db.ts
│   │   │   │   └── wrangler.ts
│   │   │   └── index.ts
│   │   └── tsconfig.base.json
│   └── db/
│       ├── src/
│       │   ├── client.ts
│       │   └── schema/
│       │       ├── auth.ts
│       │       └── index.ts
│       ├── drizzle.config.ts
│       └── package.json
├── .github/workflows/ci.yml
├── .four.json
├── Dockerfile
├── docker-compose.yml
├── package.json
├── pnpm-workspace.yaml
└── wrangler.jsonc

07

add

subcommand

Every module is also reachable through add. The CLI walks up five directory levels to find .four.json, so you can layer modules from anywhere inside the project.

Same installer code as init. Same prompts when a module has options. No hidden state.

cd my-app
create-four-app add tailwind
create-four-app add db          # select dialect in prompt
create-four-app add auth        # select providers in prompt

08

idempotency

contract

init(all modules) === init(base) + add(each module)

The order in which modules are added does not change the resulting tree. Verified by the test suite on every commit.