diff --git a/package.json b/package.json index 602bb7ee..fe6df619 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "pnpm preflight && turbo run typecheck", "test:checkout": "node --test scripts/*.test.mjs", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", - "test:installer": "bash tools/install-next-lane.test.sh", + "test:installer": "bash tools/install-next-lane.test.sh && bash tools/install-node-provisioning.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "prepare": "node scripts/install-hooks.mjs" diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh index 1d017106..dff68544 100755 --- a/tools/install-next-lane.test.sh +++ b/tools/install-next-lane.test.sh @@ -153,17 +153,21 @@ reset_state() { } reset_state +# The installer now provisions Node itself, so Node 20 no longer stops a --next +# install -- it gets replaced. What still has to hold is that the >= 22 gate fires +# before anything is installed, so this asserts it on the one lane where refusing is +# still the outcome. The replacement path is covered by install-node-provisioning.test.sh. echo "[test] --next rejects Node 20 before any install action" if OUTPUT="$( HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \ MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \ MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \ - bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch --no-node-install 2>&1 )"; then echo "expected Node 20 next-lane install to fail" >&2 exit 1 fi -grep -qF 'Node.js >= 22 required for the --next lane' <<<"$OUTPUT" +grep -qF 'Node >= 22 required and --no-node-install was given.' <<<"$OUTPUT" [[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; } reset_state diff --git a/tools/install-node-provisioning.test.sh b/tools/install-node-provisioning.test.sh new file mode 100755 index 00000000..8fa154d2 --- /dev/null +++ b/tools/install-node-provisioning.test.sh @@ -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" < "$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/mosaic@0.0.50-next.999" "$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" diff --git a/tools/install.sh b/tools/install.sh index 46c54f34..b785e624 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -25,6 +25,9 @@ # tarballs and installs them globally. Use to test a branch # end-to-end before cutting a release. # --yes Accept all defaults; headless/non-interactive install +# --no-node-install Do not provision Node; fail if Node >= 20 (>= 22 with +# --next) is not already present. Default is to install a +# user-local Node under ~/.mosaic/node when it is missing. # --no-auto-launch Skip automatic mosaic wizard + gateway install on first install # --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line # @@ -38,6 +41,11 @@ # MOSAIC_NEXT — equivalent to --next (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) +# MOSAIC_NODE_HOME — user-local Node install dir (default: ~/.mosaic/node) +# MOSAIC_NODE_VERSION — pin the Node release (default: latest of the +# required major, e.g. v22.23.2) +# MOSAIC_NODE_DIST — Node download mirror (default: nodejs.org/dist) +# MOSAIC_NO_NODE_INSTALL — equivalent to --no-node-install (set to 1) # ────────────────────────────────────────────────────────────────────────────── # # Wrapped in main() for safe curl-pipe usage. @@ -82,7 +90,7 @@ if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then fi installer_usage() { - printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref ] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2 + printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref ] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--no-node-install] [--uninstall]\n' >&2 } while [[ $# -gt 0 ]]; do @@ -109,6 +117,7 @@ while [[ $# -gt 0 ]]; do --next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;; --yes|-y) FLAG_YES=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; + --no-node-install) MOSAIC_NO_NODE_INSTALL=1; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;; *) printf 'Error: Unknown argument: %s\n' "$1" >&2 @@ -309,6 +318,264 @@ require_cmd() { fi } +# ─── node provisioning ──────────────────────────────────────────────────────── +# +# Node is a hard prerequisite for everything below, and a greenfield host does not +# have it. Treating that as the operator's problem made the documented one-command +# install a two-command install that fails first — so the installer provisions Node +# itself. +# +# It installs into the user's own tree rather than through apt/dnf/brew on purpose: +# no root, one code path on every distro, and it works on an immutable host where +# there is no system package manager to reach for. A system Node that is already +# new enough is always preferred and left untouched. + +NODE_HOME="${MOSAIC_NODE_HOME:-$HOME/.mosaic/node}" +NODE_DIST="${MOSAIC_NODE_DIST:-https://nodejs.org/dist}" +FLAG_NO_NODE_INSTALL=false +if [[ "${MOSAIC_NO_NODE_INSTALL:-0}" == "1" ]]; then + FLAG_NO_NODE_INSTALL=true +fi + +node_major_of() { + # Read the major from the binary rather than parsing `node --version` text, so a + # build with a suffix (v22.1.0-nightly…) does not read as a different major. + "$1" -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0 +} + +# The platform triple in a nodejs.org tarball name, or empty where nodejs.org +# publishes no build we can use. +node_platform() { + local os arch + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) return 1 ;; + esac + # Official Linux builds are glibc-linked; on musl they install and then fail to run. + if [[ "$os" == "linux" ]] && ldd --version 2>&1 | grep -qi musl; then + return 1 + fi + case "$(uname -m)" in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + armv7l) arch=armv7l ;; + *) return 1 ;; + esac + printf '%s-%s' "$os" "$arch" +} + +# Newest release of the wanted major. Resolved rather than pinned so a fresh install +# picks up security releases; MOSAIC_NODE_VERSION pins it when reproducibility matters. +node_resolve_version() { + local want="$1" index + if [[ -n "${MOSAIC_NODE_VERSION:-}" ]]; then + printf '%s' "$MOSAIC_NODE_VERSION" + return 0 + fi + index="$(curl -fsSL --retry 3 "${NODE_DIST}/index.json" 2>/dev/null)" || return 1 + # index.json is newest-first, so the first match is the latest of that major. + # grep/sed rather than a JSON parser because node is the thing we do not have yet. + printf '%s' "$index" \ + | grep -o "\"version\":\"v${want}\.[0-9]\+\.[0-9]\+\"" \ + | head -1 \ + | sed 's/.*"v/v/; s/"$//' +} + +node_verify_checksum() { + local dir="$1" file="$2" expected + expected="$(grep " ${file}\$" "${dir}/SHASUMS256.txt" | awk '{print $1}')" + if [[ -z "$expected" ]]; then + fail "No checksum published for ${file}" + return 1 + fi + local actual + if command -v sha256sum &>/dev/null; then + actual="$(sha256sum "${dir}/${file}" | awk '{print $1}')" + elif command -v shasum &>/dev/null; then + actual="$(shasum -a 256 "${dir}/${file}" | awk '{print $1}')" + else + fail "Cannot verify the Node download: neither sha256sum nor shasum is present." + return 1 + fi + if [[ "$actual" != "$expected" ]]; then + fail "Node download failed checksum verification (${file})" + dim " expected ${expected}" + dim " got ${actual}" + return 1 + fi +} + +# Download, verify and unpack one Node release into a scratch dir, then move it into +# place. Staging first means a failed or interrupted download never leaves a half-tree +# that the next run would mistake for an installed Node. +node_fetch_and_unpack() { + local version="$1" platform="$2" work="$3" + local base="node-${version}-${platform}" + local tarball="${base}.tar.gz" + local dest="${NODE_HOME}/${version}" + + info "Downloading Node ${version} (${platform})…" + curl -fsSL --retry 3 -o "${work}/${tarball}" "${NODE_DIST}/${version}/${tarball}" || { + fail "Could not download ${NODE_DIST}/${version}/${tarball}" + return 1 + } + curl -fsSL --retry 3 -o "${work}/SHASUMS256.txt" "${NODE_DIST}/${version}/SHASUMS256.txt" || { + fail "Could not download the Node checksum file" + return 1 + } + node_verify_checksum "$work" "$tarball" || return 1 + + mkdir -p "$NODE_HOME" + tar -xzf "${work}/${tarball}" -C "$work" || { fail "Could not unpack ${tarball}"; return 1; } + rm -rf "${dest}.partial" + mv "${work}/${base}" "${dest}.partial" || { fail "Could not stage Node into ${NODE_HOME}"; return 1; } + rm -rf "$dest" + mv "${dest}.partial" "$dest" || { fail "Could not install Node into ${dest}"; return 1; } + ok "Installed Node ${version} → ${dest}" +} + +# Install one Node release, reusing it if this installer already put it there. +# +# The scratch dir is removed here rather than by a RETURN trap inside the worker: a +# RETURN trap set inside a function stays installed after that function returns, so it +# fires again on the next unrelated function return, where its variables are gone. +node_install() { + local version="$1" platform="$2" + local dest="${NODE_HOME}/${version}" + + if [[ -x "${dest}/bin/node" ]]; then + info "Reusing Node ${version} already at ${dest}" + return 0 + fi + + local work rc=0 + work="$(mktemp -d)" || return 1 + node_fetch_and_unpack "$version" "$platform" "$work" || rc=$? + rm -rf "$work" + return "$rc" +} + +# Put a directory on PATH for future shells, once. A user-local Node and a +# user-local npm prefix are only useful if the next shell can still find them, and +# the installer used to do no more than warn about it. +# +# Both the login profile and the interactive rc get the line, because neither one +# alone covers the shells that matter. Debian's ~/.bashrc returns early when the +# shell is not interactive, so a line there is invisible to `bash -lc`, to an ssh +# command, and to a systemd unit -- which is exactly how an agent seat starts. +persist_path_line() { + local dir="$1" line rc wrote="" + line="export PATH=\"${dir}:\$PATH\"" + + local files=() + case "$(basename "${SHELL:-/bin/bash}")" in + zsh) files=("$HOME/.zprofile" "$HOME/.zshrc") ;; + *) files=("$HOME/.profile" "$HOME/.bashrc") ;; + esac + + for rc in "${files[@]}"; do + if [[ -f "$rc" ]] && grep -Fq "$line" "$rc"; then + continue + fi + { + printf '\n# Added by the Mosaic Stack installer\n' + printf '%s\n' "$line" + } >> "$rc" + wrote+="${wrote:+, }${rc}" + done + + if [[ -n "$wrote" ]]; then + ok "Added ${dir} to PATH in ${wrote}" + dim " This shell: export PATH=\"${dir}:\$PATH\"" + fi +} + +# Make the installed `mosaic` reachable, now and in the next shell. Warning about +# this and moving on left a completed install whose CLI could not be found, which +# reads to an operator as a failed install. +ensure_prefix_on_path() { + persist_path_line "$PREFIX/bin" + if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then + PATH="$PREFIX/bin:$PATH" + export PATH + fi +} + +# Guarantee a Node of at least $1 on PATH for the rest of this run. +ensure_node() { + local want="$1" current=0 + if command -v node &>/dev/null; then + current="$(node_major_of node)" + if [[ "$current" -ge "$want" ]]; then + ok "Node $(node --version) satisfies the >= ${want} requirement" + return 0 + fi + fi + + # A Node this installer put there previously, from an earlier run or another lane. + local candidate + for candidate in "$NODE_HOME"/*/bin/node; do + [[ -x "$candidate" ]] || continue + if [[ "$(node_major_of "$candidate")" -ge "$want" ]]; then + PATH="$(dirname "$candidate"):$PATH" + export PATH + ok "Using Node $(node --version) from ${NODE_HOME}" + persist_path_line "$(dirname "$candidate")" + return 0 + fi + done + + if [[ "$current" == "0" ]]; then + info "Node is not installed; the Mosaic CLI needs Node >= ${want}." + else + info "Node v${current} is older than the required >= ${want}." + fi + + if [[ "$FLAG_NO_NODE_INSTALL" == "true" ]]; then + fail "Node >= ${want} required and --no-node-install was given." + echo " Install Node >= ${want} and re-run, or drop --no-node-install." + exit 1 + fi + + local platform + if ! platform="$(node_platform)"; then + fail "No official Node build for $(uname -s)/$(uname -m)." + echo " Install Node >= ${want} with your system package manager and re-run." + exit 1 + fi + + require_cmd curl + require_cmd tar + + local version + version="$(node_resolve_version "$want")" || true + if [[ -z "$version" ]]; then + fail "Could not resolve a Node ${want}.x release from ${NODE_DIST}." + echo " Check network access, or pin one: MOSAIC_NODE_VERSION=v${want}.0.0" + exit 1 + fi + + info "Installing Node ${version} into ${NODE_HOME} (no root required)…" + if ! node_install "$version" "$platform"; then + fail "Node installation failed." + echo " Install Node >= ${want} manually and re-run, or re-run with --no-node-install" + echo " once it is present." + exit 1 + fi + + PATH="${NODE_HOME}/${version}/bin:$PATH" + export PATH + persist_path_line "${NODE_HOME}/${version}/bin" + + # Prove it, rather than assuming the unpack produced a working binary. + if ! command -v node &>/dev/null || [[ "$(node_major_of node)" -lt "$want" ]]; then + fail "Node ${version} was installed but is not usable on PATH." + exit 1 + fi + ok "Node $(node --version) ready" +} + installed_cli_version() { local json json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true @@ -518,17 +785,26 @@ install_next_cli_from_registry() { # ─── preflight ──────────────────────────────────────────────────────────────── -require_cmd node -require_cmd npm - -NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')" -if [[ "$NODE_MAJOR" -lt 20 ]]; then - fail "Node.js >= 20 required (found v$(node --version))" - exit 1 +NODE_REQUIRED=20 +if [[ "$FLAG_NEXT" == "true" ]]; then + NODE_REQUIRED=22 fi -if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then - fail "Node.js >= 22 required for the --next lane (found v$(node --version))" - exit 1 + +if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then + # Neither lane installs anything, so neither one may install Node. + require_cmd node + require_cmd npm + NODE_MAJOR="$(node_major_of node)" + if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then + fail "Node.js >= ${NODE_REQUIRED} required (found $(node --version))" + exit 1 + fi +else + ensure_node "$NODE_REQUIRED" + # npm ships inside the Node tarball, so this only fails on a system Node that + # was packaged without it — which is worth saying out loud rather than dying later. + require_cmd npm + NODE_MAJOR="$(node_major_of node)" fi echo "" @@ -682,11 +958,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ensure_monorepo install_cli_from_source - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path elif is_next_registry_lane; then info "Next mode — trying fast npm @next install from ${REGISTRY}…" if install_next_cli_from_registry; then @@ -699,11 +971,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path else if [[ -z "$LATEST" ]]; then warn "Could not reach registry at $REGISTRY — skipping npm CLI." @@ -721,11 +989,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)." fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path fi fi