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
MemberBehaviour
.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.