Skip to main content

Running commands

sh is how a stage says "run this". It has four forms, and the difference between them is whether a shell is involved.

FormShell?Takes options?
sh`npm test` — tagged templateNoNo
sh(["npm", "test"], opts) — argv arrayNoYes
sh("a | b", opts) — plain stringYesYes
sh.raw("a | b", opts) — explicitYesYes

The first two build an argument list and execute it directly. The last two hand a string to sh -c.

Tagged templates

await sh`npm run build`;

Interpolation is tokenized, not escaped — and that distinction is the whole safety story:

const message = "a file with spaces.txt; rm -rf /";
await sh`cat ${message}`;

This runs cat with exactly one argument: the literal string a file with spaces.txt; rm -rf /. The ; isn't a separator, the spaces don't split the argument, and no quoting was needed — because there is no shell to interpret them. Injection isn't prevented here, it's structurally impossible.

Values attach to the token they're adjacent to, so this does what it looks like:

await sh`docker build --tag=${name}:${version} .`;

An array expands to one argument per element:

const files = ["a.ts", "b.ts"];
await sh`prettier --write ${files}`; // → prettier --write a.ts b.ts
The tagged form takes no options

There's nowhere to put them — a tagged template's arguments are the interpolated values. If you need cwd, env or timeout, use the array form.

Argv arrays

Same execution model, but with options:

await sh(["npm", "run", "build"], {
cwd: "packages/api",
env: { NODE_ENV: "production" },
timeout: "5m",
});
OptionDefaultMeaning
cwd"."Working directory, relative to the workspace root
env{}Extra environment variables for this command
timeoutnoneKill the command after this duration

env adds to the environment rather than replacing it — except in container stages, which start from nothing. See Containers.

A command timeout is reported explicitly:

step timed out after 5000ms

which is more diagnosable than a stage-level timeout. Durations use the same grammar as stage timeouts — always write the unit.

When you actually want a shell

Pipes, redirection, globs and command substitution need one:

await sh.raw("cat report.json | jq '.total' > total.txt");
await sh.raw('docker build -t "app:$(git rev-parse HEAD)" .');

sh("...") with a plain string does the same thing; sh.raw just says so out loud. Prefer it when you mean it — a reader can tell at a glance which calls involve a shell.

The tradeoff is that you're now responsible for quoting. Don't interpolate anything you didn't write:

// Don't. `input` is now shell syntax.
await sh.raw(`cat ${input}`);

// Do. `input` is an argument, whatever it contains.
await sh`cat ${input}`;

Command substitution is also the standard workaround for the fact that sh can't return output at authoring time — see the overview.

Writing files

import { file } from "@flowwright/core";

stage("Configure", async () => {
await file.write("config/build.json", JSON.stringify({ mode: "prod" }, null, 2));
await file.write("scripts/run.sh", "#!/bin/sh\nexec node server.js\n", { mode: 0o755 });
});

file.write is a step like sh is — it happens in order, interleaved with your commands. It writes directly, without a shell, so there's no quoting or escaping to get wrong, and no echo ... > to mangle a multi-line string.

Paths are confined to the workspace; writing outside it fails the step.

Ordering

sh and file.write are the only ordered effects. They become steps, and steps run in the order you wrote them.

Everything else — cache.restore, cache.save, stash, unstash, artifact.upload — is a stage-level declaration. Where you write it in the body makes no difference to when it happens. That's covered in Artifacts, cache and stash.

Seeing what you recorded

flow explain prints the steps of each stage in order:

build ← install
$ npm run build
$ write dist/meta.json

Shell steps show the raw command; argv steps show the joined arguments; file.write shows as write <path>.