Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/dev-tools/backends/npm.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ The code for this is inside of the mise repository at [`./src/backend/npm.rs`](h

## Dependencies

This relies on having `npm` installed for resolving package versions.
Version resolution (`mise ls-remote`, resolving `latest`) queries the npm
registry directly over HTTP, so no node/npm installation is needed for it. The
registry, scoped registries (`@scope:registry`), and auth tokens configured in
`~/.npmrc` (or `NPM_CONFIG_USERCONFIG`) and `NPM_CONFIG_*` environment
variables are honored. If your setup relies on npm-only configuration such as
`cafile`, client certificates, or an auth token helper, set
[`npm.use_npm_view`](/configuration/settings.html#npm_use_npm_view) to shell
out to `npm view` for version metadata instead (this requires npm to be
installed).

Installing packages relies on the configured package manager.
With the default `npm.package_manager = "auto"` setting, mise uses
[`aube`](https://aube.jdx.dev/) for installing npm packages when it is installed,
similar to how the pipx backend uses `uv` when available.
Expand Down
17 changes: 8 additions & 9 deletions e2e/backend/test_backend_missing_deps
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,24 @@ test_npm() {
echo "Testing npm backend with missing npm..."
check_command_missing "npm"

# Test ls-remote - should show warning but continue
# Version queries go directly to the registry over HTTP and work without
# node/npm installed, in any package manager mode
assert_contains "mise ls-remote npm:left-pad" "1.3.0"
MISE_NPM_PACKAGE_MANAGER=bun assert_contains "mise ls-remote npm:left-pad" "1.3.0"

# Legacy `npm view` mode still needs npm and warns when it is missing
export MISE_NPM_USE_NPM_VIEW=1
test_backend_warning "npm" "mise ls-remote npm:test-package" "npm may be required but was not found"

# Check for helpful install instructions
local output
output=$(MISE_LOG_LEVEL=warn mise ls-remote npm:test-package 2>&1 || true)
test_error_message "npm suggests installing node" "$output" "mise use node@latest"
test_error_message "npm mentions npm is needed for queries" "$output" "npm is required for querying package information"
unset MISE_NPM_USE_NPM_VIEW

# Test install - should show warning but continue
test_backend_warning "npm" "mise install npm:test-package@latest" "npm may be required but was not found"

# Test with bun mode enabled but npm still missing
echo "Testing npm backend with bun mode enabled but npm missing..."
export MISE_NPM_PACKAGE_MANAGER=bun
test_backend_warning "npm (bun mode)" "mise ls-remote npm:test-package" "npm may be required but was not found"
output=$(mise ls-remote npm:test-package 2>&1 || true)
test_error_message "npm (bun mode) shows npm is required for queries" "$output" "npm is required for querying package information"
unset MISE_NPM_PACKAGE_MANAGER
}

# Test cargo backend
Expand Down
5 changes: 5 additions & 0 deletions e2e/backend/test_npm_shim_recursion_system_dir
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
# This test mirrors `test_go_shim_recursion` but uses the SYSTEM shims dir
# specifically to validate the dual-dir fix.

# Version queries only shell out to `npm view` in legacy mode now; the
# default path queries the registry over HTTP and never spawns npm. Pin the
# legacy mode so this test keeps covering the shim-recursion guard.
export MISE_NPM_USE_NPM_VIEW=1

export MISE_SYSTEM_DATA_DIR="$HOME/.local/share/mise-system"
mkdir -p "$MISE_SYSTEM_DATA_DIR/shims"

Expand Down
5 changes: 5 additions & 0 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,11 @@
"description": "Package manager to use for installing npm packages.",
"type": "string",
"enum": ["auto", "npm", "aube", "bun", "pnpm"]
},
"use_npm_view": {
"default": false,
"description": "Use the npm CLI (`npm view`) for version metadata queries instead of mise's built-in registry client.",
"type": "boolean"
}
}
},
Expand Down
17 changes: 17 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,23 @@ env = "MISE_NPM_PACKAGE_MANAGER"
rust_type = "NpmPackageManager"
type = "String"

[npm.use_npm_view]
default = false
description = "Use the npm CLI (`npm view`) for version metadata queries instead of mise's built-in registry client."
docs = """
By default, mise queries npm version metadata (`mise ls-remote`, `latest` resolution)
directly from the npm registry over HTTP, honoring the registry, scoped registries,
and auth tokens configured in `~/.npmrc` and `NPM_CONFIG_*` environment variables.
This means node/npm do not need to be installed to resolve `npm:` tool versions.

Enable this setting to shell out to `npm view` instead, e.g. if your setup relies on
npm-specific configuration that mise's registry client does not support, such as
`cafile`, client certificates, or an auth token helper. When enabled, npm must be
installed for version queries to work.
"""
env = "MISE_NPM_USE_NPM_VIEW"
type = "Bool"

