Working notes for Claude when editing this repo. The repo is projx, a CLI that scaffolds production-ready full-stack projects.
The repo is two things at once:
- The CLI source — under cli/. Published to npm as
create-projx. - 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.
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 withpath,upper,display
Multi-instance support: a project can have N fastify instances at different paths. Generators iterate the *Instances arrays.
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.ts —
applyOrmAddon(repoDir, orm, framework, dir, vars)is generic: readsmanifest.jsonfromrepoDir/addons/orms/<orm>/, removes the Prisma files listed inremoveFromBase, applies package.json overrides (deps, scripts, descriptionReplace), copiesshared/+<framework>/into the project, and writes a Dockerfile parameterized bymanifest.dockerfile.{extraConfigFiles,migrateCommand}. - cli/src/gen.ts —
gen()callsdownloadRepo(localRepo)when the project uses a non-Prisma ORM, thenappend<Orm>Entity(repoDir, cwd, dir, framework, config, generated)loadsgen-entity/*.tsfromrepoDir/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 intosrc/app.tsat 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.
The CLI has these subcommands (see cli/src/index.ts parseArgs):
create(default) — scaffold a new projectupdate— pull latest template changes into an existing projectadd— add components to an existing projectinit— adopt an existing projectpin/unpin— protect files fromupdatediff— previewupdatechangesdoctor— health-check a projectgen— entity generatorssync— 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"]).
# 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.
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).
.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.
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.
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.
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).
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.
Both Node backends' auto-routes honour optional hooks declared on EntityConfig:
- fastify — fastify/src/modules/_base/auto-routes.ts + entity-registry.ts
- express — express/src/modules/_base/auto-routes.ts + entity-registry.ts
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.
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.
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.
The standard is in docs/feature-templates.md. Key points:
- A feature lives at
features/<name>/<stack>/{files,patches}/(flat) orfeatures/<name>/<stack>/common/{files,patches}/+features/<name>/<stack>/<orm>/{files,patches}/(nested). The loader appliescommon/first, then the ORM-specific overlay; same-named patches in<orm>/overridecommon/. Use nested for ORM-multi features; flat for fastapi or single-ORM stacks. feature.jsonatfeatures/<name>/declaressupports,env,requires, and optionalrequiresOrm.- Patches are JSON:
package-json(object merge) ortext(anchor-based insert). Apply mechanism is in cli/src/features.ts, idempotent via sentinel comments. applyFeaturesruns after the base copy inscaffold.ts.- Currently shipped:
authacross 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 underfeatures/auth/<stack>/<orm>/, shared bits underfeatures/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.
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.
pnpm execafter a pipe — exit codes get masked bytail/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 onutilsModuleinstead. See existing CLI test pattern.- Frontend tests live in
tests/, notsrc/. Co-located tests undersrc/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
cpthem into the worktree as step 0. - gitleaks runs on CI for the projx repo. Test secrets in
.env.testneed an allowlist entry in.gitleaks.toml. prisma migrate devneedsDATABASE_URLwhen run viasetup.sh. The bootstrap step skips silently if it's unset.