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.

toolic
15k5 gold badges29 silver badges210 bronze badges
asked Apr 4 at 8:54
\$\endgroup\$
3
  • \$\begingroup\$ I think I'm going to replace the dumb nvm with this... \$\endgroup\$ Commented Apr 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\$ Commented Apr 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\$ Commented Apr 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.

answered Apr 4 at 10:48
\$\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.

answered Apr 18 at 5:37
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.