- docs
- Concepts
- Configuration
Configuration
This page explains how OCX assembles its configuration chain and why each tier exists. The strict surface (every key, every path, every error string) lives at the Configuration reference.
Most package managers couple configuration to the system they are installed on. Homebrew bakes its prefix into the binary, apt scatters config under /etc/apt, and pip relies on per-Python-version site files. Each works for its own scope. Each breaks when the same install must behave differently on a laptop, a CI runner or an air-gapped image.
OCX takes the opposite approach. Configuration is a layered chain of optional TOML files. The binary ships sensible defaults, and every layer above is opt-in. Explicit paths win over ambient ones. Environment variables and CLI flags win over any file, except the five toolchain and lazy variables named under Discovery and merge precedence.
Why Configuration Files Exist
Section titled “Why Configuration Files Exist”OCX works without any config file, using compiled-in defaults. Most users never write one. The file format earns its keep in three cases:
- A team needs every machine to default to a private registry (
registry.company.com) without exporting an env var in every shell. - A CI image needs to describe itself. The registry default sits in a checked-in
config.tomlthat humans can read and review. - A portable OCX install (a zipped
$OCX_HOMEcarried between machines) needs settings that travel with the data, not with the host OS.
Each case is a different scope, and each is served by a different tier in the discovery chain.
The Three Discovery Tiers
Section titled “The Three Discovery Tiers”OCX looks for config files in three tiers. None has to exist, and OCX silently skips missing files. The tiers match the scope of effect to the scope of the file location. A system-tier setting affects every user on the machine, and a user-tier setting affects only that user. A setting in $OCX_HOME travels with the OCX install.
System tier
Section titled “System tier”/etc/ocx/config.toml holds machine-wide defaults set by sysadmins, Dockerfiles or provisioning tools. It is the only tier the OS treats as privileged, and production environments lock it down with file permissions. The most common use is baking a private default registry into a base image:
RUN mkdir -p /etc/ocxCOPY config.toml /etc/ocx/config.tomlMissing files are skipped at every tier, but an unreadable system file is not. Operator policy lives in /etc/ocx/config.toml, including the locked sections no lower tier can override. A system file that exists but cannot be consulted aborts the invocation with exit 78. Typical causes are a symlink, a permission error or a stale mount.
Dropping that file would drop every locked section with it, on every invocation, behind a warning. The user and $OCX_HOME tiers stay best-effort. An unreadable candidate there is skipped with a warning, and discovery continues.
User tier
Section titled “User tier”The user tier follows the OS convention for per-user app configuration. On Linux, OCX honors the XDG Base Directory specification. It reads $XDG_CONFIG_HOME/ocx/config.toml and falls back to ~/.config/ocx/config.toml. On macOS, Apple’s directory conventions put config at ~/Library/Application Support/ocx/config.toml. macOS ignores XDG_CONFIG_HOME even when set, like every other native macOS tool.
The user tier sits apart from the OCX data directory (~/.ocx/) by design. Settings live with the user, and data lives where the user pointed $OCX_HOME. The two scale independently. A user can delete the data directory without losing config preferences, and the reverse holds too.
OCX home tier
Section titled “OCX home tier”$OCX_HOME/config.toml (default ~/.ocx/config.toml) sits beside the OCX data directory. It is the only tier that moves with the data when you relocate $OCX_HOME. Use it for settings that must survive a zip-and-move of a whole OCX install. Examples are a portable bundle or an air-gapped install.
The system and user tiers live under OS-specific locations that do not travel with the data. A portable install that wrote to the user tier on machine A would silently lose those settings once you copied $OCX_HOME to machine B.
Explicit Additions
Section titled “Explicit Additions”Two mechanisms add an extra file on top of the discovery chain. They do not replace it. This supports the common “refine ambient config with a targeted override” case:
--config FILEis a CLI flag, passed before the subcommand. It suits one-off overrides.OCX_CONFIG=/path/to/file.tomlis an environment variable. It suits CI and Docker, where env vars are more practical than flags.
The specified file layers at the top of the file-tier chain. A missing file is an error, because explicit paths represent deliberate intent, not ambient discovery. Both can coexist, and --config layers on top of OCX_CONFIG.
To disable an ambient OCX_CONFIG for one invocation, set it to the empty string. This helps when a shell profile exports it:
OCX_CONFIG= ocx package install kitware/cmake:3.28An empty OCX_CONFIG is treated as unset, not as an error.
The Managed-Configuration Tier
Section titled “The Managed-Configuration Tier”The three discovery tiers are files a human or a provisioning script writes once, when a machine is set up. That stops working when a setting must change after the fleet is provisioned. Examples are rotating the corporate mirror, redirecting the patch registry to a new host or retiring an old default registry. Re-running a Dockerfile or a playbook against every running CI runner and laptop is the fleet-wide chore OCX aims to spare you.
The [managed] tier turns the corporate config into an ordinary OCX package whose content is one config.toml. It uses the same distribution channel as [mirrors] and [patches], and you publish it with ocx config push.
A small pointer (source, required, refresh, interval) lives in $OCX_HOME/config.toml. Adopt it with ocx config setup, or with ocx self setup --managed-config, which runs the same adoption during a full install. The pointer resolves to the payload in the operator’s registry. OCX syncs the payload into local state and merges it above the user config on every invocation.
Push a new version once, and every host that adopted the pointer converges on its own schedule. No re-provisioning is needed. Versioned tags, cascades and rollbacks come along because the config travels the package machinery.
The sibling page Managed configuration in depth covers the tier’s place in the chain and why refresh never blocks. It also covers forward compatibility, offline behavior, the seed fields, system locks and publishing.
Discovery and Merge Precedence
Section titled “Discovery and Merge Precedence”OCX resolves settings from lowest to highest precedence, and higher sources override lower ones. This is the same shape as Cargo’s config and uv’s config. Start with conservative defaults, then layer the user’s intent on top.
The full stack runs from compiled defaults at the bottom to CLI flags at the top:
| Priority | Source | Notes |
|---|---|---|
| 1 (lowest) | Compiled defaults | Built into the binary: the ocx.sh index entry under [registries."ocx.sh"] |
| 2 | System config, /etc/ocx/config.toml | Discovered tier |
| 3 | User config | Discovered tier |
| 4 | OCX home config, $OCX_HOME/config.toml | Discovered tier |
| 5 | [managed] snapshot | Local and identity-gated, see Where it sits in the chain |
| 6 | OCX_CONFIG | Layered on top of the discovered chain and the managed snapshot |
| 7 | --config FILE | Layered on top of OCX_CONFIG |
| 8 | Environment variables (OCX_*) | Win over any config file, except the five ladder variables below |
| 9 (highest) | CLI flags | Per-invocation, always win |
Five variables invert row 8 and are the only ones that do. They are OCX_TOOLCHAIN_ACTIVATE, OCX_TOOLCHAIN_PINNED, OCX_TOOLCHAIN_DIR, OCX_LAZY_MODE and OCX_LAZY_REPORT. Each is the weakest tier of its own resolution ladder. A file that states the key wins over an exported value, and the variable decides only where no file speaks. The reasoning is in Toolchain activation.
Three principles drove the order:
- Explicit beats ambient. A
--configflag the user just typed beats a~/.config/ocx/config.tomlthey wrote months ago. - Per-invocation beats persistent. Env vars and CLI flags are per-invocation, so they always win over any file.
- Inner tiers beat outer tiers. A setting in
$OCX_HOME/config.toml(which travels with the data) wins over one in/etc/ocx/config.toml(which is machine-global).
Environment overrides
Section titled “Environment overrides”Most settings also have an OCX_* spelling. An environment you control, such as CI or a container image, can then set them without touching the command line. Boolean variables read case-insensitively. The values 1, y, yes, on and true enable the option, and 0, n, no, off and false disable it. The --offline flag, for example, is the same as OCX_OFFLINE=1.
A value outside both lists depends on the variable. For OCX_OFFLINE, OCX_FROZEN, OCX_NO_CONSENT and OCX_NO_VERIFY, a bad value would silently change what OCX does, so it is refused. The variables ban the network (OCX_OFFLINE), refuse unpinned tags missing from the local index (OCX_FROZEN), suppress consent-stamp writes (OCX_NO_CONSENT) and skip auto-verify (OCX_NO_VERIFY). The invocation exits 78 and names the variable. Every other boolean variable logs a warning and uses its default.
Merge rules
Section titled “Merge rules”- Scalars (strings): the nearest (highest-precedence) value wins.
- Tables (for example
[registries.<name>]): merged key by key across tiers, and inner keys use nearest-wins. - Layering: OCX loads every file in the chain in precedence order and merges them. Explicit paths (
OCX_CONFIG,--config) layer on top of the discovered tiers and do not replace them.
Tables merge the same way inside [mirrors] (field-wise per host and role) and [registries.<name>] (field-wise per entry). The sections that deviate from nearest-wins are listed under system locks and in the trust policy tier rule.
A [registries.company] entry in /etc/ocx/config.toml and a [registries.private] entry in ~/.config/ocx/config.toml both end up in the resolved config. The user file does not erase the system entry. Each layer contributes, and precedence resolves only conflicting values on the same key.
Unknown keys and sections are ignored. OCX skips an unknown top-level section and an unknown key inside any known section. The file still loads and every setting this ocx understands takes effect. The rationale is the managed tier, where every ocx version in a fleet reads the same config.toml. A file written for a recent ocx must therefore degrade to “the parts I understand” on an earlier binary.
The cost is that a typo silently does nothing. For example, [registries."ocx.sh"] indx = "..." sets no index. Two things blunt that cost:
- A typo never becomes the field it resembles and never widens anything. An unknown key cannot populate
trusted_hostsor any other setting. - The config schema lists every key OCX knows, so an editor pointed at it flags a typo as you write.
Two places stay strict. An unknown key in [shell.consent] drops that table with a warning and fails closed, because an unknown key could only narrow a grant. The rest of the file still applies. A scope table in a trust policy rejects them too.
The Kill Switch
Section titled “The Kill Switch”OCX_NO_CONFIG=1 skips the discovered chain (the system, user and $OCX_HOME tiers). It also skips the managed-configuration snapshot candidate, including the OCX_MANAGED_CONFIG env-override read. Hermetic means hermetic. Explicit paths (--config and OCX_CONFIG) still load, because they represent deliberate intent rather than ambient environment.
The compiled defaults (tier 1) still load, because they are part of the binary and not ambient host state. A section locked at the system scope also survives the flag. The system file still loads, filtered to its locked sections. A hermetic CI job cannot use the flag to drop operator policy, such as a corporate CA or an execution-record sink (see system locks).
This separation gives all four common modes from two orthogonal primitives:
| Goal | Invocation |
|---|---|
| Default: use ambient config | (no flags) |
| Layer an override on ambient config | --config extra.toml |
| Hermetic with a specific file | OCX_NO_CONFIG=1 --config ci.toml |
| Hermetic, no files at all | OCX_NO_CONFIG=1 |
OCX_NO_PROJECT=1 is the same switch for the project tier. It prunes the working-directory walk for ocx.toml and ignores OCX_PROJECT. An explicit --project path still loads, because it is deliberate intent:
OCX_NO_PROJECT=1 ocx --project /ci/ocx.toml exec -- cmake --versionLike OCX_NO_CONFIG, it exists only as an environment variable. A --no-project flag would add surface without solving a new problem, since CI already injects policy through the environment.
OCX forwards OCX_NO_PROJECT to every subprocess it spawns. Generated launchers and nested ocx exec calls therefore skip project discovery too, instead of adopting a project the parent deliberately had none of. The one exception is the example above. When the invocation also carries an explicit --project, OCX forwards the path and not the variable. A child reads the prune before it reads OCX_PROJECT and would discard the path.
Worked Examples
Section titled “Worked Examples”Private default registry
Section titled “Private default registry”Teams hosting packages on an internal registry can set it as the default, so bare identifiers resolve there:
[registry]default = "registry.company.com"Any package without an explicit registry prefix resolves to registry.company.com. Examples are kitware/cmake:3.28 and acme/myapp:1.0. Put the setting in the user tier (~/.config/ocx/config.toml) or the OCX home tier (~/.ocx/config.toml). The choice depends on whether it should travel with the data when $OCX_HOME is relocated.
Docker image with system-wide config
Section titled “Docker image with system-wide config”Provision the default registry in a Dockerfile, so all users on the image share it:
RUN mkdir -p /etc/ocxCOPY config.toml /etc/ocx/config.toml# config.toml (mounted to /etc/ocx/config.toml)[registry]default = "registry.company.com"The system tier is the natural home here. Every user inside the container shares the default, and the file lives at a path the build pipeline already controls.
CI with explicit config file
Section titled “CI with explicit config file”In CI, pair OCX_CONFIG with OCX_NO_CONFIG=1 for a hermetic run. The ambient discovery chain is suppressed, and only the checked-in CI config loads:
env: OCX_NO_CONFIG: "1" OCX_CONFIG: ${{ github.workspace }}/.ocx-ci.toml[registry]default = "ghcr.io"Alternatively, skip the file and rely on env vars:
env: OCX_NO_CONFIG: "1" OCX_DEFAULT_REGISTRY: "ghcr.io"Either form is hermetic. Choose the file form when humans on the team need to read what the CI run does without leaving the repository. Choose the env-var form when the CI configuration already lives in the workflow file.
See Also
Section titled “See Also”- Managed configuration in depth: the managed tier’s position, refresh, locks and publishing
- Configuration reference: every key, type, default, error string
- Environment variable reference: the
OCX_*variables that override config keys - Command-line
--configflag: invocation-level explicit config - Managed-configuration walkthrough: corporate onboarding, CI recipe, publisher recipe
ocx config updatereference: exit codes, JSON shape,--check