Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Replacing a shell script

The task: a nightly bash script that writes a manifest of the day’s CSV exports (rows, refunds, a checksum) and then compresses them. Here it is:

#!/bin/bash
# List the day's CSV exports in a manifest, then compress them.
set -euo pipefail

dir=recipes-exports
: > manifest.txt
for f in "$dir"/*.csv; do
  name=$(basename "$f")
  rows=$(tail -n +2 "$f" | wc -l)
  refunds=$(grep -c ',refunded$' "$f" || true)
  sum=$(sha256sum "$f" | cut -c 1-12)
  echo "$name rows=$rows refunds=$refunds sha256=$sum" >> manifest.txt
  gzip -f "$f"
done
cat manifest.txt

And the same script with proc:

import { type Enumerable, read, run } from "@j50n/proc";

/** Like `$(...)`: all the output as one string, without the last newline. */
async function text(output: Enumerable<Uint8Array>): Promise<string> {
  return (await output.lines.collect()).join("\n");
}

const dir = "recipes-exports";
const files: string[] = [];
for await (const entry of Deno.readDir(dir)) {
  if (entry.name.endsWith(".csv")) files.push(entry.name);
}
files.sort(); // a glob is sorted; readDir is not

const manifest: string[] = [];
for (const name of files) {
  const f = `${dir}/${name}`;
  const rows = await read(f).lines.drop(1).count();
  const refunds = await read(f).lines.count((l) => l.endsWith(",refunded"));
  const sum = await text(run("sha256sum", f).run("cut", "-c", "1-12"));
  manifest.push(`${name} rows=${rows} refunds=${refunds} sha256=${sum}`);
  await run("gzip", "-f", f).collect();
}

await Deno.writeTextFile("manifest.txt", manifest.join("\n") + "\n");
await read("manifest.txt").toStdout(); // cat manifest.txt
orders-east.csv rows=4 refunds=1 sha256=4d08cbb7e44c
orders-north.csv rows=3 refunds=2 sha256=7d787de6f357
orders-west.csv rows=2 refunds=0 sha256=a6ce666dd7b3

Run it with only what it touches:

deno run --allow-read=recipes-exports,manifest.txt --allow-write=manifest.txt \
  --allow-run=sha256sum,cut,gzip manifest.ts

What maps to what

bashproc
set -ealways on: a failed command throws from the await that reads it
set -o pipefailalways on: every command in a .run() chain is checked
a | brun("a").run("b")
x=$(cmd)await text(run("cmd")), with the helper above
for f in dir/*.csvDeno.readDir, filter, sort
"$f"f: each argument is its own string, so nothing needs quoting
cmd || truetry/catch, or an fnError handler
cat fileawait read("file").toStdout()
> fileDeno.writeTextFile, or .writeTo("file")
cd dir && cmdrun({ cwd: "dir" }, "cmd")
VAR=x cmdrun({ env: { VAR: "x" } }, "cmd")
cmd 2>/dev/nullrun({ fnStderr: (s) => s.forEach(() => {}) }, "cmd")
exit 1Deno.exitCode = 1, or Deno.exit(1)

Some steps are simpler in TypeScript than as a command. tail -n +2 | wc -l became read(f).lines.drop(1).count(), and the refund count became a count() with a test, which removed the bash script’s most fragile line: grep -c exits with code 1 when it counts zero, so under set -e it needs || true, and forgetting that kills the script on the first file with no refunds. When you do keep grep, Searching logs shows how to let “no match” through.

When a command fails

Nothing catches errors in the script, so a failure ends it the way set -e does, with exit code 1. The difference is what you see. If sha256sum can’t read a file:

sha256sum: missing.csv: No such file or directory
error: Uncaught (in promise) UpstreamError: sha256sum exited with code 1
    ...
Caused by: ExitCodeError: sha256sum exited with code 1

The first line is sha256sum’s own stderr, which goes to your terminal as it would from bash. cut succeeded, so the error at the end is an UpstreamError from cut, and its cause is the ExitCodeError of the command that failed. To handle failures rather than stop, put a try around the await; see Errors.

Traps when translating

  • A command you don’t consume isn’t checked. run("gzip", f) on its own starts gzip, and your script waits for it to exit, but nothing reads its exit code, so a failure passes silently. Always await a consumer: await run("gzip", f).collect().
  • .lines.first is not $(...). It stops reading after one line, and stopping early skips the exit-code check, so a command that prints a line and then fails gives you the line and no error. Collect the output, as text() does, when the exit code matters.
  • There is no glob expansion. run("ls", "*.csv") passes the text *.csv to ls, which fails because no file has that name. List the files in TypeScript, or run a real shell for the parts that need one: run("sh", "-c", "ls *.csv"). Never build a sh -c string from data you didn’t write; that is where quoting bugs and injection come from.
  • Deno.readDir is unsorted. A glob is sorted by name; sort the list yourself if order matters.