Skip to content

feat: support nvm alongside fnm for Node version management#1113

Open
raven-pierce wants to merge 21 commits into
lerd-env:mainfrom
raven-pierce:feat/node-manager-nvm
Open

feat: support nvm alongside fnm for Node version management#1113
raven-pierce wants to merge 21 commits into
lerd-env:mainfrom
raven-pierce:feat/node-manager-nvm

Conversation

@raven-pierce

Copy link
Copy Markdown
Contributor

lerd used to talk to fnm everywhere Node was involved. This adds a Manager abstraction with fnm and nvm backends, driven by node.manager in config (defaulting to fnm so existing installs stay the same).

At install, if nvm is already on the machine, lerd asks whether to keep using it or download fnm, persists that as node.manager, and skips the fnm download when nvm wins.

CLI, shims, host workers, MCP, TUI, and the UI all go through the active manager. lerd node:manager fnm|nvm and an fnm/nvm control on the System Node page switch later; when lerd is managing Node, a switch rewrites the shims and re-syncs host workers.

With nvm, uninstall and unmanage only drop lerd's shims, never the user's installed versions. The nvm path also refuses to exec node by bare name after a failed activate, so it cannot re-enter lerd's PATH shims.

Closes #1112

Record which Node version manager lerd drives so installs and the dashboard can choose between the bundled fnm and a user-installed nvm. Empty keeps the historical fnm default for configs that predate the field.
Abstract install, list, uninstall, default, and shell exec behind a Manager so callers stop hardcoding fnm. Ship fnm as the bundled backend and nvm as a sourced-bash backend that never installs itself and never execs node by bare name, which would re-enter lerd's PATH shims.
Route install, use, uninstall, exec, shims, host workers, idle Vite builds, MCP, and the TUI picker through the active Manager so every surface follows node.manager instead of calling fnm directly.
When lerd install finds nvm, ask whether to drive that or download fnm, persist the choice as node.manager, and skip the fnm download when nvm wins. Shims and the default Node install now go through the active manager either way.
Add lerd node:manager and POST /api/node/set-manager, expose node_manager on status, and put an fnm/nvm control on the System Node page. Switching persists the choice and, when lerd manages Node, rewrites shims and re-syncs host workers. node:unmanage only removes versions when fnm is active so a user's nvm installs stay untouched.
Document node.manager, the install-time nvm choice, node:manager, and the dashboard switch, and raise the lerd-reference size ceiling for the added guidance.
@raven-pierce
raven-pierce requested a review from a team as a code owner July 23, 2026 21:18
lerd PATH shims ahead of nvm made nvm ls/use re-enter the wrappers and
hang. With node.manager=nvm, skip writing those shims and remove any
stale ones; managed mode follows node.managed so the CLI and dashboard
still work without a node shim on disk.

@geodro geodro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Manager abstraction is the right shape and I would keep it exactly as is, the problems are all inside the nvm backend. I ran it on Ubuntu 26.04 and Fedora 44 against real Laravel sites with nvm 0.40.6 from the official installer, and there are three things that stop it working.

First, lerd npm and lerd npx fail on every invocation under nvm. runNode and runNpmCaptured put npm_config_prefix into the child environment, and nvm hard refuses to activate when that variable is set, so every call ends with "nvm is not compatible with the npm_config_prefix environment variable" and then the guard aborting. nvm only checks this at nvm use time, so exporting the prefix after activation instead of putting it in cmd.Env works fine and lerd keeps its managed global dir, npm root -g still resolves into node-global. That probably wants a small addition to the Manager interface so the caller can hand over env exports to apply after activation, with fnm just appending them to cmd.Env like it does today.

Second, the activation guard in nvmActivate is wrong twice over, and it is the same defect behind the red CI job. nvm use already returns a non zero code when the version is not installed, rc 3, and that gets thrown away by the redirect, then the script tests NVM_BIN, which sourcing nvm.sh has already populated with the default version, so it can never be empty. The visible effect is that the version pin silently does not hold. A site pinned to Node 24, on a machine where nvm only had 18, 20 and 22, ran v20.20.2 with no error at all, where fnm would have failed loudly. Honouring the exit status fixes it, and nvm which makes a clean cross check if you want one, it returns non zero with a proper "not yet installed" message.

Third, HasDefault treats the string system as a usable default. nvm version default returns literally "system" when the default alias points at the system node, so HasDefault says yes while activation produces nothing, which is exactly what the Linux runner is hitting. It is a real user state too, plenty of people run nvm alias default system. It can also loop, with the default set to system I got nvm which default resolving to lerd's own node shim, so nvm hands back a path pointing straight back into lerd.

Then the smaller ones.

fnm gets downloaded even when the user picks nvm. downloadBinaries runs several steps before the question is asked, so on a fresh install the zip has already been fetched and extracted by the time you are offered the choice, I watched it pull the full 3.3MB and then ask. The decision only needs the config, the system node probe, the nvm probe and the bun path, none of which depend on the download, so hoisting it and persisting the manager before the download step should be enough, since downloadBinaries already reads config.

The nvm question is also gated on the system node prompt, so it only appears when detectSystemNode finds something. Somebody who has nvm installed but has not installed any Node versions into it yet never gets asked and silently ends up on fnm. Better to ask whenever managed Node is wanted, nvm is present, it is not an update, and nothing is saved yet.

