Core library
Beyond running processes, @zuke/core ships a small standard
library so a build rarely needs an extra dependency. Everything here is
exported from jsr:@zuke/core (the $ shell from the
/shell submodule), works on the bare Deno runtime, and is
designed to be unit-testable.
The $ shell
Ergonomic process execution built on Deno.Command. A
$ tagged template builds a lazy command;
awaiting it runs the process and throws a CommandError on a
non-zero exit.
import { $ } from "jsr:@zuke/core/shell";
await $`deno test -A`; // throws CommandError on non-zero exit
const sha = await $`git rev-parse HEAD`.text(); // trimmed stdout
const files = await $`git diff --name-only`.lines(); // string[]
const code = await $`flaky-cmd`.noThrow().code(); // exit code, never throws
await $`build`.env({ NODE_ENV: "prod" }).cwd("./app").quiet();
const server = $`npm run dev`.spawn(); // start without awaiting | Member | Behaviour |
|---|---|
.text() | Run; resolve to trimmed stdout. Throws on non-zero unless .noThrow(). |
.lines() | Run; resolve to string[] (stdout split on newlines; empty output → []). |
.code() | Run; resolve to the numeric exit code. Never throws on non-zero. |
.noThrow() | Suppress throwing on a non-zero exit. |
.env(record) | Merge environment variables. |
.cwd(path) | Set the working directory. |
.quiet() | Suppress live stdout/stderr streaming. |
.killAfter(ms) | Terminate the child if it outlives ms, raising a CommandTimeoutError. Polite then forceful: SIGTERM, a grace window, then SIGKILL — so a child that traps and ignores the first signal can't hang the run. |
.signal(sig) | The signal used to terminate it (default SIGTERM); composes with .killAfter() and run cancellation. |
.maxCapturedBytes(n) | Raise or lower the per-stream capture cap — see below. |
.spawn() | Start the process without awaiting it, returning a handle — how services stay running. |
Awaiting a command resolves to a CommandOutput
({ code, stdout, stderr, truncated }, plus a
.text() helper for trimmed stdout) — the same shape a
tool wrapper resolves to. By default a command
streams its output live to your terminal and captures stdout;
.text()/.lines() capture without echoing;
.quiet() does neither.
A secret parameter interpolated into a command is masked wherever the line is rendered — a dry-run echo, a failure's error message, a spawned service's recorded line — not only in Zuke's own reporter output. The argv handed to the operating system is untouched.
Bounded capture
Each captured stream keeps at most 8 MiB, so a runaway
child cannot grow the buffer until the run dies of memory exhaustion. Past
the cap the newest bytes are kept — the end of the output is
the part anyone reads for a failure — truncated is
true, and .text() prefixes
[output truncated to last 8 MiB] so a caller cannot mistake a
tail for the whole of it.
// Raise the cap for a command whose whole stdout must be parsed…
const all = await $`huge-report`.maxCapturedBytes(64 * 1024 * 1024).text();
// …the same setting exists on every tool wrapper's settings lambda.
await SomeTasks.run((s) => s.maxCapturedBytes(64 * 1024 * 1024));
const out = await $`chatty`;
out.truncated; // true once the cap was reached — the NEWEST bytes are kept The cap must be a positive whole number of bytes; there is no unlimited sentinel, so to keep everything pass a cap larger than the output you expect. Live streaming to the terminal is never capped — every byte still reaches it as it arrives.
Injection: what $ does and doesn't cover
Interpolated values become discrete argv entries. They are never spliced into a shell string, and no shell is involved at all — so there is no shell-injection surface. Arrays expand to multiple arguments.
const files = ["a.ts", "b.ts"];
await $`deno fmt ${files}`; // → ["deno", "fmt", "a.ts", "b.ts"]
const dirty = "; rm -rf /";
await $`echo ${dirty}`; // prints the literal string; runs nothing else
// …but a value that STARTS with "-" is still read as a flag by the tool:
const ref = "--output=/tmp/leak"; // untrusted
await $`git diff --name-only ${ref}`; // git honours it as an option
That is shell injection, and only shell injection.
Argument injection is still yours to validate: a value
beginning with - is passed through faithfully as one argv
entry, and the invoked tool reads it as a flag, not as
data. Validate any untrusted value you interpolate into a leading position
— reject a leading -, or separate data from options with
-- where the tool supports it. Zuke does this for the inputs
it accepts itself: --affected rejects a base revision
starting with - for exactly this reason.
Input that arrives as one already-written command line — a
package.json script, a Makefile recipe — has to become discrete
argv before it can be run. splitShellArgs does that with POSIX
quoting semantics:
import { splitShellArgs } from "jsr:@zuke/core/shell";
splitShellArgs(`deno test --filter "my suite"`);
// → ["deno", "test", "--filter", "my suite"]
// Unclosed quote → ShellArgsError naming the quote and its offset.
Single quotes are fully literal; inside double quotes a backslash escapes
only the five characters a POSIX shell says it does; a backslash-newline
pair is a line continuation. Expansion, globbing, substitution, and
operators are documented non-goals — the input becomes
argv, it is never interpreted. This is what
zuke import uses, which is why
a segment it cannot translate faithfully is preserved verbatim behind a
// TODO rather than mistranslated.
File operations
FileTasks groups the filesystem operations a
clean/package target reaches for, in the same
namespaced shape as the tool wrappers — but it runs no subprocess, so methods
take direct arguments instead of a settings-lambda. They're written to be
idempotent: remove tolerates a missing target (like
rm -f) and returns whether anything was deleted, and a recursive
createDirectory is a no-op when the directory already exists.
import { FileTasks } from "jsr:@zuke/core";
await FileTasks.cleanDirectory("dist"); // empty it (if it exists)
await FileTasks.createDirectory("dist/assets"); // mkdir -p
await FileTasks.copy("static", "dist/static"); // recursive
await FileTasks.move("build.log", "logs/build.log");
const removed = await FileTasks.remove("tmp", { recursive: true }); // rm -rf → boolean
if (await FileTasks.exists("deno.json")) {
const cfg = await FileTasks.readJson<{ name: string }>("deno.json");
await FileTasks.writeText("VERSION", cfg.name);
} Globbing
glob(pattern, { cwd? }) expands a pattern to the
matching paths — relative to cwd and sorted for determinism. It
is dependency-free, walks from the pattern's static prefix, and does not
follow symlinked directories. Supported syntax: * (any run of
non-/), ** (any run including /),
? (a single non-/), and brace alternation
{a,b}.
import { glob } from "jsr:@zuke/core";
const sources = await glob("src/**/*.ts"); // sorted, relative
const specs = await glob("**/*.{test,spec}.ts", { cwd: "packages" });
await DenoTasks.fmt((s) => s.check().paths(...sources));
To match a pattern without touching the filesystem,
globToRegExp(pattern) compiles the same syntax to a
RegExp — handy for filtering an in-memory list of paths.
HTTP
Fetch over HTTP from a build script, built on the platform
fetch. httpDownload(url, dest) streams a URL to a
file, while httpText(url) and httpJson(url) return
the body. All accept { headers, fetch } — the
fetch seam makes them unit-testable — and throw an
HttpError (carrying .status) on a non-2xx response.
import { httpDownload, httpJson, httpText } from "jsr:@zuke/core";
await httpDownload("https://example.com/tool.tar.gz", ".zuke/tool.tar.gz");
const notes = await httpText("https://example.com/CHANGELOG.txt");
const release = await httpJson<{ tag_name: string }>(
"https://api.github.com/repos/zuke-build/zuke/releases/latest",
);
// a non-2xx response throws an HttpError carrying .status Compression & archives
Pack build artifacts without a dependency. gzip() /
gunzip() wrap the platform CompressionStream;
tar() / untar() read and write the POSIX
ustar format in memory; and the file helpers
createTarGzip(files, dest, { cwd }) /
extractTarGzip(src, destDir) produce and unpack
.tar.gz archives. Archives use a fixed mtime, so output is
reproducible.
import { createTarGzip, extractTarGzip, gzip } from "jsr:@zuke/core";
await createTarGzip(["dist/app.js", "README.md"], "artifact.tar.gz");
await extractTarGzip("artifact.tar.gz", "out");
// in-memory primitives, built on the platform CompressionStream
const packed = await gzip(new TextEncoder().encode("hello")); Installing tools
Prepare an environment by fetching a CLI one of Zuke's wrappers drives, then
point the wrapper at it. installRelease({ name, url, destDir
}) is the low-level primitive: it resolves a per-platform URL (via
an ({ os, arch }) => string callback and
hostPlatform()), downloads it, and returns the installed
binary's AbsolutePath — ready for .toolPath(...).
Set archive: "tar.gz" or archive: "zip" to unpack
an archive and take binaryPath from inside; the default
"raw" installs the download as the binary itself.
import { installRelease } from "jsr:@zuke/core";
import { CmdTasks } from "jsr:@zuke/cmd";
const arches = { x86_64: "amd64", aarch64: "arm64" } as const;
const bin = await installRelease({
name: "helm",
destDir: ".zuke/bin",
archive: "tar.gz", // unpack a tarball ("raw" by default)
binaryPath: `${Deno.build.os}-${arches[Deno.build.arch]}/helm`,
url: (p) =>
`https://get.helm.sh/helm-v3.14.0-${p.osLabel({ macos: "darwin" })}-${
arches[p.arch]
}.tar.gz`,
});
// returns the installed binary's AbsolutePath — ready for .toolPath(...)
await CmdTasks.exec(String(bin), (s) => s.args("version"));
For the fluent, build-facing surface — ToolTasks.install() for
one tool, toolchain() for many, with SHA-256 pinning,
verification, and caching — see the dedicated
Installing tools guide. The
download seam keeps it unit-testable, and on Windows the
filename gains an .exe suffix. Both .tar.gz and
.zip assets are unpacked (via extractTarGzip and
extractZip), so the same call handles the tarballs and zips
release pages ship.
Assertions
Fail a target fast with a clear message when an expectation doesn't hold.
assert(condition, message?) throws when the condition is falsy;
assertExists(value, message?) throws on
null/undefined and returns the value narrowed to
its non-nullable type; fail(message) always throws. The async
assertFileExists(path) / assertDirectoryExists(path)
check the filesystem. All throw an AssertionError.
import { assert, assertExists, assertFileExists, fail } from "jsr:@zuke/core";
const token = assertExists(Deno.env.get("TOKEN"), "TOKEN is required"); // narrows
assert(this.environment.value !== "", "environment must be set");
await assertFileExists("dist/app.js");
if (unreachable) fail("should never happen"); // always throws AssertionError Paths
TypeScript has no operator overloading, so a literal / path join
isn't possible — absolutePath gets as close as the language
allows. The returned AbsolutePath is callable
(and has an equivalent .join(...)), immutable, and normalised,
with accessors like .name, .stem,
.extension, .parent(), and
.relativeTo(...).
import { absolutePath, repoRoot } from "jsr:@zuke/core";
const root = absolutePath("/app");
const main = root("src", "main.ts"); // callable → /app/src/main.ts
main.name; // "main.ts"
main.stem; // "main"
main.parent(); // AbsolutePath → /app/src
main.relativeTo(root); // "src/main.ts"
repoRoot("dist"); // <repo root>/dist — resolved at runtime via zuke.json
await $`deno run ${main}`; // toString() drops straight into $`` and args() repoRoot is absolutePath anchored at your
repository root — the directory containing the zuke.json that
zuke setup scaffolds. It's resolved at runtime by walking up
from the working directory, so nothing machine-specific is ever committed.
CI & host detection
isCI() answers whether a build is on a runner, and
detectCiHost() answers which one
("github-actions", "gitlab-ci",
"local", …) — pair them with .onlyWhen(...) to
gate work like deploys to CI:
import { detectCiHost, hostPlatform, isCI, operatingSystem } from "jsr:@zuke/core";
deploy = target()
.onlyWhen(() => isCI()) // run only on a CI runner
.executes(async () => {
// A CiHost whose values line up with the CiProvider names cicd() uses.
console.log(`running on ${detectCiHost()}`);
});
publish = target()
// "linux" | "macos" | "windows" — normalised, so you branch on "macos", not "darwin".
.onlyWhen(() => operatingSystem() === "linux")
.executes(() => {});
// hostPlatform() adds the Architecture and the label helpers used to build
// tool download URLs (see Installing tools).
const cpu = hostPlatform().archLabel({ x86_64: "amd64", aarch64: "arm64" });
Prefer detectCiHost() in new code: it
returns the same detection as a CiHost whose values line up
with the CiProvider names
cicd() uses, so a build can
match the host it is on against the provider it generates
for. ciHost() is kept for compatibility.
From here, see Core concepts for the build model or Examples for complete build files.