[ The sdxc command ]
Generate files
Write a controller, job, model, migration or any other kind of file where the app layout puts it, declared, mapped and tested, with sdxc make.
Last updated 2026-10-19
sdxc make <kind> <name> writes one file of that kind where the app layout puts it, with a test
beside it, and makes every edit the file needs to run: a route declared and mapped, a job
registered with the dispatcher, a model added to the registry, a middleware added to a chain. A
new handler answers as soon as the command finishes.
sdxc make controller sponsors
sdxc make job reports/daily
sdxc make model post title body published_at author_id
This page covers what every kind shares. Each kind has its own guide with its flags, the file it writes and how to fill it in.
Kinds
| Kind | File | Test | Also edits |
|---|---|---|---|
controller | app/http/controllers/<name>.tsx | yes | the route contract, the router, the sitemap |
middleware | app/http/middleware/<name>.ts | yes | a chain, with --mount |
validator | app/http/validators/<name>.ts | yes | |
job | app/jobs/<name>.ts | yes | app/jobs/index.ts, the dispatcher |
model | app/models/<name>.ts | yes | app/models/index.ts; writes its migration and factory |
migration | database/migrations/<number>_<Name>.sql | no | |
factory | database/factories/<name>.ts | no | |
service | app/services/<name>.ts | yes | |
policy | app/policies/<name>.ts | yes | |
initializer | app/initializers/<name>.ts | yes | |
email | app/emails/<name>.tsx | yes | |
mcp | app/mcp/tools/<name>.ts | yes | app/mcp/tools/index.ts, the MCP handler |
command | app/commands/<name>.ts | yes | app/commands/index.ts, the command line |
sdxc make alone asks for the kind and the name on a terminal. Without one, a missing kind or
name is a usage error that lists the kinds, so a script always names both. sdxc make <kind> --help prints the flags of one kind.
Names
A slash in the name is a folder inside the kind's folder, so a feature stays together across
layers: sdxc make job trials/expire writes app/jobs/trials/expire.ts, and
sdxc make service trials/extend writes app/services/trials/extend.ts. A migration's name is
the one that holds no folders.
Every segment is written in kebab-case on disk, so Trials/ExpireOld, trials/expire_old and
trials/expire-old write the same file. Each segment must start with a letter and hold only
letters, digits and dashes:
"1abc" is not a name; use letters, digits and dashes in each segment, starting with a letter
The other spellings come from the last segment: expire-old is the camelCase key expireOld,
the PascalCase ExpireOld, the snake_case expire_old and the title Expire old. Each kind's
guide says which of them its declaration uses.
Flags every kind takes
--dry-runprints each file it would write in full and each edit as a diff, and changes nothing.--no-testskips the test for one run, and--testwrites it whenmake.testsisfalse. Migrations and factories never get one.
A run plans every file and every edit before it writes any, and never overwrites a file: a name that is taken, test included, stops the command and leaves the project as it was.
app/http/controllers/home.tsx already exists; sdxc make leaves it as it is
When the run finishes it prints Wrote and Edited for each path, then runs the format
command from your configuration once over all of them, so the files land in your project's
style. A formatter that fails leaves the files as make wrote them and says so.
Declared and mapped
A controller, a job, a model, an MCP tool or resource and a command each need an entry in a
declaration module and a mapping before the app reaches them, and make adds both:
| Kind | Declaration | Mapping |
|---|---|---|
controller | sponsors: get("/sponsors") in routes/web.ts | router.map(…, lazy(() => import(…))) |
job | daily: job({ input: s.object({}) }) in app/jobs/index.ts | dispatcher.map(…, () => import(…)) |
model | post: () => import("./post") in app/models/index.ts | read as ctx.models.post |
mcp | searchPosts: tool("search_posts", { … }) in app/mcp/tools/index.ts | mcp.tools.map(…, lazy(…)) |
command | grant: command({ … }) in app/commands/index.ts | cli.map(…, lazy(() => import(…))) |
make finds the initializer that serves each one by reading app/initializers/: the one that
imports the declaration module and calls createRouter, createJobDispatcher, createHandler
or createCLI. The new mapping goes after the last mapping already there, and imports follow
the form the file already uses, ~/app/… or relative.
When an edit cannot be placed
A project laid out differently from the template, such as one whose dispatcher lives outside
app/initializers/, may have no place make can edit. The command then writes the file and
its test, leaves every other file as it is, and prints the edits for you to make, so a
declaration never lands without its mapping:
Declare and map it by hand, since no initializer in app/initializers/ creates the job dispatcher:
In app/jobs/index.ts, in jobs({ … }):
digest: job({ input: s.object({}) }),
In the job dispatcher's initializer:
dispatcher.map(jobs.digest, () => import("~/app/jobs/digest"));
--dry-run prints the same instructions after the files.
Packages the code imports
When the generated code imports an @sdxc package your package.json does not list yet, make
adds it at the same release as the packages you have, the newest version published on or
before that date, and prints the install command to run:
Added @sdxc/uuid@2026.10.13 to package.json; run bun install
Configuration
make reads its settings from .config/sdxc.jsonc, and sdxc init writes the file with every
default spelled out:
{
"make": {
// Route contract `sdxc make controller` adds to, a module in routes/
"routes": "web",
// Migration chains by name; `--chain <name>` picks one
"migrations": { "default": "database/migrations" },
// Write a test beside every generated file except migrations and factories
"tests": true,
// Stubs here replace the built-in ones; `sdxc make stubs` copies them out
"stubs": ".config/sdxc/stubs",
},
// Command run on every file the CLI writes or edits, paths appended
"format": ["bunx", "vp", "fmt", "--no-error-on-unmatched-pattern"],
}
| Key | Default | What it decides |
|---|---|---|
make.routes | web | the contract in routes/ a controller is declared in without --routes |
make.migrations | { "default": "database/migrations" } | the chains make migration --chain picks from |
make.tests | true | whether a test is written beside the file without --test/--no-test |
make.stubs | .config/sdxc/stubs | the folder whose stubs replace the built-in ones |
format | none | the command run once over every written and edited file |
The doctor key holds the settings of sdxc doctor:
doctor.checks, the folder of your own checks (.config/sdxc/checks), and
doctor.migrations.accept, the migration errors its migrations check steps past ([]).
Make the generated files your own
Each file is written from a stub, and a stub in make.stubs replaces the built-in one of the
same name. Copy the built-in stubs to start from:
sdxc make stubs controller job
Wrote .config/sdxc/stubs/job.cron.stub
Wrote .config/sdxc/stubs/job.stub
Wrote .config/sdxc/stubs/job.test.stub
That writes every stub whose name starts with each kind, such as controller.stub,
controller.form.stub, controller.api.stub and their .test.stub partners. sdxc make stubs scaffold copies the ones sdxc scaffold writes from, and with
no kind it copies all of them. A stub already in the folder is kept, so your edits survive a
second run. Delete the ones you leave unchanged: every kind without a stub of yours keeps the
built-in one, and takes its improvements when you upgrade.
A kind's stub is <kind>.stub, a flag that changes the file selects <kind>.<variant>.stub, and
the test is <kind>.test.stub or <kind>.<variant>.test.stub, falling back to the kind's test
stub when a variant has none of its own:
| Stub | Selected by |
|---|---|
controller.form.stub | make controller --method form |
controller.api.stub | make controller --routes api, by contract name |
job.cron.stub | make job --cron |
mcp.resource.stub | make mcp --resource |
A stub is the file's text with {{placeholder}}s. Every kind can use these:
| Placeholder | For trials/expire-old |
|---|---|
{{name}} | expire-old |
{{key}} | expireOld |
{{Name}} | ExpireOld |
{{snake}} | expire_old |
{{title}} | Expire old |
{{specifier}} | ~/app/jobs/trials/expire-old, the file's import path |
{{header}} | the @author and @copyright tags, from package.json's author or your git user |
Some kinds add their own:
| Kind | Placeholders |
|---|---|
controller | {{path}}, the route's key path (posts.show); {{routes}}, the contract (~/routes/web) |
job | {{path}}, the job's key path (trials.expireOld) |
model | {{columns}}, a column line per field; {{table}}, the plural table (posts) |
factory | {{model}}, the model's import path (~/app/models/post); {{attributes}}, a sample value per field |
mcp | {{path}}; {{tool}}, the tool name (search_posts); {{uri}}, a resource's URI |
command | {{command}}, the words typed (users grant); {{member}} (.users.grant); {{words}} ("users", "grant") |
A placeholder the kind does not define stops the command and lists the ones it accepts, so a typo never reaches a generated file.
Where to go next
Scaffold a resource — every file a resource needs, from one command.
Make a controller — the kind most features start from.
List routes — confirm what a new controller is mapped to.