Skip to content

feat: add env use local Node version management (nvm) functionality, implementing devEngines.runtime.version #9707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

danielbayley
Copy link

@danielbayley danielbayley commented Jul 1, 2025

This PR implements #3434.

It also takes into account package.*devEngines.runtime.version, possibly closing #8153.

Closes #9364, and probably #7211 plus #7645.

See also related discussion around this here: nvm-sh/nvm#651.

npm implementation here: npm/cli#7766.

@danielbayley danielbayley requested a review from zkochan July 4, 2025 18:53
@danielbayley danielbayley mentioned this pull request Jul 5, 2025
1 task
@danielbayley
Copy link
Author

danielbayley commented Jul 5, 2025

I’m thinking if devEngines.runtime.version is unset, or has been manually set to a range, resolve that with getNodeVersion(), and set it to a specific version (i.e. it replaces useNodeVersion), but if it is already set to a specific version, that is the source of truth, and it doesn’t get overwritten, only read from (again, in place of useNodeVersion). I’m not suggesting anything randomly alter that version, other than the user either manually changing devEngines.runtime.version, or setting it via pnpm env use <version> (which would also resolve to a specific version before writing).

ok

Ok, implemented, and working nicely for me locally… Please test @zkochan.

Also @jwanner83 @GeoffreyBooth @moisout, any input from you guys would be appreciated…

@danielbayley danielbayley changed the title feat: add env use local Node version management functionality feat: add env use local Node version management (nvm) functionality, implementing devEngines.runtime.version Jul 6, 2025
Copy link

@GeoffreyBooth GeoffreyBooth left a comment

Choose a reason for hiding this comment

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

I think this calls env use to automatically switch to the defined Node version, regardless of onFail? I think that corresponds to onFail: 'download', but that's not the default behavior. By default I think it would just error that the user is running the wrong Node version (same as onFail: 'error').

pnpm/src/main.ts Outdated
Comment on lines 296 to 297
if (!semver.valid(runtimeNodeVersion)) {
await updateProjectManifest(prefix, {
devEngines: {
runtime: {
name: 'node',
version: nodeVersion
}
}
}).then(() => {
const message = `"devEngines.runtime.version": "${runtimeNodeVersion}" was resolved to "${nodeVersion}"`
logger.info({ message, prefix })
})
).extraBinPaths
Copy link
Member

Choose a reason for hiding this comment

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

I thought the idea was to keep the range in devEngines and save the exact version in useNodeVersion, no?

Copy link
Author

Choose a reason for hiding this comment

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

I thought the idea was to keep the range in devEngines and save the exact version in useNodeVersion, no?

Ah, I think we got wires crossed slightly @zkochan on this…

I’m thinking if devEngines.runtime.version is unset, or has been manually set to a range, resolve that with getNodeVersion(), and set it to a specific version (i.e. it replaces useNodeVersion), but if it is already set to a specific version, that is the source of truth, and it doesn’t get overwritten, only read from (again, in place of useNodeVersion). I’m not suggesting anything randomly alter that version, other than the user either manually changing devEngines.runtime.version, or setting it via pnpm env use <version> (which would also resolve to a specific version before writing).

ok

I was thinking ideally we could try and deprecate useNodeVersion, as there is always too much config! I thought if we could pin down devEngines.runtime.version as an exact version, this would simplify things, but maybe I am overlooking other reasons why that should be a SemVer range? For now I have taken the useNodeVersion approach. Seems to be working pretty well, but do test…

Copy link
Member

Choose a reason for hiding this comment

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

I am fine with devEngines.runtime.version getting pinned down to an exact version. But according to the spec it can be a range, no?

Copy link
Author

@danielbayley danielbayley Jul 10, 2025

Choose a reason for hiding this comment

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

I am fine with devEngines.runtime.version getting pinned down to an exact version. But according to the spec it can be a range, no?

Yeah. So in the previous iteration, I had it resolving the range provided by devEngines.runtime.node to an exact version, and then overwriting that, rather than leaving useNodeVersion in the mix to complicate matters…

