feat(install): add shared and system install directories#8581
Conversation
Add MISE_SHARED_INSTALL_DIRS setting to allow searching additional read-only directories for installed tool versions. This enables shared environments like Docker containers and bastion hosts to pre-install tools in a shared location while users install additional tools in their own directory. Closes #8549 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request enhances Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
Greptile SummaryThis PR adds experimental support for shared, read-only tool install directories in mise, targeting Docker/toolbox/bastion-host use-cases. It introduces Key changes:
Issues found:
Confidence Score: 3/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["mise install tool@ver"] --> B{Install target flag?}
B -->|"--system"| C["tv.install_path = SYSTEM_INSTALLS_DIR/tool/ver"]
B -->|"--shared path"| D["tv.install_path = path/tool/ver"]
B -->|"neither"| E["tv.install_path = None, resolved later"]
C --> F["backend::install_version()"]
D --> F
E --> F
F --> G{force and no explicit install_path and path is shared?}
G -->|"yes"| H["Redirect to primary installs dir"]
G -->|"no"| I["Proceed as-is"]
H --> I
I --> J["is_version_installed?"]
J -->|"yes, no force"| K["Return early: already installed"]
J -->|"no or force"| L["create_install_dirs + install"]
L --> M["Post-install manifest write"]
M --> N{starts with primary dirs::INSTALLS?}
N -->|"yes"| O["write to primary manifest OK"]
N -->|"no"| P{install_path_category != Local?}
P -->|"System or known Shared"| Q["write to shared manifest OK"]
P -->|"Local - fresh --shared path not yet in settings"| R["No manifest written - silent bug"]
style R fill:#f88,stroke:#c00
|
There was a problem hiding this comment.
Code Review
This pull request introduces a useful feature for shared, read-only install directories, which is well-implemented across the configuration, schema, and core logic. The changes are logical and include a new end-to-end test to validate the functionality. My review focuses on a few opportunities to improve code structure and maintainability by addressing code duplication and a minor documentation placement issue.
| // Scan shared install directories (read-only fallback directories) | ||
| for shared_dir in env::MISE_SHARED_INSTALL_DIRS.iter() { | ||
| if !shared_dir.is_dir() { | ||
| continue; | ||
| } | ||
| let shared_manifest_path = shared_dir.join(".mise-installs.toml"); | ||
| let shared_manifest: Manifest = match file::read_to_string(&shared_manifest_path) { | ||
| std::result::Result::Ok(body) => toml::from_str(&body).unwrap_or_default(), | ||
| Err(_) => Default::default(), | ||
| }; | ||
| let shared_subdirs = match file::dir_subdirs(shared_dir) { | ||
| std::result::Result::Ok(d) => d, | ||
| Err(err) => { | ||
| warn!( | ||
| "reading shared install dir {} failed: {err:?}", | ||
| display_path(shared_dir) | ||
| ); | ||
| continue; | ||
| } | ||
| }; | ||
| for dir_name in shared_subdirs { | ||
| let dir = shared_dir.join(&dir_name); | ||
| let versions: Vec<String> = file::dir_subdirs(&dir) | ||
| .unwrap_or_else(|err| { | ||
| warn!("reading versions in {} failed: {err:?}", display_path(&dir)); | ||
| Default::default() | ||
| }) | ||
| .into_iter() | ||
| .filter(|v| !v.starts_with('.')) | ||
| .filter(|v| !runtime_symlinks::is_runtime_symlink(&dir.join(v))) | ||
| .filter(|v| !dir.join(v).join("incomplete").exists()) | ||
| .sorted_by_cached_key(|v| { | ||
| let normalized = normalize_version_for_sort(v); | ||
| (Versioning::new(normalized), v.to_string()) | ||
| }) | ||
| .collect(); | ||
|
|
||
| if versions.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| let (short, full, explicit_backend, opts) = | ||
| if let Some(mt) = shared_manifest.get(&dir_name) { | ||
| ( | ||
| mt.short.clone(), | ||
| mt.full.clone(), | ||
| mt.explicit_backend, | ||
| mt.opts.clone(), | ||
| ) | ||
| } else { | ||
| (dir_name.clone(), None, true, BTreeMap::new()) | ||
| }; | ||
|
|
||
| // Merge with existing tool entry or create new one | ||
| let tool = tools | ||
| .entry(short.clone()) | ||
| .or_insert_with(|| InstallStateTool { | ||
| short: short.clone(), | ||
| full: full.clone(), | ||
| versions: Vec::new(), | ||
| explicit_backend, | ||
| opts: opts.clone(), | ||
| }); | ||
| // Add versions from shared dir that aren't already present | ||
| for v in versions { | ||
| if !tool.versions.contains(&v) { | ||
| tool.versions.push(v); | ||
| } | ||
| } | ||
| // Re-sort after merging | ||
| tool.versions.sort_by_cached_key(|v| { | ||
| let normalized = normalize_version_for_sort(v); | ||
| (Versioning::new(normalized), v.to_string()) | ||
| }); | ||
| // Fill in metadata if not yet set | ||
| if tool.full.is_none() { | ||
| tool.full = full; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
There's a significant amount of code duplication between the logic for scanning the primary installs directory (lines 201-285) and this new block for scanning shared install directories. The logic for iterating through tool subdirectories, listing versions, and parsing metadata is almost identical in both places.
To improve maintainability and reduce redundancy, consider refactoring this duplicated logic into a helper function. This function could take a directory path and a manifest as input and return the discovered tools and versions, which can then be merged into the main tools map accordingly.
src/toolset/tool_request.rs
Outdated
| /// Returns the shared path if found, otherwise the original primary path. | ||
| fn resolve_shared_install_path(primary_path: PathBuf, short: &str, pathname: &str) -> PathBuf { | ||
| if !primary_path.exists() { | ||
| let tool_dir_name = heck::ToKebabCase::to_kebab_case(short); | ||
| for shared_dir in env::MISE_SHARED_INSTALL_DIRS.iter() { | ||
| let shared_path = shared_dir.join(&tool_dir_name).join(pathname); | ||
| if shared_path.exists() { | ||
| return shared_path; | ||
| } | ||
| } | ||
| } | ||
| primary_path | ||
| } | ||
|
|
There was a problem hiding this comment.
This new function resolve_shared_install_path and its documentation have been inserted between the documentation for version_sub and the function itself. This detaches the doc comment from version_sub, making the code harder to read and potentially causing issues with documentation generation.
Please move this new function and its documentation to a more appropriate location, for example, before the version_sub function's documentation block, to ensure that doc comments are correctly associated with their respective functions.
src/toolset/tool_version.rs
Outdated
| // Check shared install directories if the primary path doesn't exist | ||
| if !path.exists() && !matches!(&self.request, ToolRequest::Path { .. }) { | ||
| let tool_dir_name = heck::ToKebabCase::to_kebab_case(self.ba().short.as_str()); | ||
| for shared_dir in env::MISE_SHARED_INSTALL_DIRS.iter() { | ||
| let shared_path = shared_dir.join(&tool_dir_name).join(&pathname); | ||
| if shared_path.exists() { | ||
| CACHE.insert(self.clone(), shared_path.clone()); | ||
| return shared_path; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This block of code for checking shared install directories duplicates logic already present in src/toolset/tool_request.rs within the resolve_shared_install_path function. While this implementation also handles caching, the core logic of iterating through shared directories and constructing paths is the same.
To avoid this duplication, consider making resolve_shared_install_path a public helper function (e.g., in a shared utility module) that can be reused here. You could then handle the caching logic in this file after calling the shared function, based on whether the returned path is different from the primary path.
- Use Settings::try_get() for shared_install_dirs when available, falling back to env var. Config file settings now work correctly. - Add warn! for invalid TOML in shared manifest files (consistency with primary manifest error handling). - Fix misplaced doc comment: restore version_sub docs, separate resolve_shared_install_path docs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…shared flags - Mark shared_install_dirs setting as [experimental] - Centralize shared dir lookup into env::find_in_shared_installs(), removing duplicate logic from tool_version.rs and tool_request.rs - Add /usr/local/share/mise/installs as automatic system-level shared dir (MISE_SYSTEM_INSTALLS_DIR), always checked when it exists - Add `mise install --system` to install to the system shared dir - Add `mise install --shared <path>` to install to a custom shared dir - Add install_dir field to InstallOptions for path override Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents stale shared-dir paths from persisting after installs/uninstalls within a single mise process. The CACHE DashMap in ToolVersion::install_path() is now cleared when install state resets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…A_DIR Point to /usr/local/share/mise (the root), not the installs subdir. Derive installs path as MISE_SYSTEM_DATA_DIR/installs internally, consistent with how MISE_DATA_DIR relates to MISE_INSTALLS_DIR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename for parity with MISE_SYSTEM_DATA_DIR. MISE_SYSTEM_DIR is still supported as a legacy fallback in the env var but is no longer referenced in docs. Internal usage updated to SYSTEM_CONFIG. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…talls When installing with --system or --shared, write_backend_meta now writes the .mise-installs.toml to the target installs directory instead of skipping the write. Refactored manifest read/write into path-parameterized helpers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that shared versions are treated as installed (skip re-install), primary uninstalls don't affect shared versions, and shared versions remain usable after primary versions are removed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mise ls now annotates versions from shared/system install directories with a label like "(system)" or "(shared)" in cyan, similar to how "(symlink)" is shown. JSON output also reflects the active state correctly for shared versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hyperfine Performance
|
| Command | mise-2026.3.8 | mise | Variance |
|---|---|---|---|
| install (cached) | 119ms | 115ms | +3% |
| ls (cached) | 73ms | 76ms | -3% |
| bin-paths (cached) | 69ms | 69ms | +0% |
| task-ls (cached) | 726ms | 730ms | +0% |
- Only write .mise-installs.toml for system/shared paths, not arbitrary install-into paths - Use read_manifest_from() in shared dir scan instead of duplicating - Update Docker docs: explain devcontainer home mount use case for --system installs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use BackendArg::tool_dir_name() (from installs_path) instead of re-applying to_kebab_case on short names in shared dir lookups - Use std::env::split_paths for MISE_SHARED_INSTALL_DIRS to handle Windows paths with colons correctly - Extract resolve_version_status() to deduplicate shared/system detection in mise ls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
When a version exists only in a shared/system dir, `mise install --force` now installs to the primary dir instead of overwriting the shared copy. The shared dir's read-only semantics are preserved unless --system or --shared is explicitly passed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
### 🚀 Features - **(github)** use release latest endpoint to get latest release by @roele in [#8516](#8516) - **(install)** add shared and system install directories by @jdx in [#8581](#8581) - **(vfox)** add provenance metadata to lockfile for tool plugins by @malept in [#8544](#8544) ### 🐛 Bug Fixes - **(aqua)** expose main binary when files field is empty and symlink_bins is enabled by @AlexanderTheGrey in [#8550](#8550) - **(env)** redact secrets in `mise set` listing and task-specific env by @jdx in [#8583](#8583) - **(prepare)** install config tools before running prepare steps by @jdx in [#8582](#8582) - **(task)** allow ctrl-c to interrupt tool downloads during `mise run` by @jdx in [#8571](#8571) - **(tasks)** add file task header parser support for spaces around = by @roele in [#8574](#8574) ### 📚 Documentation - **(task)** add property description for interactive by @roele in [#8562](#8562) - add missing `</bold>` closing tag by @muzimuzhi in [#8564](#8564) - rebrand site with new chef logo and warm culinary palette by @jdx in [#8587](#8587) ### 📦️ Dependency Updates - update ghcr.io/jdx/mise:alpine docker digest to de4657e by @renovate[bot] in [#8577](#8577) - update ghcr.io/jdx/mise:copr docker digest to eef29a2 by @renovate[bot] in [#8578](#8578) - update ghcr.io/jdx/mise:rpm docker digest to 5a96587 by @renovate[bot] in [#8580](#8580) - update ghcr.io/jdx/mise:deb docker digest to 464cf7c by @renovate[bot] in [#8579](#8579) ### 📦 Registry - fix flatc version test mismatch by @jdx in [#8588](#8588) ### Chore - **(registry)** skip spark test-tool by @jdx in [#8572](#8572) ### New Contributors - @AlexanderTheGrey made their first contribution in [#8550](#8550) ## 📦 Aqua Registry Updates #### New Packages (6) - [`bahdotsh/mdterm`](https://github.com/bahdotsh/mdterm) - [`callumalpass/mdbase-lsp`](https://github.com/callumalpass/mdbase-lsp) - [`facebook/ktfmt`](https://github.com/facebook/ktfmt) - [`gurgeous/tennis`](https://github.com/gurgeous/tennis) - [`tektoncd/pipelines-as-code`](https://github.com/tektoncd/pipelines-as-code) - [`weedonandscott/trolley`](https://github.com/weedonandscott/trolley) #### Updated Packages (2) - [`apple/container`](https://github.com/apple/container) - [`cocogitto/cocogitto`](https://github.com/cocogitto/cocogitto)
## Summary Adds support for shared, read-only tool install directories — useful for Docker containers, toolbox containers, and bastion hosts where a base set of tools is pre-installed in a shared location. - **`shared_install_dirs`** setting (`MISE_SHARED_INSTALL_DIRS`, colon-separated) for additional read-only directories to search for installed tool versions - **System install directory** at `/usr/local/share/mise/installs` (`MISE_SYSTEM_DATA_DIR/installs`) — automatically checked when it exists, no config needed - **`mise install --system`** — install tools to the system-wide shared directory - **`mise install --shared <path>`** — install tools to a custom shared directory - **`mise ls`** — shows `(system)` and `(shared)` labels for versions from shared directories - **`MISE_SYSTEM_CONFIG_DIR`** — renamed from `MISE_SYSTEM_DIR` for parity with `MISE_SYSTEM_DATA_DIR` (legacy env var still supported) - Feature is marked **[experimental]** Closes jdx#8549 ## How it works - **Lookup order**: primary install dir → system dir → user-configured shared dirs - **Read-only**: shared dirs are never written to during normal `mise install`; versions found there are treated as already installed - **Install flags**: `--system` and `--shared` install to alternate directories and write `.mise-installs.toml` manifests there - **Config**: works via env var or config file (`shared_install_dirs = [...]` in settings) - **Display**: `mise ls` annotates shared versions with `(system)` or `(shared)` in cyan ## Test plan - [x] All 531 unit tests pass - [x] e2e test (`test_shared_install_dirs`) covers: - Shared versions appear in `mise ls --installed` - `mise x` executes tools from shared dirs - `mise install` skips versions already in shared dirs - Uninstalling primary versions doesn't affect shared versions - Shared versions remain usable after primary removal - [x] All lint checks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes tool install/lookup paths and manifest handling, which can affect whether versions are considered installed and where `--force` writes. Also renames the system config env var with a legacy alias, which could impact deployments relying on the old name. > > **Overview** > Adds **experimental shared/system install directories** so tool version resolution can fall back from the primary installs dir to `/usr/local/share/mise/installs` and any user-configured `shared_install_dirs` (`MISE_SHARED_INSTALL_DIRS`). Tool path resolution (`ToolRequest::install_path`, `ToolVersion::install_path`) and install-state discovery now incorporate these directories, with caching and manifest reading/writing updated to support per-directory `.mise-installs.toml`. > > Extends `mise install` with `--system` and `--shared <path>` to install into alternate directories while ensuring `--force` doesn’t mutate shared installs unless explicitly targeted, and updates `mise ls` to label shared/system versions. Renames `MISE_SYSTEM_DIR` to `MISE_SYSTEM_CONFIG_DIR` (legacy alias retained), updates schema/settings/docs/manpages/completions, and adds an e2e test covering shared-dir fallback and force/uninstall behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b70caa7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
### 🚀 Features - **(github)** use release latest endpoint to get latest release by @roele in [jdx#8516](jdx#8516) - **(install)** add shared and system install directories by @jdx in [jdx#8581](jdx#8581) - **(vfox)** add provenance metadata to lockfile for tool plugins by @malept in [jdx#8544](jdx#8544) ### 🐛 Bug Fixes - **(aqua)** expose main binary when files field is empty and symlink_bins is enabled by @AlexanderTheGrey in [jdx#8550](jdx#8550) - **(env)** redact secrets in `mise set` listing and task-specific env by @jdx in [jdx#8583](jdx#8583) - **(prepare)** install config tools before running prepare steps by @jdx in [jdx#8582](jdx#8582) - **(task)** allow ctrl-c to interrupt tool downloads during `mise run` by @jdx in [jdx#8571](jdx#8571) - **(tasks)** add file task header parser support for spaces around = by @roele in [jdx#8574](jdx#8574) ### 📚 Documentation - **(task)** add property description for interactive by @roele in [jdx#8562](jdx#8562) - add missing `</bold>` closing tag by @muzimuzhi in [jdx#8564](jdx#8564) - rebrand site with new chef logo and warm culinary palette by @jdx in [jdx#8587](jdx#8587) ### 📦️ Dependency Updates - update ghcr.io/jdx/mise:alpine docker digest to de4657e by @renovate[bot] in [jdx#8577](jdx#8577) - update ghcr.io/jdx/mise:copr docker digest to eef29a2 by @renovate[bot] in [jdx#8578](jdx#8578) - update ghcr.io/jdx/mise:rpm docker digest to 5a96587 by @renovate[bot] in [jdx#8580](jdx#8580) - update ghcr.io/jdx/mise:deb docker digest to 464cf7c by @renovate[bot] in [jdx#8579](jdx#8579) ### 📦 Registry - fix flatc version test mismatch by @jdx in [jdx#8588](jdx#8588) ### Chore - **(registry)** skip spark test-tool by @jdx in [jdx#8572](jdx#8572) ### New Contributors - @AlexanderTheGrey made their first contribution in [jdx#8550](jdx#8550) ## 📦 Aqua Registry Updates #### New Packages (6) - [`bahdotsh/mdterm`](https://github.com/bahdotsh/mdterm) - [`callumalpass/mdbase-lsp`](https://github.com/callumalpass/mdbase-lsp) - [`facebook/ktfmt`](https://github.com/facebook/ktfmt) - [`gurgeous/tennis`](https://github.com/gurgeous/tennis) - [`tektoncd/pipelines-as-code`](https://github.com/tektoncd/pipelines-as-code) - [`weedonandscott/trolley`](https://github.com/weedonandscott/trolley) #### Updated Packages (2) - [`apple/container`](https://github.com/apple/container) - [`cocogitto/cocogitto`](https://github.com/cocogitto/cocogitto)

Summary
Adds support for shared, read-only tool install directories — useful for Docker containers, toolbox containers, and bastion hosts where a base set of tools is pre-installed in a shared location.
shared_install_dirssetting (MISE_SHARED_INSTALL_DIRS, colon-separated) for additional read-only directories to search for installed tool versions/usr/local/share/mise/installs(MISE_SYSTEM_DATA_DIR/installs) — automatically checked when it exists, no config neededmise install --system— install tools to the system-wide shared directorymise install --shared <path>— install tools to a custom shared directorymise ls— shows(system)and(shared)labels for versions from shared directoriesMISE_SYSTEM_CONFIG_DIR— renamed fromMISE_SYSTEM_DIRfor parity withMISE_SYSTEM_DATA_DIR(legacy env var still supported)Closes #8549
How it works
mise install; versions found there are treated as already installed--systemand--sharedinstall to alternate directories and write.mise-installs.tomlmanifests thereshared_install_dirs = [...]in settings)mise lsannotates shared versions with(system)or(shared)in cyanTest plan
test_shared_install_dirs) covers:mise ls --installedmise xexecutes tools from shared dirsmise installskips versions already in shared dirs🤖 Generated with Claude Code
Note
Medium Risk
Medium risk because it changes tool install/lookup paths and manifest handling, which can affect whether versions are considered installed and where
--forcewrites. Also renames the system config env var with a legacy alias, which could impact deployments relying on the old name.Overview
Adds experimental shared/system install directories so tool version resolution can fall back from the primary installs dir to
/usr/local/share/mise/installsand any user-configuredshared_install_dirs(MISE_SHARED_INSTALL_DIRS). Tool path resolution (ToolRequest::install_path,ToolVersion::install_path) and install-state discovery now incorporate these directories, with caching and manifest reading/writing updated to support per-directory.mise-installs.toml.Extends
mise installwith--systemand--shared <path>to install into alternate directories while ensuring--forcedoesn’t mutate shared installs unless explicitly targeted, and updatesmise lsto label shared/system versions. RenamesMISE_SYSTEM_DIRtoMISE_SYSTEM_CONFIG_DIR(legacy alias retained), updates schema/settings/docs/manpages/completions, and adds an e2e test covering shared-dir fallback and force/uninstall behavior.Written by Cursor Bugbot for commit b70caa7. This will update automatically on new commits. Configure here.