Contexts
Share dependencies across commands with lazy setup, automatic cleanup, and test replacements.
A Context supplies a dependency, such as an API client or database connection, to your commands, exposing through ctx.
Use a Context when commands share a service, setup or cleanup needs managing, or tests need a replacement. For stateless helpers, a plain import is enough.
Define and provide
Start the definition with defineContext(), finish it with .setup(), provide an instance to the app, and await its value in the action:
import { Crust, defineContext } from "@crustjs/core";
const api = defineContext("api").setup(() => ({
get: (path: string) => `https://api.example.com${path}`,
}));
const status = new Crust("status")
.provide(api())
.action(async ({ ctx, stdout }) => {
const = await ctx.api;
stdout(client.get("/status"));
});
await status.execute();
$ status
https://api.example.com/statusapiis the factory returned by.setup().api()creates an instance for.provide(); it does not run setup yet.await ctx.apiruns setup on first access and returns its value. Context values are always accessed through promises, even when setup is synchronous.
Provide a Context before adding commands that need it. Later-added descendants inherit it. To accept factory options without losing inference, annotate setup's second parameter; see configurable Contexts.
Use from a reusable command
A reusable command declares its dependencies with .use(). Using api from above:
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) says “I need this Context”; .provide(api()) supplies it. Add the command after providing its dependencies—reversing the order is a type error.
Setup and cleanup
Setup starts on first access to ctx.database. Once it succeeds, its value is shared for that invocation. Register cleanup with defer() as soon as you acquire a resource:
import { Crust, defineCommand, defineContext } from "@crustjs/core";
const database = defineContext("database").setup(({ 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 closedCleanup runs after postRun hooks. Deferred callbacks can be async and run in reverse registration order.
Disposable values
Instead of defer(), the returned value can own its cleanup. Replace the database definition above with:
export const database = defineContext("database").setup(({ stdout }) => {
stdout("database opened");
return {
query: (sql: string) => `${sql}: ok`,
[Symbol.dispose]() {
stdout("database closed");
},
};
});The commands and output stay the same. For asynchronous cleanup, use async [Symbol.asyncDispose]() instead of [Symbol.dispose]().
Crust disposes the value for you. Do not also close it with defer() or await using, or use it after the invocation ends. See the lifecycle rules for ordering and cleanup dependencies.
Cancellable setup
Pass setup's signal to work that should stop when the invocation is cancelled:
export const config = defineContext("config").setup(async ({ signal }) => {
const response = await fetch("https://api.example.com/config", { signal });
if (!response.ok) throw new Error(`Config request failed: ${response.status}`);
return response.text();
});Crust waits for setup to settle before cleanup, so ignoring cancellation can delay shutdown. Cleanup that must finish should not use the aborted signal.
Flags owned by a Context
When a flag configures the dependency rather than a particular command, declare it with the Context's .flags():
import { Crust, defineContext } from "@crustjs/core";
const api = defineContext("api")
.flags({ name: "api-url", type: "string", default: "https://api.example.com" })
.setup(({ }) => ({
get: (path: string) => `${flags["api-url"]}${path}`,
}));
const app = new Crust("app").provide(api()).command("deploy", (command) =>
command.action(async ({ ctx, stdout }) => {
stdout((await ctx.api).get("/deploy"));
}),
);
await app.execute();
$ app deploy
https://api.example.com/deploy
$ app deploy --api-url https://staging.example.com
https://staging.example.com/deploy.provide() installs these flags on the providing command and later-added descendants. Setup receives their validated values. See .provide() for collision rules.
Documentation owned by a Context
import { Crust, defineCommand, defineContext } from "@crustjs/core";
import { help } from "@crustjs/extensions";
const api = defineContext("api")
.sections({ title: "Environment", body: "API_TOKEN Token sent with every request" })
.setup(() => ({ token: process.env.API_TOKEN }));
const deploy = defineCommand("deploy", (command) =>
command.use(api).action(async ({ ctx, stdout }) => {
stdout((await ctx.api).token ? "deploying" : "missing API_TOKEN");
}),
);
const app = new Crust("app").extend(help()).provide(api()).add(deploy);
await app.execute();
$ app --help
app
Usage:
app <command> [options]
Commands:
deploy
Options:
-h, --help Show help
Environment:
API_TOKEN Token sent with every requestWhen a Context needs explaining — the environment variables it reads, the files it opens — declare it with .sections(...sections) before .setup(). Each call appends to a new builder, taking the same { title, body, only?, except? } shape as a command's own sections. Sections are validated and copied when declared.
Unlike flags, sections document only the command where the Context is provided, never the commands below it: the reader sees them once, where the dependency is set up. .provide(api()) on the root puts them in root help; .provide(api()) inside db puts them in app db --help only. A Context provided by an Extension documents the root once. Help, manual pages, and every other consumer of the command snapshot render them after the command's own sections, the same way.
A Context section cannot reuse a title already on that command, from the command's own sections or another provided Context: preparing the app throws a DEFINITION error (duplicate-section). When an Extension replaces a same-name Context, only the final provider's sections are used, including for .of() replacements. @crustjs/env uses this to document the variables its Context reads.
Contexts that use other Contexts
Declare dependencies with .use(), then await them through ctx inside setup:
import { Crust, defineContext } from "@crustjs/core";
const config = defineContext("config").setup(() => ({ region: "eu" }));
const api = defineContext("api")
.use(config)
.setup(async ({ ctx }) => {
const { region } = await ctx.;
return { get: (path: string) => `https://${region}.api.example.com${path}` };
});
const app = new Crust("app").provide(config(), api()).command("regions", (command) =>
command.action(async ({ ctx, stdout }) => {
stdout((await ctx.api).get("/regions"));
}),
);
await app.execute();
$ app regions
https://eu.api.example.com/regionsProvide every dependency. Order within one .provide() call does not matter. Setup stays lazy: config is built only because api reads it.
Replace a Context in tests
.of(value) supplies a value without running the factory's setup. Using the original defer()-based database and query:
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 1The command stays unchanged, but neither database opened nor its deferred database closed runs. Use a plain fake for repeated tests: a disposable supplied through .of() is still disposed by each invocation that reads it.
See Testing for run() and assertions, or Context types for the full API.