Skip to content

Publish installed engine CLIs on the user's PATH - #113

Open
ckelseynv wants to merge 1 commit into
developfrom
feat/engine-install-path-gh
Open

ckelseynv wants to merge 1 commit into
developfrom
feat/engine-install-path-gh

Conversation

@ckelseynv

@ckelseynv ckelseynv commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Changelog title

Installed engines are added to your PATH automatically

Changelog body

  • Installing Ollama or LM Studio through PAIR now puts its command-line tool on your PATH, so ollama or lms works in a new terminal without extra setup.
  • Removing an engine, or PAIR itself, gives back only the entries PAIR added and leaves your own PATH entries and shell configuration untouched.

Bumps

  • services: minor
  • nvpair-cluster-manager: none
  • nvpair-engine-manager: minor
  • nvpair-errors: none
  • nvpair-job-scheduler: none
  • nvpair-manual-nodes: none
  • nvpair-node-info: none
  • nvpair-node-scanner: none
  • nvpair-node-settings: none
  • nvpair-proxy: none
  • nvpair-tui: none
  • nvpair-ui-broker: none
  • nvpair-workload-manager: none

Summary

When PAIR installs Ollama or LM Studio, the engine manager adds that engine's
command-line directory to the installing user's persistent PATH on that device,
and gives back only what it recorded once the engine or the application is
removed. Open a new terminal after either operation.

Local installs only. An install driven by a cluster peer deliberately skips the
PATH step, because rewriting the login shell of whoever is sitting at the target
node is not a paired peer's decision.

Ownership is the whole design

Nothing else can identify what PAIR put on a user's PATH, so an ownership record
is written outside the engine directory and before the PATH is touched. That
ordering makes a crash mid-update retryable, and it means an uninstall deletes
exactly what PAIR added. Existing entries, user-edited shell snippets, and
engines the user installed themselves are all preserved, because an entry PAIR
cannot prove it wrote is an entry it must not remove.

The record also notes that PAIR ran the installer, which is a longer-lived fact
than any individual PATH entry. It is what lets a reinstall re-adopt an engine
whose vendor owns its location — LM Studio writes ~/.lmstudio, so the path
alone is no evidence of who put it there. A record that exists but cannot be
parsed fails an uninstall rather than reporting a clean one, since the
alternative is claiming the entries were released while stranding them.

Platform behavior

Windows persists through HKCU\Environment; the other platforms append a
self-identifying block to the login shell's profiles, resolving ZDOTDIR for
zsh users. Both run under a cross-process lock that covers the entire
read-modify-write rather than just the write, bounded with a timeout so a hung
peer cannot wedge the operation while the engine's own mutex is held. Both
publish through a temporary file and an atomic rename, with the file and its
directory flushed — a rename is atomic for a concurrent reader but says nothing
about bytes reaching stable storage.

A profile rewrite re-checks the file's size and modification time immediately
before the rename, so a concurrent editor's save is refused as retryable rather
than silently discarded. A symlinked profile is written through to its target
instead of being replaced by a regular file.

The platform uninstallers release the entries before deleting the data directory
that holds the records, and keep that data when the release fails, so a
reinstall can finish the cleanup instead of leaving entries nothing can
identify.

A manifest whose CLI resolves to a relative path is rejected with a warning
rather than anchored to the daemon's working directory, which would publish an
entry that means nothing. {install_dir} is resolved first, so a manifest
written against the documented placeholders works.

Adaptations to this tree

Two changes are not part of the feature and exist because this tree has moved
independently. Both are called out so they are reviewed as decisions rather than
skimmed as noise.

  • runCommand takes a variadic environment, for install commands that declare
    overrides. The launch closure in lifecycle.go therefore accepts and ignores
    it: a launch carries its own environment separately, and the one call site
    passes none, so merging two sources there would be dead code pretending to be
    a feature.
  • writeJSONAtomic keeps this tree's unique temporary filename and gains the
    file and directory flush the ownership records need. It states 0600
    explicitly rather than inheriting it from os.CreateTemp, because
    TestSettingsOverrideRestrictsExistingPermissions requires the write to
    restrict an already group-readable file, not merely avoid widening one. The
    temporary is also named after its target, since the same helper now writes
    port overrides, desired state, and PATH records.

