Skip to content

Latest commit

 

History

History
205 lines (138 loc) · 17.1 KB

File metadata and controls

205 lines (138 loc) · 17.1 KB

CLAUDE.md

Working notes for Claude when editing this repo. The repo is projx, a CLI that scaffolds production-ready full-stack projects.

What's in here

The repo is two things at once:

  1. The CLI source — under cli/. Published to npm as create-projx.
  2. The templates the CLI ships — every other top-level directory is a template that gets copied into the user's new project.

Top-level layout:

cli/         create-projx CLI source (TypeScript, ESM, tsup build, vitest)
fastify/     Fastify + Prisma backend template
fastapi/     FastAPI + SQLAlchemy + Alembic backend template
frontend/    React + Vite frontend template
mobile/      Flutter app template
e2e/         Playwright E2E template
infra/       Terraform IaC template
features/    Opt-in feature overlays applied via --<feature>=<targets> (e.g. --auth=fastify)
addons/      Out-of-tree drop-ins (e.g. ORM addons in addons/orms/<orm>/)
docs/        Design docs (feature templates, etc.)
scripts/     Static scripts copied into scaffolded projects
.githooks/   Pre-commit hooks for the projx repo itself

The CLI fetches the whole repo (or uses --local <path>) and copies the component directories into the user's project. Shared scaffolding files (CI yaml, README, docker-compose, pre-commit, setup.sh) live in cli/src/templates/ as .ejs files rendered at scaffold time by the hand-rolled engine in cli/src/utils.ts.

ORM-specific scaffolding (Drizzle, Sequelize, TypeORM) lives in addons/orms// at the repo root — same fetch-from-repo model as features/ and the base templates. The CLI bundle on npm only ships cli/dist/ + cli/src/templates/; addons are pulled in at scaffold-time and gen-time from the projx repo tarball.

Hand-rolled template engine

The EJS-like engine in cli/src/utils.ts (render) supports <% if %>, <% for %>, <%= expr %>. It is intentionally minimal — do not introduce a dep on real EJS. Shared template vars:

  • projectName, components, paths, pm (package-manager commands)
  • fastapiInstances, fastifyInstances, frontendInstances, mobileInstances, e2eInstances, infraInstances — all enriched with path, upper, display

Multi-instance support: a project can have N fastify instances at different paths. Generators iterate the *Instances arrays.

ORM addons

ORMs other than Prisma (the default) are scaffolded via self-contained addons at addons/orms// — sibling to features/ at the repo root, not inside cli/. Currently shipped: drizzle, sequelize, typeorm. Each addon has:

addons/orms/<orm>/
  manifest.json                # deps to add/remove, files to remove from base, scripts, Dockerfile config
  shared/                      # files identical between fastify and express
    src/db/                    # connection setup (client.ts / data-source.ts)
    src/{models,entities}/     # aggregator with anchor for `gen entity` to append into
    src/modules/_base/         # query-engine.ts (ORM-flavored helpers)
    scripts/db-sync.ts         # schema sync (drizzle uses drizzle-kit push instead)
  fastify/                     # fastify-specific overlay
    src/app.ts                 # with `// projx-anchor: entity-imports` + `entity-registrations`
    src/modules/_base/         # auto-routes.ts (Fastify), index.ts
    tests/, vitest.config.ts
  express/                     # express-specific overlay (same shape)
  gen-entity/                  # templates used by `gen entity`, placeholders like `__ENTITY_PASCAL__`