[oci.default_from]
default = "debian:bookworm-slim"
description = "Default base image for `mise oci build` when [oci].from is not set."
Expand Down
1 change: 1 addition & 0 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub mod go;
pub mod http;
pub mod jq;
pub mod npm;
pub mod npm_registry;
pub(crate) mod options;
pub mod pipx;
pub mod pkgx;
Expand Down
165 changes: 112 additions & 53 deletions src/backend/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::Result;
use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::backend_type::BackendType;
use crate::backend::npm_registry;
use crate::backend::options::BackendOptions;
use crate::backend::platform_target::PlatformTarget;
#[cfg(windows)]
Expand Down Expand Up @@ -43,6 +44,8 @@ pub struct NPMBackend {
ba: Arc<BackendArg>,
// use a mutex to prevent deadlocks that occurs due to reentrant cache access
latest_version_cache: TokioMutex<CacheManager<Option<String>>>,
// one packument fetch serves both version listing and dist-tag lookup
packument: tokio::sync::OnceCell<Arc<npm_registry::Packument>>,
}

#[derive(Debug, Clone, Copy)]
Expand Down Expand Up @@ -247,11 +250,14 @@ impl Backend for NPMBackend {
}

fn get_dependencies(&self) -> eyre::Result<Vec<&str>> {
// npm CLI is always needed for version queries (npm view), plus the configured
// package manager for installation. We avoid listing all package managers to
// prevent incorrect dependency edges.
// Version queries go directly to the npm registry over HTTP, so npm is
// only needed when it is (or may be) the installer, or when the legacy
// `npm.use_npm_view` setting routes queries through the npm CLI. We
// avoid listing all package managers to prevent incorrect dependency
// edges.
let settings = Settings::get();
let package_manager = settings.npm.package_manager;
let use_npm_view = settings.npm.use_npm_view;
let tool_name = self.tool_name();

// Explicit `npm:npm` still bootstraps through node's bundled npm even
Expand All @@ -269,20 +275,24 @@ impl Backend for NPMBackend {
}

// Avoid circular dependency when installing the configured package manager
// e.g., npm:bun with bun configured, or npm:pnpm with pnpm configured
// e.g., npm:bun with bun configured, or npm:pnpm with pnpm configured.
// npm stays here as the bootstrap installer for that package manager.
if tool_name == package_manager.to_string() {
// Still need npm for version queries
return Ok(vec!["node", "npm"]);
}

// For regular packages: need npm (for version queries) + configured package manager
let mut deps = vec!["node", "npm"];
// For regular packages: node + the configured package manager. npm is
// included when it may end up doing the install (Auto falls back to it)
// or when `npm.use_npm_view` needs it for version queries.
let mut deps = vec!["node"];
match package_manager {
NpmPackageManager::Auto => {}
NpmPackageManager::Auto | NpmPackageManager::Npm => deps.push("npm"),
NpmPackageManager::Aube => deps.push("aube"),
NpmPackageManager::Bun => deps.push("bun"),
NpmPackageManager::Pnpm => deps.push("pnpm"),
NpmPackageManager::Npm => {} // npm is already in deps
}
if use_npm_view && !deps.contains(&"npm") {
deps.push("npm");
}
Ok(deps)
}
Expand All @@ -307,66 +317,46 @@ impl Backend for NPMBackend {
}

async fn _list_remote_versions(&self, config: &Arc<Config>) -> eyre::Result<Vec<VersionInfo>> {
// Use npm CLI so user/global .npmrc and NPM_CONFIG_* registry settings apply.
// --prefix points at a neutral cache dir so project package.json (e.g. devEngines)
// cannot fail the query. Install already uses --prefix the same way.
self.ensure_npm_for_version_check(config).await;
if Settings::get().npm.use_npm_view {
return self.list_remote_versions_npm_view(config).await;
}
// Query the registry directly over HTTP so node/npm are not required
// for version metadata. User .npmrc and NPM_CONFIG_* registry/auth
// settings still apply via NPM_REGISTRY_CONFIG.
timeout::run_with_timeout_async(
async || {
let env = self.dependency_env(config).await?;
let prefix = Self::npm_meta_prefix()?;

let raw = cmd!(
NPM_PROGRAM,
"view",
self.tool_name(),
"versions",
"time",
"--json",
"--prefix",
&prefix
)
.full_env(&env)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()?;
let data: Value = serde_json::from_str(&raw)?;
let version_info = npm_view_versions_time(&data)?;

Ok(version_info)
let packument = self.fetch_packument().await?;
Ok(packument
.versions_with_time()
.into_iter()
.map(|(version, created_at)| VersionInfo {
version: version.to_string(),
created_at: created_at.map(|s| s.to_string()),
prerelease: is_semver_prerelease(version),
..Default::default()
})
.collect())
},
Settings::get().fetch_remote_versions_timeout(),
)
.await
}

