Recipe: replace your shell scripts
Every small project has a scripts/release.sh. It started as
three lines, grew a flag and two guards, and now nobody wants to touch it.
This recipe replaces it with the $ shell from
@zuke/core/shell — and nothing else. No build class, no
targets: the smallest possible door into Zuke, and a script you run
exactly as before.
The script you have
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-}"
if [ -z "$VERSION" ]; then
echo "usage: scripts/release.sh <version>" >&2
exit 2
fi
if [ -n "$(git status --porcelain)" ]; then
echo "release: the working tree is not clean" >&2
exit 1
fi
deno test -A
git tag -a "v$VERSION" -m "Release $VERSION"
git push origin --follow-tags
It works, until a version string has a space in it, or someone copies it
to a project without set -e, or the $(...)
quoting is one level off.
The same script with $
#!/usr/bin/env -S deno run -A
import { $ } from "jsr:@zuke/core/shell";
const [version, flag] = Deno.args;
if (version === undefined) {
console.error("usage: deno run -A scripts/release.ts <version> [--dry-run]");
Deno.exit(2);
}
const dirty = await $`git status --porcelain`.quiet().text();
if (dirty !== "") throw new Error("release: the working tree is not clean");
await $`deno test -A`;
if (flag === "--dry-run") {
console.log(`dry run: would tag v${version} and push`);
} else {
await $`git tag -a ${`v${version}`} -m ${`Release ${version}`}`;
await $`git push origin --follow-tags`;
} deno run -A scripts/release.ts 1.1.0 --dry-run
deno run -A scripts/release.ts 1.1.0 What you gained
- No shell, so nothing to break out of. Every
${…}interpolation becomes one discrete argv entry; the process is spawned directly. A version with a space, a quote, or a;in it is just a string — the bash script has to trust its quoting. - A non-zero exit throws.
set -eis not something you can forget, and the error names the command and its code..noThrow()is the explicit opt-out, and.code()gives you the exit code as a number when that is what you want. - Typed output.
.text()is trimmed stdout,.lines()is astring[], and both replace$(...),IFStricks and$?..quiet()replaces> /dev/null 2>&1. - The rest of TypeScript. Arrays expand to multiple
arguments, a value can be validated before it is used, and the editor
knows what
$returns. See the shell guide for.env(),.cwd(),.killAfter()and.spawn().
When to take the next step
The moment you have two scripts that share a step, give the steps names.
The same routine as targets — each one runnable alone, visible in
--list, and free to depend on the others:
import { Build, parameter, run, target } from "jsr:@zuke/core";
import { $ } from "jsr:@zuke/core/shell";
import { DenoTasks } from "jsr:@zuke/deno";
import { GitTasks } from "jsr:@zuke/git";
class Release extends Build {
version = parameter("The version to release, e.g. 1.1.0").required();
clean = target()
.description("Refuse to release from a dirty working tree")
.executes(async () => {
// `$` still works inside a target, for the command that has no wrapper.
const dirty = await $`git status --porcelain`.quiet().text();
if (dirty !== "") throw new Error("the working tree is not clean");
});
test = target()
.description("Run the tests")
.dependsOn(this.clean)
.executes(() => DenoTasks.test((s) => s.allowAll()));
tag = target()
.description("Create the annotated release tag")
.dependsOn(this.test)
.executes(() =>
GitTasks.tag((s) =>
s.name(`v${this.version.value}`)
.message(`Release ${this.version.value}`)
)
);
push = target()
.description("Push the tag")
.dependsOn(this.tag)
.executes(() => GitTasks.push((s) => s.remote("origin").followTags()));
}
await run(Release); ./zuke tag --version 1.1.0 # clean → test → tag, and stop there
./zuke --list # every step, with its description
The git steps moved from $ to the typed GitTasks
wrapper, where the flags are methods and a typo is a compile error.
./zuke --list, graph, --parallel and
./zuke mcp come with the targets for free. All three versions
of this script, side by side, are in
examples/scripts-to-zuke.