I think this is a key decision, which affects how I structure the rest of the code around it in this PR, so would be good to get @GeoffreyBooth’s thoughts on this behaviour in particular, since he wrote the spec.

Another idea might be to restrict it to a caret ^ range?

cc: @ljharb @wesleytodd

Copy link
Member

Choose a reason for hiding this comment

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

Another idea might be to restrict it to a caret ^ range?

That is still not an exact version. It doesn't make a difference.

Choose a reason for hiding this comment

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

I think this is a key decision, which affects how I structure the rest of the code around it in this PR, so would be good to get @GeoffreyBooth’s thoughts on this behaviour in particular, since he wrote the spec.

The point of devEngines is to ensure that developers of a particular project are running the intended Node version and package manager as appropriate to develop that project. Generally major versions are sufficient for this purpose, though if people want to lock down exact versions, sure, why not. The onFail field tells the thing doing the checking (here pnpm, or also npm in its implementation) what should happen if the running version is outside of the specified range; so if the specified Node version is >= 24 and the user is running 22, the default behavior is to just error and exit, but onFail: download could do Corepack/env use-like behavior to change the currently-running Node version to the newest version that fits within the specified range. There's also onFail: warn which just prints that the versions mismatch but ignores the issue, and onFail: ignore which doesn't do anything. (That turns devEngines essentially into documentation, like a comment.)

Rather than going straight to onFail: download, it might be simpler to start with onFail: error | warn | ignore, which is what npm has implemented, and land that first and then tackle download as a follow-up.

So in the previous iteration, I had it resolving the range provided by devEngines.runtime.node to an exact version, and then overwriting that

I don't know why pnpm would ever overwrite anything within devEngines. The point of the field is for developers to keep their team in sync running the correct versions of the software needed to develop the project. If devEngines wants to lock the team into Node 22, then pnpm shouldn't be updating it to specify Node 24. If devEngines wants to tell the team they should be using npm, then pnpm shouldn't be updating it to specify that the team should use pnpm. That would defeat the purpose of the field.

Choose a reason for hiding this comment

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

I don't know why pnpm would ever overwrite anything within devEngines.

I think users expect the package manager to make changes to package.json, so I don't see that as a clear problem. I agree in most cases it feels likely folks would want to manually edit, but I also like the idea of tools starting to write the field (either on init or as a recommendation when running) since that will help raise awareness that the feature exists and how to use it.

I admit I did not take the time to fully read the implementation, but is it currently updating the range in an experience that may not be directly asked for by the user?

Choose a reason for hiding this comment

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

I haven't read the code of this PR; I was just responding to what was written above. But if this PR edits anything in the version field, that's problematic. The point of that field is to define the range of versions of Node or a package manager that are acceptable to use in developing the current project. Such a range should only be updated by a human, because there's no way for a tool like pnpm to know what an acceptable range would be. Maybe this project needs to be limited to Node 22 because that's the newest Node version that AWS lambda supports; or Node 20 because that's the newest version that some instrumentation library supports; who knows. The point being, it's not safe to update automatically, because there's no way for the tool to know the motivation behind the current value.

Copy link
Author

@danielbayley danielbayley Jul 11, 2025

Choose a reason for hiding this comment

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

Generally major versions are sufficient for this purpose, though if people want to lock down exact versions, sure, why not.

But if this PR edits anything in the version field, that's problematic.

I don't know why pnpm would ever overwrite anything within devEngines. The point of the field is for developers to keep their team in sync running the correct versions of the software needed to develop the project. If devEngines wants to lock the team into Node 22, then pnpm shouldn't be updating it to specify Node 24.

@GeoffreyBooth To be clear, ‘overwrite’ here meant resolving the existing devEngines.runtime.version range to an exact version, then overwriting it with that specific version, not straying outside of the boundaries set by that initial range. So as per your example, Node 22 would never become 24. More like ^22 would become e.g. 22.17.0.

