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.
| Form | Shell? | Takes options? |
|---|---|---|
sh`npm test` — tagged template | No | No |
sh(["npm", "test"], opts) — argv array | No | Yes |
sh("a | b", opts) — plain string | Yes | Yes |
sh.raw("a | b", opts) — explicit | Yes | Yes |
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
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",
});
| Option | Default | Meaning |
|---|---|---|
cwd | "." | Working directory, relative to the workspace root |
env | {} | Extra environment variables for this command |
timeout | none | Kill 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>.