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:]])
2 Answers 2
Documentation
It is great that you added a docstring at the top of the code. The PEP 8 style guide also recommends adding docstrings for functions.
You could convert this comment:
# 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):
into a docstring:
def list_get[T](lst: list[T], index: int, default=None):
""" Create a .get for lists like the one for dicts """
There's a typo in the top docstring: "globla"
a globla shim in /usr/bin/{node,npm}, etc. in addition to itself.
Naming
A couple function names are hard to read. For example, rmrf
could be rm_rf
,
and lnsfT
could be ln_sfT
.
Command-line
Consider using argparse for the command-line options.
DRY
You use this pattern in several different functions:
system = platform.system()
if "Windows" in system or "_NT" in system:
#
elif "Darwin" in system:
# etc.
Consider creating a new function to simply return the system type, perhaps as an Enum
.
Comments
Many of the comments in the code are helpful.
You should remove the TODO
comments and store them in another file
in your version control system.
Tools
You could run code development tools to automatically find some style issues with your code.
ruff
advises against using a bare except
:
E722 Do not use bare `except`
|
| with open(get_nodeup_settings_file(), "w", encoding="utf-8") as f:
| json.dump(config, f)
| except:
| ^^^^^^ E722
| die("Failed to set symlink usage!")
|
First of all... thanks for sharing! This is a great piece of software I'd be glad to take maintenance over from you as a previous dev. Really. I will follow with quite a few notes, but they do not change the general stance: this WorksTM and is in maintainable state.
Now to the problems.
Type hints
Since you're relying on type hints, please do so consistently. Return types aren't magically inferred in python, it's not typescript (and even there explicit annotations are usually preferred). Using correct annotations would have helped down the road.
E.g. in forward
:
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
and in nodeup_install
:
# 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
It's not the type system, it's missing type hints. mypy
does not infer return types (and does not have to).
If die
was typed correctly (below), both asserts would be unnecessary. Here's the right die
signature:
from typing import Never # `NoReturn` on python<3.11
def die(message: str | None = None) -> Never:
...
# your impl
Never
means "does not return", and sys.exit
is exactly that kind of function.
There are other annotation problems that you will find after adding return types. lnsfT
expects str
but is passed Path
, for example. Run mypy
on your code to do the actual type checking.
Python version compat
You're writing a cross-platform, general use tool. It would be nice to support all non-EOL pythons (3.9 and newer as of now). You only use a couple of features from newer versions - PEP695 type parameter in list_get
, introduced in 3.12 (has 1:1 older equivalent using typing.TypeVar
) and match
statement introduced in 3.10.
I can agree with match
since it's only one version away from being globally available, but not with PEP695 type param - it's too new to use in a widely distributed tool.
However, if you decide to depend on this 3.12 feature, use 3.11+ contextlib.chdir
as well - it does the same thing as your tmpchdir
context manager.
Dependencies
Great job avoiding any third-party deps. Since you're writing a script, it's nice to not depend on any libs installed in the environment. This makes me think that pydantic
is an overkill for this case, given that you only need a very simple validation.
Exception handling
You're too generous with exceptions. Bare except
and except Exception
are not equivalent; the former should (almost) never be used as it swalows everything including KeyboardInterrupt
, the latter is a catch-all for all "regular" exceptions. Still, you only really expect a handful of them, better only catch what you need and let everything else raise a helpful error with a traceback. For example,
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
This is really interested in FileNotFoundError
. If there's no file, use default. But your implementation also swallows any other problem: something weird stored in the config file (e.g. [1]
) or a missing key (which also means you can't trust this config - the contract is violated, someone messed up with your file!). This function will helpfully return None
in any of those cases, leaving no trace to debug the problem down the road.
Validation
I see the following TODO in two places:
TODO: find the Python equivalent of TypeScript zod
There is an equivalent library, pydantic. But see Dependencies section above.
I'd rather avoid pulling in a dependency for this and slightly restructure your code. Python is great at mixing functional and OO styles, and your config operations really look like they belong to a class. Like this one (untested stub; get_nodeup_settings_file
should probably also be its classmethod):
from dataclasses import dataclass, field
def _should_use_symlinks() -> bool:
system = platform.system()
return not ("Windows" in system or "_NT" in system)
@dataclass(kw_only=True)
class Config:
active_node_version: str | None = None
use_symlinks: bool = field(default_factory=_should_use_symlinks)
def save(self) -> None:
with open(get_nodeup_settings_file(), "w", encoding="utf-8") as f:
json.dump(
{
"active_node_version": self.active_node_version,
"use_symlinks": self.use_symlinks,
},
f,
)
@classmethod
def load(cls) -> "Config":
file = get_nodeup_settings_file()
with open(file, "r", encoding="utf-8") as f:
config = json.load(f)
if not isinstance(config, dict):
die(f"Malformed config file at {file}, expected an object at top level.")
ver = config['active_node_version']
if ver is not None and not isinstance(ver, str):
die(
f"Malformed config file at {file},"
" expected a string or null at .active_node_version"
)
use_symlinks = config['use_symlinks']
if not isinstance(use_symlinks, bool):
die(f"Malformed config file at {file}, expected a boolean at .use_symlinks")
return cls(active_node_version=ver, use_symlinks=use_symlinks)
@classmethod
def load_or_default(cls) -> "Config":
try:
return cls.load()
except FileNotFoundError:
return cls()
Even this minor refactoring (+ adding validation) can greatly reduce the code size of some functions:
def initialize_nodeup_files():
try:
Path.mkdir(get_nodeup_home_dir(), parents=True, exist_ok=True)
Config().save()
Path.mkdir(get_nodeup_node_versions_dir(), parents=True, exist_ok=True)
except Exception:
die("Failed to initialize nodeup files!")
def set_active_node_version(version: str):
config = config.load_or_default()
config.active_node_version = version
config.save()
if config.should_use_symlinks:
try:
lnsfT(
get_nodeup_node_versions_dir() / version,
get_nodeup_active_node_version_dir(),
)
except Exception:
die("Failed to set active Node.js version!")
def get_active_node_version():
if should_use_symlinks(): # See below regarding this branching
try:
return get_nodeup_active_node_version_dir().readlink().name
except FileNotFoundError:
return None
return Config().load_or_default().active_node_version
Dead code
set_symlink_usage
is unused. Maybe something else is too.
Code organization
In addition to extracting a Config
class I already mentioned, there's one more architectural problem. A lot of your functions branch on if should_use_symlinks()
and do completely different things in two branches. That means you have two sources of truth: active_node_version
in config matters on some platforms and is ignored on others. This might be fine, but I'd rather stick with one source of truth (config file - it's always available) and enforce/validate that another one is in sync.
If you do that, all get_
and set_
helpers will become completely unnecessary - just load a Config
and read its attribute.
CLI
Python has a great built-in CLI arguments parser - argparse
. You don't need to package it. It can pretty-print the help for you along with subcommands and flags descriptions. It will also be easier to maintain later when you decide to add some flags (-v/--version
? -h/--help
? --user/--system
?)
Other minor notes
if not dir_to_check.exists() or not dir_to_check.is_dir():
is justif not dir_to_check.is_dir():
- if it doesn't exist, it's also not a directorysha256sum
can indeed be trivially reimplemented in python in <10 lines, see e.g. here - that's easier than spawning a subprocess to some binary that may not be found on the system. If you decide to spawn, please at least check that it exists beforehand and die with a helpful error message suggesting to install it.f.write(json.dumps(...))
should be just dumping to the file directly withjson.dump(..., f)
- Use booleans directly.
if condition: flag = False; else: flag = True
is a huge anti-pattern. Please prefer justflag = not condition
instead. - You have a great docstring and some CLI help, thanks! If you ever plan to package this, please write a short manpage - that'd be really helpful for your users (well,
man 1 sometool
is the first instinct,sometool --help
is the second, and fortunately the latter will still print help despite it being accompanied with unrecognized command notice)
-
\$\begingroup\$ Typescript tries a lot harder to infer things, it seems. Python does a poor job inferring things Typescript would infer just fine. In typescript, I only use explicit types to enforce they are a certain type at a certain point, but for trracking general purpose "don't let me do stupid things", the automatic inferrence is a lot better. Even for a library, the types are usually generated from the .ts files into .d.ts, rather than manually typed. \$\endgroup\$404 Name Not Found– 404 Name Not Found2025年04月11日 07:13:42 +00:00Commented Apr 11 at 7:13
-
\$\begingroup\$ For the
active_node_version
andshould_use_symlinks()
split, that's because symlinks are unreliable on Windows, but when they can be used, offers a superior experience, more comparable to the real install of Node.js in terms ofnpm install -g
handling. \$\endgroup\$404 Name Not Found– 404 Name Not Found2025年04月11日 07:15:38 +00:00Commented Apr 11 at 7:15 -
\$\begingroup\$ And for the JSON config, I do intentionally swallow errors - if the file is broken for any reason, I think it would be better to reset to sane defaults rather than attempt to salvage it. But yes, it would probably be helpful to log the problem. \$\endgroup\$404 Name Not Found– 404 Name Not Found2025年04月11日 07:21:00 +00:00Commented Apr 11 at 7:21
-
\$\begingroup\$ You may want to prefer Pyright instead of
mypy
then - it infers return types (won't infer Never, but should handle everything else here) which is incidentally one of two main reasons I only use mypy myself. // Splitting two implementations is fine, it's not the problem - the problem is thatactive_node_version
config value AND the symlink, if any, are both sources of truth and may not be in sync. It'd be cleaner to only rely on config (since it's always available) and only check that symlink also points to the correct location. @404NameNotFound \$\endgroup\$STerliakov– STerliakov2025年04月11日 14:41:28 +00:00Commented Apr 11 at 14:41
Explore related questions
See similar questions with these tags.
contextlib.chdir
built-in to replace yourtmpchdir
, and maybe a few more ver-specific comments depend on that. \$\endgroup\$