AI-first CLI & codegen
Turn specs and prompts into working endpoints and modules—generate code, improve docs, and review quality from the CLI so routine API work does not eat your week.
AI docs
DX · Decorators · Express · Plugins · AI-native CLI
Exceptional DX on Express — structure and automation for teams that want clear APIs and a path to domain-driven design, with a CLI that fits how modern teams work — without dragging in a heavyweight runtime.
A deliberate stack. Not a thin wrapper.
Not a thin wrapper around Express — a deliberate stack for teams who want great DX, structure, automation, and maintainable domains without adopting a monolithic platform. Invest in tooling so you spend time on product behavior, not repetitive files. Plugins let capabilities grow without bloating the core.
If you've shipped on Express and want NestJS-style structure without leaving the runtime, you're the target user.
Six phases — LLM setup to a reviewed, tested, production-ready module. Every command is real and runs today.
$ bjs ai setup # one-time per project
? LLM provider › Anthropic
? Model › claude-3-5-sonnet-20241022
? API key › sk-ant-••••••••••••••••••••
✔ Written .bananarc.json
✔ Added to .gitignore
All bjs ai commands now read this config automatically.
Run bjs ai generate to scaffold your first module.{
"llm": {
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022",
"apiKey": "sk-ant-••••••••••••••••••••"
},
"generate": {
"outDir": "src",
"orm": "mongoose"
}
}$ bjs ai generate --module \
"Issues: title, description, status (open/in-progress/done),
priority (low/medium/high/critical), assignee, project" \
--orm mongoose
✔ Analyzing requirements...
✔ Planning module...
✔ Writing 8 files...
src/modules/issue/
domain/
Issue.entity.ts ← entity + value types
Issue.repository.ts ← port interface + token
application/
Issue.service.ts ← application service
infrastructure/
Issue.mongoose-model.ts ← Mongoose schema
Issue.mongoose-repository.ts ← adapter (implements port)
Issue.dto.ts ← Zod validation schemas
Issue.controller.ts ← @Controller + routes
index.ts ← createModule() DI wiring
✔ issueModule registered in bootstrap.ts# Generate from an existing JSON Schema or OpenAPI file
$ bjs ai generate --from-schema ./docs/issue-openapi.json
✔ Loading JSON Schema...
✔ Extracting shapes from 3 schemas...
✔ Writing flat files...
src/
IssueController.ts ← GET / POST / PUT / DELETE routes
issue.dto.ts ← Zod schemas derived from the spec
IssueService.ts ← application service stub
Tip: add --module to produce a DDD-layered module instead
of a flat controller + service output.# Preview which files would be created — nothing touches disk
$ bjs ai generate --module "Issues tracker" \
--orm mongoose --dry-run
✔ Planning module...
[dry-run] would write:
src/modules/issue/domain/Issue.entity.ts
src/modules/issue/domain/Issue.repository.ts
src/modules/issue/application/Issue.service.ts
src/modules/issue/infrastructure/Issue.mongoose-model.ts
src/modules/issue/infrastructure/Issue.mongoose-repository.ts
src/modules/issue/Issue.dto.ts
src/modules/issue/Issue.controller.ts
src/modules/issue/index.ts
Nothing written. Remove --dry-run to generate.// Issue.dto.ts — generated schemas (edit freely)
import { z } from 'zod'
export const CreateIssueSchema = z.object({
title: z.string().min(1).max(200),
projectId: z.string().min(1),
priority: z.enum(['low', 'medium', 'high', 'critical']),
assigneeId: z.string().optional(),
})
export const UpdateIssueSchema = CreateIssueSchema
.partial()
.extend({ status: z.enum(['open', 'in-progress', 'done']).optional() })
// Types are fully inferred — no separate interface needed
export type CreateIssueDto = z.infer<typeof CreateIssueSchema>
export type UpdateIssueDto = z.infer<typeof UpdateIssueSchema>// Issue.controller.ts — HTTP layer only
@injectable()
@Controller('issues')
export class IssueController extends BaseController {
constructor(
@inject(IssueAppService)
private readonly app: IssueAppService,
) { super() }
@Post('')
@Body(CreateIssueSchema) // 400 on schema failure — automatic
async create(req: Request, res: Response) {
return this.ok(res, 'created', await this.app.create(req.body))
}
@Put(':id')
@Params(z.object({ id: z.string().min(1) }))
@Body(UpdateIssueSchema)
async update(req: Request, res: Response) {
return this.ok(res, 'ok', await this.app.update(req.params.id, req.body))
}
@Get('')
@Query(z.object({ projectId: z.string().min(1) }))
async list(req: Request, res: Response) {
return this.ok(res, 'ok', await this.app.findByProject(req.query.projectId as string))
}
}# POST /issues { "title": "" }
# HTTP 400 Bad Request — handler was never called
{
"status": "error",
"message": "Validation failed",
"errors": [
{
"field": "title",
"message": "String must contain at least 1 character(s)"
},
{
"field": "projectId",
"message": "Required"
},
{
"field": "priority",
"message": "Invalid enum value. Expected low | medium | high | critical"
}
]
}// application/Issue.service.ts
@injectable()
export class IssueAppService {
constructor(
@inject(IssueRepositoryToken)
private readonly repo: IssueRepository, // port, not Mongoose
) {}
async create(dto: CreateIssueDto): Promise<Issue> {
const issue = this.repo.create({ ...dto, status: 'open' })
return this.repo.save(issue)
}
async update(id: string, dto: UpdateIssueDto): Promise<Issue> {
const issue = await this.repo.findById(id)
if (!issue) throw new NotFoundError('Issue', id)
issue.update(dto) // domain rule lives in the entity
return this.repo.save(issue)
}
async findByProject(projectId: string): Promise<Issue[]> {
return this.repo.findByProject(projectId)
}
}// domain/Issue.repository.ts — ORM-agnostic interface (port)
export interface IssueRepository {
save(issue: Issue): Promise<Issue>
findById(id: string): Promise<Issue | null>
findByProject(projectId: string): Promise<Issue[]>
}
// Symbol token — used by tsyringe for DI
export const IssueRepositoryToken = Symbol('IssueRepository')
// Wired once in index.ts → createModule({
// providers: [
// { token: IssueRepositoryToken,
// useClass: IssueMongooseRepository }, ← swap freely
// ]
// })
// The service never changes when you swap adapters.// infrastructure/Issue.mongoose-repository.ts
@injectable()
export class IssueMongooseRepository implements IssueRepository {
async save(issue: Issue): Promise<Issue> {
await IssueModel.findOneAndUpdate(
{ _id: issue.id },
toDocument(issue),
{ upsert: true },
)
return issue
}
async findById(id: string): Promise<Issue | null> {
const doc = await IssueModel.findById(id).lean()
return doc ? toDomain(doc) : null
}
async findByProject(projectId: string): Promise<Issue[]> {
const docs = await IssueModel
.find({ projectId })
.sort({ createdAt: -1 })
.lean()
return docs.map(toDomain)
}
}// src/modules/issue/index.ts — generated DI wiring
import { createModule } from '@banana-universe/bananajs'
import { IssueController } from './Issue.controller.js'
import { IssueAppService } from './application/Issue.service.js'
import { IssueRepositoryToken } from './domain/Issue.repository.js'
import { IssueMongooseRepository } from './infrastructure/Issue.mongoose-repository.js'
export const issueModule = createModule({
id: 'issue',
controller: IssueController,
providers: [
IssueAppService,
{
token: IssueRepositoryToken,
useClass: IssueMongooseRepository, // ← swap for any adapter
},
],
})
// Register in bootstrap.ts:
import { BananaApp } from '@banana-universe/bananajs'
import { mongoosePlugin } from '@banana-universe/plugin-mongoose'
const app = await BananaApp.create({
plugins: [mongoosePlugin({ uri: process.env.MONGO_URI! })],
modules: [issueModule], // ← add more modules here
})
app.listen(3000)$ bjs ai doc --file src/modules/issue/Issue.controller.ts
✔ Reading Issue.controller.ts...
✔ Generating JSDoc for 3 routes...
✔ Written — 3 descriptions added
# Generated JSDoc (sample):
/**
* Create a new issue in the specified project.
* @route POST /issues
* @body {CreateIssueDto} Issue creation payload
* @returns {Issue} 201 - Created issue object
* @returns {ValidationError} 400 - Schema validation failed
*/
async create(req: Request, res: Response) { ... }$ bjs ai openapi enrich
✔ Loading openapi.json (12 endpoints)...
✔ Enriching /issues endpoints (3 routes)...
✔ Added summaries and descriptions to 3 routes
✔ Added request body examples to 2 routes
✔ Added 400, 404, 422 response schemas to 3 routes
✔ Written docs/openapi.json
# Before: "summary": ""
# After: "summary": "Create a new issue",
# "description": "Creates an issue in the given project.",
# "requestBody": {
# "example": { "title": "Fix login bug", "priority": "high" }
# }$ bjs ai review --module src/modules/issue
✔ Scanning 5 files in src/modules/issue...
⚠ Issue.controller.ts:8 No @Auth() guard — all routes are public.
Add @Auth() at the controller class level.
⚠ Issue.controller.ts:32 GET /issues has no pagination — add
limit/offset or cursor query params.
✔ Issue.entity.ts Domain model clean, no ORM leakage
✔ Issue.repository.ts Port correctly typed and isolated
✔ index.ts DI wiring matches repository token
2 warnings · 0 errors · 3 passing// bjs ai test → writes src/__tests__/issue.test.ts
import { BananaTestApp } from '@banana-universe/bananajs/testing'
import { issueModule } from '../modules/issue/index.js'
import assert from 'node:assert/strict'
import { describe, it, before, after } from 'node:test'
import request from 'supertest'
describe('IssueController', () => {
let app: BananaTestApp
before(async () => {
app = await BananaTestApp.create({ modules: [issueModule] })
})
after(() => app.close())
it('POST /issues returns 201', async () => {
const res = await request(app.server)
.post('/issues')
.send({ title: 'Fix login bug', projectId: 'p1', priority: 'high' })
assert.equal(res.status, 201)
})
it('POST /issues returns 400 on bad payload', async () => {
const res = await request(app.server)
.post('/issues').send({ title: '' })
assert.equal(res.status, 400)
})
})$ bjs ai perf --file src/modules/issue/Issue.service.ts
✔ Analyzing Issue.service.ts...
⚠ findByProject (line 24): result set unbounded
— add .limit() or pagination to the Mongoose query
⚠ Issue.mongoose-model.ts: no index for { projectId }
— add: IssueSchema.index({ projectId: 1, createdAt: -1 })
✔ No synchronous blocking operations detected
✔ No N+1 query patterns found in service methods
2 warnings · 0 errorsEvery decorator below ships in @banana-universe/bananajs — no extra packages, no configuration files.
@Auth() // all routes require auth
@Controller('issues')
export class IssueController extends BaseController {
@Get('') // inherits @Auth()
async list(req, res) { ... }
@Public() // opt-out for this route only
@Get('health')
async health(_req, res) {
return this.ok(res, 'ok', 'up')
}
@Roles('admin', 'manager') // role check on top of auth
@Delete(':id')
async remove(req, res) { ... }
}@Auth()
@Controller('issues')
export class IssueController extends BaseController {
@Can('create', 'issue')
@Post('')
@Body(CreateIssueSchema)
async create(req, res) { ... }
@Can('delete', 'issue')
@Delete(':id')
async remove(req, res) { ... }
@Can('read', 'issue')
@Get('')
async list(req, res) { ... }
}@Tenant()
@Auth()
@Controller('issues')
export class IssueController extends BaseController {
@Get('')
async list(_req, res) {
const tenantId = getTenantId()
return this.ok(res, 'ok',
await this.app.findByProject(tenantId!))
}
}
// In your service or repository — no extra arguments:
import { getTenantId } from '@banana-universe/bananajs'
const tenantId = getTenantId() // same ALS store@Cache({ ttl: 30 })
@Get('')
async list(_req, res) {
return this.ok(res, 'ok', await this.app.findByProject(...))
}
@CacheEvict({ pattern: 'list:*' })
@Post('')
@Body(CreateIssueSchema)
async create(req, res) {
return this.ok(res, 'created', await this.app.create(req.body))
}
// Tenant-scoped key:
@Cache({ ttl: 60, key: (req) => `issues:${getTenantId()}` })
@Get('')
async listForTenant(req, res) { ... }// Per-user sliding window
@Throttle({ windowMs: 10_000, max: 5, keyBy: 'userId' })
@Post('')
@Body(CreateIssueSchema)
async create(req, res) { ... }
// Controller-wide fixed window
@RateLimit({ windowMs: 60_000, max: 100 })
@Auth()
@Controller('issues')
export class IssueController extends BaseController { ... }
// Distributed: Redis store
@Throttle({ windowMs: 60_000, max: 20,
keyBy: 'userId', store: redisThrottleStore })
@Post('comments')
async addComment(req, res) { ... }// Strip all HTML
@Sanitize()
@Post('')
@Body(CreateIssueSchema)
async create(req, res) {
return this.ok(res, 'created', await this.app.create(req.body))
}
// Allow a safe subset for rich-text fields
@Sanitize({
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
allowedAttributes: { a: ['href'] },
})
@Put(':id')
@Body(UpdateIssueSchema)
async update(req, res) { ... }