diff --git a/tools/install.sh b/tools/install.sh index f41040e4..d6db3f28 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -565,8 +565,151 @@ install_next_cli_from_registry() { ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" } +# ─── node bootstrap ─────────────────────────────────────────────────────────── +# +# Nothing on a greenfield host installs Node.js, yet this installer and the CLI +# it installs both hard-require it. Measured on a clean Debian 13 image: the +# installer stopped at `require_cmd node` with "Required command not found" and +# nothing was installed, with no hint of how to proceed. +# +# Inlined rather than factored into a sibling file on purpose: this script is +# fetched standalone by curl and has nothing to source. +# +# No-op when a suitable node is already on PATH, so it never fights an +# operator's nvm/fnm/distro node. + +NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}" +NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}" +NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}" +NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}" + +# Major version of the node at $1, or empty if it will not run. +node_major_of() { + local candidate="$1" version + version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0 + printf '%s' "${version%%.*}" +} + +node_is_suitable() { + local major + major="$(node_major_of "$1")" + [[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]] +} + +install_node() { + local node_os node_arch tarball release_url work_dir extracted target node_bin + + case "$(uname -s)" in + Linux) node_os="linux" ;; + Darwin) node_os="darwin" ;; + *) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; + esac + + case "$(uname -m)" in + x86_64|amd64) node_arch="x64" ;; + aarch64|arm64) node_arch="arm64" ;; + armv7l) node_arch="armv7l" ;; + *) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; + esac + + # .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is + # not, and a minimal image is exactly the case this exists to handle. + tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz" + release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}" + + work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")" + + info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT…" + + if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then + fail "Download failed: ${release_url}/${tarball}" + rm -rf "$work_dir"; return 1 + fi + + if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then + fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime." + rm -rf "$work_dir"; return 1 + fi + + # Keep only our artifact's line, so a missing entry is an error not a pass. + if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then + fail "$tarball has no entry in SHASUMS256.txt; refusing to install." + rm -rf "$work_dir"; return 1 + fi + + if ! (cd "$work_dir" && verify_sha256 expected.sha256); then + fail "Checksum mismatch for $tarball; refusing to install." + rm -rf "$work_dir"; return 1 + fi + ok "Checksum verified" + + tar xzf "$work_dir/$tarball" -C "$work_dir" + extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}" + if [[ ! -x "$extracted/bin/node" ]]; then + fail "Extracted archive has no bin/node" + rm -rf "$work_dir"; return 1 + fi + + mkdir -p "$NODE_ROOT" + target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION" + rm -rf "$target.incoming" + mv "$extracted" "$target.incoming" + rm -rf "$target" + mv "$target.incoming" "$target" + ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current" + rm -rf "$work_dir" + + node_bin="$NODE_ROOT/current/bin" + if ! node_is_suitable "$node_bin/node"; then + fail "Installed node at $node_bin/node did not run" + return 1 + fi + + export PATH="$node_bin:$PATH" + ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')" + return 0 +} + +ensure_node() { + if command -v node &>/dev/null && node_is_suitable node; then + return 0 + fi + + # A previous run may have installed one that is not on this shell's PATH. + if node_is_suitable "$NODE_ROOT/current/bin/node"; then + export PATH="$NODE_ROOT/current/bin:$PATH" + return 0 + fi + + if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then + fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download." + echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script." + exit 1 + fi + + require_cmd curl + require_cmd tar + + # sha256sum on Linux, shasum on macOS. Verification is not optional: without a + # checksum this would install an unauthenticated runtime. + if command -v sha256sum &>/dev/null; then + verify_sha256() { sha256sum -c --status "$1"; } + elif command -v shasum &>/dev/null; then + verify_sha256() { shasum -a 256 -c --status "$1"; } + else + fail "sha256sum or shasum required to verify the Node.js download" + exit 1 + fi + + if ! install_node; then + fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run." + exit 1 + fi +} + # ─── preflight ──────────────────────────────────────────────────────────────── +ensure_node require_cmd node require_cmd npm