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
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"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": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -153,17 +153,21 @@ reset_state() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"
|
echo "[test] --next rejects Node 20 before any install action"
|
||||||
if OUTPUT="$(
|
if OUTPUT="$(
|
||||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
|
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_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
||||||
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
|
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
|
)"; then
|
||||||
echo "expected Node 20 next-lane install to fail" >&2
|
echo "expected Node 20 next-lane install to fail" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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; }
|
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
|
|||||||
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"
|
||||||
+289
-25
@@ -25,6 +25,9 @@
|
|||||||
# tarballs and installs them globally. Use to test a branch
|
# tarballs and installs them globally. Use to test a branch
|
||||||
# end-to-end before cutting a release.
|
# end-to-end before cutting a release.
|
||||||
# --yes Accept all defaults; headless/non-interactive install
|
# --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
|
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
||||||
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
|
# --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_NEXT — equivalent to --next (set to 1)
|
||||||
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
||||||
# MOSAIC_ASSUME_YES — equivalent to --yes (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.
|
# Wrapped in main() for safe curl-pipe usage.
|
||||||
@@ -82,7 +90,7 @@ if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
installer_usage() {
|
installer_usage() {
|
||||||
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--no-node-install] [--uninstall]\n' >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
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 ;;
|
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=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 ;;
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
*)
|
*)
|
||||||
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
||||||
@@ -309,6 +318,264 @@ require_cmd() {
|
|||||||
fi
|
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() {
|
installed_cli_version() {
|
||||||
local json
|
local json
|
||||||
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
||||||
@@ -518,17 +785,26 @@ install_next_cli_from_registry() {
|
|||||||
|
|
||||||
# ─── preflight ────────────────────────────────────────────────────────────────
|
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
require_cmd node
|
NODE_REQUIRED=20
|
||||||
require_cmd npm
|
if [[ "$FLAG_NEXT" == "true" ]]; then
|
||||||
|
NODE_REQUIRED=22
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then
|
|
||||||
fail "Node.js >= 22 required for the --next lane (found v$(node --version))"
|
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
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -682,11 +958,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
ensure_monorepo
|
ensure_monorepo
|
||||||
install_cli_from_source
|
install_cli_from_source
|
||||||
|
|
||||||
# PATH check for npm prefix
|
ensure_prefix_on_path
|
||||||
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
|
|
||||||
elif is_next_registry_lane; then
|
elif is_next_registry_lane; then
|
||||||
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
|
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
|
||||||
if install_next_cli_from_registry; then
|
if install_next_cli_from_registry; then
|
||||||
@@ -699,11 +971,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# PATH check for npm prefix
|
ensure_prefix_on_path
|
||||||
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
|
|
||||||
else
|
else
|
||||||
if [[ -z "$LATEST" ]]; then
|
if [[ -z "$LATEST" ]]; then
|
||||||
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
|
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)."
|
ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# PATH check for npm prefix
|
ensure_prefix_on_path
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user