Commands v4.26.0+

Run a command in a Sandbox as one durable step, then read its output as text, lines, or JSON.

Experimental. getCommandError is exported from inngest/experimental, and group.sandbox is 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.

TypeScript
await 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's PATH.

  • Options before the command:

    $({ ... }) returns a $ with those options set. Use it once, or keep it for many commands.

  • $.sh runs 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, and false are 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`;
OptionDoes
cwdThe working directory.
envEnvironment for this command, over the Sandbox's. Values are kept out of the trace.
timeoutHow long it may run. Defaults to 5m. Longer is fine. Takes any duration.
exitCodesThe exit codes that are not failures. Defaults to [0]; "any" allows all.
idThe 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:

FieldValue
type"failed" for a disallowed exit or a process that ended without one, or "timeout" for a command stopped at its timeout.
messageThe error message.
commandThe command text.
exitCodeThe exit code, when it exited.
stdoutTail, stderrTailThe 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 timeout does 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