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
| bash | proc |
|---|---|
set -e | always on: a failed command throws from the await that reads it |
set -o pipefail | always on: every command in a .run() chain is checked |
a | b | run("a").run("b") |
x=$(cmd) | await text(run("cmd")), with the helper above |
for f in dir/*.csv | Deno.readDir, filter, sort |
"$f" | f: each argument is its own string, so nothing needs quoting |
cmd || true | try/catch, or an fnError handler |
cat file | await read("file").toStdout() |
> file | Deno.writeTextFile, or .writeTo("file") |
cd dir && cmd | run({ cwd: "dir" }, "cmd") |
VAR=x cmd | run({ env: { VAR: "x" } }, "cmd") |
cmd 2>/dev/null | run({ fnStderr: (s) => s.forEach(() => {}) }, "cmd") |
exit 1 | Deno.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. Alwaysawaita consumer:await run("gzip", f).collect(). .lines.firstis 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, astext()does, when the exit code matters.- There is no glob expansion.
run("ls", "*.csv")passes the text*.csvtols, 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 ash -cstring from data you didn’t write; that is where quoting bugs and injection come from. Deno.readDiris unsorted. A glob is sorted by name; sort the list yourself if order matters.