Modest Tools

Recreating NVM in Bash

· Alex P.

Like most other Node developers, I’ve been using NVM for years. It’s a great tool that allows you to easily switch between different versions of Node. But have you ever wondered how it works under the hood? Or how to go without it if you’re feeling adventurous?

In this article, I’ll show you how to create a modest version of NVM in Bash. We’ll go through the core functionality of NVM and replicate it in a few lines of Bash. You’ll learn how to install and switch between different versions of Node without any external tools. And you’ll get a better understanding of how your system works.

Why I decided to do this

One day I decided to run nvm install to get the current LTS version of Node. My machine at the time was an ARM-based MacBook Pro, for which a precompiled binary was already available. But to my surprise, NVM started downloading the source code and building it from scratch. “That’s weird”, I thought “And more than a little inefficient”.

Confused, I decided to investigate the source code of NVM. I soon found out that this seemingly simple CLI tool was a lot more complex than I expected. I wasn’t going to find my answer without going really deep into it.

“Screw it”, I decided “I’ll just do it myself”. I opened https://nodejs.org/, downloaded the precompiled binary, unzipped it, put it into a folder inside my home directory and updated my PATH variable to include the new binary. “Wow, that was easy”, I thought “I wonder what else you can do with a few lines of Bash”.

What NVM really does

In a nutshell, all NVM actually does is:

  1. It maintains a hidden folder $HOME/.nvm where it stores all the versions of Node that you’ve installed.

  2. When you need a specific version you can use nvm install to download it. NVM will then extract the binary to a folder inside $HOME/.nvm.

  3. After that you can switch between the installed versions with nvm use. This command updates the PATH variable to include the bin folder of the specified version.

  4. You can also nvm uninstall a version to remove it from your system (and make more space on for cat memes or whatever).

That’s about it. There are a few more commands and options like nvm ls to list all installed versions, but the core functionality is described above. I rarely use anything else in my day-to-day work.

Do it yourself in Bash

As the core functionality of NVM is quite simple, we can replicate it in a few lines of Bash. We’ll create a folder in our home directory where we’ll store all the Node versions. And we’ll write a few functions to install, use, and uninstall the versions.

Step 0: Create a folder for your Node versions

First, you need to create a folder for all your Node versions. Let’s call it .node_versions and put it inside your home directory:

mkdir -p ~/.node_versions

The -p flag will prevent an error if the folder already exists. Let’s cd into our new folder:

cd ~/.node_versions

Step 1: Download and unzip the Node binary

First, you need to know the URL of the Node binary you want to install. You can find it on the official Node website. For example, the URL for the latest version of Node for macOS (Apple Silicon) is https://nodejs.org/dist/v22.5.1/node-v22.5.1-darwin-x64.tar.gz

There is a nice-looking download page at https://nodejs.org/en/download/prebuilt-binaries. Or you can just open the root URL https://nodejs.org/dist/ and see all the available versions in a more raw form.

When we have the URL, we can download the binary with curl and untar it with tar. For example, to download the latest version of Node for macOS (Apple Silicon) you can run:

curl -o node.tar.gz https://nodejs.org/dist/v22.5.1/node-v22.5.1-darwin-x64.tar.gz
tar -xzf node.tar.gz

After running these commands you should see two new files in your ~/.node_versions folder:

drwxr-xr-x  9 user  group       288 Jul 19 05:32 node-v22.5.1-darwin-x64
-rw-r--r--  1 user  group  47832563 Jul 29 12:44 node.tar.gz

node.tar.gz is the archive we downloaded, it can now be safely removed:

rm node.tar.gz

The other folder is the extracted Node binary. You can rename it to something more readable:

mv node-v22.5.1-darwin-x64 22.5.1

If you are following along, I suggest you download a couple more versions to test switching between them later.

Step 2: Making the version available in your shell

Now that we have the Node binary extracted, we need to make it available in our shell. We can do this by updating the PATH variable.

export PATH=$HOME/.node_versions/22.5.1/bin:$PATH

By exporting the PATH variable we are telling the shell to look for executables in the bin folder of the specified Node version first. This way we can have multiple versions of Node installed and switch between them easily.

If you ls the bin folder you will see all the Node executables (like node, npm, corepack, etc.):

ls $HOME/.node_versions/22.5.1/bin

You can test if the installation was successful by running:

which node
node --version

The first command should output the path to the node executable (the one we just created) and the second command should output the version of Node we just installed.

Now technically you have a working Node version manager. You can install multiple versions of Node and switch between them by updating the PATH variable. But we can make it a bit more user-friendly.

Step 3: Default version

If you exit the shell right now and open a new one, the PATH variable will be reset to its default value. The Node version we just installed will be unavailable again. To make it persist between shell sessions we need to update our shell configuration file.

For example, if you are using bash you can add the following line to your .zshrc or .bashrc file:

# ~/.bashrc or ~/.zshrc
export PATH=$HOME/.node_versions/22.5.1/bin:$PATH

Yes, exactly the same line we used before. That is what I love about using shell scripts. You can first do something manually to see if it works, and then just copy-paste it into a script to automate it.

To make this even more robust we can create an alias for the default version. This way we can easily switch between versions without having to update the PATH variable manually.

ln -s ~/.node_versions/22.5.1 ~/.node_versions/default

This command creates a symbolic link that points to the version we just installed. Now we can update the PATH variable to include the default version (replace 22.5.1 with default):

export PATH=$HOME/.node_versions/default/bin:$PATH

Step 4: Switching between versions

We can now easily implement nvm use and nvm alias default functionality. First, let’s do nvm use. It should be as simple as updating the PATH variable for the current shell session (it won’t persist after you close the shell).

Add the following function to your .zshrc or .bashrc file to make it available in every shell session from now on:

nvm_use() {
  export PATH=$HOME/.node_versions/$1/bin:$PATH
}

If you’re not familiar with shell scripting, the $1 variable is a placeholder for the first argument passed to the function. So if you run nvm_use 22.5.1 the function will update the PATH variable to include the 22.5.1 version.

The nvm alias default functionality is also simple. We just need a function that updates the default symbolic link to point to a different version. Here is how you can implement it:

nvm_default() {
  ln -sf ~/.node_versions/$1 ~/.node_versions/default
}

Now you can run nvm_default 22.5.1 to set the default version to 22.5.1. And you can run nvm_use default to switch to the default version.

Step 5: Listing installed versions

At this point, we’re almost done. We can install, switch, and set the default version. The only thing missing is a way to list all installed versions. We can do this by listing the contents of the ~/.node_versions folder:

nvm_ls() {
  ls -1 ~/.node_versions
}

The -1 flag tells ls to list one file per line. Almost like -l but without the extra information.

Step 6: Removing versions

Finally let’s create an inverse function to nvm install. It should remove the specified version from the ~/.node_versions folder. Here is the simplest way to do it:

nvm_remove() {
  rm -rf ~/.node_versions/$1
}

Of course, there are a bunch of edge cases we are not handling here. For example, what if you try to remove the current default version? Or what if you try to remove a version that is not installed? But this is a good starting point.

Conclusion

I tried to make it as simple as possible. In a real-world scenario you would want to handle possible errors, missing arguments, and make it more user-friendly in general. And probably move all these functions to a separate file so you don’t have to clutter your .zshrc or .bashrc file.

But I hope you can see how easy it can be to create your own Node version manager. It doesn’t have all the features of nvm but it’s extremely simple and because of that, it’s easy to understand and modify. Which is the thing I love most about such projects.

I actually use a similar script to manage my Node versions. It’s a bit more complex and has a few more features but the core idea is the same. You can find it here.