8
\$\begingroup\$

On Arch Linux, there are 2 packages that provide the Rust toolchain: rust itself and rustup. When users try to install a package that depends on a Rust toolchain, they are presented with those options, the default one being the rust package, which is a fixed version that (presumably) all other official Arch Linux packages use when building packages that depend on a Rust toolchain. The latter, rustup, is provided by the Rust project as the official way to install a Rust toolchain, and can be used to install any version and component of the toolchain, e.g., newer/older, stable/beta, compiler/documentation, etc.

Similar to Rust, Node.js has an official distribution, but unlike Rust, not an official version manager - those are community managed. There are various to choose from, some more cross-platform than others. But to my knowledge, none work the way the Rust's version manager work: rustup provides a global shim in /usr/bin/{rustc,cargo}, etc., similar to the standalone, fixed version, rust package, but redirects calls to them to the per-user ~/.rustup/toolchains. This way, the package can be used to provides=(rust) for the entire system, despite the binaries technically being per-user.

Given that none of the Node.js version managers work this way (again, at least to my knowledge, they all focus on per-user setups, with no global shim), I've decided to build one for myself that works the way I'd like it to. Similar to rustup, this script expects to be symlinked as /usr/bin/{node,npm}, etc., all of which redirect to this script and the per-user toolchains.

#!/usr/bin/bash # We know how strings work # shellcheck disable=SC2016 # We use `source`ing the "config" file as a poor man's easy to write/parse config # shellcheck disable=SC1090 # We deliberately use subshells to ensure variables don't leak # shellcheck disable=SC2030 # shellcheck disable=SC2031 # Enable "strict mode" set -euo pipefail NODEUP_HOME_DIR="${NODEUP_HOME_DIR:-"${HOME}/.nodeup"}" NODEUP_SETTINGS_FILE="${NODEUP_HOME_DIR}/settings" NODEUP_NODE_VERSIONS_DIR="${NODEUP_HOME_DIR}/versions" die() { if (( $# >= 1 )); then printf '%s\n' "$*" >&2 fi exit 1 } get_active_node_version() {( # Setup so shellcheck doesn't complain about undeclared variables nodeup_setting_active_node_version='' source "${NODEUP_SETTINGS_FILE}" printf '%s' "${nodeup_setting_active_node_version}" )} set_active_node_version() {( if (( $# != 1 )); then die 'Invalid usage of `set_active_node_version`!' fi source "${NODEUP_SETTINGS_FILE}" export nodeup_setting_active_node_version="$1" env | grep nodeup_setting_ | sed 's/^/export /g' > "${NODEUP_SETTINGS_FILE}" )} forward() { if (( $# < 1 )); then die 'Missing forward target!' fi local active_node_version='' active_node_version="$(get_active_node_version)" if [[ -z "${active_node_version}" ]]; then die 'No version of node available, install one using `nodeup install`' fi local node_dir="${NODEUP_NODE_VERSIONS_DIR}/${active_node_version}" if [[ ! -d "${node_dir}" ]]; then die "Node v${active_node_version} is not installed, install it using \`nodeup install ${active_node_version}\`" fi local target="$1" shift "${node_dir}/${target}" "$@" } nodeup_install() { if (( $# != 1 )); then die 'Invalid usage of `nodeup_install`!' fi local version="$1" local node_rootname="node-v${version}-win-x64" local nodezip_filename="${node_rootname}.zip" local nodezip__download_url="https://nodejs.org/dist/v${version}/${nodezip_filename}" local shatxt_filename='SHASUMS256.txt' local shatxt_download_url="https://nodejs.org/dist/v${version}/${shatxt_filename}" # Pretty sure this never expands to empty since we set it up top and never # mess with it afterwards, but shellcheck thinks otherwise rm -rf "${NODEUP_NODE_VERSIONS_DIR:?}/${version}" pushd "${NODEUP_NODE_VERSIONS_DIR}" >/dev/null curl -LOs "${nodezip__download_url}" curl -LOs "${shatxt_download_url}" if ! sha256sum -c "${shatxt_filename}" --status --ignore-missing; then rm "${nodezip_filename}" "${shatxt_filename}" die 'Integrity check failed!' fi # Nicer interface than unzip, also available by default bsdtar -xf "${nodezip_filename}" rm "${nodezip_filename}" "${shatxt_filename}" mv "${node_rootname}" "${version}" set_active_node_version "${version}" popd >/dev/null } nodeup_use() { if (( $# != 1 )); then die 'Invalid usafe of `nodeup_use`!' fi local version="$1" if [[ ! -d "${NODEUP_NODE_VERSIONS_DIR}/${version}" ]]; then die "Node v${version} is not installed, install it using \`nodeup install ${version}\`" fi set_active_node_version "${version}" } nodeup_ls() { find "${NODEUP_NODE_VERSIONS_DIR}" -mindepth 1 -maxdepth 1 -exec basename {} \; } nodeup_ls_remote() { curl -Ls 'https://nodejs.org/dist/index.json' | jq -r '.[].version' } nodeup_uninstall() { if (( $# != 1 )); then die 'Invalid usage of `nodeup_uninstall`!' fi local version="$1" # Pretty sure this never expands to empty since we set it up top and never # mess with it afterwards, but shellcheck thinks otherwise rm -rf "${NODEUP_NODE_VERSIONS_DIR:?}/${version}" } nodeup_main() { case "$1" in 'install') shift; nodeup_install "$@" ;; 'use') shift; nodeup_use "$@" ;; 'ls') shift; nodeup_ls "$@" ;; 'ls-remote') shift; nodeup_ls_remote "$@" ;; 'uninstall') shift; nodeup_uninstall "$@" ;; *) die "Unknown command: $1" ;; esac } main() { mkdir -p "${NODEUP_HOME_DIR}" || die "Failed to create \`${NODEUP_HOME_DIR}\`!" touch "${NODEUP_SETTINGS_FILE}" || die "Failed to create \`${NODEUP_SETTINGS_FILE}\`!" mkdir -p "${NODEUP_NODE_VERSIONS_DIR}" || die "Failed to create \`${NODEUP_NODE_VERSIONS_DIR}\`!" local argv0 argv0="$(basename "$0")" case "${argv0}" in nodeup) nodeup_main "$@" ;; *) forward "${argv0}" "$@" ;; esac } main "$@" 

