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>.