Development Preview build — APIs and content may change. Visit ocx.sh for the current release.
Skip to content

Declaring Dependencies

When your tool needs to find another tool on disk at runtime — a runtime, a shared toolchain, a configuration generator — declare it as a dependency. OCX resolves the graph at install time, hardlinks every dependency's content into the consumer's environment, and composes their env surfaces according to the visibility you choose. This page covers the publisher decisions: when to declare a dependency at all, how to pin it, and how visibility changes what propagates to consumers.

When to Declare

Bundle vs. depend is the first question. Bundling means shipping the dependency's bytes inside your own archive — every install carries them. Depending means pointing at another package by digest — the consumer fetches it once and shares it across every package that references it.

Reach for a declared dependency when at least one of these is true:

  • The dependency is large or commonly needed. Bundling Node.js inside every npm-tool wrapper would mean re-shipping the same ~30 MB node-vXX.x.x-linux-x64.tar.xz (or ~57 MB Gzip equivalent) per tool. A declared ocx.sh/nodejs:24 dependency means one cached install across all tools that pin the same digest.
  • You need a specific version of someone else's tool. Wrapping terraform to add organisation defaults means pinning the upstream terraform build — a declared dependency captures that pin in metadata, so consumers can audit it without unpacking your archive.
  • Your tool genuinely runs the dependency at runtime. A wrapper that shells out to cmake needs cmake on disk in a known place — ${deps.cmake.installPath} provides that.

Bundling stays the right call when the dependency is tiny, single-use, or version-coupled to your build (a vendored library you patched, for example).

Pinning by Digest

Every dependency entry pins by OCI digest. The tag in the identifier is advisory — it documents what you pinned against and enables future update tooling, but the digest is what OCX resolves. Two consumers installing the same package on different days, against a registry whose tags have moved, get the same dependency graph because the digest is immutable.

json
{
  "dependencies": [
    {
      "identifier": "ocx.sh/cmake:3.28@sha256:a1b2c3d4e5f6...",
      "visibility": "public"
    }
  ]
}

The full identifier rules — required registry component, identifier syntax, the "no version ranges" decision — live in the dependencies reference. The short version: every dependency is a fully-qualified registry/repo:tag@sha256:... string, registry mandatory, digest mandatory, tag advisory.

When You Need a name Override

By default, the placeholder ${deps.NAME.installPath} derives NAME from the last path segment of the OCI repository — ocx.sh/cmake becomes cmake, ocx.sh/myorg/cmake becomes cmake. That collides whenever two dependencies share that final segment, or when the segment is awkward to type (my-very-long-tool-name). The optional name field overrides the lookup key:

json
{
  "dependencies": [
    { "identifier": "ocx.sh/myorg/cmake@sha256:...",    "name": "myorg_cmake" },
    { "identifier": "ocx.sh/upstream/cmake@sha256:...", "name": "cmake" }
  ],
  "env": [
    { "key": "BUILD_TOOL",   "type": "constant", "value": "${deps.cmake.installPath}/bin/cmake",  "visibility": "public" },
    { "key": "PATCH_SCRIPT", "type": "constant", "value": "${deps.myorg_cmake.installPath}/bin/patch.sh", "visibility": "private" }
  ]
}

The name override must itself satisfy ^[a-z0-9][a-z0-9_-]*$ and stay at most 64 characters — same rule as entry-point names. Identifiers always carry an explicit registry (the pinning rules reject myorg/cmake@sha256:… without one).

Choosing Edge Visibility

Each dependency entry carries a visibility field, distinct from the entry visibility on env entries. This one controls how the dependency's environment propagates through the chain. Four values map onto two axes (private = the package's own runtime sees it; interface = consumers see it):

ValuePrivate surfaceInterface surfaceUse case
sealed (default)NoNoStructural dependency — content accessed only via ${deps.NAME.installPath}, no env propagation.
privateYesNoPackage's own shims need the dep's env; consumers don't.
publicYesYesBoth the package and consumers need the dep's env.
interfaceNoYesMeta-packages that forward env to consumers without using it themselves.

The default — sealed — is the right pick for most dependencies. A wrapper that hardcodes ${deps.cmake.installPath}/bin/cmake in an entry-point target doesn't need cmake's env to leak; it accesses cmake by path. Promote to private when your own launchers need to invoke the dep with its env applied. Promote to public when consumers should also see it (a mise-style meta-package that exposes its components directly).

The composition mechanic — what happens at diamond dependencies, how through_edge propagates visibility transitively — lives in dependencies in depth; the runtime artifact OCX writes during install (resolve.json, capturing the resolved env) is documented in environments in depth. The publisher decision compresses to one rule: pick the narrowest visibility that still covers your runtime need.

Inspired by CMake's target_link_libraries

The vocabulary maps cleanly to CMake's PUBLIC/PRIVATE/INTERFACE link visibility — what a target publishes to its build consumers. OCX applies the same intuition to the runtime env propagation graph. Use the analogy as a memory aid; the behaviours are not identical.

Ordering Matters

The order of entries in the dependencies array defines the canonical order for env composition. The first entry's environment is applied first, then each subsequent dependency layers on. path-type entries stack from later dependencies prepended onto earlier ones; constant-type entries follow the last-wins rule within the composition.

When composing a non-trivial graph, put dependencies whose env should "win" closer to the end of the array — they overwrite earlier constants and prepend to earlier path lists. Transitive deps preserve topological order (deps before dependents), and the importing package's own env always emits last, so its prepends end up highest priority on PATH. The full ordering rule lives in #dependencies-ordering.

See Also