Pinning tool versions

This is a task-oriented guide to the engines field: what to write for each intent. For the underlying model — the three-mode gradient, PATH activation, and cache identity — see Engines and provisioning. For the exhaustive table of built-in engines and drivers, see Toolchains.

Every recipe below is a real engines block you can drop into lattice.json, either at the root (applies to every workspace) or inside a workspaces entry (applies to that workspace only, overriding the root value key by key).

I just want to fail fast if the host tool is too old

Give the engine a bare version constraint and nothing else. Lattice checks the tool already on PATH and installs nothing:

{
  "latticeVersion": "0.1.0",
  "engines": {
    "node": ">=20.0.0"
  }
}

This only works for the engines Lattice already knows how to version-check — node, deno, bun, pnpm, yarn, npm, rust, cargo, go, python, python3, ruby, bundler, java, gradle, maven, dotnet. A bare string for any other name is rejected at load time, not at run time. If your tool is not on this list, see “My tool isn’t one Lattice knows” below.

Verify it:

lattice run build

If the host tool satisfies the constraint, the run proceeds silently — there is nothing to log, because nothing was installed. If it does not, the run fails before any task starts:

Error: engine 'node' 18.19.0 on PATH does not satisfy constraint '>=20.0.0'

Tell a teammate whose machine fails this check: the constraint is a lower bound on whatever node (or your engine’s version command) reports, so the fix is to upgrade the host tool — nvm install 20, brew upgrade node, whatever your platform uses. Lattice does not install anything for you in this mode; that’s the next recipe.

I want Lattice to install it

Add installCmd and Lattice provisions the tool itself instead of trusting whatever is on PATH. The install command runs once, into a directory it controls, and is version-checked afterward:

{
  "latticeVersion": "0.1.0",
  "engines": {
    "node": {
      "version": ">=20.0.0",
      "installCmd": "curl -fsSL https://get.volta.sh | VOLTA_HOME=$LATTICE_TOOLCHAIN_DIR bash && $LATTICE_TOOLCHAIN_DIR/bin/volta install node@20",
      "bin": "bin"
    }
  }
}

bin is the directory, relative to the install, that gets prepended to a task’s PATH; it defaults to bin if omitted. $LATTICE_TOOLCHAIN_DIR is available both as a literal substring (substituted before the shell runs the command) and as an environment variable, so an installer that reads env vars and one that only takes a path both work.

Run it:

lattice setup
lattice run build

lattice setup provisions every declared engine before installing any workspace’s native dependencies, so a package manager that shells out to a provisioned compiler sees it on PATH during install. lattice run also provisions on demand if you skip setup — a task never runs against a missing or wrong-versioned toolchain.

What you see the first time: a provisioning line for the engine, then the task itself.

lattice: provisioning engine 'node' via installCmd into .lattice/toolchains/node/tmp-a1b2c3d4
lattice: running `build` across 1 workspace(s)

What you see every time after: nothing — an existing, verified install is reused. If the install command itself fails, the run stops with its output inline:

Error: engine 'node': installCmd failed:
<installer's stderr>

If the install succeeds but the result still does not satisfy the version constraint, that is also a hard failure:

Error: engine 'node' provisioned 19.8.1 does not satisfy '>=20.0.0'

Tell a teammate whose machine fails this check: nothing on their machine needs to change. The install command runs the same way for them; if it fails, the fix is in the installCmd itself (network access, a broken URL, a platform-specific installer), not in anything local to their environment.

Where the provisioned tool lands

Every provisioned engine goes under .lattice/toolchains/<engine>/, inside the repo:

.lattice/toolchains/node/20.11.1-a1b2c3d4/
  bin/          prepended to the task's PATH
  pins.json     { "engine", "version", "installHash", "bin" }

The version-and-hash directory name (20.11.1-a1b2c3d4) is content-addressed: the hash is the first 8 hex characters of sha256(installCmd). Change the installCmd and Lattice provisions into a new directory instead of mutating the old one.

Nothing is written outside the repo — no shell profile, no global package manager state. rm -rf .lattice removes every provisioned toolchain along with the cache; the next lattice run or lattice setup reprovisions from installCmd from scratch. This is also the fix when a provisioned tool ends up in a bad state you don’t want to debug.

Different workspaces need different versions

Declare engines inside a workspace instead of (or in addition to) the root. A workspace’s engines entries override the root’s, key by key — everything else in the root map still applies:

{
  "latticeVersion": "0.1.0",
  "engines": {
    "node": ">=18.0.0"
  },
  "workspaces": [
    { "name": "legacy-api", "path": "services/legacy-api" },
    {
      "name": "web",
      "path": "apps/web",
      "engines": { "node": ">=20.0.0" }
    }
  ]
}

Here legacy-api runs against whatever node on PATH satisfies >=18.0.0, and web requires >=20.0.0 — both checked against the same PATH, so this only works if one host node binary can satisfy both, or if you swap in installCmd per workspace so each gets its own provisioned copy:

{
  "latticeVersion": "0.1.0",
  "workspaces": [
    {
      "name": "legacy-api",
      "path": "services/legacy-api",
      "engines": {
        "node": {
          "version": ">=18.0.0",
          "installCmd": "install-node.sh --version 18 --dest $LATTICE_TOOLCHAIN_DIR"
        }
      }
    },
    {
      "name": "web",
      "path": "apps/web",
      "engines": {
        "node": {
          "version": ">=20.0.0",
          "installCmd": "install-node.sh --version 20 --dest $LATTICE_TOOLCHAIN_DIR"
        }
      }
    }
  ]
}

Because the provisioned directory is content-addressed by the resolved installCmd, two workspaces asking for the same engine name with different install commands provision into two separate directories under .lattice/toolchains/node/, and each task only gets its own workspace’s bin dir on PATH. Verify both resolve independently:

lattice run build --filter legacy-api
lattice run build --filter web

My tool isn’t one Lattice knows

If your engine’s name is not in the well-known list above, the bare string form is rejected — Lattice has no built-in rule for checking its version. Loading a config like this fails immediately:

{
  "engines": { "alpes": ">=2.0.0" }
}
Error: engine 'alpes' in root uses the string (version-only) form, but
'alpes' is not a well-known engine Lattice can version-check on its own. Use
the object form with an explicit `versionCmd`, e.g. "alpes": { "version":
">=1.0.0", "versionCmd": "alpes --version" }

Use the object form and tell Lattice how to ask the tool its own version:

{
  "engines": {
    "alpes": {
      "version": ">=2.0.0",
      "versionCmd": "alpes --version"
    }
  }
}

This is validate-only, the same as a well-known engine with a bare string — add installCmd to have Lattice provision it instead, exactly as in “I want Lattice to install it” above. Lattice parses whatever number-looking substring versionCmd prints (v2.6.7, alpes 2.6.7 (build 41), 2.6) and compares it against the constraint, so versionCmd only needs to print a version somewhere in its output.

Removing a provisioned toolchain entirely

.lattice/toolchains and .lattice/cache are both regenerable and both excluded from version control (lattice init writes them into .gitignore). Deleting .lattice is a complete, safe reset of every provisioned tool this repo has ever installed:

rm -rf .lattice
lattice run build

The next run reprovisions each declared installCmd from scratch and rebuilds the cache. Nothing outside the repo is touched, so this never affects a tool installed globally on the machine or any other project.