Installing tools
A Zuke build can fetch the command-line tools it drives rather than assume they're already installed. That makes a build hermetic — a fresh clone or a bare CI runner has everything it needs — and reproducible: versions are pinned, downloads are verified, and every machine runs the same binary.
Tools are provisioned in Zuke's fluent settings-lambda style — the same
(s) => s.method(...) shape the
tool wrappers use — through two entry
points in @zuke/core:
| API | Installs | Reach for it when |
|---|---|---|
ToolTasks.install((s) => …) | one binary | you need a single release binary |
toolchain() | many tools | a build depends on several tools |
ToolTasks.installTree(...) / .tree(...) | a multi-file tree | the tool is a runtime shipping many files (Node.js, a JDK) |
ToolTasks.npm(...) / .npm(...) | an npm package | the tool ships on the npm registry |
Both return the installed binary's AbsolutePath;
hand it to a wrapper (.toolPath(...)), to CmdTasks,
or to defineTool — see
Working with an installed tool. This page is about
acquiring binaries; for the typed *Tasks
wrappers that run them, see the
tool wrappers.
Why install tools from the build
- No "install these first" prose. The build file is the setup — nothing to document in a README, nothing to forget on a new laptop.
- Pinned and verified. A
checksumties the build to an exact artifact and fails loudly if the download is corrupt or tampered with. - CI equals local. The same fetch runs everywhere; no separate "set up tool X" CI step that drifts from what developers use.
- Cached. A pinned tool is downloaded once and reused, so provisioning adds no ongoing cost.
ToolTasks.install() — one tool
ToolTasks.install((s) => …) fetches a single tool, configured
through a ToolInstallSettings lambda, and resolves to the
installed binary's path.
import { ToolTasks } from "jsr:@zuke/core";
import { CmdTasks } from "jsr:@zuke/cmd";
const bin = await ToolTasks.install((s) =>
s
.name("codecov")
.destDir(".zuke/bin")
// `p.os` is "linux" | "macos" | "windows" — exactly Codecov's dir names.
.url((p) => `https://cli.codecov.io/latest/${p.os}/codecov`)
.checksum("d34db33f…") // pin + verify + cache (see below)
);
await CmdTasks.exec(String(bin), (s) => s.args("--version")); | Method | Purpose |
|---|---|
.name(name) | The tool name, and the installed filename (.exe appended on Windows). Required. |
.url((platform) => string) | Resolve the download URL for the target platform. Required. |
.destDir(dir) | Directory to install into (created if missing). Defaults to .zuke/tools. |
.archive("tar.gz" | "zip") | Unpack a gzipped tarball or a zip; the default "raw" treats the download as the binary. |
.binaryPath(path) | For an archive, the binary's path inside it (e.g. Helm's linux-amd64/helm). Defaults to the name. |
.checksum(sha256) | Expected SHA-256, or a (platform) => string resolver — verifies and caches. |
.platform({ os, arch }) | Resolve for a specific platform instead of the host. |
.download(fn) | Override the downloader (defaults to an HTTPS download); mainly a test seam. |
With "raw" (default) the URL points straight at
the executable; it's saved as <destDir>/<name> and
chmod +x'd. With .archive("tar.gz")
or .archive("zip") Zuke unpacks the archive to
a scratch directory, copies .binaryPath(...) out, then discards
the scratch. Zip reading covers the stored and
deflate methods release assets use (dprint et al. ship
zip-only); encrypted or zip64 archives are rejected with a clear error, and
no archive entry may escape the destination directory (a "zip slip").
On Windows the installed filename gains an .exe suffix and
the executable bit is skipped.
Pinning, verification & caching
Set a .checksum(...) — the lowercase-hex
SHA-256 that release pages publish — and it does three jobs
at once:
- Pins the build to an exact artifact.
- Verifies the download: the fetched bytes are hashed and compared before anything is installed. A mismatch throws and leaves nothing behind; a value that isn't a 64-character hex SHA-256 is rejected up front.
- Caches the install: a sidecar marker (
<destDir>/<name>.install.json) records the checksum and the installed binary's own hash. A later run skips the download only when the pin matches and the on-disk binary still hashes to what was installed — so a swapped or corrupted binary is re-downloaded and re-verified, never silently reused.
What the checksum covers depends on the format: for "tar.gz"
and "zip" it's the SHA-256 of the archive
(what projects list in their checksums.txt); for
"raw" it's the SHA-256 of the binary itself.
Without a checksum, the tool is downloaded every run and left unverified —
fine for a quick spike, but pin one for anything real.
Checksums are per-artifact, so they're per-platform.
.url(...) resolves a different download for each OS/arch, and
each has its own hash — pass .checksum(...) a resolver, just
like .url(...), when a build runs on more than one platform:
const sums: Record<string, string> = {
"linux-x86_64": "…",
"linux-aarch64": "…",
"macos-aarch64": "…",
};
await ToolTasks.install((s) =>
s.name("helm").url(helmUrl).checksum(({ os, arch }) => sums[`${os}-${arch}`])
); toolchain() — many tools
When a build needs several tools, toolchain() declares them in
one place so the build file fully describes its environment. Add tools with
.tool((s) => …), then install() fetches them all
concurrently — pinned, verified, and cached — and returns a
Map<name, AbsolutePath>.
import { Build, target, toolchain } from "jsr:@zuke/core";
import { HelmTasks } from "jsr:@zuke/helm";
import { KubectlTasks } from "jsr:@zuke/kubectl";
const arches = { x86_64: "amd64", aarch64: "arm64" } as const;
class Deploy extends Build {
tools = toolchain((t) =>
t
.tool((s) =>
s.name("helm").archive("tar.gz")
.binaryPath(`linux-${arches[Deno.build.arch]}/helm`)
.checksum(helmSum)
.url(({ arch }) => `https://get.helm.sh/helm-v3.15.2-linux-${arches[arch]}.tar.gz`)
)
.tool((s) =>
s.name("kubectl").checksum(kubectlSum)
.url(({ arch }) => `https://dl.k8s.io/release/v1.30.2/bin/linux/${arches[arch]}/kubectl`)
)
);
deploy = target().executes(async () => {
const bin = await this.tools.install(); // fetches both, concurrently
await HelmTasks.version((s) => s.toolPath(bin.get("helm")));
await KubectlTasks.version((s) => s.toolPath(bin.get("kubectl")));
});
} - Install directory. Tools default to
.zuke/tools(DEFAULT_TOOLS_DIR). Override for all withinstall({ destDir }), or per tool with.destDir(...). - Custom downloader.
install({ download })swaps the downloader for every tool — a test seam. - Introspection.
chain.toolsreturns the configured release settings in order;chain.npmToolsthe npm-package tools. - Cheap to re-run. A matching checksum is a cache hit, so calling
install()again (locally or on CI) is a no-op once the tools are present.
installTree() — multi-file runtimes
ToolTasks.install() extracts a single binary.
Some tools aren't one file — a language runtime like Node.js
or a JDK ships a whole directory (bin/node, bin/npm,
lib/node_modules/**, symlinks and all) in one archive.
ToolTasks.installTree((s) => …) unpacks the
entire tree and resolves to its
AbsolutePath root
rather than a lone binary. A tree always ships packed, so
.archive(...) defaults to "tar.gz" and
"raw" is rejected.
import { ToolTasks, prependPath } from "jsr:@zuke/core";
import { CmdTasks } from "jsr:@zuke/cmd";
const v = "22.11.0";
// Node.js ships bin/node, bin/npm, lib/node_modules/** in one tarball.
// installTree keeps the whole extracted tree and returns its root.
const root = await ToolTasks.installTree((s) =>
s
.name("node")
.archive("tar.gz")
.strip(1) // unwrap the node-v22.11.0-…/ dir
.bins("bin/node", "bin/npm", "bin/npx") // marked +x on POSIX
.checksum(nodeSum)
// Node spells macOS "darwin", Windows "win", and uses x64/arm64.
.url((p) =>
`https://nodejs.org/dist/v${v}/node-v${v}-${
p.osLabel({ macos: "darwin", windows: "win" })
}-${p.archLabel({ x86_64: "x64", aarch64: "arm64" })}.tar.gz`
)
);
// The root is a callable AbsolutePath: root("bin", "node") is a binary,
// root("bin") the directory to put on PATH.
await CmdTasks.exec(String(root("bin", "node")), (s) => s.args("--version"));
prependPath(root("bin")); // now npm, npx, … resolve from the installed tree
Because AbsolutePath is callable, the returned root doubles as an
accessor: root("bin", "node") is the node binary and
root("bin") is the directory to put on PATH (hand it
to prependPath). Two settings are specific to a tree install and
ignored by a single-binary one:
| Method | Purpose |
|---|---|
.strip(n) | Drop n leading path components while unpacking (tar's --strip-components). A release tarball wraps everything in a tool-v1.2.3/ directory, so 1 unwraps it; .bins(...) and the returned root are then relative to the stripped tree. Defaults to 0. |
.bins(...paths) | Paths (relative to the stripped root) to mark executable on POSIX — the tar reader drops mode bits, so a runtime's bin/node, bin/npm, … need it. Chmod follows a symlink to its real target. Skipped on Windows. |
.checksum(...) pins, verifies, and caches exactly as for a single
binary, except the hash is always of the archive: it's
verified before anything is unpacked, and a later run reuses the tree when the
pin matches and its declared bins are still present. To group a runtime tree
with other tools in a toolchain, use
.tree((s) => …) — its install() map entry is the
tree's root:
tools = toolchain((t) =>
t.tree((s) =>
s.name("node").archive("tar.gz").strip(1)
.bins("bin/node", "bin/npm").checksum(nodeSum).url(nodeUrl)
)
);
// In install()'s Map<name, AbsolutePath>, "node" maps to the tree's root:
const bins = await this.tools.install();
prependPath(bins.get("node")!("bin")); npm-package tools
Not every tool ships a release binary — many (vitest,
dprint, @nestjs/cli, …) are published on the
npm registry. Toolchain.npm(...) provisions
one as a version-pinned, cached tool without an ambient npm ci:
import { Build, target, toolchain } from "jsr:@zuke/core";
import { VitestTasks } from "jsr:@zuke/vitest";
class Test extends Build {
tools = toolchain((t) => t.npm({ name: "vitest", version: "4.1.9" }));
test = target().executes(async () => {
const bins = await this.tools.install();
await VitestTasks.run((s) => s.toolPath(bins.get("vitest")));
});
}
Each package installs under <destDir>/npm/<name>@<version>
via npm install --prefix <dir> --no-save <name>@<version>,
and install() returns its bin under the same
Map<name, AbsolutePath> as the release tools. For a
one-off outside a toolchain, ToolTasks.npm(spec, options?) (or
the underlying installNpmTool) does the same for a single
package.
- npm is the one ambient requirement. It resolves and downloads the package; everything else is Zuke's. Nothing else needs to be on
PATH. - Pinned and cached. A marker file records the
{ name, version }, so a laterinstall()whose marker matches — and whose bin is still present — skips npm entirely. - Different bin name. When a package's bin differs from its name, set
bin:{ name: "@nestjs/cli", version: "10.4.0", bin: "nest" }resolves thenestbin. - Hermetic pins compose with
node_modulesresolution. An explicit.toolPath(bins.get(...))always wins overnode_modules/.binresolution, so atoolchain()pin stays authoritative even inside a Node workspace.
Working with an installed tool
Both ToolTasks.install and toolchain().install() hand
you an AbsolutePath. Point a tool at it three ways —
AbsolutePath stringifies to the path, so String(bin)
(or bin.path) works anywhere a string is expected:
import { HelmTasks } from "jsr:@zuke/helm";
import { CmdTasks } from "jsr:@zuke/cmd";
import { defineTool } from "jsr:@zuke/core/tooling";
const bin = await ToolTasks.install((s) => s.name("helm").url(helmUrl));
// 1. A typed wrapper — pass the path to .toolPath(...)
await HelmTasks.template((s) => s.toolPath(bin).args("./chart"));
// 2. The generic CmdTasks fallback
await CmdTasks.exec(String(bin), (s) => s.args("version"));
// 3. A one-off typed tool bound to the installed path
const helm = defineTool(String(bin));
await helm((s) => s.arg("version")); Cross-platform URL resolution
The .url(...) (and .checksum(...)) callback receives
a Platform. Its os is a Zuke
OperatingSystem — "linux" | "macos" | "windows",
normalised from Deno's raw values (darwin → macos) —
so the common case needs no mapping. osLabel/archLabel
map to a tool's naming, falling back to the value itself for
anything not aliased, so you only list the differences:
type OperatingSystem = "linux" | "macos" | "windows";
type Architecture = "x86_64" | "aarch64";
interface Platform {
os: OperatingSystem;
arch: Architecture;
osLabel(aliases?: Partial<Record<OperatingSystem, string>>): string;
archLabel(aliases?: Partial<Record<Architecture, string>>): string;
}
// Helm spells macOS "darwin" and uses amd64/arm64 — alias only the differences.
s.url((p) =>
`https://get.helm.sh/helm-v3.15.2-${p.osLabel({ macos: "darwin" })}-${
p.archLabel({ x86_64: "amd64", aarch64: "arm64" })
}.tar.gz`
);
By default the callback reflects the host; set .platform({ os, arch })
to resolve a foreign one (e.g. to pre-stage a Linux binary from a Mac).
Outside a callback, hostPlatform() returns the same
Platform for the running machine, and operatingSystem()
returns just the OS union — the counterparts of isCI() for "what
am I running on".
On CI
The ./zuke launcher bootstraps Deno, and the build fetches its tools on demand inside the target that needs them — so a CI job needs no "set up tool X" step:
steps:
- uses: actions/checkout@v4
- run: ./zuke deploy # installs helm + kubectl on first use, then runs - Egress. If the runner restricts network egress, allow the tools' download hosts (e.g.
get.helm.sh,dl.k8s.io) alongside Deno's. - Re-downloads. An ephemeral runner starts with an empty
.zuke/, so tools are fetched each run — safe and verified thanks to the checksum. To skip the download, persist the install directory between runs (e.g.actions/cachekeyed on the tool versions/checksums), or share it through the remote cache.
Security
- Pin a
.checksum(...). It makes the download tamper-evident; an unpinned install trusts whatever the URL serves. - Get the hash from the source. Use the SHA-256 the project publishes (a release
checksums.txt,*.sha256, or the GitHub release assets), matched to the exact artifact your platform downloads. - Restrict egress on CI to the hosts a build legitimately fetches from, so a compromised URL can't pull an arbitrary binary.
ToolTasks.install and toolchain() are built on the
lower-level installRelease(options) primitive, which takes the
same fields as a plain options object and returns the installed
AbsolutePath — see the
Core library for it. Reach for the
fluent surface above in a build; use installRelease directly if
you already have an options object in hand.