sdxc
SearchCtrlK

[ 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

FlagDefaultWhat it does
--test, --no-testmake.testswrite the test beside it, or skip it
--dry-runprint 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:

app/commands/users/grant.ts
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:

app/commands/users/grant.test.ts
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

  1. Declare the arguments and flags in app/commands/index.ts, beside the description, such as an email argument and a --role flag; the handler's ctx.arguments and ctx.flags take their types.

  2. Do the work through what the command line publishes: ctx.db and ctx.models over the local database, the mailer, the log.

  3. 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

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