Commands v4.26.0+
Run a command in a Sandbox as one durable step, then read its output as text, lines, or JSON.
Experimental.
getCommandErroris exported frominngest/experimental, andgroup.sandboxis experimental. The API can change.
- Write commands like a script. Each interpolated value is one argument, so there is no quoting to get wrong.
- Read output directly.
.text(),.lines(), and.json(). - Fail loudly. A command that exits with an unexpected code fails its step, with the output attached.
- Run long commands. A command that outlasts one step's wait is still one row in the trace.
Run a command
Every Sandbox has a $. Each command is one step.
TypeScriptawait group.sandbox("build", async (sb) => { await sb.$`git clone --depth 1 ${repoUrl} /work`; const sha = await sb.$({ cwd: "/work" })`git rev-parse HEAD`.text(); await sb.$({ cwd: "/work", timeout: "20m" }).sh`pnpm install && pnpm test`;});Code notes
- No shell, no quoting:
The text splits on whitespace and each
${value}is exactly one argument, so a URL with spaces or quotes can't change the command. The first word is looked up on the Sandbox'sPATH. - Options before the command:
$({ ... })returns a$with those options set. Use it once, or keep it for many commands. $.shruns a shell script:Pipes,
&&, redirects, and shell builtins need a shell, so use$.sh.
group comes from the function's arguments. $ is also on the Sandbox from step.sandbox.create(). See Managed lifecycle.
Use a shell
$.sh`...` runs the script with /bin/sh -c. Interpolated values are quoted for you, so put them on their own.
await sb.$.sh`ls ${dir} | wc -l`; // quoted for you
await sb.$.sh`ls "${dir}"`; // throws: no values inside quotes or after a backslash
In $ and $.sh:
- Arrays spread into several arguments:
$`rm ${files}`. null,undefined, andfalseare dropped:$`npm test ${verbose && "--verbose"}`.
Read the output
The result is a promise with helpers. Awaiting it directly gives { stdout, stderr, exitCode, output }.
const sha = await sb.$`git rev-parse HEAD`.text(); // trimmed stdout
const files = await sb.$`git ls-files`.lines(); // string[], [] when empty
const pkg = await sb.$`cat package.json`.json<{ name: string }>(); // parsed stdout
const { stdout, stderr, exitCode } = await sb.$`node -v`;
.json() throws a NonRetriableError, naming the command, when the output is not JSON.
Set options
const work$ = sb.$({
cwd: "/work",
env: { NODE_ENV: "test", API_TOKEN: token },
timeout: "10m",
exitCodes: [0, 1],
});
await work$`pnpm lint`;
await work$.sh`pnpm test | tee out.log`;
await sb.$({ id: "install" }).sh`pnpm install`;
| Option | Does |
|---|---|
cwd | The working directory. |
env | Environment for this command, over the Sandbox's. Values are kept out of the trace. |
timeout | How long it may run. Defaults to 5m. Longer is fine. Takes any duration. |
exitCodes | The exit codes that are not failures. Defaults to [0]; "any" allows all. |
id | The step ID for commands run by that $({ id }); later $() calls do not inherit it. Defaults to the command text; repeats are numbered. |
A configured $ can be reused for many commands, and calling it again merges: work$({ cwd: "/other" }) keeps env and exitCodes.
commands.run takes exitCodes too, but defaults to "any", so there the exit code comes back as data. exitCodes can't be combined with tools on commands.run.
Handle failures
A command that exits with a code not in exitCodes fails its step and is not retried. A program that isn't found also fails the step. Read the details with getCommandError.
import { getCommandError } from "inngest/experimental";
try {
await sb.$`pnpm test`;
} catch (error) {
const failure = getCommandError(error);
if (failure) {
console.log(failure.type); // "failed" or "timeout"
console.log(failure.exitCode); // 1 (not set for a timeout)
console.log(failure.stderrTail); // last 8 KiB of stderr
}
throw error;
}
getCommandError returns a plain object, or undefined for any other error:
| Field | Value |
|---|---|
type | "failed" for a disallowed exit or a process that ended without one, or "timeout" for a command stopped at its timeout. |
message | The error message. |
command | The command text. |
exitCode | The exit code, when it exited. |
stdoutTail, stderrTail | The last 8 KiB of each stream. Both are empty when a command with a timeout of 5m or less times out. |
The error reaches your handler as a StepError (a NonRetriableError for a timeout of 5m or less), so check it with getCommandError, not instanceof. To treat an exit as success, allow it with exitCodes.
Long-running commands
A timeout over five minutes runs the command as a process that the steps follow, with one wait step per five minutes. It is still one [$] row in the trace.
await sb.$({ timeout: "45m" }).sh`pnpm test:e2e`;
- Output is the last 512 KiB.
- Each wait counts against the run's step limit, one per five minutes of
timeout. - The
timeoutdoes not extend the Sandbox's one hour of active runtime. See Runtime expiry. - A command that times out is killed, and fails with
type: "timeout".
In the trace
Each command is one row tagged [$] and named after the command. See Traces.
env values are redacted from the trace, but the command text and step ID are not. Pass secrets in env rather than interpolating them into the command. See Secrets.
await sb.$({ env: { NPM_TOKEN: token } }).sh`pnpm publish`; // token stays out of the trace
await sb.$`npm login --token ${token}`; // token is in the command text