Package reference
Mirrors the package README (single source). Install @basaltkit/generator v1.5.0 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/generator
Code generator ("scaffolding") for Basalt applications: the basalt make:* commands create all the files for a resource for you — schema, repository, service, plugin, HTTP routes, and test — already wired together and compiling. You need this whenever you're adding a new "entity" to the application (Projects, Customers, Invoices…) and don't want to write the same skeleton by hand.
What this module solves
Scaffolding is the practice of automatically creating the repetitive files for a new feature. In a well-organized application, each resource (e.g. "Project") usually needs the same set of pieces every time: a schema (the validated description of the data, built with Zod), a repository (the layer that stores and reads the data), a service (the business logic), a plugin (which registers everything in the dependency container), HTTP routes (the REST endpoints), and a test.
Writing these six pieces by hand for every resource is slow and prone to naming mistakes — swap blogPost for blogpost in one spot and nothing compiles. The generator derives all the name variations at once (BlogPost, blogPost, blog-post, blog-posts, BLOG_POST) and uses them consistently across all files.
Besides generating the files, make:resource also wires the new resource into src/app.ts automatically (imports the plugin and routes and inserts them in the right places) and, with --prisma, generates a repository connected to the database via Prisma instead of the in-memory version.
Installation
pnpm add @basaltkit/generator> Note: depends on @basaltkit/cli (the basalt command framework). The generated code uses @basaltkit/core, @basaltkit/fastify, zod, and — in the generated tests — @basaltkit/testing, so it's worth having them in the project. If you created the project with create-basalt --cli, everything is already set up.
Get started in 5 minutes
- Make sure your application registers the generator's commands. In
src/app.ts:
import { createApp } from '@basaltkit/core'
import { commandsPlugin } from '@basaltkit/cli'
import { fastifyPlugin } from '@basaltkit/fastify'
import { generatorCommands } from '@basaltkit/generator'
import { appRoutes } from './routes.js'
export function buildApp() {
return createApp({
plugins: [
commandsPlugin(generatorCommands()),
fastifyPlugin({ routes: [...appRoutes] }),
],
})
}Make sure you have the
basaltexecutable (created automatically bycreate-basalt --cli; see the@basaltkit/cliREADME if you don't have it).Generate a complete resource:
pnpm basalt make:resource Project- See what was created:
Generated 6 file(s):
src/modules/project/project.plugin.ts
src/modules/project/project.repository.ts
src/modules/project/project.routes.ts
src/modules/project/project.schema.ts
src/modules/project/project.service.ts
tests/project.test.ts
Wired the plugin + routes into src/app.ts.- Run the tests and try out the endpoints:
pnpm test # the generated test covers create/list/get/update/delete
pnpm dev # GET/POST /projects, GET/PATCH/DELETE /projects/:idSecure by default
Generated code is safe to ship as a starting point:
- Authenticated routes. Every generated route carries
meta: { auth: true }(applied to the whole exported array), so the app refuses to boot without an auth plugin and anonymous callers get 401. Pass--public(alias--no-auth) only for a deliberately public resource. - Tenant-owned data. When the project depends on
@basaltkit/tenancy(or with--tenant), the repository scopes every read and write withrequireTenantId()— no tenant resolved meansTENANT_REQUIRED(400), never a shared view — by-id writes useupdateMany/deleteManyso another tenant's row is simply "not found", and the Prisma model gets an indexedtenantIdcolumn.--no-tenantturns it off. - After generating, a short security note says which of the two applies. Row-level authorization (who may read or write which rows) is still yours to add.
Usage guide
basalt make:resource <Name> — the complete resource
Generates the entire "vertical slice": schema → repository → service → plugin → routes → test. By default, the repository is in-memory (data is lost on restart — great for getting started) and the resource is automatically wired into src/app.ts.
pnpm basalt make:resource BlogPostThe name can be given in any format — BlogPost, blog-post, blog post — the generator normalizes it. Endpoints use the plural in kebab-case: /blog-posts.
Options (common to all make:* commands, unless noted):
| Flag | What it does |
|---|---|
--dir=<path> | Project root to write to (default: current directory) |
--force | Overwrites existing files instead of refusing |
--prisma | Generates a repository connected to Prisma + a model for schema.prisma |
--no-register | (only make:resource) Doesn't touch src/app.ts |
--public | Generates routes WITHOUT meta.auth (anonymous access). Default: every route requires an authenticated user. Alias: --no-auth |
--tenant / --no-tenant | Forces tenant scoping on/off. Default: on when package.json depends on @basaltkit/tenancy |
--crud / --no-crud | (only make:service) Forces the CRUD service or the minimal one. Default: CRUD when the sibling <name>.repository.ts and <name>.schema.ts are already in the target directory, minimal when they are not |
--prisma — real persistence with a database
pnpm basalt make:resource BlogPost --prismaInstead of the in-memory repository, generates PrismaBlogPostRepository (which uses db() from @basaltkit/prisma) and an extra file src/modules/blog-post/blog-post.prisma with the model block to copy into your schema.prisma. Then run prisma migrate dev.
Project defaults, including which Prisma client
The generated repository types itself against PrismaClient from @prisma/client. An application with a second client — schema-per-tenant, database-per-tenant, a read replica — needs the other one, and against that one the default either fails to compile or, worse, compiles and points at the wrong models.
That is a fact about the project, not about one invocation, so it goes where the commands are registered rather than into a flag typed every time:
import { generatorCommands } from '@basaltkit/generator'
commandsPlugin(
generatorCommands({
prisma: true, // every repository here is Prisma-backed
prismaClient: { import: '../../tenant-db.js', type: 'TenantDb' },
}),
)The repository then opens with import type { TenantDb } from '../../tenant-db.js' and reads db<TenantDb>().blogPost. A relative import is resolved from the generated file, which lives at src/modules/<name>/.
Flags still win, in both directions: --no-prisma generates the in-memory repository even with prisma: true configured. A default a flag cannot turn off is a trap.
Automatic wiring into src/app.ts
After writing the files, make:resource tries to:
- add the plugin and routes
imports after the last import; - insert
blogPostPlugin,immediately beforefastifyPlugin(; - spread
...blogPostRoutes,at the start of thefastifyPlugin({ routes: [...] })array.
It is idempotent (running it twice doesn't duplicate anything) and all-or-nothing: if src/app.ts doesn't exist, is already wired, or doesn't have the shape fastifyPlugin({ routes: [...] }), it changes nothing and explains why — in that case, wire it up by hand.
Generating just one piece: make:schema, make:repository, …
Each artifact type has its own command:
pnpm basalt make:schema Invoice # src/modules/invoice/invoice.schema.ts
pnpm basalt make:repository Invoice # src/modules/invoice/invoice.repository.ts
pnpm basalt make:service Invoice # src/modules/invoice/invoice.service.ts
pnpm basalt make:plugin Invoice # src/modules/invoice/invoice.plugin.ts
pnpm basalt make:routes Invoice # src/modules/invoice/invoice.routes.ts
pnpm basalt make:test Invoice # tests/invoice.test.tsWithout a name, any command prints usage and returns exit code 1:
Usage: basalt make:resource <Name> [--dir=<path>] [--force] [--prisma] [--soft-delete] [--public] [--tenant|--no-tenant]Services that are not CRUD
make:service on its own does not assume a resource. A CRUD service delegates to a sibling repository and imports the sibling schema; generated where those files do not exist, it would not compile (TS2307: Cannot find module './invoice.repository.js'). So the command looks at the target directory first:
<name>.repository.tsand<name>.schema.tsalready there (typically aftermake:resource, or after writing them yourself) → the CRUD service, exactly as before;- either one missing → a minimal service: the class, its
createTokeninjection token and a constructor with no dependencies, importing nothing but@basaltkit/core. It compiles the moment it is written, and carries a TODO pointing atmake:resourcefor the CRUD vertical.
That is the shape you want for orchestration, domain rules, transactions, schedulers — the services a repository has nothing to do with.
pnpm basalt make:service Billing # minimal: no repository next to it
pnpm basalt make:service Invoice --crud # force the CRUD shape (you will add the siblings)
pnpm basalt make:service Invoice --no-crud # force the minimal shape, siblings or notmake:resource is unaffected — the vertical always gets the CRUD service, because it generates the repository and the schema in the same batch.
One artifact at a time: the sibling warning
The service is the only artifact with a shape that stands on its own. The others are members of a vertical and import one another: the plugin needs the repository and the service, the routes need the service and the schema, the test needs the plugin and the routes, the repository needs the schema. Generate one of them alone and the file is written — the sibling may be the next thing you write by hand — but the generator now tells you what it refers to and cannot find:
Generated 1 file(s):
src/modules/invoice/invoice.plugin.ts
Warning: src/modules/invoice/invoice.plugin.ts imports 2 file(s) that do not exist yet:
src/modules/invoice/invoice.repository.ts
src/modules/invoice/invoice.service.ts
Generate the whole vertical with `basalt make:resource Invoice`, or write them yourself — until then this file does not compile.make:schema never warns (it imports nothing of the module) and make:resource never warns (it writes every one of them). The same list is available programmatically as missingSiblings.
Using the generator as a library (Advanced)
You can generate files programmatically, without going through the CLI:
import { generateResource, writeGenerated, registerResourceInApp } from '@basaltkit/generator'
const files = generateResource('BlogPost', { prisma: false })
const written = await writeGenerated(files, { baseDir: '/path/to/project' })
console.log(written) // relative paths, sorted
const result = await registerResourceInApp('BlogPost', { baseDir: '/path/to/project' })
console.log(result.registered) // true if it wired into src/app.tsAPI reference
Exported from @basaltkit/generator:
names(input: string): Names
Derives all variations of a name. Throws Error if it can't extract words from the input.
Names field | Example (blog-post) | Used for |
|---|---|---|
raw | blog-post | Original input |
pascal | BlogPost | Class and type names |
camel | blogPost | Variables and identifiers |
kebab | blog-post | File and folder names |
pluralKebab | blog-posts | Route paths |
constant | BLOG_POST | Tokens and error codes |
Pluralization is English and simplified (company → companies, box → boxes, otherwise → +s).
generate(kind, name, options?): GeneratedFile
Generates one artifact. kind is a GeneratorKind: 'schema' | 'repository' | 'service' | 'plugin' | 'routes' | 'test'.
For 'service' the default is the CRUD shape; pass { crud: false } for the minimal one. A caller that scaffolds a lone service should decide the same way the CLI does — with serviceSiblingsExist (below).
generateResource(name, options?): GeneratedFile[]
Generates the complete vertical slice. With options.prisma: true, adds the .prisma file and swaps the repository for the Prisma version.
GeneratorOptions:
| Field | Type | Required? | Default | Description |
|---|---|---|---|---|
prisma | boolean | No | false | Prisma repository (+ schema.prisma model) instead of in-memory |
auth | boolean | No | true | Every generated route requires an authenticated user (meta.auth). false = deliberately public |
tenant | boolean | No | false (the CLI detects @basaltkit/tenancy) | Tenant-owned repository scoped with requireTenantId() + indexed tenantId model column |
crud | boolean | No | true (the CLI detects the sibling files) | Shape of the generated service: CRUD over the sibling repository, or minimal (class + token + empty constructor, no imports beyond @basaltkit/core). generateResource always generates CRUD |
GeneratedFile: { path: string; content: string } — the path is relative to the project root.
expectedSiblings(kind, name, options?): string[]
The files kind imports but does not create, as project-relative paths. Pure — it says nothing about what is on disk.
kind | Imports but does not create |
|---|---|
schema | — |
repository | <name>.schema.ts |
service | <name>.repository.ts, <name>.schema.ts (nothing with { crud: false }) |
plugin | <name>.repository.ts, <name>.service.ts |
routes | <name>.service.ts, <name>.schema.ts |
test | <name>.plugin.ts, <name>.routes.ts |
(All under src/modules/<name>/.) moduleFile(names(name), 'repository') builds one such path.
missingSiblings(kind, name, options?, write?): Promise<string[]>
expectedSiblings minus what is already under write.baseDir (default process.cwd()) — the files the generated artifact will import and nobody has written. Empty for every kind once make:resource has run.
missingSiblingsWarning(name, generatedPath, missing): string[] renders the exact lines the CLI prints, so tooling that calls the generator programmatically can surface the same message.
import { generate, missingSiblings, missingSiblingsWarning, writeGenerated } from '@basaltkit/generator'
const file = generate('plugin', 'Invoice')
const missing = await missingSiblings('plugin', 'Invoice', {}, { baseDir: root })
await writeGenerated([file], { baseDir: root }) // written either way
for (const line of missingSiblingsWarning('Invoice', file.path, missing)) console.warn(line)serviceSiblingsExist(name, options?): Promise<boolean>
true when both files a CRUD service imports — src/modules/<name>/<name>.repository.ts and src/modules/<name>/<name>.schema.ts — are already under options.baseDir (default process.cwd()); missingSiblings('service', …) with nothing missing. This is what basalt make:service consults when neither --crud nor --no-crud was given.
import { generate, serviceSiblingsExist, writeGenerated } from '@basaltkit/generator'
const crud = await serviceSiblingsExist('Invoice', { baseDir: root })
await writeGenerated([generate('service', 'Invoice', { crud })], { baseDir: root })writeGenerated(files, options?): Promise<string[]>
Writes the files to disk (creates the necessary folders). Returns the written paths, sorted. If any file already exists and force is false, throws FileExistsError before writing anything.
WriteOptions:
| Field | Type | Required? | Default | Description |
|---|---|---|---|---|
baseDir | string | No | process.cwd() | Project root paths are resolved against |
force | boolean | No | false | Overwrite existing files |
registerResourceInApp(name, options?): Promise<AppRegistration>
Wires a generated resource into src/app.ts (imports + plugin + routes spread). Never throws because of the file's shape — it reports the reason instead.
AppRegistration:
| Field | Type | Description |
|---|---|---|
registered | boolean | true if it changed the file |
reason | string? | When not registered: 'src/app.ts not found', 'already registered', or 'app.ts does not use fastifyPlugin({ routes: [...] })' |
appPath | string | Absolute path of the src/app.ts considered |
generatorCommands(): CommandDefinition[]
Returns the make:resource and make:<kind> commands (one per GeneratorKind) ready to register with commandsPlugin from @basaltkit/cli.
GENERATORS (Advanced)
Map of kind → generator function ({ schema, repository, service, plugin, routes, test }). GeneratorKind is keyof typeof GENERATORS.
FileExistsError
Error thrown by writeGenerated when there are conflicts without force. Has a paths: string[] property with the conflicting files.
Exported types
Names, GeneratedFile, GeneratorKind, GeneratorOptions, WriteOptions, AppRegistration — described above.
Common issues and solutions (FAQ)
Refusing to overwrite existing files (use force to replace): … The generator never overwrites files by default. If you really want to regenerate, add --force (in the CLI) or { force: true } (in the API). Warning: you'll lose manual changes to those files.
Could not auto-wire src/app.ts (app.ts does not use fastifyPlugin({ routes: [...] })). Automatic wiring only recognizes the fastifyPlugin({ routes: [...] }) shape in src/app.ts. If you reorganized the file, wire it up by hand: import <name>Plugin and <name>Routes from the generated module, add the plugin to the plugins list, and spread the routes (...<name>Routes) into the routes array.
I generated with --prisma but get an error that blogPost doesn't exist on PrismaClient. The Prisma repository assumes a model with the PascalCase name (model BlogPost) in your schema.prisma. Copy the generated .prisma file's contents into schema.prisma, run prisma migrate dev, and regenerate the Prisma client.
Warning: … imports N file(s) that do not exist yet. You generated one artifact of a vertical whose siblings are not there. The file was written anyway (the warning lists exactly what is missing, in the order the file imports it): write those files, or run basalt make:resource <Name> to generate the whole vertical. Until then that file does not compile — TS2307: Cannot find module.
make:service gave me a service with no CRUD methods. Expected: there was no <name>.repository.ts / <name>.schema.ts next to it, so you got the minimal shape instead of a file that could not compile (the note printed after generation says so). Run make:resource <Name> for the whole vertical, or make:service <Name> --crud if you are writing the siblings yourself.
Data disappears when I restart the server. This is the expected behavior of the in-memory repository (the default). For real persistence, generate with --prisma or implement the <Name>Repository interface yourself and register it in the plugin.
Usage: basalt make:resource <Name> … and exit code 1. The resource name is missing: pnpm basalt make:resource Project.
I ran make:resource twice and app.ts didn't change the second time. Correct — wiring is idempotent. The message Already wired into src/app.ts — left it as is. confirms nothing was duplicated.
How it connects to other modules
@basaltkit/cli— direct dependency:generatorCommands()returnsCommandDefinition[]to register withcommandsPlugin; this is what makesmake:*appear inbasalt.@basaltkit/core— the generated code usescreateToken,definePlugin, andctxfrom the framework core.@basaltkit/fastify— generated routes useroute(...)andHttpError; automatic wiring looks forfastifyPlugin({ routes: [...] })inapp.ts.@basaltkit/prisma— with--prisma, the generated repository usesdb()from this package.@basaltkit/testing— the generated test usescreateTestAppto exercise the full CRUD.create-basalt— with--cli, the new project already comes with@basaltkit/generatorinstalled and the commands registered.