Crust logoCrust

Contexts

Supply dependencies to commands with lazy setup, automatic cleanup, and test replacements.

A Context is a named dependency, such as an API client or database connection, that the app creates and commands consume. This is dependency injection: commands declare what they need, the app decides how to build it, and Crust runs setup lazily and cleans up afterwards.

Reach for a Context when several commands share a service, setup should run only when used, a resource must be released afterwards, or you want to swap in a fake for tests. For stateless helpers, a plain import is enough.

Define and provide

status.ts
import { Crust, defineContext } from "@crustjs/core";

const api = defineContext("api", () => ({
	get: (path: string) => `https://api.example.com${path}`,
}));

const status = new Crust("status")
	.provide(api()) 
	.action(async ({ ctx, stdout }) => {
		const client = await ctx.api;
		stdout(client.get("/status"));
	});

await status.execute();
$ status
https://api.example.com/status

defineContext returns a factory. api() creates an instance and .provide() attaches it to the app. In the action, ctx.api is the value setup returned; it is always awaited because setup may be asynchronous.

An inline command can shadow an inherited Context with a different value type before binding its action. After .action(), a local replacement must satisfy the Context value types that action received. Calling .action() again replaces the callback and binds the current Context contract. These obligations belong to that command, not its descendants: a child may still shadow independently.

Use from a reusable command

app.ts
import { Crust, defineCommand, defineContext } from "@crustjs/core";

const api = defineContext("api", () => ({
	get: (path: string) => `https://api.example.com${path}`,
}));

const health = defineCommand("health", (command) =>
	command.use(api).action(async ({ ctx, stdout }) => {
		stdout((await ctx.api).get("/health"));
	}),
);

const app = new Crust("app").provide(api()).add(health);

await app.execute();
$ app health
https://api.example.com/health

.use(api) declares that the reusable command needs api; it creates nothing. The app supplies the instance with .provide(api()) and then adds the command. Reversing that order is a type error: Uses Context "api" which is not provided.

Setup and cleanup

work.ts
import { Crust, defineCommand, defineContext } from "@crustjs/core";

const database = defineContext("database", ({ stdout, defer }) => {
	stdout("database opened");
	defer(() => stdout("database closed"));
	return { query: (sql: string) => `${sql}: ok` };
});

const query = defineCommand("query", (command) =>
	command.use(database).action(async ({ ctx, stdout }) => {
		stdout((await ctx.database).query("select 1"));
	}),
);

const work = new Crust("work")
	.provide(database())
	.command("ping", (command) => command.action(({ stdout }) => stdout("pong")))
	.add(query)
	.command("fail", (command) =>
		command.action(async ({ ctx }) => {
			await ctx.database;
			throw new Error("query failed");
		}),
	);

await work.execute();
$ work ping
pong
$ work query
database opened
select 1: ok
database closed
$ work fail
database opened
Error: query failed
database closed

Setup runs the first time a command reads ctx.database, so ping never opens it. Each invocation gets its own value. defer(cleanup) registers cleanup for that value; Crust runs it after the action and postRun hooks, including when the action throws. Callbacks may be async and run in reverse registration order. Calling defer after setup has settled throws a DEFINITION error.

When setup acquires several resources, call defer right after each acquire. Then a later failure in the same setup still releases what was already opened. Returned values that implement [Symbol.dispose] or [Symbol.asyncDispose] are still disposed automatically, before that setup's deferred callbacks.

Flags owned by a Context

app.ts
import { Crust, defineCommand, defineContext, defineFlag } from "@crustjs/core";

const apiUrl = defineFlag("api-url", { type: "string", default: "https://api.example.com" });
const api = defineContext("api", { flags: [apiUrl] }, ({ flags }) => ({
	get: (path: string) => `${flags["api-url"]}${path}`,
}));

const deploy = defineCommand("deploy", (command) =>
	command.use(api).action(async ({ ctx, stdout }) => {
		stdout((await ctx.api).get("/deploy"));
	}),
);

const app = new Crust("app").provide(api()).add(deploy);

await app.execute();
$ app deploy
https://api.example.com/deploy
$ app deploy --api-url https://staging.example.com
https://staging.example.com/deploy

When a flag configures the dependency rather than the command, list it in the Context's flags. .provide() installs it on that command and every command added below, and setup receives the validated value. See .provide() for collision rules.

Contexts that use other Contexts

app.ts
import { Crust, defineCommand, defineContext } from "@crustjs/core";

const config = defineContext("config", () => ({ region: "eu" }));
const api = defineContext("api", { uses: [config] }, async ({ ctx }) => {
	const { region } = await ctx.config;
	return { get: (path: string) => `https://${region}.api.example.com${path}` };
});

const regions = defineCommand("regions", (command) =>
	command.use(api).action(async ({ ctx, stdout }) => {
		stdout((await ctx.api).get("/regions"));
	}),
);

const app = new Crust("app").provide(config(), api()).add(regions);

await app.execute();
$ app regions
https://eu.api.example.com/regions

List the factories a Context needs in uses and read them from ctx in setup, the same way an action does. Dependencies are lazy too: config is built only because api reads it. Provide every instance; order within one .provide() call does not matter.

Replace a Context in tests

work.test.ts
import { Crust, defineCommand, defineContext } from "@crustjs/core";

const database = defineContext("database", ({ stdout, defer }) => {
	stdout("database opened");
	defer(() => stdout("database closed"));
	return { query: (sql: string) => `${sql}: ok` };
});

const query = defineCommand("query", (command) =>
	command.use(database).action(async ({ ctx, stdout }) => {
		stdout((await ctx.database).query("select 1"));
	}),
);

const fakeDatabase = database.of({ query: (sql: string) => `fake: ${sql}` });
const testApp = new Crust("work").provide(fakeDatabase).add(query);

const outcome = await testApp.run(["query"]);
console.log(outcome.stdout); // => fake: select 1

database.of(value) builds an instance that returns value without running setup, so neither database opened nor the deferred database closed runs. It keeps the Context's name and value type, which is why query is unchanged. See Testing for run().

For the full contracts, see Context types and .use().

On this page