Test plan

  • Full nvpair-engine-manager Go suite passes on this tree, on Linux as
    well as Windows. Running it on Linux was not optional: the permissions test
    above skips on Windows, and the first push of this branch failed on it.
    TestUninstallTerminatesRunningInstance fails in a container, but it does so
    identically on unmodified develop, so it is an environment artifact rather
    than a regression — it passes on the hosted runners.
  • go vet clean for linux, darwin, and windows; gofmt clean.
  • New coverage for the layers this feature rests on, which previously had none:
    cross-handle lock exclusion and a bounded wait, a symlinked profile written
    through rather than replaced, the concurrent-edit refusal, and a ZDOTDIR
    parsing table including a trailing comment after an unquoted value and an
    unreadable .zshenv.
  • Two regression tests were confirmed to fail against the previous behavior and
    pass after: republishing PATH for an engine living outside the install
    directory, and reporting failure for an unreadable ownership record.
  • Desktop typecheck, lint, dead-code, and unit suite pass; the four desktop
    files here are byte-identical to the versions those checks ran against.
  • SPDX headers pass (1033 files, 0 missing).

Not covered by automation, and worth native verification before release: real
OS PATH changes, the Windows registry functions (compiled but not executed by
CI), the installer's uninstall branch, and live vendor installations. Tests use
temporary profiles, fake engines, and in-memory Windows PATH values throughout,
so no run can edit a developer's own account.

When PAIR installs Ollama or LM Studio, add that engine's command-line
directory to the installing user's persistent PATH on that device, and give
back only what PAIR recorded when the engine or the application is removed.
Open a new terminal after either operation.

Local installs only. An install driven by a cluster peer deliberately skips
the PATH step, because rewriting the login shell of whoever is sitting at the
target node is not a paired peer's decision.

Ownership is recorded outside the engine directory and before the PATH is
touched, so a crash mid-update is retryable and an uninstall can delete
exactly what PAIR added. Existing entries, user-edited shell snippets, and
engines the user installed themselves are preserved. The record also notes
that PAIR ran the installer, which is what lets a reinstall re-adopt an engine
whose vendor owns its location: LM Studio writes ~/.lmstudio, so the path is
no evidence of who put it there. A record that exists but cannot be parsed
fails an uninstall rather than reporting a clean one.

Windows persists through HKCU\Environment; the other platforms append a
self-identifying block to the login shell's profiles. Both run under a
cross-process lock that covers the whole read-modify-write, bounded so a hung
peer times out instead of wedging the operation, and both publish through a
temporary file and an atomic rename. A profile rewrite re-checks the file's
fingerprint before renaming, so a concurrent editor's save is refused rather
than silently discarded.

The platform uninstallers release the entries before deleting the data
directory that holds the records, and keep that data when the release fails so
a reinstall can finish the cleanup.

Two changes are adaptations to this tree rather than part of the feature.
runCommand takes a variadic environment for install commands that declare
overrides, so the launch closure in lifecycle.go accepts and ignores it; a
launch carries its own environment separately. writeJSONAtomic keeps this
tree's unique temporary name and gains the file flush and directory flush that
the ownership records need, since a rename alone says nothing about bytes
reaching stable storage.

Signed-off-by: Chris Kelsey <ckelsey@nvidia.com>
@ckelseynv
ckelseynv force-pushed the feat/engine-install-path-gh branch from a6828dc to a1b3de5 Compare September 22, 2026 23:18
@kjlubick
kjlubick self-requested a review September 23, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant