As a followup to my previous version, I decided to try make this more cross-platform. I considered several languages: C++ is pretty cross platform, Rust is gaining popularity (and the language used by rustup, the inspiration source of this project), but eventually I settled with Python since it's easier to use while still reasonably cross-platform. Unfortunately, I'm generally more comfortable with shellscript than Python as a scripting language, so there's certainly things to improve here.
I still tried to keep it simple by trying to make a direct translation of the shellscript version, but eventually it got bloated and is now double the length of the old shellscript version, which is somewhat disappointing to me. Perhaps due to the the extra comments and formatting, and perhaps it could be improved by separating this into modules, but given the shellscript version didn't need it, I think of it as yet another indication that this was a rewrite I didn't need. The only benefit I can think of is that I imagine a Python interpreter is more common to install on Windows than a Unix shellscript interpreter, and the config system is less janky with Python than with bash.
This was tested with Python 3.12.9 on Windows with MSYS2 and Python 3.13.2 on Linux. Unfortunately, I have no MacOS machine to test with, so I'm flying blind over there.
#!/usr/bin/env python
"""
Simple Node.js version manager. Similar to rustup, expecting to be installed as
a globla shim in /usr/bin/{node,npm}, etc. in addition to itself.
Unlike Rust's rustup, which only deals with a known set of binaries, npm install
-g is expected to provide a globally available executable, which requires
administrator access with this approach. Similarly, unlike Python's pip, which
has a way of disabling pip install to system locations, npm does not.
To get around the problem of making globally available arbitrary executables,
the active node version directory should be added to PATH. This allows npm
install -g to work as expected out of the box, automating the analogous cargo
install to to a directory in PATH. This is achieved by borrowing rustup's idea
of shipping a file in /etc/profile.d, which many systems use as additional
"overlays" to /etc/profile. On Windows, creating symlinks requires
administrator access or a recent version of Windows with Developer Mode
enabled, so there is an option to use JSON config instead for routing, though
this loses the the ability to access executables installed via npm install -g.
As a workaround, npx may be used instead, similar to how it would be done with
a non-global npm install.
Tested with Python 3.12.9 on Windows and Python 3.13.2 on Linux. Some external
binaries are required (sha256sum, rm, and python itself), but they should be
available on a stock install of any mainstream Linux distro (e.g., Ubuntu,
Fedora, etc.) and through Cygwin/derivatives (e.g., MSYS2, Git Bash, etc.) on
Windows.
"""
from contextlib import contextmanager
import json
import os
from pathlib import Path
import platform
import subprocess
import sys
import tarfile
import urllib.request
import zipfile
def die(message: str | None = None):
if message is not None:
print(message, file=sys.stderr)
sys.exit(1)
# For some reason there is no .get for lists, unlike dicts, so here we are
def list_get[T](lst: list[T], index: int, default=None):
try:
return lst[index]
except IndexError:
return default
def rmrf(*paths: str):
# There doesn't seem to be a good way to rm -rf...
# The tricky part is the -f flag, which should never be a problem unless
# actually lacking permissions
# https://stackoverflow.com/q/814167
subprocess.run(
["rm", "-rf", *paths],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def lnsfT(src: str, dst: str):
# Similar to rm -rf, -f strikes again, this time with ln -sfT
# https://stackoverflow.com/q/8299386
# --no-target-directory since we want to "make a directory", rather
# than "make in a directory"
subprocess.run(
["ln", "-sfT", src, dst],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
@contextmanager
def tmpchdir(path: str):
old_path = Path.cwd()
try:
os.chdir(path)
yield
finally:
os.chdir(old_path)
# bsdtar is quite magical but Python does not have an equivalent magical
# "extract anything with the exact same interface", so here we are
def extract(file: str):
"""
Extracts a zip, tgz, or txz file to the current working directory.
"""
# bsdtar uses heuristics nd not the file extension, but for simplicity
# we'll trust the extension
if file.endswith(".zip"):
with zipfile.ZipFile(file, "r") as zip_ref:
zip_ref.extractall()
elif file.endswith(".tar.gz"):
with tarfile.open(file, "r:gz") as tar_ref:
# The official Node.js distribution files should be fine
# Probably could be dropped to "data" but untested
tar_ref.extractall(filter="fully_trusted")
elif file.endswith(".tar.xz"):
with tarfile.open(file, "r:xz") as tar_ref:
# The official Node.js distribution files should be fine
# Probably could be dropped to "data" but untested
tar_ref.extractall(filter="fully_trusted")
else:
die(f"Unsupported file extension: {file}")
def get_nodeup_home_dir():
nodeup_home_dir = os.getenv("NODEUP_HOME_DIR")
if nodeup_home_dir is not None:
return Path(nodeup_home_dir)
# Unix-like and Cygwin/derivatives
home = os.getenv("HOME")
if home is not None:
return Path(home) / ".nodeup"
# Windows
appdata = os.getenv("APPDATA")
if appdata is not None:
return Path(appdata) / "nodeup"
die("Failed to find home directory!")
def get_nodeup_settings_file():
return get_nodeup_home_dir() / "settings.json"
def get_nodeup_node_versions_dir():
return get_nodeup_home_dir() / "versions"
def get_nodeup_active_node_version_dir():
if should_use_symlinks():
return get_nodeup_home_dir() / "active_node_version"
version = get_active_node_version()
if version is None:
return None
return get_nodeup_node_versions_dir() / version
def initialize_nodeup_files():
use_symlinks = None
system = platform.system()
if "Windows" in system or "_NT" in system:
use_symlinks = False
else:
use_symlinks = True
try:
Path.mkdir(get_nodeup_home_dir(), parents=True, exist_ok=True)
with open(get_nodeup_settings_file(), "w", encoding="utf-8") as f:
f.write(
json.dumps(
{
"active_node_version": None,
"use_symlinks": use_symlinks,
}
)
)
Path.mkdir(get_nodeup_node_versions_dir(), parents=True, exist_ok=True)
except Exception:
die("Failed to initialize nodeup files!")
def should_use_symlinks():
try:
with open(get_nodeup_settings_file(), "r", encoding="utf-8") as f:
return json.load(f)["use_symlinks"]
except Exception:
return None
def set_symlink_usage(use_symlinks: bool):
try:
with open(get_nodeup_settings_file(), "r", encoding="utf-8") as f:
config = json.load(f)
except Exception:
config = {}
config["use_symlinks"] = use_symlinks
try:
with open(get_nodeup_settings_file(), "w", encoding="utf-8") as f:
json.dump(config, f)
except:
die("Failed to set symlink usage!")
def get_active_node_version():
if should_use_symlinks():
try:
return get_nodeup_active_node_version_dir().readlink().name
except FileNotFoundError:
return None
try:
with open(get_nodeup_settings_file(), "r", encoding="utf-8") as f:
# TODO: find the Python equivalent of TypeScript zod
return json.load(f)["active_node_version"]
except Exception:
return None
def set_active_node_version(version: str):
config = None
try:
with open(get_nodeup_settings_file(), "r", encoding="utf-8") as f:
config = json.load(f)
except Exception:
config = {}
# TODO: find the Python equivalent of TypeScript zod
# This should not be possible to hit but Python's type hints are not smart
# enough to detect that
assert config is not None
config["active_node_version"] = version
try:
with open(get_nodeup_settings_file(), "w", encoding="utf-8") as f:
json.dump(config, f)
if should_use_symlinks():
lnsfT(
get_nodeup_node_versions_dir() / version,
get_nodeup_active_node_version_dir(),
)
except:
die("Failed to set active Node.js version!")
def forward(argv: list[str]):
version = get_active_node_version()
node_base_dir = (
get_nodeup_active_node_version_dir() if version is not None else None
)
if version is None or node_base_dir is None:
die("No version of node available, install one using `nodeup install`")
# This should not be possible to hit but Python's type hints are not smart
# enough to detect that
assert version is not None
assert node_base_dir is not None
node_bin_dir = None
system = platform.system()
if "Windows" in system or "_NT" in system:
node_bin_dir = "."
else:
node_bin_dir = "bin"
node_dir = node_base_dir / node_bin_dir
if not node_dir.exists() or not node_dir.is_dir():
die(
f"Node v{version} is not installed, install it using `nodeup install {version}`"
)
os.execv(node_dir / argv[0], argv)
def nodeup_install(version: str):
if (get_nodeup_node_versions_dir() / version).exists():
die(
f"Node v{version} is already installed, uninstall it first with `nodeup uninstall {version}`"
)
node_rootname: str | None = None
zipext = None
system = platform.system()
if "Windows" in system or "_NT" in system:
# Down at the bottom of its heart, Cygwin/derivatives is still Windows
node_rootname = f"node-v{version}-win-x64"
zipext = ".zip"
elif "Darwin" in system:
node_rootname = f"node-v{version}-darwin-arm64"
zipext = ".tar.gz"
elif "Linux" in system:
node_rootname = f"node-v{version}-linux-x64"
zipext = ".tar.xz"
else:
die("Unsupported platform!")
# Yet again unlike TS, Python's type hints are not strong enough to catch
# this is definitely not None by the time we get here which causes Mypy to
# freak out, so we have to assert here
assert node_rootname is not None
nodezip_filename = f"{node_rootname}{zipext}"
nodezip_url = f"https://nodejs.org/dist/v{version}/{nodezip_filename}"
shatxt_filename = "SHASUMS256.txt"
shatxt_url = f"https://nodejs.org/dist/v{version}/{shatxt_filename}"
with tmpchdir(get_nodeup_node_versions_dir()):
try:
with urllib.request.urlopen(nodezip_url) as response:
with open(nodezip_filename, "wb") as f:
f.write(response.read())
with urllib.request.urlopen(shatxt_url) as response:
with open(shatxt_filename, "wb") as f:
f.write(response.read())
except:
rmrf(nodezip_filename, shatxt_filename)
die(f"Failed to download Node.js v{version}!")
# There's probably a way to do this directly in Pythong but shelling
# out is easier for now
try:
subprocess.run(
[
"sha256sum",
"-c",
shatxt_filename,
"--status",
"--ignore-missing",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
rmrf(nodezip_filename, shatxt_filename)
die("Integrity check failed!")
extract(nodezip_filename)
rmrf(nodezip_filename, shatxt_filename)
os.rename(node_rootname, version)
set_active_node_version(version)
def nodeup_use(version: str):
dir_to_check = get_nodeup_node_versions_dir() / version
if not dir_to_check.exists() or not dir_to_check.is_dir():
die(
f"Node v{version} is not installed, install it using `nodeup install {version}`"
)
set_active_node_version(version)
def nodeup_ls():
# It says "arbitrary order" but it's probably whatever the filesystem says
# which is probably ASCII-betical
for v in get_nodeup_node_versions_dir().iterdir():
print(v.name)
def nodeup_ls_remote():
# Prepare a filter
file = None
system = platform.system()
if "Windows" in system or "_NT" in system:
file = "win-x64-zip"
elif "Darwin" in system:
file = "osx-arm64-tar"
elif "Linux" in system:
file = "linux-x64"
else:
die("Unsupported platform!")
# Python's type hints are not strong enough to catch this is
# definitely not None by the time we get here
assert file is not None
# Get the versions
# We know the root is an array,
versions = json.loads(
urllib.request.urlopen("https://nodejs.org/dist/index.json")
.read()
.decode("utf-8")
)
# an array of objects,
for version in versions:
# an object which has a field called "version" of type string,
# but include it only if it has the file we neeed,
# and also strip off the "v" prefix
print(version["version"][1:]) if file in version["files"] else None
def nodeup_uninstall(version: str):
rmrf(get_nodeup_node_versions_dir() / version)
def get_nodeup_help(argv0: str):
return f"""Usage:
Install a specific version of Node.js:
{argv0} install <version>
Switch to a specific version of Node.js:
{argv0} use <version>
List installed versions of Node.js:
{argv0} ls
List available versions of Node.js:
{argv0} ls-remote
Uninstall a specific version of Node.js:
{argv0} uninstall <version>
Show this help message:
{argv0} help
"""
def nodeup_main(argv0: str, argv: list[str]):
match cmd := list_get(argv, 0):
case "install":
# Python's type hints seems a lot weaker than TypeScript... It
# doesn't catch passing str | None into str like TS would
if (version := list_get(argv, 1)) is None:
die("Invalid usage of `nodeup_install`!")
nodeup_install(version)
case "use":
# More str | None -> str
if (version := list_get(argv, 1)) is None:
die("Invalid usage of `nodeup_use`!")
nodeup_use(version)
case "ls":
nodeup_ls()
case "ls-remote":
nodeup_ls_remote()
case "uninstall":
# Yet more str | None -> str
if (version := list_get(argv, 1)) is None:
die("Invalid usage of `nodeup_uninstall`!")
nodeup_uninstall(version)
case "help":
print(get_nodeup_help(argv0))
case _:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(get_nodeup_help(argv0), file=sys.stderr)
die()
if __name__ == "__main__":
if not get_nodeup_home_dir().exists():
initialize_nodeup_files()
argv0 = Path(sys.argv[0]).name
match argv0:
case "nodeup":
nodeup_main(argv0, sys.argv[1:])
case _:
# will exec away the script
forward([argv0, *sys.argv[1:]])
contextlib.chdir
built-in to replace yourtmpchdir
, and maybe a few more ver-specific comments depend on that. \$\endgroup\$