async fn latest_stable_version(&self, config: &Arc<Config>) -> eyre::Result<Option<String>> {
// TODO: Add bun support for getting latest version without npm
// See TODO in _list_remote_versions for details
self.ensure_npm_for_version_check(config).await;
if Settings::get().npm.use_npm_view {
self.ensure_npm_for_version_check(config).await;
}

let cache = self.latest_version_cache.lock().await;
let this = self;
timeout::run_with_timeout_async(
async || {
cache
.get_or_try_init_async(async || {
// Always use npm for getting version info since bun info requires package.json
// bun is only used for actual package installation
let prefix = Self::npm_meta_prefix()?;
let raw = cmd!(
NPM_PROGRAM,
"view",
this.tool_name(),
"dist-tags",
"--json",
"--prefix",
&prefix
)
.full_env(this.dependency_env(config).await?)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()?;
let dist_tags: Value = serde_json::from_str(&raw)?;
npm_view_latest_dist_tag(&dist_tags)
if Settings::get().npm.use_npm_view {
return this.latest_dist_tag_npm_view(config).await;
}
Ok(this.fetch_packument().await?.latest_dist_tag())
})
.await
},
Expand Down Expand Up @@ -565,9 +555,78 @@ impl NPMBackend {
.build(),
),
ba: Arc::new(ba),
packument: tokio::sync::OnceCell::new(),
}
}

async fn fetch_packument(&self) -> eyre::Result<&Arc<npm_registry::Packument>> {
self.packument
.get_or_try_init(|| async {
let packument = npm_registry::NPM_REGISTRY_CONFIG
.fetch_packument(&self.tool_name())
.await?;
Ok(Arc::new(packument))
})
.await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Packument never refreshes

Medium Severity

The npm backend caches the packument in a OnceCell for the lifetime of the backend instance. When the remote version cache expires or is refreshed, _list_remote_versions runs again but still reuses that first packument instead of hitting the registry, so version lists and dist-tag resolution can stay stale until a new process starts.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a1b8b90. Configure here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 05c1ed5. Removed the process-lifetime packument OnceCell. Both _list_remote_versions and latest_stable_version are already wrapped in their own TTL disk caches by the backend layer, so the memo was a redundant third layer that could shadow those TTLs with stale data — each now fetches directly and the disk caches handle dedup across invocations.

This reply was generated by an AI coding assistant.


/// Legacy `npm view` version listing, kept behind `npm.use_npm_view` for
/// setups relying on npm-only config (cafile, client certs, token helpers).
/// --prefix points at a neutral cache dir so project package.json (e.g.
/// devEngines) cannot fail the query. Install already uses --prefix the
/// same way.
async fn list_remote_versions_npm_view(
&self,
config: &Arc<Config>,
) -> eyre::Result<Vec<VersionInfo>> {
self.ensure_npm_for_version_check(config).await;
timeout::run_with_timeout_async(
async || {
let env = self.dependency_env(config).await?;
let prefix = Self::npm_meta_prefix()?;

let raw = cmd!(
NPM_PROGRAM,
"view",
self.tool_name(),
"versions",
"time",
"--json",
"--prefix",
&prefix
)
.full_env(&env)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()?;
let data: Value = serde_json::from_str(&raw)?;
let version_info = npm_view_versions_time(&data)?;

Ok(version_info)
},
Settings::get().fetch_remote_versions_timeout(),
)
.await
}

/// Legacy `npm view` dist-tags lookup, see [`Self::list_remote_versions_npm_view`].
async fn latest_dist_tag_npm_view(&self, config: &Arc<Config>) -> eyre::Result<Option<String>> {
let prefix = Self::npm_meta_prefix()?;
let raw = cmd!(
NPM_PROGRAM,
"view",
self.tool_name(),
"dist-tags",
"--json",
"--prefix",
&prefix
)
.full_env(self.dependency_env(config).await?)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()?;
let dist_tags: Value = serde_json::from_str(&raw)?;
npm_view_latest_dist_tag(&dist_tags)
}

async fn build_transitive_release_age_args(
&self,
config: &Arc<Config>,
Expand Down
Loading