projx
ukanhaupa/projx/CLAUDE.md
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: Top-level layout: 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/<orm>/…
- Reads credentials
# 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/](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
vitejs/ React + Vite frontend template (legacy alias: `frontend`)
nextjs/ React + Next.js (App Router) frontend template (standalone node server)
mobile/ Flutter app template
e2e/ Playwright E2E template
go/ Chi + GORM backend template
infra/ Terraform IaC template
admin-panel/ Go + HTMX admin-panel template (Docker-only static binary; auth-gated table browser over any Postgres via DATABASE_URL)
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/](cli/src/templates/) as `.ejs` files rendered at scaffold time by the hand-rolled engine in [cli/src/utils.ts](cli/src/utils.ts).
ORM-specific scaffolding (Drizzle, Sequelize, TypeORM) lives in [addons/orms/<orm>/](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](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`, `vitejsInstances`, `nextjsInstances`, `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/<orm>/](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](cli/src/baseline.ts) — `applyOrmAddon(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.ts](cli/src/gen.ts) — `gen()` 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](cli/src/utils.ts) and to the help string in [cli/src/index.ts](cli/src/index.ts). Add an `append<Orm>Entity` function in [cli/src/gen.ts](cli/src/gen.ts) following the existing pattern. Update [setup.sh.ejs](cli/src/templates/setup.sh.ejs) + [ci.yml.ejs](cli/src/templates/ci.yml.ejs) if the migrate command differs from `tsx scripts/db-sync.ts`. No `baseline.ts` core changes needed.
**Per-instance ORM** (heterogeneous ORMs in one project): the ORM is resolved **per backend instance**, not project-wide. Each instance's ORM is recorded in its `.projx-component` marker (`orm` field) and falls back to the project-wide `.projx` `orm` when absent (back-compat). `resolveInstanceOrm` in [cli/src/utils.ts](cli/src/utils.ts) does the resolution — instance marker → project global when it is the **same backend family** → family default (`BACKEND_DEFAULT_ORM`), so a Node global like `drizzle` never leaks into a Go/Rust/Laravel instance. `add <type> --name <dir> --orm <x>` (and `add <type> --orm <x>`) applies the addon to **only** the new instance and records its ORM. The per-instance value is read by `applyOrmProviderToInstance` in [cli/src/baseline.ts](cli/src/baseline.ts), the `*Instances` enrichment in [cli/src/generators/index.ts](cli/src/generators/index.ts) (so `setup.sh`, `ci.yml`, and the per-instance Dockerfile migrate command branch on `inst.orm`), and `doctor`'s Go check. `update` preserves each instance's ORM.
## Commands
The CLI has these subcommands (see [cli/src/index.ts](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>][,...]`, accepted by `create`, `add`, and `update` (see Feature templates below). Only `--auth` is implemented today; see [docs/feature-templates.md](docs/feature-templates.md) for the standard. Known features live in `KNOWN_FEATURES` in [cli/src/utils.ts](cli/src/utils.ts). Manifests may set `requiresOrm: ["prisma", ...]` — [cli/src/features.ts](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
```bash
# 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](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% |
| `go/` | gofmt -l | golangci-lint (incl. goimports) | (in lint) | go test -race | [scripts/check-coverage.sh](go/scripts/check-coverage.sh) ≥80% |
| `rust/` | cargo fmt | cargo clippy -D warnings | cargo check | cargo test | cargo tarpaulin ≥80% |
| `laravel/` | pint | phpstan --level=8 | (in phpstan) | pest | pcov ≥80% |
| `vitejs/` | prettier | eslint | `tsc --noEmit` | vitest | v8 ≥80% |
| `nextjs/` | prettier | eslint | `tsc --noEmit` | vitest | v8 ≥80% |
| `mobile/` | dart format | `dart analyze --fatal-infos` | (in analyze) | flutter test | [scripts/check-coverage.sh](mobile/scripts/check-coverage.sh) ≥80% |
| `e2e/` | prettier | eslint | `tsc --noEmit` | n/a | n/a |
| `admin-panel/` | gofmt | `go vet` | (in build) | `go test` (real Postgres) | [scripts/check-coverage.sh](admin-panel/scripts/check-coverage.sh) ≥80%, entrypoint excluded |
CI runs all of these — see [.github/workflows/ci.yml](.github/workflows/ci.yml). Locally, [scripts/ci-local.sh](scripts/ci-local.sh) runs every available section in parallel — pass `cli`, `fastapi`, `fastify`, `express`, `go`, `rust`, `laravel`, `vitejs`, `nextjs`, `e2e`, `infra`, `admin_panel`, 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. The `express` section ends with a boot smoke: it syncs the schema from `.env.test`, boots `src/server.ts` on a free port, and polls `/api/health` — catching boot-path breakage (e.g. a missing Prisma model) that delegate-mocked unit tests can't. The `admin-panel` Go tests self-skip the integration suite when `TEST_DATABASE_URL` is unset (unit tests still run).
Prettier config is unified across `cli/`, `fastify/`, `express/`, `vitejs/`, `e2e/`: `{semi: true, singleQuote: true, trailingComma: "all", printWidth: 80, tabWidth: 2}` — `vitejs/` 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](.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](cli/src/templates/pre-commit.ejs) — keep both in sync when adding gates.
A secret scan runs first via `gitleaks` (which reads this repo's `.gitleaks.toml`), falling back to a minimal AWS-key grep only when gitleaks isn't installed. 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:
- [fastify/src/plugins/error-handler.ts](fastify/src/plugins/error-handler.ts)
- [fastapi/src/exception_handlers.py](fastapi/src/exception_handlers.py)
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](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.
### Go startup discipline
Go: panic-on-misconfig at startup for the entity registry — fail-loud is preferred over silent fallback. Pool sizes and HTTP timeouts come from env. Distroless static runtime image — `CGO_ENABLED=0` default.
### Entity lifecycle hooks (fastify + express)
Both Node backends' auto-routes honour optional hooks declared on `EntityConfig`:
- fastify — [fastify/src/modules/\_base/auto-routes.ts](fastify/src/modules/_base/auto-routes.ts) + [entity-registry.ts](fastify/src/modules/_base/entity-registry.ts)
- express — [express/src/modules/\_base/auto-routes.ts](express/src/modules/_base/auto-routes.ts) + [entity-registry.ts](express/src/modules/_base/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.
### 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.
### Never hard-code a component directory in a shipped artifact
A component can live at any directory (`add <type> --name <dir>`, or several instances of one type). Its real directory is recorded in the per-dir `.projx-component` marker and resolved by `discoverComponentsFromMarkers` in [cli/src/utils.ts](cli/src/utils.ts). Anything copied into or generated for a scaffold must resolve the directory, never assume `fastify/` / `vitejs/` / `fastapi/` / `infra/` / `admin-panel/`:
- **Rendered `.ejs`** (shared templates and per-component files rendered by `renderEjsInDir`) emit `<%= paths.<type> %>` / iterate the `*Instances` arrays — same as `ci.yml.ejs` / `docker-compose.yml.ejs`. For the single "primary" backend/frontend (infra's one-pipeline assumption), `resolvePrimarySourceDirs(paths)` is injected into every component render as `backendSourceDir` / `frontendSourceDir`.
- **Static shell copied verbatim** (`scripts/ci-local.sh`, both `setup-ssl.sh`, `validate-nginx-config.sh`) source **`scripts/projx-dirs.sh`** — a `bash 3.2`-safe resolver (`projx_dirs_of_type`, `projx_first_dir_of_type`, `projx_has_type`, `projx_primary_*`) that scans the markers and falls back to conventional directory names when none exist (so the projx repo, which has no markers, behaves unchanged). It is in the static-copy list in `utils.ts`.
- **Runtime `.ts`** (`e2e/playwright.config.ts`) resolves sibling directories from their markers at load time.
- **Infra** parameterizes the backend/frontend source dir via the `backend_source_dir` / `frontend_source_dir` Terraform variables, overridden per scaffold by the rendered `infra/stack/projx.auto.tfvars`.
The projx repo's own `ci-local.sh` / gates use the conventional-name fallback, so they stay green while scaffolds get the resolved directories. Regression-guarded by `cli/tests/instance-aware-dirs.test.ts` and the `--name` scaffold path.
## Feature templates (opt-in modules)
The standard is in [docs/feature-templates.md](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](cli/src/features.ts), idempotent via sentinel comments.
- `applyFeatures` runs after the base copy in `scaffold.ts`, after the component copy in `add.ts` (so `add <component> --auth <target>` applies a feature to an existing project), and after the merge in `update.ts` (which re-applies each component's recorded features to heal patches the merge may have reverted). Each applied feature is recorded in the target's `.projx-component` marker under `features: []` — the single source of truth `update` reads to know what to re-apply. `writeComponentMarker` in [cli/src/utils.ts](cli/src/utils.ts) preserves that field across rewrites.
- Currently shipped: `auth` across **15 backend × ORM combinations** — fastify + {prisma, drizzle, sequelize, typeorm}, express + {prisma, drizzle, sequelize, typeorm}, fastapi, go + {gorm, sqlc, ent}, rust + seaorm, laravel + eloquent. 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/` (or the flat layout `features/auth/<stack>/files+patches/` for single-ORM stacks).
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](cli/package.json). `prepublishOnly` builds. CHANGELOG entry + version bump on the same commit.
**Publishing is gated behind a draft release — two steps, not one.** Pushing a `v*` tag fires [.github/workflows/release.yml](.github/workflows/release.yml), which lint/typecheck/build/tests and then cuts a **draft** GitHub Release with an auto-generated changelog. **Nothing reaches npm yet.** Publishing that draft (GitHub UI or `gh release edit <tag> --draft=false`) fires [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml), which checks out the tag and runs `pnpm publish` to npm using `secrets.NPM_TOKEN` — no local `npm publish` needed. Tags matching `-(alpha|beta|rc)` publish under the `next` dist-tag; all others under `latest`. The version published is taken from the tag (`npm version ${TAG#v}`), so the tag and `cli/package.json` must agree. Net: bump version + CHANGELOG on a commit, tag `vX.Y.Z` and push it to stage a draft, review it, then publish the release to ship to npm.
## 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](https://github.com/ukanhaupa/projx/issues/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 in the pre-commit hook and 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.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
No one has posted yet. Be the first.

