- docs
- Concepts
- Entry Points
Entry Points
This page explains how entry points work. It covers how launchers are generated and how they integrate with env composition. It also covers how collisions are detected and how transitive dependencies contribute their launchers through the synth-PATH mechanism. For the field-level schema see the metadata reference. For the flags that wrap launchers at runtime see ocx package exec and ocx package select.
Most installed packages end up driven through one or two commands, such as cmake, ctest, node or go. OCX’s clean-env execution model normally routes every invocation through ocx package exec <package> -- <command>. That is the right default for reproducibility. It adds friction when humans sit in front of a shell and want to type cmake --build . without remembering which package owns cmake.
Entry points close that gap. A publisher declares named launchers in metadata.json. At install time OCX generates a small script per launcher that re-enters the package through the internal ocx launcher exec subcommand. Users add one directory to $PATH and every declared name becomes a top-level command. It has the same clean-env guarantees as a direct ocx package exec call.
Declaring Entry Points
Section titled “Declaring Entry Points”Entry points live as a sibling object on the bundle metadata, keyed by the command name users will invoke:
{ "type": "bundle", "version": 1, "env": [ { "key": "CMAKE_ROOT", "type": "constant", "value": "${installPath}/share/cmake" } ], "entrypoints": { "cmake": {}, "ctest": {} }}entrypoints is a JSON object keyed by command name. The value object carries the optional command and args fields, and omitting both yields {} as the value. The map shape mirrors Cargo’s [dependencies.X], Compose’s services:, and GitHub Actions’s jobs:, so uniqueness within a package follows from JSON object key semantics.
At runtime the launcher resolves the entry’s name against the composed PATH from the package’s env block. The publisher therefore declares the binary’s location once via env, and the launcher exec resolver picks it up from there. The complete field reference lives at Entry Points in the metadata reference. The publisher guide covers baked arguments.
At install time OCX writes the launchers for every declared name into a sibling entrypoints/ directory inside the content-addressed package directory. It does this on every platform, whichever one is installing. POSIX hosts get a .sh launcher (mode 0755). Windows hosts get a native <name>.exe shim plus a one-line <name>.shim sidecar that carries the absolute package root. Generating every shape on every platform keeps publishers from having to fork metadata per host OS.
Interface-Only Surface
Section titled “Interface-Only Surface”Entry points are always on the interface surface. They represent the commands a package exposes to consumers. A package’s launchers appear on PATH only under the consumer view (default ocx package exec, --self off).
Under --self, the root’s own launchers are suppressed from its PATH to prevent a launcher from finding itself on PATH and recursing infinitely. The private surface is for a package’s internal runtime env (compilers, runtimes, shared libraries), not its own CLI commands.
Transitive dependency launchers that pass the interface edge filter do appear in both views via the synth-PATH mechanism described below.
Name Rules and Collisions
Section titled “Name Rules and Collisions”Names are validated at metadata deserialization time. The regex is ^[a-z0-9][a-z0-9_-]*$: a lowercase slug where digits, - and _ are allowed and the first character must be alphanumeric. The name must be at most 64 characters. Capital letters, dots, slashes, leading separators, and names exceeding the limit are rejected with a descriptive error, so invalid metadata never reaches the registry.
Uniqueness is enforced at three layers:
- Within a package: the map shape gives intra-package uniqueness via JSON object key semantics. Duplicate keys in the on-wire JSON are rejected at deserialization rather than silently last-wins, which is the
serde_jsondefault. A bundle that lists"cmake"twice fails to parse and never reaches the registry. - Across the interface surface (install time):
ocx package installaborts when the package’s own bundle plus its interface-visible transitive deps declare the same entry-point name twice. The abort happens before the temp-to-object-store atomic move and exits withEntrypointCollision(exit 65,DataError). The scope is one package’s interface closure, installed with or without--select. See Multi-Owner Collision Reporting below for the exact error shape. - Across roots (consumption time): two different installed packages that both declare
cmakecoexist on disk. The conflict surfaces whenocx envorocx package execis given two or more roots. The compose-time check projects each root’s interface surface (bundle entry points plus interface-visible entries). It reports the sameEntrypointCollisionerror if two roots claim the same name. The per-package install gate cannot see such conflicts, for example two roots installed independently and combined only at exec time.
ocx package select performs no collision check. It flips current for a single package and never composes an environment.
A name that appears as an entrypoint in a private-edge dep is not an install error. Private-surface duplicates do not collide at install time. They resolve at runtime by topological PATH order, and the dep whose entrypoints/ directory appears first in the composed PATH wins the lookup. This is intentional: private-edge deps are invisible to consumers and their launchers never appear on the consumer’s PATH. Launcher naming conflicts among private deps are the package’s internal concern.
How Launchers Work at Runtime
Section titled “How Launchers Work at Runtime”On Unix a generated launcher is a deliberately small sh script. It is exactly:
#!/bin/sh# Generated by ocx at install time. Do not edit.exec "${OCX_BINARY_PIN:-ocx}" launcher exec '/home/alice/.ocx/packages/ocx.sh/sha256/ab/c123…' -- "${0##*/}" "$@"On Windows the launcher is a native compiled <name>.exe shim accompanied by a <name>.shim sidecar. The sidecar is one UTF-8 line holding the absolute package root, the same string the .sh body inlines:
C:\Users\alice\.ocx\packages\ocx.sh\sha256\ab\c123…The shim derives its own entry-point name from its filename (the file stem, minus the final .exe). It probes for a sibling sidecar in the order <stem>.shim, <stem>.shimref, <stem>.exec. The .shim form holds the package root. A .shimref belongs to a deferred package and holds a pinned identifier instead of a root path. For a .shim it then spawns ocx launcher exec "<pkg-root>" -- "<stem>" <args…> directly via the Win32 CreateProcessW API. It never routes through cmd.exe, so caller arguments are forwarded verbatim with no second command-line parse.
Only the absolute package-root path is baked (into the .sh body on Unix, into the .shim sidecar on Windows). The ocx launcher exec subcommand (hidden from ocx --help) forces the self view internally. Private, public and interface entries are all visible to the launched binary. Internal helpers (compilers, runtimes, shared libraries) are therefore available without any flag baked into the launcher.
The launcher injects its own entry-point name as the first positional after --. On Unix it uses ${0##*/}, a POSIX parameter expansion the shell evaluates itself with no subprocess and no PATH lookup. On Windows it uses the .exe file stem.
launcher exec reads that argv0 and composes the runtime env, including PATH. It uses the standard PATH search, Env::resolve_command, to locate the executable. The optional command field overrides the dispatch target when the binary name differs from the invocable name. When it is absent, the entry-point name is dispatched directly on the composed PATH from the package’s env block.
Both flavors use OCX_BINARY_PIN when the variable is set, falling back to plain ocx on $PATH otherwise. The Unix ${OCX_BINARY_PIN:-ocx} form treats an empty value as unset. The Windows shim follows cmd-style IF DEFINED semantics, so a variable that is present but empty still takes the pinned branch. The variable is propagated from the parent ocx process to all child processes via env::Env::apply_ocx_config. A user-level OCX upgrade that sets OCX_BINARY_PIN therefore takes effect for every launcher without touching the launchers on disk.
The stable wire ABI is the launcher exec subcommand name pair and the positional shape (<pkg-root> -- <argv0> [args...]). Both are frozen, so launchers generated by one OCX release keep working after ocx is upgraded.
Why bake the package root instead of resolving current at runtime?
A launcher whose body expanded current on every invocation would drift the moment the user selected a different tag. Baking the resolved package root at install time means a candidate’s launcher always points at the candidate’s content, even while current floats. This matches the broader OCX model: candidates are immutable pins and current is mutable state. The entry-point name is read from the launcher’s own filename. OCX therefore never has to bake a second path that could drift out of sync with the first.
Synth-PATH Mechanism
Section titled “Synth-PATH Mechanism”Entry points from transitive interface-visible dependencies become available without each intermediate package manually re-exporting them. The composer may encounter a dep whose effective visibility has the interface axis set (has_interface() is true) and that declares entrypoints. It then synthesizes a PATH ⊳ <dep-pkg-root>/entrypoints entry.
The synth-PATH entry for a dep is emitted in the same position as that dep’s other env contributions, in topological order before the root. The root’s own synth-PATH entry (consumer surface only) is emitted last. That gives the root’s launchers PATH priority over all transitive deps’ launchers.
This mechanism lets a top-level package depend on a runtime that ships its own commands, such as a Ninja runtime under a CMake bundle. Those commands surface automatically on the consumer’s $PATH without redeclaring them.
PATH Integration
Section titled “PATH Integration”Launchers are harmless files until something puts them on $PATH. OCX does that through the per-repo current symlink (which targets the package root) plus the entrypoints/ subdirectory inside it:
ocx package select <package>flipscurrentto the selected package root. Consumers that need launchers reach them via{registry}/{repo}/current/entrypoints. There is no separate symlink for entry points. The same anchor exposes content (current/content), launchers (current/entrypoints), and metadata (current/metadata.json).ocx package deselect <package>andocx package uninstall <package>removecurrentas part of their cleanup. Both operations are idempotent, so an already-absent symlink is not an error.ocx package env --shell=bash <package>emits eval-safe export lines for the named package. The output includes aPATH ⊳ current/entrypointsentry for every selected package that declares entry points. The global formeval "$(ocx --global env --shell=bash)"activates the full toolchain when packages are managed viaocx.toml.
The typical shell wire-up adds one of these to a login dotfile:
ocx package install --select "astral-sh/uv:0.10.0"
# Verify the current symlink was created (--select happened during install).ocx package which --current "astral-sh/uv:0.10.0" >/dev/null
# Print the resolved env for the installed package (the eval-safe form).# This is the per-package equivalent of shell profile activation.ocx package env --shell=bash "astral-sh/uv:0.10.0" >/dev/nullFrom the user’s perspective, once the shell is open, cmake --build . resolves to your launcher, which resolves to your binary, all inside a clean environment.
Multi-Owner Collision Reporting
Section titled “Multi-Owner Collision Reporting”When two or more packages in the interface surface declare the same entrypoint name, the error reports all of them. The collision surfaces on stderr as a single line in the standard thiserror Display form:
error: entrypoint name collision: 'cmake' declared by 3 packages: ocx.sh/cmake-extras:1.0@sha256:aaa..., ocx.sh/cmake-wrapper:1.0@sha256:bbb..., ocx.sh/kitware/cmake:3.28@sha256:ccc...; deselect one before selecting anotherThe line has three parts:
'cmake': the colliding entry-point name.N packages: …: every interface-visible owner, comma-separated, each printed as a fully-qualified pinned identifier (registry/repository:tag@sha256:…). All N owners are listed so the publisher knows exactly which packages in the dependency graph compete for the name.deselect one before selecting another: suggested action.
The error is not a structured JSON payload. The collision variant is reported via the same Rust error chain as every other failure (PackageErrorKind::EntrypointCollision). The process exits with code 65 (DataError).
Cross-Platform Caveats
Section titled “Cross-Platform Caveats”Launchers are the first OCX artifact whose correctness depends on the user’s shell as well as their OS. A few platform-specific quirks are worth calling out.
Unix shells
Section titled “Unix shells”The Unix launcher is a POSIX sh script with the package-root path inlined as a single-quoted literal. The unsafe-character set rejected at generation time (described in the runtime section above) keeps that literal trivially safe. In sh there is no safe way to escape single quotes inside single-quoted strings. OCX therefore refuses to ship a launcher whose package-root path contains one. In practice this only bites packages whose OCX_HOME contains a literal apostrophe, which is vanishingly rare.
The launcher is chmod-ed to 0755 as part of the write, so the install is complete with no follow-up chmod step.
Windows
Section titled “Windows”The Windows launcher is a native compiled .exe shim, not a script. When a shell resolves cmake it finds cmake.exe. The .EXE extension is unconditionally in the default Windows PATHEXT, so no PATHEXT configuration is ever needed for launcher discovery. The shim reads its cmake.shim sidecar for the package root. It spawns ocx launcher exec through CreateProcessW, passing caller arguments straight through with Win32 CommandLineToArgvW-compatible quoting.
Nothing re-parses the command line through cmd.exe, so arguments survive verbatim. Spaces, Unicode, shell metacharacters (&, |, ^, <, >), and literal empty strings (cmake "" --version) all reach the binary exactly as written. This closes the BatBadBut / CVE-2024-24576 %* argument-injection class for default resolution. Automation that interpolates untrusted strings (a CI branch name, a build label) into launcher arguments is not exposed through OCX’s own launchers.
The shim is transparent. It propagates the child’s exit code unchanged (full 32-bit passthrough) and forwards stdin, stdout and stderr to the real console. It also uses a Win32 job object, so a force-killed launcher reaps its child tree rather than orphaning it. Errors before the child starts go to stderr as a single ocx-shim: line with a sysexits-aligned exit code.
A missing sidecar is recoverable. Re-running the install regenerates the entrypoint, and the stderr line itself asks you to do so.
When ocx cannot be resolved, the message depends on how resolution was attempted. With no OCX_BINARY_PIN set and ocx not on $PATH, the shim prints ocx-shim: ocx not found (set OCX_BINARY_PIN or add ocx to PATH). When OCX_BINARY_PIN is set but points at a path that does not exist, the shim names that path instead: ocx-shim: pinned ocx not found: C:\path\to\ocx.exe (OCX_BINARY_PIN points at a missing path). The generic “add ocx to PATH” hint would be misleading there.
The shim’s pre-child exit codes are stable and scriptable:
| Code | Meaning | Operator action |
|---|---|---|
69 | ocx unavailable (not pinned and not on $PATH, or the pinned path is missing) | Put ocx on $PATH or fix OCX_BINARY_PIN before any launcher runs |
74 | CreateProcessW failed — the Win32 error code is on the stderr line | Inspect the reported Win32 code; usually a corrupt binary or a transient OS condition — retry, then re-run ocx package install |
77 | Permission denied (ERROR_ACCESS_DENIED) starting the child, or the package root in the sidecar resolves outside the OCX home | For access denied, check execution policy / AppLocker / antivirus blocking the launcher and grant execute on the package root. For a root outside the OCX home, re-run ocx package install so the sidecar is rewritten |
78 | Bad or missing .shim sidecar (absent, empty, oversized, not absolute, or corrupt) | Re-run ocx package install or ocx package select to regenerate the entrypoint |
The child’s own exit code passes through untouched on the success path. A non-zero value above is therefore unambiguously a launcher-stage failure, not the binary’s.
PowerShell
Section titled “PowerShell”PowerShell resolves cmake to cmake.exe on $PATH and runs it directly. There is no intervening cmd.exe, so PowerShell’s own %VAR%-in-double-quotes expansion quirk does not apply to OCX launchers. OCX does not generate a .ps1 variant. The native .exe shim already covers the entire PowerShell user base with no script-language argument-forwarding caveats.
The practical rule is that PowerShell users get this for free from the installer. The installer writes $OCX_HOME/env.ps1 and adds a block to $PROFILE that sources it. On every session env.ps1 runs Invoke-Expression ((& ocx --global env --shell=pwsh) | Out-String), and the resulting $PATH entries pick up the .exe launchers automatically.
Git Bash and MSYS2
Section titled “Git Bash and MSYS2”On Windows, POSIX-emulation shells (Git Bash, MSYS2, Cygwin) resolve cmake.exe directly by its explicit extension. These shells do not consult PATHEXT, so the native .exe is exactly what they need with no extra .sh launcher. The same shim runs identically from cmd.exe, PowerShell, and a Git Bash prompt because none of them route the call through cmd.exe.
End-to-End Example
Section titled “End-to-End Example”Publishing a CMake package with two launchers looks like this on the publisher side:
{ "type": "bundle", "version": 1, "strip_components": 1, "env": [ { "key": "PATH", "type": "path", "required": true, "value": "${installPath}/bin" } ], "entrypoints": { "cmake": {}, "ctest": {} }}And on the consumer side:
ocx package install --select "astral-sh/uv:0.10.0"
# Print the eval-safe env for the selected package.# In a shell profile, this lets launchers declared in the package's metadata# appear on $PATH. The global toolchain form (eval "$(ocx --global env --shell=bash)")# is used when the package is managed via ocx.toml.ocx package env --shell=bash "astral-sh/uv:0.10.0"Switching to a different version is a single ocx package select:
# Install the first (primary) version and select it.ocx package install --select "amazon/corretto:21.0.0"No re-sourcing dotfiles. The ocx package env --shell output is stable. It emits exports for whichever packages are selected. The stable current/entrypoints path was already on $PATH. The select just re-points the current symlink at the new package root, and the next shell open picks up the updated env output automatically.