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.
2 Answers 2
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.
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.
nvm
with this... \$\endgroup\$set -euo pipefail
- please read mywiki.wooledge.org/BashFAQ/105 to learn why most experienced shell programmers would not use that construct. \$\endgroup\$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\$