[ The sdxc command ]
Make a command
Write a command for your app's own command line with sdxc make command, declared in app/commands/index.ts, mapped through lazy() and run with sdxc run.
Last updated 2026-10-19
An app command is work you run by hand or from a script, outside any request: seeding, a
backfill, granting a role. sdxc make command writes the handler in app/commands/, declares it
in app/commands/index.ts and maps it on the app's command line, so it runs right away with
sdxc run:
sdxc make command users/grant
sdxc run users grant
Wrote app/commands/users/grant.ts
Wrote app/commands/users/grant.test.ts
Edited app/commands/index.ts
Edited app/initializers/cli.ts
Flags
| Flag | Default | What it does |
|---|---|---|
--test, --no-test | make.tests | write the test beside it, or skip it |
--dry-run | print the files and the diffs, and change nothing |
Names are the words you type
Each segment of the name is a word of the command, kept as you spell it in kebab-case:
users/grant runs as sdxc run users grant, and users/send-digest as
sdxc run users send-digest. Each folder is a commands({ … }) group, created when missing and
joined when it exists; a folder whose name is already a command stops the run, since a command
holds no others.
What it writes
The handler prints its title and succeeds, for you to replace with the work:
import { createCommand, success } from "@sdxc/cli";
import commandset from "~/app/commands";
export default createCommand(commandset.users.grant, (ctx) => {
// TODO: do the work, then report what changed
ctx.stdout("Grant\n");
return success();
});
What it edits
The command is declared with a description, and mapped through lazy() on the command line the
initializer creates with createCLI, so a run imports only the command it reaches:
--- a/app/commands/index.ts
+++ b/app/commands/index.ts
typegen: command({
description: "Write the Worker's env types from cloudflare.config.ts",
}),
+ users: commands({
+ grant: command({ description: "Grant" }),
+ }),
});
--- a/app/initializers/cli.ts
+++ b/app/initializers/cli.ts
+cli.map(commandset.users.grant, lazy(() => import("~/app/commands/users/grant")));
A word that is no identifier is reached by its quoted key, commandset.users["send-digest"].
The test
The test runs the command through the app's command line, so its middleware runs as it does for
sdxc run:
import { invoke } from "@sdxc/cli/test";
import { describe, expect, test } from "vitest";
import { cli } from "~/app/initializers/cli";
describe("users grant", () => {
test("runs and exits 0", async () => {
let run = await invoke(cli, ["users", "grant"]);
expect(run.code).toBe(0);
});
test.todo("does the Grant work");
});
bun run test app/commands/users/grant.test.ts
Fill it in
Declare the arguments and flags in
app/commands/index.ts, beside the description, such as anemailargument and a--roleflag; the handler'sctx.argumentsandctx.flagstake their types.Do the work through what the command line publishes:
ctx.dbandctx.modelsover the local database, the mailer, the log.Print what changed with
ctx.stdout, and return a failure for an exit code other than 0.
Your app's command line covers arguments,
flags, prompts and the services a command reads, and @sdxc/cli the whole API.
Where to go next
Run your app's commands — run it from any folder in the project.
Make a service — the rule a command shares with a controller or a job.
Generate files — the flags, configuration and stubs every kind shares.