The version list picks up nvm's system row. nvm ls with no alias still prints a system line with the resolved version on it, and the regex takes the version out of that line, so the system node's major shows up as an nvm installed version in the dashboard, the picker and MCP, and removing it would try an nvm uninstall that cannot work.

NVM_DIR is worth persisting rather than reading from the environment on every call. It is exported only by the shell rc, and systemd's user environment has none of it, so lerd-ui and the watcher always fall back to ~/.nvm no matter where the user actually put nvm. Resolving it once at install or switch time and storing it alongside node.manager would keep the daemon and the CLI agreeing.

nvmManager.ShimScript looks like dead code now, WritesPathShims returns false for nvm so nothing reaches it outside its own test. And runNodeSetManager formats a nil error with %w when the config loads as nil without an error, which prints the %!w placeholder.

One UI nit, the segmented control offers nvm even when nvm is not installed, and clicking it just produces the nvm not found error, so it probably wants to be disabled with the reason showing.

On tests, requireNvm needs to skip when activation yields nothing rather than when HasDefault is false, otherwise CI stays red on any runner whose default is system.

The branch also conflicts with main on the configuration docs now.

Everything else held up well. The manager switch removes and restores the PATH shims correctly on both distros, the deeply nested quoting survives systemd, the Vite host worker started under nvm with real Vite output on 5173, node install and node use both behave, the dashboard reports the active manager correctly, and the sites kept serving throughout.

Keep the fnm/nvm node.manager docs and take main's new
share.default_tool section.
Honour nvm use exit status instead of trusting NVM_BIN, treat a system default as unusable, and skip the system row when parsing nvm ls. ApplyEnv lets callers set npm_config_prefix after activation so nvm no longer aborts on every lerd npm/npx run.
Persist the fnm/nvm choice and node.nvm_dir before downloadBinaries so an nvm pick skips the fnm zip, and offer nvm whenever managed Node is wanted rather than only when a system node prompt runs. node:manager records nvm_dir the same way and no longer wraps a nil config error.
Expose nvm_available on status and grey out the nvm segment with a reason instead of letting a click fail with not found.
Document the persisted nvm path for daemons, the earlier manager prompt, and the dashboard disable state.
The suite asserts .bashrc and Linux certutil gating; on Darwin those paths differ, so force detect_os=linux for those cases.

@geodro geodro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ran the candidate on the Ubuntu and Fedora VMs against real Laravel sites with nvm 0.40.6, and the npm side is properly fixed now, lerd npm and lerd npx both work under nvm and npm root -g still lands in lerd's managed global dir. Three things stop me signing it off though.

The activation guard still lets the version pin fall through silently. Dropping the NVM_BIN check and keeping only the exit status is not enough, because when the default alias points at system and a system node exists, nvm use takes its system branch, runs nvm deactivate, unsets NVM_BIN and returns zero. I set nvm alias default system with the distro node installed on both boxes and lerd ran /usr/bin/node with no error, exactly the silent fall through this commit set out to kill. Worse, PATH then becomes an empty leading entry followed by the rest, so the current directory is on PATH for every child, and anything named node in a site root gets picked up first. The two checks catch different failures, so both need to be there, something like nvm use ... and a non empty NVM_BIN test, otherwise abort.

An unattended lerd install silently migrates an existing fnm user to nvm. Starting from a config with no manager key, which is every install predating this PR, a non interactive lerd install prints nvm detected, use it instead of fnm, defaulting to yes with no terminal, then persists manager nvm and deletes the node, npm and npx shims. lerd update is safe because it passes from-update, but a plain install rerun is not, and that is what install.sh and the release battery run. Even with a terminal the default is yes, so a bare Enter migrates too. That is a default flip on an established setup, which we normally do not do without demand, so I think the question should default to no.

Picking nvm is also a trap you cannot get out of. Once the manager is nvm, fnm is never downloaded, so node:manager fnm fails with fnm not found, and lerd install skips the fnm fetch precisely because the manager is nvm, so it stays failing. The dashboard fnm switch hits the same wall. Having node:manager fnm fetch fnm on demand when it is missing would keep the nvm install lean and still leave a way back. Combined with the previous point it means an unattended upgrade can strand someone on nvm with no route home.

Everything else held up. The persisted nvm_dir means the daemon reports the right manager and version list even though systemd has no NVM_DIR, the fnm download is correctly skipped under nvm, a bad pin now fails loudly, the shims come and go with the manager, and the sites kept serving through all the switching on both distros.

nvm use can return zero on the system alias while deactivating and
clearing NVM_BIN, which left lerd on the host node and put an empty
entry at the front of PATH. Honour both the exit status and a non-empty
NVM_BIN before activating.
Configs without a manager key, which is every install predating this
change, were silently migrated to nvm on an unattended lerd install
because the prompt defaulted to yes. Keep fnm unless the user explicitly
opts in.
An nvm-only install skips the fnm download, so node:manager fnm and the
dashboard switch failed with fnm not found and had no way to recover.
Extract ensureFnmBinary and call it when switching to fnm if the binary
is missing.
Document that the install-time nvm offer keeps fnm by default, and that
switching back to fnm downloads it when an earlier nvm-only install
skipped it.
@raven-pierce
raven-pierce requested a review from geodro July 24, 2026 21:05
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.

feat: support nvm alongside fnm for Node version management

3 participants