9
\$\begingroup\$

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:]])

\$\endgroup\$
1
  • \$\begingroup\$ What (minimal) python version are you targeting? 3.11+ has contextlib.chdir built-in to replace your tmpchdir, and maybe a few more ver-specific comments depend on that. \$\endgroup\$
    – STerliakov
    Commented Apr 10 at 17:00

2 Answers 2

7
\$\begingroup\$

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!")
|
\$\endgroup\$
5
\$\begingroup\$

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 Works™ 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 just if not dir_to_check.is_dir(): - if it doesn't exist, it's also not a directory
  • sha256sum 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 with json.dump(..., f)
  • Use booleans directly. if condition: flag = False; else: flag = True is a huge anti-pattern. Please prefer just flag = 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)
\$\endgroup\$
4
  • \$\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\$ Commented Apr 11 at 7:13
  • \$\begingroup\$ For the active_node_version and should_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 of npm install -g handling. \$\endgroup\$ Commented 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\$ Commented 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 that active_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
    Commented Apr 11 at 14:41

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.