CLI dispatch:

  • cli/src/baseline.tsapplyOrmAddon(repoDir, orm, framework, dir, vars) is generic: reads manifest.json from repoDir/addons/orms/<orm>/, removes the Prisma files listed in removeFromBase, applies package.json overrides (deps, scripts, descriptionReplace), copies shared/ + <framework>/ into the project, and writes a Dockerfile parameterized by manifest.dockerfile.{extraConfigFiles,migrateCommand}.
  • cli/src/gen.tsgen() calls downloadRepo(localRepo) when the project uses a non-Prisma ORM, then append<Orm>Entity(repoDir, cwd, dir, framework, config, generated) loads gen-entity/*.ts from repoDir/addons/orms/<orm>/gen-entity/, substitutes __PLACEHOLDER__ tokens (e.g. __ENTITY_PASCAL__, __SAMPLE_PAYLOAD__, __COLUMN_DECORATORS__), writes the schema/model/entity file, the router, the test, and inserts wiring lines into src/app.ts at the two anchors (entity-imports, entity-registrations) and into the models/entities aggregator at its two anchors (model-imports, model-exports).

All four ORMs (Prisma in the base + Drizzle/Sequelize/TypeORM via addons) ship the same runtime surface: CRUD via registerEntityRoutes, pagination, equality filtering, ILIKE search, order_by with - prefix for desc, bulk operations, and the lifecycle hook contract (beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete).

Addons are NOT bundled in the published npm package — cli/package.json#files ships only dist and src/templates. The CLI fetches the projx repo tarball at scaffold-time and gen-time and reads addons/ from the extracted tree (same model as features/ and the base templates). Use --local <path> during development to point at a local checkout instead of fetching.

Adding a new ORM (e.g., Kysely): create addons/orms/kysely/ matching the layout above. Add kysely to ORM_PROVIDERS in cli/src/utils.ts and to the help string in cli/src/index.ts. Add an append<Orm>Entity function in cli/src/gen.ts following the existing pattern. Update setup.sh.ejs + ci.yml.ejs if the migrate command differs from tsx scripts/db-sync.ts. No baseline.ts core changes needed.

Commands

The CLI has these subcommands (see cli/src/index.ts parseArgs):

  • create (default) — scaffold a new project
  • update — pull latest template changes into an existing project
  • add — add components to an existing project
  • init — adopt an existing project
  • pin / unpin — protect files from update
  • diff — preview update changes
  • doctor — health-check a project
  • gen — entity generators
  • sync — pull types from a running backend

Feature flags: --<feature>=<component>[:<instance>][,...]. Only --auth is implemented today; see docs/feature-templates.md for the standard. Known features live in KNOWN_FEATURES in cli/src/utils.ts. Manifests may set requiresOrm: ["prisma", ...]cli/src/features.ts validates this against the project's --orm before any file I/O and errors with Feature "<name>" requires --orm <list> (got "<orm>").. The auth feature ships across all three backends and all four Node ORMs (requiresOrm: ["prisma", "drizzle", "sequelize", "typeorm"], supports: ["fastify", "fastapi", "express"]).

Local development loop

# Build the CLI
pnpm --dir cli build

# Run quality gates
pnpm --dir cli exec tsc --noEmit
pnpm --dir cli exec eslint src/ tests/
pnpm --dir cli test

# Scaffold a project with the local templates (do not skip --local during dev)
node cli/dist/index.js my-app --components fastify --no-install --no-git --local "$(pwd)"

pnpm --dir cli test runs vitest with v8 coverage. The 80% threshold (statements/branches/functions/lines) is enforced — see cli/vitest.config.ts.

Per-template gates

Each template has its own test suite that must stay green on the projx repo itself (not just in scaffolded projects):

Template Format Lint Typecheck Test Coverage
cli/ prettier eslint tsc --noEmit vitest v8 ≥80%
fastify/ prettier eslint tsc --noEmit vitest (real Postgres) v8 ≥80%
express/ prettier eslint tsc --noEmit vitest (real Postgres) v8 ≥80%
fastapi/ ruff format ruff check mypy pytest pytest-cov ≥80%
frontend/ prettier eslint tsc --noEmit vitest v8 ≥80%
mobile/ dart format dart analyze --fatal-infos (in analyze) flutter test scripts/check-coverage.sh ≥80%
e2e/ prettier eslint tsc --noEmit n/a n/a

CI runs all of these — see .github/workflows/ci.yml. Locally, scripts/ci-local.sh runs every available section in parallel — pass cli, fastapi, fastify, express, frontend, e2e, infra, or no args for all. cli is the only section that gates the CLI itself; the rest gate the templates as they sit in the projx repo.

Prettier config is unified across cli/, fastify/, express/, frontend/, e2e/: {semi: true, singleQuote: true, trailingComma: "all", printWidth: 80, tabWidth: 2} — frontend keeps jsxSingleQuote + bracketSameLine as overrides. All four .prettierignore files cover node_modules, dist, coverage, and all three lockfile names (pnpm-lock.yaml, package-lock.json, yarn.lock).

Pre-commit hooks

.githooks/pre-commit runs format + lint + typecheck on staged files per template. It does NOT run the full test suite. The template counterpart that scaffolded projects inherit is cli/src/templates/pre-commit.ejs — keep both in sync when adding gates.

CLI block scopes by ^cli/.*\.ts$ (including tests). FastAPI block enforces the private cross-module import rule and runs lint-imports.

Conventions

Templates ship schema, not migrations

Pre-baked Prisma or Alembic migrations do not belong in the templates. The schema files (schema.prisma, alembic env) ship; users generate their own migrations on first setup (setup.sh bootstraps the initial migration when DATABASE_URL is set). This applies template-wide and to features.

Error handling is centralized

Both backends use a single global error handler that emits { detail, request_id }. See:

Routes throw typed errors; handlers map them. Do NOT add inline reply.status(N).send({ detail }) without going through err() (or equivalent) that injects request_id. Mobile parses request_id into AppException.requestId.

Runtime config is DB-backed

JWT_SECRET, SMTP creds, service configs, etc. live in the encrypted service_configs table, read via fastify/src/lib/service-config.ts and the FastAPI equivalent. Env vars are bootstrap-only (first run / CRED_ENCRYPTION_KEY).

Private module imports

FastAPI: files inside src/ cannot from src.<pkg>._<file> import .... Import from the package's __init__.py. CI and pre-commit enforce this via grep. The base _-prefixed files are package-private.

Entity lifecycle hooks (fastify + express)

Both Node backends' auto-routes honour optional hooks declared on EntityConfig:

Same contract on both, with adapter-specific request/response types (FastifyRequest/FastifyReply vs Request/Response):

Hook Signature When Failure mode
beforeCreate (request, data) => void Before service.create; mutate data in place Throws → 500 (or your error class)
afterCreate (request, record) => void After persist Best-effort — caught + logged, record stays
beforeUpdate (request, reply, data) => void After scope check, before service.update Send reply to short-circuit; throw to abort
afterUpdate (request, before, after) => void After persist; before is pre-update snapshot Best-effort — caught + logged
beforeDelete (request, recordId) => void Before service.delete Throw to abort (no best-effort)

Use these for: audit logs, derived-field updates, cache invalidation, outbound webhooks, soft-validation that needs request context. Don't put load-bearing business logic in after* hooks — they're best-effort and can fail silently.

The beforeCreate hook has a sibling beforeCreateFields: string[] that lists which fields the hook will populate, so validateCreateCoverage() can enforce coverage at registration time. The other hooks have no equivalent — they don't change the persisted shape.

Anchor comments

Base templates carry // projx-anchor: imports, // projx-anchor: plugins, // projx-anchor: models (in fastify/prisma/schema.prisma). Feature patches insert relative to these — keep them stable.

No inline comments unless WHY is non-obvious

Strip docstrings, TODOs, and explainer comments from lifted code. Well-named identifiers carry the meaning. The only comments that belong are: a workaround for a specific bug, a subtle invariant, behaviour that would surprise a reader.

Feature templates (opt-in modules)

The standard is in docs/feature-templates.md. Key points:

  • A feature lives at features/<name>/<stack>/{files,patches}/ (flat) or features/<name>/<stack>/common/{files,patches}/ + features/<name>/<stack>/<orm>/{files,patches}/ (nested). The loader applies common/ first, then the ORM-specific overlay; same-named patches in <orm>/ override common/. Use nested for ORM-multi features; flat for fastapi or single-ORM stacks.
  • feature.json at features/<name>/ declares supports, env, requires, and optional requiresOrm.
  • Patches are JSON: package-json (object merge) or text (anchor-based insert). Apply mechanism is in cli/src/features.ts, idempotent via sentinel comments.
  • applyFeatures runs after the base copy in scaffold.ts.
  • Currently shipped: auth across all 9 backend × ORM combinations — fastify + {prisma, drizzle, sequelize, typeorm}, express + {prisma, drizzle, sequelize, typeorm}, fastapi. Same external surface (signup, login, MFA, password reset, sessions, refresh rotation with replay detection, email verification, mailer, cron-driven cleanup) on every port; ORM-specific bits live under features/auth/<stack>/<orm>/, shared bits under features/auth/<stack>/common/.

When adapting code from sister projects (docusift, ops-pilot, memoria), strip business specifics: tenant orchestration, billing plans, queue scheduling, grace windows, UTM tracking. Keep the security hardening: rotation, lockout, recovery codes, request_id propagation.

Releasing

Versions live in cli/package.json. prepublishOnly builds. CI runs on push to main. Manual publish: cd cli && npm publish. CHANGELOG entry + version bump on the same commit.

Common gotchas

  • pnpm exec after a pipe — exit codes get masked by tail/head. Use ${PIPESTATUS[0]} or no pipe when verifying success.
  • vi.mock('@clack/prompts') pollutes the module cache across test files. Don't use it for the CLI; spy on utilsModule instead. See existing CLI test pattern.
  • Frontend tests live in tests/, not src/. Co-located tests under src/ were migrated to fix issue #12 — never re-introduce.
  • Worktree-isolated subagents do NOT carry uncommitted changes from the main checkout. Either commit prereqs first or cp them into the worktree as step 0.
  • gitleaks runs on CI for the projx repo. Test secrets in .env.test need an allowlist entry in .gitleaks.toml.
  • prisma migrate dev needs DATABASE_URL when run via setup.sh. The bootstrap step skips silently if it's unset.