installer: provision Node instead of refusing to run without it
ci/woodpecker/pr/ci Pipeline is pending approval
ci/woodpecker/pr/ci Pipeline is pending approval
The installer's promise is that one command turns a bare host into a working one, but Node was carved out of that: it was checked as a prerequisite and the run died on a greenfield host. That made the documented one-command install a two-command install whose first command always failed. It now installs a user-local Node under ~/.mosaic/node when the system Node is missing or too old, from the official nodejs.org tarballs, verified against SHASUMS256.txt. User-local rather than apt/dnf/brew: no root, one code path on every distro, and it works on an immutable host. A system Node that is already new enough is preferred and left untouched. --no-node-install (or MOSAIC_NO_NODE_INSTALL=1) keeps the old refuse-and-explain behaviour, and neither --check nor --uninstall provisions anything. PATH now lands in the login profile as well as the interactive rc. Writing only ~/.bashrc looked right interactively and was invisible to every way an agent seat actually starts -- bash -lc, ssh host cmd, a systemd unit -- because Debian's .bashrc returns early when non-interactive. Verified end to end on mosaic-sbx-dev rolled back to its greenfield snapshot: red on origin/next (rc=1, "Required command not found: node"), green with this change (Node v22.23.2 fetched and verified, CLI 0.0.50-next.2413 installed), and a fresh `bash -lc` finds both. tools/install-node-provisioning.test.sh pins the behaviour offline against a file:// dist fixture, including the refusals and the checksum gate. The next-lane test's Node 20 case moves to --no-node-install: the >= 22 gate must still fire before anything is installed, but refusing is no longer the outcome when provisioning is allowed. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a6fb024b4
commit
cb2bf4e4a4
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for the installer's Node provisioning.
|
||||
#
|
||||
# The installer's whole promise is that one command turns a bare host into a working
|
||||
# one. Node was the exception: it was a hard prerequisite the installer checked and
|
||||
# refused, so on a greenfield host the documented one-command install failed first.
|
||||
# These tests pin the fixed behaviour, including the refusals.
|
||||
#
|
||||
# Everything runs offline. MOSAIC_NODE_DIST points at a local directory laid out like
|
||||
# nodejs.org/dist, served over file:// -- so the download, the checksum gate, and the
|
||||
# unpack are the real code paths, with no network and no real Node download.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-provision-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
DIST="$TMP/dist"
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$TMP/prefix"
|
||||
MOSAIC_HOME_DIR="$TMP/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
NODE_HOME="$TMP/nodehome"
|
||||
mkdir -p "$DIST" "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
|
||||
REAL_NODE="$(command -v node)"
|
||||
|
||||
# The platform triple, derived the same way the installer derives it.
|
||||
case "$(uname -s)" in
|
||||
Linux) TEST_OS=linux ;;
|
||||
Darwin) TEST_OS=darwin ;;
|
||||
*) echo "[skip] no Node build for $(uname -s)"; exit 0 ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) TEST_ARCH=x64 ;;
|
||||
aarch64|arm64) TEST_ARCH=arm64 ;;
|
||||
armv7l) TEST_ARCH=armv7l ;;
|
||||
*) echo "[skip] no Node build for $(uname -m)"; exit 0 ;;
|
||||
esac
|
||||
PLATFORM="${TEST_OS}-${TEST_ARCH}"
|
||||
|
||||
VERSION=v22.99.0
|
||||
OLD_VERSION=v20.99.0
|
||||
|
||||
# ─── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# A node stub that answers the installer's version probe and defers everything else
|
||||
# to the real interpreter, so the rest of the install still runs.
|
||||
#
|
||||
# The major is baked in per stub rather than read from the environment. A shared env
|
||||
# var would be read by the downloaded Node too, so the "system Node is too old" case
|
||||
# would install a replacement that also claimed to be too old.
|
||||
write_node_stub() {
|
||||
local path="$1" major="${2:-22}"
|
||||
cat > "$path" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "\$*" == *'process.versions.node.split'* ]]; then
|
||||
printf '%s' "${major}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\${1:-}" == "--version" ]]; then
|
||||
printf 'v%s.99.0\n' "${major}"
|
||||
exit 0
|
||||
fi
|
||||
exec "\${MOSAIC_TEST_REAL_NODE:?}" "\$@"
|
||||
STUB
|
||||
chmod +x "$path"
|
||||
}
|
||||
|
||||
write_npm_stub() {
|
||||
cat > "$1" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "$*" >> "${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
if [[ "${1:-}" == "view" ]]; then
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.50-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "0.0.7-next.999" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.49" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/mosaic@"*) echo "0.0.50-next.999" > "$STATE/mosaic" ;;
|
||||
*"@mosaicstack/gateway@"*) echo "0.0.7-next.999" > "$STATE/gateway" ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "ls" ]]; then
|
||||
printf '{"dependencies":{"@mosaicstack/mosaic":{"version":"%s"},"@mosaicstack/gateway":{"version":"%s"}}}\n' \
|
||||
"$(cat "$STATE/mosaic" 2>/dev/null || echo '')" \
|
||||
"$(cat "$STATE/gateway" 2>/dev/null || echo '')"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$1"
|
||||
}
|
||||
|
||||
# Build a nodejs.org-shaped release: the tarball, and a SHASUMS256.txt over it.
|
||||
publish_release() {
|
||||
local version="$1" corrupt_checksum="${2:-false}"
|
||||
local base="node-${version}-${PLATFORM}"
|
||||
local stage="$TMP/stage-${version}"
|
||||
rm -rf "$stage"
|
||||
mkdir -p "$stage/${base}/bin"
|
||||
write_node_stub "$stage/${base}/bin/node" "$(sed 's/^v//; s/\..*//' <<<"$version")"
|
||||
write_npm_stub "$stage/${base}/bin/npm"
|
||||
|
||||
mkdir -p "${DIST}/${version}"
|
||||
tar -czf "${DIST}/${version}/${base}.tar.gz" -C "$stage" "$base"
|
||||
|
||||
local sum
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
sum="$(sha256sum "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
|
||||
else
|
||||
sum="$(shasum -a 256 "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
|
||||
fi
|
||||
if [[ "$corrupt_checksum" == "true" ]]; then
|
||||
sum="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
fi
|
||||
printf '%s %s.tar.gz\n' "$sum" "$base" > "${DIST}/${version}/SHASUMS256.txt"
|
||||
}
|
||||
|
||||
publish_release "$VERSION"
|
||||
publish_release "$OLD_VERSION"
|
||||
# Newest-first, as nodejs.org publishes it.
|
||||
printf '[{"version":"%s"},{"version":"%s"}]\n' "$VERSION" "$OLD_VERSION" > "$DIST/index.json"
|
||||
|
||||
# A PATH with the usual tools but no Node toolchain, so "a host with no Node" is
|
||||
# actually true on a developer machine and in CI, both of which have one installed.
|
||||
NONODE_BIN="$TMP/nonode-bin"
|
||||
mkdir -p "$NONODE_BIN"
|
||||
for candidate in /usr/bin/* /bin/*; do
|
||||
[[ -e "$candidate" ]] || continue
|
||||
case "$(basename "$candidate")" in
|
||||
node|npm|npx|corepack|nodejs) continue ;;
|
||||
esac
|
||||
ln -sf "$candidate" "$NONODE_BIN/$(basename "$candidate")" 2>/dev/null || true
|
||||
done
|
||||
if PATH="$NONODE_BIN" command -v node &>/dev/null; then
|
||||
echo "[skip] could not build a Node-free PATH on this host" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
reset_home() {
|
||||
rm -rf "$HOME_DIR" "$PREFIX" "$MOSAIC_HOME_DIR" "$NODE_HOME" "$LOG" "$STATE"
|
||||
mkdir -p "$HOME_DIR" "$STATE"
|
||||
: > "$LOG"
|
||||
}
|
||||
|
||||
# Run the installer with no Node anywhere on PATH.
|
||||
run_bare() {
|
||||
env -u npm_config_prefix \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_NODE_HOME="$NODE_HOME" \
|
||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$NONODE_BIN" \
|
||||
bash "$ROOT/tools/install.sh" "$@"
|
||||
}
|
||||
|
||||
# ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
reset_home
|
||||
echo "[test] a host with no Node gets one, and the CLI install proceeds"
|
||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
||||
grep -qF -- "Node is not installed" <<<"$OUTPUT"
|
||||
grep -qF -- "Installed Node ${VERSION}" <<<"$OUTPUT"
|
||||
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
||||
grep -qF -- "install -g @mosaicstack/[email protected]" "$LOG"
|
||||
|
||||
echo "[test] the newest matching major is chosen, not merely the first published"
|
||||
[[ ! -d "${NODE_HOME}/${OLD_VERSION}" ]]
|
||||
|
||||
echo "[test] future shells can find both Node and the CLI"
|
||||
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.profile"
|
||||
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.profile"
|
||||
# Debian's .bashrc returns early when non-interactive, so the login profile is the
|
||||
# one that matters -- but an interactive non-login shell only reads .bashrc.
|
||||
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
|
||||
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
|
||||
|
||||
echo "[test] re-running reuses the Node it installed and does not duplicate PATH lines"
|
||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
||||
grep -qF -- "from ${NODE_HOME}" <<<"$OUTPUT"
|
||||
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.profile")" -eq 2 ]]
|
||||
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.bashrc")" -eq 2 ]]
|
||||
|
||||
reset_home
|
||||
echo "[test] --no-node-install refuses instead of installing"
|
||||
set +e
|
||||
OUTPUT="$(run_bare --cli --next --yes --no-node-install 2>&1)"
|
||||
RC=$?
|
||||
set -e
|
||||
[[ "$RC" -ne 0 ]]
|
||||
grep -qF -- "--no-node-install was given" <<<"$OUTPUT"
|
||||
[[ ! -d "$NODE_HOME" ]]
|
||||
|
||||
reset_home
|
||||
echo "[test] --check never provisions Node"
|
||||
set +e
|
||||
OUTPUT="$(run_bare --check --cli --next 2>&1)"
|
||||
RC=$?
|
||||
set -e
|
||||
[[ "$RC" -ne 0 ]]
|
||||
grep -qF -- "Required command not found: node" <<<"$OUTPUT"
|
||||
[[ ! -d "$NODE_HOME" ]]
|
||||
|
||||
reset_home
|
||||
echo "[test] a tampered download is rejected and nothing is installed"
|
||||
publish_release "$VERSION" true
|
||||
set +e
|
||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
||||
RC=$?
|
||||
set -e
|
||||
[[ "$RC" -ne 0 ]]
|
||||
grep -qF -- "failed checksum verification" <<<"$OUTPUT"
|
||||
[[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
||||
publish_release "$VERSION"
|
||||
|
||||
reset_home
|
||||
echo "[test] a system Node that is new enough is used as-is and left alone"
|
||||
write_node_stub "$FAKE_BIN/node" 22
|
||||
write_npm_stub "$FAKE_BIN/npm"
|
||||
OUTPUT="$(
|
||||
env -u npm_config_prefix \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_NODE_HOME="$NODE_HOME" \
|
||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$NONODE_BIN" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
grep -qF -- "satisfies the >= 22 requirement" <<<"$OUTPUT"
|
||||
[[ ! -d "$NODE_HOME" ]]
|
||||
|
||||
reset_home
|
||||
echo "[test] a system Node that is too old is replaced rather than accepted"
|
||||
write_node_stub "$FAKE_BIN/node" 18
|
||||
OUTPUT="$(
|
||||
env -u npm_config_prefix \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_NODE_HOME="$NODE_HOME" \
|
||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$NONODE_BIN" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
grep -qF -- "older than the required >= 22" <<<"$OUTPUT"
|
||||
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
||||
|
||||
echo "[test] installer node provisioning tests passed"
|
||||
Reference in New Issue
Block a user