CLAUDE.md · diff

git:20260930.2f264f6 to git:20260930.3952a15

6 added, 2 removed. Audit A to A.

# CLAUDE.md — KickJS Development Guide
> **Read [`.agents/AGENTS.md`](./.agents/AGENTS.md) first.** It is the canonical,
> multi-agent reference for this monorepo (Claude, Copilot, Codex, Gemini, etc.).
> This file mirrors the same project context distilled for Claude, plus
> Claude-specific notes. When the two disagree on anything substantive, treat
> `.agents/AGENTS.md` as authoritative and flag the discrepancy.
## Project Overview
KickJS is a decorator-driven Node.js framework for TypeScript. The HTTP engine is pluggable — it runs on **Express (default), Fastify, or h3**, selected via `bootstrap({ runtime })`; controllers/modules/DI/context decorators are engine-neutral. Monorepo managed with pnpm workspaces and Turbo.
> The real framework package is `packages/kickjs` (`@forinda/kickjs`) — it holds the DI core, HTTP layer, RequestContext, and the runtime seam (`src/http/runtimes/{express,fastify,h3}.ts`). Fastify/h3 runtimes, the h3 v2 web-standard entries (`./h3-web`, `./web` — edge/Bun/Deno), and cross-engine uploads all ship in the **stable** release.
**18 workspace packages** (published under `@forinda/kickjs*` unless private), CLI with generators + a fullstack template, typed client, kick/db. Runnable example apps live in [forinda/kickjs-examples-archive](https://github.com/forinda/kickjs-examples-archive); there is no in-repo `examples/`.
## Quick Commands
```bash
pnpm build # Build all packages
pnpm test # Run all tests
pnpm format # Fix formatting
pnpm format:check # Check formatting
pnpm docs:dev # Dev docs server
pnpm docs:build # Build docs
pnpm changeset # Add a changeset to the current PR
pnpm changeset:status # Preview pending bumps
```
## Repository Structure
```
packages/ # Workspace packages (@forinda/kickjs* on npm unless private)
kickjs/ # THE framework — DI core, decorators, http layer,
# RequestContext, runtimes (express/fastify/h3/h3-web),
# web fetch entry (src/web.ts → @forinda/kickjs/web)
cli/ # kick new (rest|minimal|fullstack), generators, typegen,
# kick add / doctor / agents
client/ # @forinda/kickjs-client — typed fetch client (frontend)
vite/ # Vite HMR plugin + typegen watcher
schema/ # Schema-agnostic validation (Zod/Valibot/Yup/StdSchema)
swagger/ # OpenAPI from decorators + declared response schemas
db/ # kick/db code-first database family — dialects ship as
# subpaths (@forinda/kickjs-db/{pg,mysql,sqlite})
testing/ # createTestApp, createTestModule, plugin harness
devtools/ devtools-kit/ # /_debug dashboard + adapter tab kit
grpc/ # Connect RPC — gRPC-Web/Connect on the shared HTTP port
ai/ mcp/ ws/ queue/
cli-kit/ lint/ vscode-extension/
scripts/ # benchmarks, bundle-size check, Bun smoke test, docs snapshot/translate
docs/ # VitePress documentation site
```
## Package Manager & Build
- **pnpm** — always use `pnpm`, never npm/yarn
- **Turbo** — orchestrates builds with dependency-aware caching
- **tsdown** — builds the library packages (`tsdown.config.ts`: ESM, `dts: true`, runtime deps in `external`); `vscode-extension` and `devtools/spa` use Vite, `docs` uses VitePress
- **Vitest** — test runner with SWC for decorator support
## Code Style
- **oxfmt** — formatter (`.oxfmtrc.json`): no semicolons, single quotes, trailing commas, 100 char width. Not Prettier — `npx prettier` reformats unrelated lines
- **oxlint** — linter (`.oxlintrc.json`), plus `pnpm lint:tokens` (kick-lint token conventions)
- **Pre-commit hook** — lefthook (`lefthook.yml`) runs oxfmt check, oxlint, and token lint on staged files only; build + tests run in CI
- Format before committing: `pnpm format`
## Key Patterns
### Adding Middleware (to `packages/kickjs`)
1. Create `packages/kickjs/src/http/middleware/<name>.ts`
2. Add entry to `packages/kickjs/tsdown.config.ts` `entry` object
3. Add export map entry to `packages/kickjs/package.json`
4. Add re-export to `packages/kickjs/src/http/index.ts` (and `src/index.ts` if it's public API — the root index is selective, not `export *`)
### Adding a Package
1. Create `packages/<name>/` with `package.json`, `tsconfig.json`, `tsdown.config.ts`, `vitest.config.ts` (copy `packages/schema/` as the template — tsdown is the repo standard; the old vite-library recipe is retired)
2. Name it `@forinda/kickjs-<name>`, start at `0.0.0` — changesets will set the first published version on the next release PR
3. Use `workspace:*` for internal deps
4. tsdown config: `format: ['esm']`, `dts: true`, all runtime deps in `external`
5. Scripts: `"build": "tsdown"`, `"typecheck": "tsc --noEmit"` — and CHECK the exports map matches what tsdown emits (`dist/index.js` + `dist/index.d.ts`; a mismatch shipped an unresolvable package once)
### Adding an Adapter
Implement `AppAdapter` from `@forinda/kickjs/adapter`:
- `name: string`
- `beforeMount?({ app }: AdapterContext)`, `beforeStart?({ container }: AdapterContext)`, `afterStart?({ server }: AdapterContext)`
- `shutdown?(): Promise<void>`
- `middleware?(): AdapterMiddleware[]`
### Adding an Example App
Example apps live in [forinda/kickjs-examples-archive](https://github.com/forinda/kickjs-examples-archive), not this repo. Scaffold them with the built CLI — never create files manually; a scaffold failure is a CLI bug to fix first.
```bash
pnpm build # in this repo
# in your kickjs-examples-archive clone:
node <path-to-kick-js>/packages/cli/bin.js new my-example-api --yes --no-install --force
```
`--yes` (alias `--non-interactive`) bypasses every prompt with safe defaults (template=minimal, repo=inmemory); explicit flags override individual answers.
Available flags: `--template rest|minimal|fullstack`, `--pm pnpm|npm|yarn|bun`, `--repo inmemory|<any-name>`, `--packages swagger,ws,...`, `--no-git`, `--no-install`, `--force`, `-y / --yes / --non-interactive`.
### Decorators
```ts
@Controller('/path') // Route prefix
@Get('/'), @Post('/'), @Put('/'), @Delete('/'), @Patch('/')
@Service() // DI-registered singleton
@Repository() // DI-registered singleton (semantic)
@Autowired() // Property injection
@Inject('token') // Token-based injection
@Value('ENV_VAR') // Config value injection
@Middleware(fn) // Attach middleware
@Cron('0 * * * *') // Cron schedule
```
### Context Contributors (#107)
Typed, ordered, declarative way to populate `ctx.set('key', value)` before a handler runs. Use this **instead of `@Middleware()`** when the only job is to compute a value other code reads off `ctx`.
```ts
const LoadTenant = defineContextDecorator({
key: 'tenant',
deps: { repo: TENANT_REPO }, // typed DI
resolve: (ctx, { repo }) => repo.findById(ctx.req.headers['x-tenant-id'] as string),
})
const LoadProject = defineContextDecorator({
key: 'project',
dependsOn: ['tenant'], // topo-sorted at startup; cycles fail boot
resolve: (ctx) => projectsRepo.find(ctx.get('tenant')!.id, ctx.params.id),
})
@LoadTenant
@LoadProject
@Get('/projects/:id')
getProject(ctx: RequestContext) { ctx.json(ctx.get('project')) }
```
Five registration sites, precedence high→low: **method > class > module > adapter > global**. Apply via `@`-decorator (method/class), `AppModule.contributors?()`, `AppAdapter.contributors?()`, or `bootstrap({ contributors })`. Full guide at `docs/guide/context-decorators.md`. Do NOT use this for short-circuiting responses, response-stream mutation, or pre-route-matching middleware — keep using `@Middleware()` / global middleware for those.
### RequestContext
Every controller method receives `ctx: RequestContext` with:
- `ctx.body`, `ctx.params`, `ctx.query`, `ctx.headers`
- `ctx.requestId`, `ctx.session`, `ctx.file`, `ctx.files`
- `ctx.qs(fieldConfig)` — parsed query with filters/sort/pagination
- `ctx.paginate(handler, config)` — auto-paginated response
- `ctx.json(data)`, `ctx.created(data)`, `ctx.noContent()`, `ctx.notFound()`
### Built-in Middleware
```ts
import express from 'express'
import { bootstrap, helmet, cors, requestId, requestLogger, csrf, rateLimit } from '@forinda/kickjs'
bootstrap({
modules: [/* your modules */],
middlewares: [
helmet(), // Security headers (X-Frame-Options, HSTS, etc.)
cors({ origin: ['https://app.example.com'] }), // CORS with spec-correct behavior
requestId(), // X-Request-Id generation/propagation
requestLogger(), // Pino-based request logging (method, URL, status, duration)
csrf(), // CSRF protection (double-submit cookie)
rateLimit(), // Rate limiting with pluggable store
express.json(), // Body parsing
],
})
```
Also available: `validate()` (Zod body/query/params), `upload()` (multer file handling), `session()` (cookie sessions).
### Git Workflow
Use feature branches — never commit directly to `main` or `dev`:
- **Stable work** → branch from `main`, PR to `main`
- **Experimental work** → branch from `dev`, PR to `dev`
- **Promote** → PR `dev` → `main` when stable
```bash
git checkout main && git pull origin main
git checkout -b feat/my-feature
# ... make changes ...
git commit -m "feat: description (#issue)"
git push -u origin feat/my-feature
gh pr create --base main
```
### PR / issue bodies with markdown — always via a temp file
Anything richer than a single-line description (code fences, lists, tables, backticks, `$`, multi-paragraph) goes through a temp file. **Never** inline a multi-line body in `gh pr create --body "$(cat <<'EOF' ... EOF)"` or `gh pr edit --body "..."` — the shell escapes backticks and dollar signs, fenced code blocks lose their language hint, and the body lands on GitHub with literal `\`` everywhere.
```bash
# Right — write the body to a file, then pass it:
cat > /tmp/pr-body.md <<'EOF'
## Why
…full markdown, no escape gymnastics…
EOF
gh pr create --base main --title "…" --body-file /tmp/pr-body.md
# For edits to existing PRs, gh pr edit can also take --body-file:
gh pr edit 123 --body-file /tmp/pr-body.md
# If `gh pr edit` exits non-zero (often: projects-classic deprecation),
# fall back to the API:
gh api -X PATCH /repos/<owner>/<repo>/pulls/123 -F body=@/tmp/pr-body.md
```
Same rule for `gh issue create`, `gh release create`, comment posts (`gh pr comment 123 --body-file …`), and changeset bodies committed to disk.
## CLI Architecture
The CLI (`packages/cli/`) is structured as:
```
src/
cli.ts # Entry point, registers all commands
config.ts # KickConfig, ModuleConfig, defineConfig, resolveModuleConfig
commands/
generate.ts # kick g module/controller/service/...
remove.ts # kick rm module
init.ts # kick new
run.ts # kick dev/build/start
add.ts # kick add <package>
generators/
module.ts # generateModule orchestrator
remove-module.ts # removeModule + index.ts cleanup
patterns/ # Pattern-specific generators
rest.ts, minimal.ts
types.ts # ModuleContext interface
templates/ # Code template functions
types.ts # TemplateContext interface
repository.ts # inmemory + custom repo generators
controller.ts, dtos.ts, domain.ts, ...
scaffold/overlay.ts # renders templates/<layer>/files/ for kick new
templates/ # scaffold layers shipped with the CLI
- base/files/ # option-independent project files; `_dot_x` → `.x`
+ base/ # always rendered
+ template-{minimal,rest}/ # src/index.ts with slot markers
+ runtime-{express,fastify,h3}/ # runtime factory + engine packages
+ schema-{zod,valibot,yup}/ # src/config/index.ts
+ feature-{swagger,devtools,ws,queue}/ # adapter entries + packages
```
- `kick new` renders `templates/base` for files that don't depend on options; files that do (package.json, `src/index.ts`, the env schema, `kick.config.ts`, README) are still template functions until their options become layers.
+ Each layer has `files/` (copied; `_dot_x` → `.x`; `x.append` appends to `x`) and an optional `feature.json` (`dependencies`, `devDependencies`, `builds` install-script answers, `integrations`). Shared files mark slots with `// @kick:imports` / `// @kick:<slot>` comment lines; layers declare `{ import, slot, code }` entries instead of editing them. package.json ranges still come from the scaffold-time version map, so every listed package must be in `SIBLING_PACKAGES` or `THIRD_PARTY_PACKAGES`. `kick.config.ts`, `vite.config.ts`, README, agent docs and platform config are still template functions.
### Key CLI Config (kick.config.ts)
```ts
export default defineConfig({
pattern: 'rest',
modules: {
dir: 'src/modules',
repo: { name: 'postgres' }, // 'inmemory' (built-in) | { name } → custom stub
pluralize: true,
schemaDir: 'src/db/schema',
},
commands: [...],
})
```
### Template Functions
All template generators accept `TemplateContext`:
```ts
interface TemplateContext {
pascal: string // PascalCase name
kebab: string // kebab-case name
plural?: string // Pluralized kebab
pluralPascal?: string // Pluralized Pascal
repoPrefix?: string // Repository import prefix
dtoPrefix?: string // DTO import prefix
repoType?: string // Custom repo type name
}
```
## Linking the CLI Locally
```bash
pnpm build
cd packages/cli && pnpm link --global
```
Now `kick` uses your latest local code. After changes, just `pnpm build` — no re-link needed.
## Testing
- Tests live in `tests/` at root and `packages/*/src/**/*.test.ts`
- Use `Container.reset()` in `beforeEach` to isolate DI state
- Run: `pnpm test`
## Releasing
Versions are **per-package independent** via [Changesets](https://github.com/changesets/changesets); publish is automated via [npm trusted publishers](https://docs.npmjs.com/trusted-publishers/) (OIDC, no `NPM_TOKEN`).
```bash
pnpm changeset # add a changeset describing your PR's changes
pnpm changeset:status # preview pending bumps
pnpm release:enter:alpha # enter pre-release mode (alpha/beta/rc)
pnpm release:exit:pre # exit pre-release mode
```
`.github/workflows/release.yml` does the rest: it opens a "Version Packages" PR when changesets are pending, then publishes when that PR merges. See `RELEASE.md` for the full flow + npm trusted-publisher one-time setup per package.
**Old `scripts/release.js` (lockstep) is removed** — `@forinda/kickjs@5.3.0` may pair with `@forinda/kickjs-cli@5.2.1`. Each changeset picks its own bump per package.
## CI/CD
- **ci.yml** — build, typecheck, test, format on push to main/dev and PRs
- **deploy-docs.yml** — build and deploy VitePress on push to main
- **release.yml** — runs on push to `main` (or `workflow_dispatch`): opens / updates a "Version Packages" PR when changesets are pending, then publishes via npm trusted-publisher OIDC when that PR merges
## Commit Conventions
```
feat: description # New feature → minor bump
fix: description # Bug fix → patch bump
docs: description # Documentation only
chore: description # Maintenance
refactor: description # Code restructuring
ci: description # CI/CD changes
test: description # Test changes
```
## Important Notes
- Design docs, specs, plans, and experiment notes live in the git-ignored `.ignored/designs/` and are never referenced from tracked files (code comments, tests, docs, changesets, PR bodies). See "Design Notes Stay Private" in `.agents/AGENTS.md`.
- Decorators fire at class definition time — tests need `Container.reset()` + re-registration
- Don't manually publish — the changesets workflow does it via OIDC. Private packages are skipped by changesets v3 by default — no `ignore` entry needed.
- All internal links in docs must be **relative** (for versioning/i18n support)
- The `kick` CLI binary comes from `packages/cli/src/cli.ts`
- tsdown configs: `format: ['esm']`, `dts: true`, all runtime deps in `external`
- Old top-level config fields (`modulesDir`, `defaultRepo`, etc.) are deprecated — use `modules` block
- **Env wiring** (applies to **generated applications**, not to the workspace's own library packages under `packages/` — none of which have or need an `src/env.ts`): `src/env.ts` must call `loadEnv(envSchema)` as a side effect AND be imported from `src/index.ts` (`import './env'`) before `bootstrap()` runs. Otherwise `ConfigService.get('CUSTOM_KEY')` returns `undefined` while `@Value('CUSTOM_KEY')` _appears_ to work via its `process.env` fallback. The CLI generators wire both halves automatically; manual upgrades must add both. See `docs/guide/configuration.md#wiring-the-schema-at-startup`.