- docs
- Concepts
- Deferred Packages
Deferred Packages
This page explains how lazy mode defers a package’s download until the first time one of its commands runs.
A project toolchain can declare a dozen packages and use three of them in any given job. A monorepo’s ocx.toml might list every compiler, linter, and formatter the team owns, while a single CI step runs eslint and nothing else. ocx env and ocx exec compose the whole declared set by default. Every package’s content therefore downloads before the job’s first command even starts.
lazy-mode changes when a package’s content downloads, not what ends up composed. A package set to always still lands on PATH immediately. Its declared names resolve, ocx package which finds it, and ocx exec <name> sees it in scope. The bytes behind it stay unfetched until the first invocation of one of its names. A job that never calls eslint never pays for it.
Composing a shim
Section titled “Composing a shim”Every env-composing command accepts --lazy-mode:
ocx initocx add --no-pull "kitware/cmake:4.2.0" "astral-sh/uv:0.10.0"ocx env --lazy-mode alwaysocx package which --lazy-mode always "kitware/cmake:4.2.0" "astral-sh/uv:0.10.0"ocx exec --lazy-mode always -- cmake --versionocx package which --lazy-mode always "kitware/cmake:4.2.0" "astral-sh/uv:0.10.0"Under always, OCX writes a small generated launcher per declared name into a shim directory and composes that directory onto PATH. It is the same PATH slot an eagerly-materialized package root’s entrypoints/ would occupy, just pointed at a directory with no content/ yet.
ocx package which and the JSON report of ocx pull both say so explicitly. Every entry carries a kind of package or shim, so a script can tell the two apart without probing the filesystem.
Resolution ladder
Section titled “Resolution ladder”--lazy-mode is a policy, not a one-off flag. It resolves through five tiers, most specific first, with never (eager) as the floor:
| Tier | Source |
|---|---|
| 1 | --lazy-mode on the invoked command |
| 2 | [package."<id>"] in ocx.toml |
| 3 | [group.<name>] in ocx.toml |
| 4 | The toolchain-level lazy-mode key in ocx.toml |
| 5 | OCX_LAZY_MODE |
| — | Floor: never |
Each tier is independently optional. An absent tier means inherit from the next one down, never this tier said never. Suppose lazy-mode = "always" is set at the toolchain level and lazy-mode = "never" on one package’s own entry. That one package composes eagerly while every other declared package defers. The package-tier entry is a decision, not a gap.
The tiers are written in ocx.toml. A lazy-mode key on a [package."<id>"] entry (never or always) is the package tier. A lazy-mode key on a [group.<name>] table is the group tier. A bare lazy-mode key at the top level, beside [tools], is the toolchain tier.
All three are excluded from declaration_hash, like no-patches. They change when a package materializes, never which digest resolves, so editing one does not invalidate ocx.lock.
OCX_LAZY_MODE sits last before the floor (never) and is not forwarded to child processes. Its values are parsed case-insensitively (Always, ALWAYS and always are equivalent). The --lazy-mode flag and the ocx.toml key are lowercase only. An unrecognized value is ignored with a warning, the same as an unset variable, and resolution continues to the floor.
lazy-mode = "always"lazy-report = "silent"
[tools]cmake = "ocx.sh/kitware/cmake:3.28"When one package is selected under several groups, ocx pull materializes it eagerly if any of those groups resolves to never.
First invocation
Section titled “First invocation”A shim’s generated launcher is a small script, deliberately similar to the one an entry point writes:
#!/bin/sh# Generated by ocx at install time. Do not edit.exec "${OCX_BINARY_PIN:-ocx}" launcher shim '<pinned-id>' -- "${0##*/}" "$@"It execs the hidden ocx launcher shim subcommand. The subcommand receives the pinned identifier the shim was built for and the name the caller typed. It runs the ordinary pull, which is the same three-layer fetch, extract, and assemble pipeline an eager install uses. It then resolves the requested name on the freshly materialized package’s own PATH and executes it.
Nothing about the pull is special-cased for laziness. A lazily materialized package is byte-identical to the same package installed eagerly.
Once materialized, the package’s real entrypoints/ directory outranks the shim on PATH for every later invocation in that environment. A second call never re-triggers the shim path. ocx package which reflects the same flip. Its kind reports shim before the first use and package after.
Progress during the download
Section titled “Progress during the download”lazy-report controls whether that first-invocation download renders progress. It cannot be a flag on ocx env or ocx exec. The process rendering it, ocx launcher shim, is a separate process the shell already exec’d into by the time any content moves. Nothing survives from the composing command to tell it what to show.
lazy-report therefore resolves independently, inside the shim process itself. It uses its own four-tier ladder, one tier shorter than the one for lazy-mode, because there is no group to consult once composition is over:
| Tier | Source |
|---|---|
| 1 | --lazy-report (only on ocx launcher shim, never typed by a user) |
| 2 | [package."<id>"] in ocx.toml |
| 3 | The toolchain-level lazy-report key in ocx.toml |
| 4 | OCX_LAZY_REPORT |
| — | Floor: silent |
Its tiers are set the same way. lazy-report (silent or progress) goes on a [package."<id>"] entry or as a bare top-level key. Both are excluded from declaration_hash for the same reason.
OCX_LAZY_REPORT is the last tier before the floor and is not forwarded to child processes. It is read inside the hidden ocx launcher shim subcommand that a generated shim launcher execs into, never by the command that composed the environment. It parses case-insensitively like OCX_LAZY_MODE, and an unrecognized value is ignored with a warning. The --lazy-report flag is declared only on ocx launcher shim, so a user never types it.
A package-tier lazy-report exists although there is no group tier for it. The shim process receives only a pinned identifier and a basename, so it cannot learn which group composed the package.
progress opens a channel on the controlling terminal for the download itself. An invocation that finds the package already in the store opens nothing. Where no terminal exists, such as a Docker build, a CI runner, or anything under setsid, it silently degrades to silent rather than failing. silent renders the download nowhere, even when the shim’s stderr happens to be a terminal. Errors always reach stderr regardless of this setting.
Advisories
Section titled “Advisories”Some metadata.json shapes only substitute cleanly once a package’s content is on disk. One example is a ${installPath}-rooted value concatenated with something else. Another is a claim of zero binaries on a node that never got a chance to declare any.
Composing such a package as a shim cannot detect these the way an eager install’s file scan does. ocx env and ocx package env instead emit an advisories array in their JSON output. It is warning-only and never a compose failure. It names the affected package and, where relevant, the key.
Windows
Section titled “Windows”A deferred package’s shim slot on Windows is a <name>.exe, hardlinked from the same content-addressed shim-executable blob every generated Windows entrypoint launcher shares. It is paired with a <name>.shimref sidecar, which holds one line naming the pinned identifier the shim was built for. lazy-mode resolves and composes the same way on Windows as on any other host. No platform floor forces eager composition.
First invocation reads the sidecar and materializes the package through the same ocx launcher shim pipeline described above. It then resolves the requested name on the freshly materialized package’s own PATH, exactly as the POSIX shim does.
Garbage collection
Section titled “Garbage collection”A shim directory is kept alive by the same lock-pinned root set that keeps an eagerly-installed package alive. ocx clean regenerates a collected shim on the next compose, exactly as it would re-pull a collected package.
ocx clean --force is the one case where this differs from an installed package. A deferred package has no install symlink pointing at it, because only the lock pins reference it. The entire purpose of --force is to suppress the lock-pinned root set for the run.
So --force collects every shim directory unconditionally, the same way it already collects an unsymlinked eager package. The next ocx env or ocx exec regenerates whatever shims that composition needs. Nothing is lost, but the first invocation of a deferred package after --force re-materializes it from scratch.