If devEngines wants to tell the team they should be using npm, then pnpm shouldn't be updating it to specify that the team should use pnpm. That would defeat the purpose of the field.

This wouldn’t happen either, as onFail (or lack thereof) would throw, as in #9742.

Again, only devEngines.runtime.version would ever be overwritten under the above potential scenario…

The other being we leave it as a range always, and keep useNodeVersion around to nail down the specific version—or preferably an entry in the lockfile as @zkochan mentioned. That’s the key decision here, for which I am seeking consensus…

is it currently updating the range in an experience that may not be directly asked for by the user?

@wesleytodd Only resolving that range to a specific version within said range, or in the specific scenario where the user is delibrately running env use and onFail === 'download'.

Does all of that make sense?

@ljharb
Copy link

ljharb commented Jul 9, 2025

It would be ideal, for a library's CI, to also have a way to opt in to looking at "the union of engines and devEngines", not just "devEngines", since the devEngines range is likely to be a subset of the engines range.

Copy link

@wesleytodd wesleytodd left a comment

Choose a reason for hiding this comment

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

Awesome to see this moving forward. Are there plans to also address the other fields of devEngines? I wouldn't think this needs to block anything, just wondering if packageManager and os (the main two after runtime that I think matters most) are on your plans.

@danielbayley
Copy link
Author

Awesome to see this moving forward.

Feel free to donate :)

Are there plans to also address the other fields of devEngines? … just wondering if packageManager and…

I have already implemented packageManager checks @wesleytodd. I think os and friends can be addressed in a separate follow-up PR…

… union of engines and devEngines…

Interesting @ljharb, but sounds like a candidate for a separate issue/follow-up PR…

… regardless of onFail? I think that corresponds to onFail: 'download', but that's not the default behavior. By default I think it would just error that the user is running the wrong Node version (same as onFail: 'error').

Ok, added a switch statement to handle it @GeoffreyBooth, if you want to test that the cases line up with your ideas in the spec

@ljharb
Copy link

ljharb commented Jul 9, 2025

@danielbayley without that possibility, it basically forces engines and devEngines to be identical, which largely (but not entirely) erases the point of the feature.

@danielbayley
Copy link
Author

danielbayley commented Jul 9, 2025

@danielbayley without that possibility, it basically forces engines and devEngines to be identical, which largely (but not entirely) erases the point of the feature.

I agree it sounds useful. Regardless of whether it should be a follow-up PR, how do you envisage those changes to the algorithm exactly? I think I can use something like semver.simplifyRange() to make a union of engines.node and devEngines.runtime.version… But I mean logically, with say, your use case of CI for a library @ljharb.

@ljharb
Copy link

ljharb commented Jul 9, 2025

Ideally an environment variable (and perhaps also a command line argument) that sets a config flag for this behavior, that way CI can set it once in a workflow and forget about it.

