sdxc
SearchCtrlK

[ 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

KindFileTestAlso edits
controllerapp/http/controllers/<name>.tsxyesthe route contract, the router, the sitemap
middlewareapp/http/middleware/<name>.tsyesa chain, with --mount
validatorapp/http/validators/<name>.tsyes
jobapp/jobs/<name>.tsyesapp/jobs/index.ts, the dispatcher
modelapp/models/<name>.tsyesapp/models/index.ts; writes its migration and factory
migrationdatabase/migrations/<number>_<Name>.sqlno
factorydatabase/factories/<name>.tsno
serviceapp/services/<name>.tsyes
policyapp/policies/<name>.tsyes
initializerapp/initializers/<name>.tsyes
emailapp/emails/<name>.tsxyes
mcpapp/mcp/tools/<name>.tsyesapp/mcp/tools/index.ts, the MCP handler
commandapp/commands/<name>.tsyesapp/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-run prints each file it would write in full and each edit as a diff, and changes nothing.

  • --no-test skips the test for one run, and --test writes it when make.tests is false. 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:

KindDeclarationMapping
controllersponsors: get("/sponsors") in routes/web.tsrouter.map(…, lazy(() => import(…)))
jobdaily: job({ input: s.object({}) }) in app/jobs/index.tsdispatcher.map(…, () => import(…))
modelpost: () => import("./post") in app/models/index.tsread as ctx.models.post
mcpsearchPosts: tool("search_posts", { … }) in app/mcp/tools/index.tsmcp.tools.map(…, lazy(…))
commandgrant: command({ … }) in app/commands/index.tscli.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:

.config/sdxc.jsonc
{
	"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"],
}
KeyDefaultWhat it decides
make.routeswebthe contract in routes/ a controller is declared in without --routes
make.migrations{ "default": "database/migrations" }the chains make migration --chain picks from
make.teststruewhether a test is written beside the file without --test/--no-test
make.stubs.config/sdxc/stubsthe folder whose stubs replace the built-in ones
formatnonethe 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:

StubSelected by
controller.form.stubmake controller --method form
controller.api.stubmake controller --routes api, by contract name
job.cron.stubmake job --cron
mcp.resource.stubmake mcp --resource

A stub is the file's text with {{placeholder}}s. Every kind can use these:

PlaceholderFor 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:

KindPlaceholders
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

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.