I wrote and tested this on a Windows machine with MSYS2 (hence a couple of Windows-specific things inside a Unix shellscript: Windows download links, and Windows Node.js package layout assumptions - the layout is slightly different for Unix-based systems) with the idea that I can rewrite this in a more easily cross-platform, maintainable, and/or performant language/manner if needed/desired, so I am also looking for feedback on the architecture. For this script specifically, shellcheck seems happy with it, and my own testing and usage of it was fine, albeit minimal in function and debugability. Additionally, I wouldn't mind hearing about a Node.js version manager that does provide a global shim, should it turn out that I've simply missed its existence.

\$\endgroup\$
3
  • \$\begingroup\$I think I'm going to replace the dumb nvm with this...\$\endgroup\$CommentedApr 4 at 17:14
  • \$\begingroup\$Regarding set -euo pipefail - please read mywiki.wooledge.org/BashFAQ/105 to learn why most experienced shell programmers would not use that construct.\$\endgroup\$
    – Ed Morton
    CommentedApr 11 at 0:29
  • 1
    \$\begingroup\$@EdMorton Thanks for the resource. I think the examples demonstrating the flaws of set -e are dubious, or at least do not apply to me - I don't often find myself writing such constructs, plus shellcheck helps me on at least one of those flaws mentioned, so I imagine it might be helping on more. Good to know its quirks, though.\$\endgroup\$CommentedApr 11 at 1:15

2 Answers 2

6
\$\begingroup\$

It is great that you have already run shellcheck and addressed common issues. The code layout is excellent, with consistent indentation and well-named functions and variables.

Here are some minor suggestions.

Documentation

It is great that you have header comments that itemize your shellcheck settings. However, you should should summarize the purpose of the code before jumping into those details. Perhaps include some of the text from the question as background information first:

#!/usr/bin/bash # Simple Node.js version manager 

Also add details about the expected environment:

# This script expects to be symlinked as /usr/bin/{node,npm}, etc. 

Usage

It is common practice to have a function that prints out the script usage with some example commands to show what options are available.

\$\endgroup\$
    2
    \$\begingroup\$

    Nice Bash script!

    More informative error messages

    In this failure mode the user might not understand what went wrong and how to fix it:

    if (( $# != 1 )); then die 'Invalid usage of `nodeup_install`!' fi 

    This is more informative:

    if (( $# != 1 )); then die "Use nodeup_install with exactly one argument; got: $*" fi 

    Similarly in other places too.

    Subshells instead of pushd-popd

    With pushd my concern is always the risk of forgetting to later popd. So in nodeup_install I would use a subshell (...):

    ( cd "${NODEUP_NODE_VERSIONS_DIR}" curl -LOs "${nodezip__download_url}" curl -LOs "${shatxt_download_url}" if ! sha256sum -c "${shatxt_filename}" --status --ignore-missing; then rm "${nodezip_filename}" "${shatxt_filename}" die 'Integrity check failed!' fi # Nicer interface than unzip, also available by default bsdtar -xf "${nodezip_filename}" rm "${nodezip_filename}" "${shatxt_filename}" mv "${node_rootname}" "${version}" set_active_node_version "${version}" ) 

    More portable shebang

    #!/usr/bin/bash requires Bash at the path /usr/bin/bash. If it's important to use that specific path, then this is fine. If you want to allow using bash from PATH, wherever it might be installed, then you can use this instead:

    #!/usr/bin/env bash 

    Avoid backticks in strings

    Backticks can execute commands. So when used in strings, they need extra care to quote or escape properly.

    I've seen people overlook this, so I would not use them for decorative purposes in strings. They are just high maintenance.

    Unnecessary /g in sed

    In sed 's/^/export /g' the /g is unnecessary, because ^ matches a single location to replace per line.

    Not a big deal, I just like every bit to have a purpose.

    Combining grep and sed

    Just an FYI, not a recommendation.

    sed can easily do basic grep tasks. Instead of:

    env | grep nodeup_setting_ | sed 's/^/export /' > "${NODEUP_SETTINGS_FILE}" 

    This has the same effect, with one less process in the pipeline:

    env | sed -ne '/nodeup_setting_/s/^/export /p' > "${NODEUP_SETTINGS_FILE}" 

    That being said, I prefer your original for its simplicity.

    \$\endgroup\$

      Start asking to get answers

      Find the answer to your question by asking.

      Ask question

      Explore related questions

      See similar questions with these tags.