Comment on lines 24 to 46
if (opts.useNodeVersion && semver.satisfies(opts.useNodeVersion, nodeVersion)) {
nodeVersion = opts.useNodeVersion
} else {
const { version, onFail } = getNodeRuntime(opts.rootProjectManifest.devEngines) ?? {}
if (version) {
const message = `"Node.js version "${nodeVersion}" is incompatible with "devEngines.runtime.version: ${version}"`
switch (onFail) {
case 'download':
overwriteVersion = version
break
case 'ignore':
return ''
case 'warn':
logger.warn({ message, prefix })
break
case 'error':
default: throw new PnpmError('INVALID_NODE_VERSION', message)
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand what is happening here. Why should the range passed via the CLI argument be validated? That's what the user asks to be used. If there are some existing settings, just update them.

Copy link
Author

Choose a reason for hiding this comment

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

I don't understand what is happening here. Why should the range passed via the CLI argument be validated? That's what the user asks to be used. If there are some existing settings, just update them.

We’ve been having severe network problems in my area, with atrocious internet speeds, like being back in the 90’s with dialup! So I was made keenly aware of how expensive unnecessarily calling getNodeVersion() more than once is (downloadNodeVersion()— which we don’t necessarily want to run, if onFail specifies otherwise—calls it again, internally). So this was an attempt to avoid repeat calls to that, resolving things against the given devEngines.runtime.version range using semver methods, if possible.

Specifically, since main.ts has already resolved the range from devEngines.runtime.node to an exact version, and “passed” it to useEnv via useNodeVersion, the if here can potentially compare the 2 without either having to call getNodeVersion() or getNodeRuntime() again, under certain circumstances. For example, if the given CLI arg is 20 and the devEngines.runtime.node range is something like ^20, semver.satisfies this, and we can skip the rest and continue… Does that make sense @zkochan?

Maybe we could memoize getNodeVersion() or something… What do you think?

Copy link
Author

Choose a reason for hiding this comment

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

@zkochan I altered getNodeVersion() to avoid unnecessary API calls if already given a valid SemVer version. Seems to speed things up quite a bit!


if ('webcontainer' in process.versions) {
globalWarn('Automatic installation of different Node.js versions is not supported in WebContainer')
} else if (!config.useNodeVersion && devEngines) {
Copy link
Member

Choose a reason for hiding this comment

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

ok, and what if useNodeVersion is set? You just removed that whole logic that existed before for useNodeVersion. It is a breaking change.

Copy link
Member

Choose a reason for hiding this comment

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

also, useNodeVersion should have bigger priority than devEngines. As we discussed, useNodeVersion should store the exact version.

Copy link
Author

@danielbayley danielbayley Jul 11, 2025

Choose a reason for hiding this comment

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

ok, and what if useNodeVersion is set? You just removed that whole logic that existed before for useNodeVersion. It is a breaking change.

Ah was just missing the extra else! I wasn’t testing with useNodeVersion, so accidentally bypassed it. Good catch @zkochan, fixed.

also, useNodeVersion should have bigger priority than devEngines.

Yeah the missing else also fixes that, so useNodeVersion should behave as before.

const {version} = getNodeRuntime(devEngines) ?? {}
if (version) {
const {nodeVersion} = await getNodeVersion(config, version)
config.useNodeVersion = nodeVersion ?? undefined
Copy link
Member

Choose a reason for hiding this comment

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

This config change should be saved in the pnpm-workspace.yaml file.

Copy link
Author

@danielbayley danielbayley Jul 10, 2025

Choose a reason for hiding this comment

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

Then wouldn’t that forcibly create pnpm-workspace.yaml file on every run of pnpm, regardless of whether there is any other configuration desired, or which command is being run?

Copy link
Member

Choose a reason for hiding this comment

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

yes, it will create pnpm-workspace.yaml to lock the node version. The alternative solution is to save it in the lockfile as I mentioned earlier.

Copy link
Member

Choose a reason for hiding this comment

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

you don't like this solution. That's OK. I will add it to the lockfile in a different PR and then we can finish this pr.

Copy link
Author

@danielbayley danielbayley Jul 11, 2025

Choose a reason for hiding this comment

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

yes, it will create pnpm-workspace.yaml to lock the node version.

you don't like this solution. That's OK.

I just think it’s bad DX to force things like this on the user with no alternative.

The alternative solution is to save it in the lockfile as I mentioned earlier.

I think that would be better, regardless of whether we decide to deprecate useNodeVersion in favour of devEngines.runtime.version as an exact version.

I will add it to the lockfile in a different PR and then we can finish this pr.

Ok, cool.

@wesleytodd
Copy link

I have already implemented packageManager checks @wesleytodd. I think os and friends can be addressed in a separate follow-up PR…

Sounds good. This question was for you and @zkochan. If you land this, would the goal be to land those other PRs so that they go out in a single release that "adds devEngines support" or would it go out in individual releases?

@GeoffreyBooth
Copy link

I have already implemented packageManager checks @wesleytodd. I think os and friends can be addressed in a separate follow-up PR…

Sounds good. This question was for you and @zkochan. If you land this, would the goal be to land those other PRs so that they go out in a single release that "adds devEngines support" or would it go out in individual releases?

I think ideally the initial release with devEngines support should include at least as much support as npm has, to avoid user frustration. In particular, it should at least check devEngines.packageManager, so that pnpm errors if this field says that a particular project should be run with npm or yarn. I don't think it needs to support onFail: download for devEngines.packageManager, just as npm doesn't, but supporting onFail: error | warn | ignore feels like a light lift and would help a lot in the goal of ensuring teams are staying consistent.

@danielbayley
Copy link
Author

danielbayley commented Jul 11, 2025

Are there plans to also address the other fields of devEngines? I wouldn't think this needs to block anything, just wondering if packageManager and os (the main two after runtime that I think matters most) are on your plans.

os and friends can be addressed in a separate follow-up PR…

would the goal be to land those other PRs so that they go out in a single release that "adds devEngines support" or would it go out in individual releases?

ideally the initial release with devEngines support should include at least as much support as npm has, to avoid user frustration. In particular, it should at least check devEngines.packageManager, so that pnpm errors if this field says that a particular project should be run with npm or yarn. I don't think it needs to support onFail: download for devEngines.packageManager, just as npm doesn't, but supporting onFail: error | warn | ignore feels like a light lift and would help a lot in the goal of ensuring teams are staying consistent.

@wesleytodd @GeoffreyBooth See #9742 for these additional checks…

@zkochan
Copy link
Member

zkochan commented Jul 18, 2025

I have created a new PR that adds support for "runtime" dependencies: #9755. This way the node.js version range will be resolved to an exact version and locked in the lockfile as a regular dependency. An integrity checksum will also be saved for the resolved version.

I probably won't enable it for the existing settings yet (like useNodeVersion). So, it will work only for devEngines.

@zkochan
Copy link
Member

zkochan commented Jul 18, 2025

I am also interested in something like "prodEngines". Like say I want to publish a CLI tool and I want to tell pnpm what runtime to install for that CLI tool.

@wesleytodd
Copy link

This way the node.js version range will be resolved to an exact version and locked in the lockfile as a regular dependency.

This is great. They are dependencies so this make sense.

I am also interested in something like "prodEngines". Like say I want to publish a CLI tool and I want to tell pnpm what runtime to install for that CLI tool.

How would that be different than engines? CLI tools already do this when installed as devDeps, and they can absolutely use it when running to complain to the user (some do this, but it is not super common). What would prodEngines add that we don't already get from engines?

@ljharb
Copy link

ljharb commented Jul 18, 2025

You can already do that by setting eg engines.node to 9999 and defining whatever other engines you want.

@danielbayley
Copy link
Author

danielbayley commented Jul 19, 2025

I am also interested in something like "prodEngines". Like say I want to publish a CLI tool and I want to tell pnpm what runtime to install for that CLI tool.

Isn’t that what regular engines is for?

You can already do that by setting eg engines.node to 9999 and defining whatever other engines you want.

Feels like an ugly CSS hack 😅

@zkochan
Copy link
Member

zkochan commented Jul 20, 2025

Coming back to this PR.

I have added support for runtime dependencies via #9755. As I mentioned earlier, I did this by introducing a new version protocol: runtime:. As a result, it is going to be possible to install node.js as a dependency via the pnpm add command. For instance:

pnpm add -D node@runtime:24

This will be saved to devEngines then. I did a new PR for that part: #9774

So, I am not sure whether we should make pnpm env use <version spec> work or we should just recommend using pnpm add. Also, I want to add support for deno and bun installation. If we do want a dedicated command, then maybe it would be better to have a pnpm runtime add <name>@<version> command instead of pnpm env use.

@zkochan zkochan added the area: runtime engine Installation of Node.js, Deno, Bun label Jul 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: runtime engine Installation of Node.js, Deno, Bun
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow pnpm env use without the global flag
5 participants