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:

APIInstallsReach for it when
ToolTasks.install((s) => …)one binaryyou need a single release binary
toolchain()many toolsa build depends on several tools
ToolTasks.installTree(...) / .tree(...)a multi-file treethe tool is a runtime shipping many files (Node.js, a JDK)
ToolTasks.npm(...) / .npm(...)an npm packagethe 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 checksum ties 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"));
MethodPurpose
.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:

  1. Pins the build to an exact artifact.
  2. 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.
  3. 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 with install({ destDir }), or per tool with .destDir(...).
  • Custom downloader. install({ download }) swaps the downloader for every tool — a test seam.
  • Introspection. chain.tools returns the configured release settings in order; chain.npmTools the 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:

MethodPurpose
.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 later install() 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 the nest bin.
  • Hermetic pins compose with node_modules resolution. An explicit .toolPath(bins.get(...)) always wins over node_modules/.bin resolution, so a toolchain() 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 (darwinmacos) — 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/cache keyed 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.