Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07373ede4d | ||
|
|
fb5bb98a32 | ||
|
|
47e90767b7 | ||
|
|
00bc602f93 | ||
|
|
d0c223bdf9 | ||
|
|
cc0d24d5c4 | ||
|
|
40fecd4d38 |
+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 && bash tools/install-node-provisioning.test.sh && bash tools/install-newest-matching-file.test.sh",
|
"test:installer": "bash tools/install-next-lane.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"
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// homedir/platform are read at call time, so they can be stubbed per case.
|
||||||
|
vi.mock('node:os', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:os')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
homedir: () => '/home/tester',
|
||||||
|
platform: () => mockPlatform,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockPlatform: NodeJS.Platform = 'linux';
|
||||||
|
|
||||||
|
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
|
||||||
|
|
||||||
|
describe('getShellProfilePath', () => {
|
||||||
|
const originalShell = process.env['SHELL'];
|
||||||
|
const originalZdotdir = process.env['ZDOTDIR'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlatform = 'linux';
|
||||||
|
delete process.env['ZDOTDIR'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalShell === undefined) delete process.env['SHELL'];
|
||||||
|
else process.env['SHELL'] = originalShell;
|
||||||
|
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
|
||||||
|
else process.env['ZDOTDIR'] = originalZdotdir;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression this guards: setupPath() in stages/finalize.ts appends the
|
||||||
|
// PATH export to whatever this returns. A line written to ~/.bashrc is
|
||||||
|
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
|
||||||
|
// default .bashrc returns early for non-interactive shells — so an install
|
||||||
|
// reported success and left `mosaic: command not found`. Same for .zshrc,
|
||||||
|
// which zsh only reads for interactive shells.
|
||||||
|
it('never targets an interactive-only rc file', () => {
|
||||||
|
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
|
||||||
|
process.env['SHELL'] = shell;
|
||||||
|
const profile = getShellProfilePath();
|
||||||
|
expect(profile).not.toMatch(/\.bashrc$/);
|
||||||
|
expect(profile).not.toMatch(/\.zshrc$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ~/.profile for bash', () => {
|
||||||
|
process.env['SHELL'] = '/bin/bash';
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ~/.zshenv for zsh', () => {
|
||||||
|
process.env['SHELL'] = '/usr/bin/zsh';
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours ZDOTDIR for zsh', () => {
|
||||||
|
process.env['SHELL'] = '/usr/bin/zsh';
|
||||||
|
process.env['ZDOTDIR'] = '/custom/zdot';
|
||||||
|
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to ~/.profile for an unknown shell', () => {
|
||||||
|
process.env['SHELL'] = '/bin/somethingelse';
|
||||||
|
expect(detectShell()).toBe('unknown');
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still routes fish to its own config', () => {
|
||||||
|
process.env['SHELL'] = '/usr/bin/fish';
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir, platform } from 'node:os';
|
import { homedir, platform } from 'node:os';
|
||||||
|
|
||||||
@@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null {
|
|||||||
|
|
||||||
const shell = detectShell();
|
const shell = detectShell();
|
||||||
switch (shell) {
|
switch (shell) {
|
||||||
|
// Both of these deliberately avoid the interactive-only rc files.
|
||||||
|
// Debian's default .bashrc returns early for non-interactive shells, so a
|
||||||
|
// PATH line appended to it never runs for `bash -lc`, systemd units, or
|
||||||
|
// agent seats — an install could report success and still leave `mosaic`
|
||||||
|
// unreachable. .profile is read by login shells and sources .bashrc for
|
||||||
|
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
|
||||||
case 'zsh': {
|
case 'zsh': {
|
||||||
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
||||||
return join(zdotdir, '.zshrc');
|
return join(zdotdir, '.zshenv');
|
||||||
}
|
}
|
||||||
case 'bash': {
|
case 'bash':
|
||||||
const bashrc = join(home, '.bashrc');
|
|
||||||
if (existsSync(bashrc)) return bashrc;
|
|
||||||
return join(home, '.profile');
|
return join(home, '.profile');
|
||||||
}
|
|
||||||
case 'fish':
|
case 'fish':
|
||||||
return join(home, '.config', 'fish', 'config.fish');
|
return join(home, '.config', 'fish', 'config.fish');
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests for newest_matching_file() in tools/install.sh.
|
|
||||||
#
|
|
||||||
# The function answers one question -- "which is the most recent backup / tarball
|
|
||||||
# here?" -- and its callers act destructively on the answer. Three ways of getting it
|
|
||||||
# wrong have already been found, and each has a case below:
|
|
||||||
#
|
|
||||||
# * `ls -1t | head -1` returns 141 under `set -o pipefail` once the listing fills a
|
|
||||||
# pipe buffer (~1600 names), because head closes the pipe and ls takes SIGPIPE.
|
|
||||||
# Callers assign it at top level under `set -e`, so a 141 aborts the run.
|
|
||||||
# * `mapfile` is a Bash 4 builtin. macOS ships Bash 3.2 and the installer supports
|
|
||||||
# Darwin, so the whole lookup was unavailable there -- and an empty answer is what
|
|
||||||
# sends the uninstaller down its delete-the-destination branch.
|
|
||||||
# * Any line-based parse of `ls` splits a filename containing a newline into two
|
|
||||||
# wrong answers.
|
|
||||||
#
|
|
||||||
# The large-population and newline cases are the point: with two or three ordinary
|
|
||||||
# names every version of this function passes, which is why the first two went
|
|
||||||
# unnoticed.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-newest-match-test-XXXXXX")"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
# Load the function under test and the mtime helper it depends on, with the same
|
|
||||||
# shell options install.sh runs under.
|
|
||||||
eval "$(sed -n '/^_MTIME_STYLE=/,/^}/p' "$ROOT/tools/install.sh")"
|
|
||||||
eval "$(sed -n '/^newest_matching_file()/,/^}/p' "$ROOT/tools/install.sh")"
|
|
||||||
|
|
||||||
POPULATED="$TMP/many"
|
|
||||||
mkdir -p "$POPULATED"
|
|
||||||
# Enough names to overflow a 64 KiB pipe buffer several times over.
|
|
||||||
for i in $(seq 1 5000); do
|
|
||||||
: > "$POPULATED/mosaicstack-mosaic-0.0.${i}.tgz"
|
|
||||||
done
|
|
||||||
sleep 1
|
|
||||||
: > "$POPULATED/mosaicstack-mosaic-9.9.9.tgz"
|
|
||||||
|
|
||||||
echo "[test] the newest match is returned from a directory large enough to fill a pipe"
|
|
||||||
GOT="$(newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz')"
|
|
||||||
[[ "$(basename "$GOT")" == "mosaicstack-mosaic-9.9.9.tgz" ]] || {
|
|
||||||
echo "expected the newest tarball, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a large population does not make the lookup fail"
|
|
||||||
set +e
|
|
||||||
newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz' >/dev/null
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -eq 0 ]] || { echo "expected rc=0, got ${RC} (141 means the SIGPIPE regression is back)" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] a small population still works"
|
|
||||||
SMALL="$TMP/few"
|
|
||||||
mkdir -p "$SMALL"
|
|
||||||
: > "$SMALL/mosaicstack-gateway-0.0.1.tgz"
|
|
||||||
sleep 1
|
|
||||||
: > "$SMALL/mosaicstack-gateway-0.0.2.tgz"
|
|
||||||
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
|
|
||||||
[[ "$(basename "$GOT")" == "mosaicstack-gateway-0.0.2.tgz" ]] || {
|
|
||||||
echo "expected the newer gateway tarball, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a name containing a space is returned whole"
|
|
||||||
SPACED="$TMP/spaced"
|
|
||||||
mkdir -p "$SPACED"
|
|
||||||
: > "$SPACED/agents.md.mosaic-bak-one two"
|
|
||||||
GOT="$(newest_matching_file "$SPACED" 'agents.md.mosaic-bak-*')"
|
|
||||||
[[ "$GOT" == "$SPACED/agents.md.mosaic-bak-one two" ]] || {
|
|
||||||
echo "expected the spaced name intact, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a name containing a newline is returned whole, not split"
|
|
||||||
# The old `ls -1t` parse reported this file as two separate shorter names, neither of
|
|
||||||
# which exists -- so the caller saw a backup path that could not be restored.
|
|
||||||
NEWLINE="$TMP/newline"
|
|
||||||
mkdir -p "$NEWLINE"
|
|
||||||
WEIRD="$NEWLINE/agents.md.mosaic-bak-$(printf 'a\nb')"
|
|
||||||
: > "$WEIRD"
|
|
||||||
GOT="$(newest_matching_file "$NEWLINE" 'agents.md.mosaic-bak-*')"
|
|
||||||
[[ "$GOT" == "$WEIRD" ]] || {
|
|
||||||
echo "expected the newline-containing name intact, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[[ -f "$GOT" ]] || { echo "the returned path does not name a real file" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] no match is an empty answer, not an error"
|
|
||||||
EMPTY="$TMP/none"
|
|
||||||
mkdir -p "$EMPTY"
|
|
||||||
set +e
|
|
||||||
GOT="$(newest_matching_file "$EMPTY" 'nothing-*.tgz')"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] a directory that does not exist is an empty answer, not an error"
|
|
||||||
set +e
|
|
||||||
GOT="$(newest_matching_file "$TMP/absent" 'nothing-*.tgz')"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] an unanswerable lookup fails loudly instead of reporting no match"
|
|
||||||
# This is the distinction the uninstaller depends on. "No backup exists" is licence to
|
|
||||||
# delete the destination; "I could not tell" must never reach that branch.
|
|
||||||
_MTIME_STYLE=none
|
|
||||||
set +e
|
|
||||||
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
_MTIME_STYLE=""
|
|
||||||
[[ "$RC" -ne 0 ]] || {
|
|
||||||
echo "expected a non-zero rc when no mtime source is usable, got rc=0 output '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] the installer uses no Bash 4 syntax"
|
|
||||||
# A lint, not an execution test: this host has no Bash 3.2 to run under. It is still
|
|
||||||
# the thing that stops the regression, because every Bash 4 construct that has broken
|
|
||||||
# macOS here was introduced by someone who never ran the script there either.
|
|
||||||
# Comments are stripped first -- the ones above name these constructs on purpose.
|
|
||||||
BASH4_HITS="$(
|
|
||||||
sed 's/#.*$//' "$ROOT/tools/install.sh" \
|
|
||||||
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)|declare[[:space:]]+-[a-zA-Z]*A|local[[:space:]]+-[a-zA-Z]*A|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)' \
|
|
||||||
|| true
|
|
||||||
)"
|
|
||||||
[[ -z "$BASH4_HITS" ]] || {
|
|
||||||
echo "tools/install.sh uses Bash 4+ syntax, which macOS's Bash 3.2 cannot run:" >&2
|
|
||||||
echo "$BASH4_HITS" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] newest_matching_file tests passed"
|
|
||||||
@@ -153,21 +153,17 @@ 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 --no-node-install 2>&1
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 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 >= 22 required and --no-node-install was given.' <<<"$OUTPUT"
|
grep -qF 'Node.js >= 22 required for the --next lane' <<<"$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
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
#!/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 # the one that must be chosen
|
|
||||||
MID_VERSION=v22.50.0 # same major, older -- catches "take the last match"
|
|
||||||
OLD_VERSION=v20.99.0 # wrong major
|
|
||||||
NEWER_MAJOR=v24.99.0 # listed first -- catches "take the first entry"
|
|
||||||
|
|
||||||
# ─── 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 "$MID_VERSION"
|
|
||||||
publish_release "$OLD_VERSION"
|
|
||||||
publish_release "$NEWER_MAJOR"
|
|
||||||
# Newest-first, as nodejs.org publishes it. Every wrong entry is genuinely installable,
|
|
||||||
# so a resolver that picks one fails on the assertion rather than on a 404 -- the
|
|
||||||
# assertion is then about version selection and not about the fixture.
|
|
||||||
printf '[{"version":"%s"},{"version":"%s"},{"version":"%s"},{"version":"%s"}]\n' \
|
|
||||||
"$NEWER_MAJOR" "$VERSION" "$MID_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 release of the required major is chosen"
|
|
||||||
# The index lists a higher major first and an older release of the right major after
|
|
||||||
# the right answer, so "first entry" and "last match" both produce a wrong directory.
|
|
||||||
[[ -d "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
[[ ! -d "${NODE_HOME}/${NEWER_MAJOR}" ]]
|
|
||||||
[[ ! -d "${NODE_HOME}/${MID_VERSION}" ]]
|
|
||||||
[[ ! -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] a real login shell resolves node, not just the text of a profile line"
|
|
||||||
# Grepping the file only proves the installer wrote something. This starts an actual
|
|
||||||
# login shell against that HOME and asks it to find the binary.
|
|
||||||
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
|
|
||||||
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
|
|
||||||
echo "a login shell resolved node to '${RESOLVED}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a systemd --user unit gets the same PATH, via environment.d"
|
|
||||||
# Units read no shell file at all, which is how a Mosaic agent seat starts.
|
|
||||||
ENVD="$HOME_DIR/.config/environment.d/50-mosaic-path.conf"
|
|
||||||
[[ -f "$ENVD" ]] || { echo "no environment.d drop-in was written" >&2; exit 1; }
|
|
||||||
grep -qF -- "PATH=${NODE_HOME}/${VERSION}/bin:\${PATH}" "$ENVD"
|
|
||||||
grep -qF -- "PATH=${PREFIX}/bin:\${PATH}" "$ENVD"
|
|
||||||
|
|
||||||
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 ]]
|
|
||||||
[[ "$(grep -c '^PATH=' "$ENVD")" -eq 2 ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a ~/.bash_profile does not silently swallow the PATH entry"
|
|
||||||
# A bash login shell reads the first of .bash_profile / .bash_login / .profile that
|
|
||||||
# exists and never looks at the rest. Writing only .profile is a no-op on such a host,
|
|
||||||
# and the failure is invisible until something cannot find node.
|
|
||||||
: > "$HOME_DIR/.bash_profile"
|
|
||||||
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
|
|
||||||
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
|
|
||||||
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
|
|
||||||
echo "with a .bash_profile present, a login shell resolved node to '${RESOLVED}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a commented-out example does not count as the PATH entry already existing"
|
|
||||||
# The idempotence check used to be an unanchored substring match, so a line like this
|
|
||||||
# in a user's profile made the installer skip the real entry.
|
|
||||||
mkdir -p "$HOME_DIR"
|
|
||||||
printf '# export PATH="%s/%s/bin:$PATH"\n' "$NODE_HOME" "$VERSION" > "$HOME_DIR/.profile"
|
|
||||||
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
|
|
||||||
[[ "$(grep -c '^export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] || {
|
|
||||||
echo "expected two real export lines, found:" >&2
|
|
||||||
cat "$HOME_DIR/.profile" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
# Not just "no usable node": nothing at all may survive. An unpack that ran before
|
|
||||||
# verification, or a staging directory left behind, would still satisfy the weaker
|
|
||||||
# check while leaving unverified bytes on disk for the next run to adopt.
|
|
||||||
[[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}.partial" ]]
|
|
||||||
[[ ! -d "$NODE_HOME" ]] || [[ -z "$(ls -A "$NODE_HOME")" ]]
|
|
||||||
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" ]]
|
|
||||||
|
|
||||||
# ─── refusals: untrusted input that reaches a path or an exec ─────────────────
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] an empty checksum manifest is refused, not read as an empty digest"
|
|
||||||
: > "${DIST}/${VERSION}/SHASUMS256.txt"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "No checksum published" <<<"$OUTPUT"
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a manifest naming a regex-equivalent file does not vouch for this one"
|
|
||||||
# The lookup used to interpolate the filename into a grep pattern. A Node tarball name
|
|
||||||
# is mostly dots, and a dot matches any character, so this line -- which names a
|
|
||||||
# different file -- was accepted as this file's checksum.
|
|
||||||
DECOY="node-${VERSION}-${PLATFORM}Xtar.gz"
|
|
||||||
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$DECOY" > "${DIST}/${VERSION}/SHASUMS256.txt"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "No checksum published" <<<"$OUTPUT"
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a manifest listing the same file twice is refused rather than guessed at"
|
|
||||||
BASE="node-${VERSION}-${PLATFORM}.tar.gz"
|
|
||||||
GOOD="$(awk '{print $1}' "${DIST}/${VERSION}/SHASUMS256.txt")"
|
|
||||||
{
|
|
||||||
printf '%s %s\n' "$GOOD" "$BASE"
|
|
||||||
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$BASE"
|
|
||||||
} > "${DIST}/${VERSION}/SHASUMS256.txt"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "refusing to guess" <<<"$OUTPUT"
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
echo "[test] a version string is checked before it becomes a path"
|
|
||||||
# MOSAIC_NODE_VERSION becomes a directory name under NODE_HOME, and that directory is
|
|
||||||
# later handed to `rm -rf`. This is defence in depth, and the honest scope should be
|
|
||||||
# recorded: the plain 'v..' case is separately refused by rm itself, and a traversal
|
|
||||||
# value breaks the download URL before the removal is reached. Measured, not assumed.
|
|
||||||
# What the check buys is that neither of those accidents is what is protecting us, and
|
|
||||||
# that a typo is refused with its own name on it rather than a curl error.
|
|
||||||
eval "$(sed -n '/^node_valid_version()/,/^}/p' "$ROOT/tools/install.sh")"
|
|
||||||
for good in v22.99.0 v0.0.0 v22.11.0 v100.0.1; do
|
|
||||||
node_valid_version "$good" || { echo "rejected a real version: ${good}" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
for bad in 'v..' '..' 'v9.9.9/../../elsewhere' '/etc' 'v22' 'v22.1' '22.1.0' 'v22.1.0-rc1' '' 'v1.0.0 ' '$(id)'; do
|
|
||||||
! node_valid_version "$bad" || { echo "accepted a bad version: '${bad}'" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a bad MOSAIC_NODE_VERSION is refused by name, before any download"
|
|
||||||
set +e
|
|
||||||
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_NODE_VERSION="v9.9.9/../../elsewhere" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
|
||||||
)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "MOSAIC_NODE_VERSION" <<<"$OUTPUT"
|
|
||||||
grep -qF -- "Downloading Node" <<<"$OUTPUT" && {
|
|
||||||
echo "the download started despite an invalid version" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a download location with no transport integrity is refused"
|
|
||||||
set +e
|
|
||||||
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="http://example.invalid/dist" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
|
||||||
)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "MOSAIC_NODE_DIST must be" <<<"$OUTPUT"
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a path containing shell syntax is not written into a profile"
|
|
||||||
# The PATH line is executed by every future shell that reads the file, so a directory
|
|
||||||
# holding $() or a quote would run there as code.
|
|
||||||
EVIL="$TMP/ev\$(touch $TMP/pwned)il"
|
|
||||||
set +e
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$EVIL" \
|
|
||||||
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" --cli --next --yes --no-auto-launch >/dev/null 2>&1
|
|
||||||
set -e
|
|
||||||
if [[ -f "$HOME_DIR/.profile" ]]; then
|
|
||||||
grep -qF -- 'touch' "$HOME_DIR/.profile" && {
|
|
||||||
echo "a command substitution was written into .profile" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
[[ ! -e "$TMP/pwned" ]] || { echo "the embedded command ran" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] installer node provisioning tests passed"
|
|
||||||
+243
-444
@@ -25,9 +25,6 @@
|
|||||||
# 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
|
||||||
#
|
#
|
||||||
@@ -41,11 +38,6 @@
|
|||||||
# 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.
|
||||||
@@ -90,7 +82,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] [--no-node-install] [--uninstall]\n' >&2
|
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -117,7 +109,6 @@ 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
|
||||||
@@ -159,43 +150,6 @@ fi
|
|||||||
WORK_DIR=""
|
WORK_DIR=""
|
||||||
EXTRACTED_DIR=""
|
EXTRACTED_DIR=""
|
||||||
|
|
||||||
# Modification time of one file, as an integer. GNU/BusyBox stat takes -c, BSD/macOS
|
|
||||||
# stat takes -f, and there is no flag both accept -- so probe once and remember.
|
|
||||||
_MTIME_STYLE=""
|
|
||||||
file_mtime() {
|
|
||||||
if [[ -z "$_MTIME_STYLE" ]]; then
|
|
||||||
if stat -c %Y . >/dev/null 2>&1; then
|
|
||||||
_MTIME_STYLE=gnu
|
|
||||||
elif stat -f %m . >/dev/null 2>&1; then
|
|
||||||
_MTIME_STYLE=bsd
|
|
||||||
else
|
|
||||||
_MTIME_STYLE=none
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
case "$_MTIME_STYLE" in
|
|
||||||
gnu) stat -c %Y -- "$1" 2>/dev/null ;;
|
|
||||||
bsd) stat -f %m -- "$1" 2>/dev/null ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# The most recently modified file in "$dir" matching "$pattern".
|
|
||||||
#
|
|
||||||
# Three separate contracts, and callers must tell them apart:
|
|
||||||
# rc=0 with output — this is the newest match
|
|
||||||
# rc=0, no output — the directory or the pattern matched nothing
|
|
||||||
# rc=1 — the answer could not be determined
|
|
||||||
#
|
|
||||||
# The third one exists because the uninstall path treats "no backup" as licence to
|
|
||||||
# delete the destination. A lookup that fails must never be mistaken for a lookup
|
|
||||||
# that succeeded and found nothing.
|
|
||||||
#
|
|
||||||
# The candidates come from a glob and are compared in-shell, never rendered as text.
|
|
||||||
# That is deliberate, and it closes three bugs at once: `mapfile` is a Bash 4 builtin
|
|
||||||
# and macOS ships Bash 3.2, which this installer supports (see node_platform); piping
|
|
||||||
# `ls` into `head` dies on SIGPIPE under `set -o pipefail` once the listing fills a
|
|
||||||
# pipe buffer, returning 141 with no output; and any line-based parse of `ls` splits a
|
|
||||||
# filename that contains a newline into two wrong answers.
|
|
||||||
newest_matching_file() {
|
newest_matching_file() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
local pattern="$2"
|
local pattern="$2"
|
||||||
@@ -206,17 +160,8 @@ newest_matching_file() {
|
|||||||
matches=("$dir"/$pattern)
|
matches=("$dir"/$pattern)
|
||||||
shopt -u nullglob
|
shopt -u nullglob
|
||||||
[[ "${#matches[@]}" -gt 0 ]] || return 0
|
[[ "${#matches[@]}" -gt 0 ]] || return 0
|
||||||
|
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
|
||||||
local newest="" newest_t="" candidate t
|
ls -1t "${matches[@]}" 2>/dev/null | head -1
|
||||||
for candidate in "${matches[@]}"; do
|
|
||||||
t="$(file_mtime "$candidate")" || return 1
|
|
||||||
[[ -n "$t" ]] || return 1
|
|
||||||
if [[ -z "$newest_t" ]] || [[ "$t" -gt "$newest_t" ]]; then
|
|
||||||
newest="$candidate"
|
|
||||||
newest_t="$t"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
printf '%s\n' "$newest"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||||
@@ -279,17 +224,12 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
|
|||||||
for dest in "${RUNTIME_DESTS[@]}"; do
|
for dest in "${RUNTIME_DESTS[@]}"; do
|
||||||
base="$(basename "$dest")"
|
base="$(basename "$dest")"
|
||||||
dir="$(dirname "$dest")"
|
dir="$(dirname "$dest")"
|
||||||
# Find most recent backup. A lookup that could not answer is not the same as
|
# Find most recent backup
|
||||||
# "there is no backup": removing the destination on a failed lookup would destroy
|
|
||||||
# the file the backup exists to restore.
|
|
||||||
backup=""
|
backup=""
|
||||||
backup_lookup_ok=true
|
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" || backup_lookup_ok=false
|
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
fi
|
fi
|
||||||
if [[ "$backup_lookup_ok" != "true" ]]; then
|
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
||||||
echo " Skipped: $dest (could not check for a backup; left in place)"
|
|
||||||
elif [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
|
||||||
cp "$backup" "$dest"
|
cp "$backup" "$dest"
|
||||||
rm -f "$backup"
|
rm -f "$backup"
|
||||||
echo " Restored: $dest"
|
echo " Restored: $dest"
|
||||||
@@ -369,376 +309,85 @@ require_cmd() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── node provisioning ────────────────────────────────────────────────────────
|
# True if any shell rc file already puts $1 on PATH.
|
||||||
#
|
#
|
||||||
# Node is a hard prerequisite for everything below, and a greenfield host does not
|
# Each file is tested for existence first and grepped one at a time, rather than
|
||||||
# have it. Treating that as the operator's problem made the documented one-command
|
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
|
||||||
# install a two-command install that fails first — so the installer provisions Node
|
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
|
||||||
# itself.
|
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless.
|
||||||
#
|
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH
|
||||||
# It installs into the user's own tree rather than through apt/dnf/brew on purpose:
|
# line on every single install.
|
||||||
# no root, one code path on every distro, and it works on an immutable host where
|
path_entry_exists() {
|
||||||
# there is no system package manager to reach for. A system Node that is already
|
local dir="$1" rc_file
|
||||||
# new enough is always preferred and left untouched.
|
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
|
||||||
|
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
|
||||||
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
|
|
||||||
|
|
||||||
# A Node version string is about to become a directory name under NODE_HOME, and that
|
|
||||||
# directory is passed to `rm -rf`. Nothing reaches a filesystem operation until it has
|
|
||||||
# matched this. `v..` is the case that matters: it resolves to NODE_HOME's parent.
|
|
||||||
node_valid_version() {
|
|
||||||
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# The download location is executable code. Refuse a scheme that carries no transport
|
|
||||||
# integrity at all, and say plainly what an override does and does not buy, since the
|
|
||||||
# tarball and the checksum that vouches for it then come from the same place.
|
|
||||||
case "$NODE_DIST" in
|
|
||||||
https://*) ;;
|
|
||||||
file://*) ;;
|
|
||||||
*)
|
|
||||||
if [[ -n "${MOSAIC_NODE_DIST:-}" ]]; then
|
|
||||||
fail "MOSAIC_NODE_DIST must be an https:// or file:// URL; got '${NODE_DIST}'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
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 resolved
|
|
||||||
if [[ -n "${MOSAIC_NODE_VERSION:-}" ]]; then
|
|
||||||
if ! node_valid_version "$MOSAIC_NODE_VERSION"; then
|
|
||||||
fail "MOSAIC_NODE_VERSION must look like v22.11.0; got '${MOSAIC_NODE_VERSION}'"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$MOSAIC_NODE_VERSION"
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
index="$(curl -fsSL --retry 3 "${NODE_DIST}/index.json" 2>/dev/null)" || return 1
|
done
|
||||||
# index.json is newest-first, so the first match is the latest of that major.
|
return 1
|
||||||
# grep/sed rather than a JSON parser because node is the thing we do not have yet.
|
|
||||||
# No `| head -1` here: head closes the pipe, grep takes SIGPIPE, and under
|
|
||||||
# `set -o pipefail` the whole substitution returns 141 -- the bug already fixed in
|
|
||||||
# newest_matching_file. Take the first line in the shell instead.
|
|
||||||
local found
|
|
||||||
found="$(printf '%s' "$index" | grep -o "\"version\":\"v${want}\.[0-9]\+\.[0-9]\+\"")" || return 1
|
|
||||||
found="${found%%$'\n'*}"
|
|
||||||
resolved="${found#\"version\":\"}"
|
|
||||||
resolved="${resolved%\"}"
|
|
||||||
# The index is remote input, and what comes out of it becomes a path.
|
|
||||||
[[ -n "$resolved" ]] || return 1
|
|
||||||
node_valid_version "$resolved" || return 1
|
|
||||||
printf '%s' "$resolved"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
node_verify_checksum() {
|
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
|
||||||
local dir="$1" file="$2" expected="" line name matched=0
|
# process. An `export` here reaches only the installer; every directory the
|
||||||
local manifest="${dir}/SHASUMS256.txt"
|
# install leaves behind has to be written down somewhere a later shell reads.
|
||||||
|
#
|
||||||
|
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
|
||||||
|
# non-interactive shells, so a PATH line appended to the bottom of it is
|
||||||
|
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
|
||||||
|
# exact consumers that need these binaries. ~/.profile is read by login shells
|
||||||
|
# and Debian's .profile sources .bashrc for interactive ones, so a single line
|
||||||
|
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
|
||||||
|
#
|
||||||
|
# $1 = directory to add, $2 = label for the comment line.
|
||||||
|
# Returns 1 (having warned) if the profile could not be written.
|
||||||
|
persist_on_path() {
|
||||||
|
local dir="$1" label="$2" profile
|
||||||
|
|
||||||
if [[ ! -f "$manifest" ]]; then
|
if path_entry_exists "$dir"; then
|
||||||
fail "No checksum manifest was downloaded for ${file}"
|
return 0
|
||||||
return 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Compare filenames exactly rather than `grep " ${file}$"`. A Node tarball name is
|
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
|
||||||
# mostly dots, and in a regex a dot matches any character -- so a manifest line for
|
profile="$HOME/.zshenv"
|
||||||
# a name that merely looks like this one would be accepted as this one's checksum.
|
|
||||||
#
|
|
||||||
# Every line is read, not just the first match: two entries for the same file mean
|
|
||||||
# the manifest is not trustworthy, and picking either one is a decision this code
|
|
||||||
# has no basis to make.
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
name="${line#* }"
|
|
||||||
[[ "$name" == "$file" ]] || continue
|
|
||||||
expected="${line%% *}"
|
|
||||||
matched=$(( matched + 1 ))
|
|
||||||
done < "$manifest"
|
|
||||||
|
|
||||||
if [[ "$matched" -eq 0 ]]; then
|
|
||||||
fail "No checksum published for ${file}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$matched" -gt 1 ]]; then
|
|
||||||
fail "Checksum manifest lists ${file} ${matched} times; refusing to guess."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
|
||||||
fail "Checksum for ${file} is not a SHA-256 digest: '${expected}'"
|
|
||||||
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
|
else
|
||||||
fail "Cannot verify the Node download: neither sha256sum nor shasum is present."
|
profile="$HOME/.profile"
|
||||||
return 1
|
|
||||||
fi
|
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
|
# Probe writability in a subshell. A redirection failure on a special built-in
|
||||||
# place. Staging first means a failed or interrupted download never leaves a half-tree
|
# aborts the shell it runs in, so it has to be a child; and the redirection on
|
||||||
# that the next run would mistake for an installed Node.
|
# the subshell is what silences the "Permission denied" the shell would
|
||||||
node_fetch_and_unpack() {
|
# otherwise print ahead of our own message.
|
||||||
local version="$1" platform="$2" work="$3"
|
if ! ( : >>"$profile" ) 2>/dev/null; then
|
||||||
local base="node-${version}-${platform}"
|
warn "$dir is not on your PATH and $profile could not be written"
|
||||||
local tarball="${base}.tar.gz"
|
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
|
||||||
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}"
|
|
||||||
|
|
||||||
# Re-checked here, not only where the version was resolved: `dest` is about to be
|
|
||||||
# handed to `rm -rf`, and this is the last place before that happens. A version of
|
|
||||||
# `..` would point the removal at NODE_HOME's parent.
|
|
||||||
if ! node_valid_version "$version"; then
|
|
||||||
fail "Refusing to install Node from an unexpected version string: '${version}'"
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
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 processes, once. A user-local Node and a
|
|
||||||
# user-local npm prefix are only useful if the next process can still find them, and
|
|
||||||
# the installer used to do no more than warn about it.
|
|
||||||
#
|
|
||||||
# There is no one file that covers this. Each target below is the only thing that
|
|
||||||
# works for some way a user -- or an agent seat -- actually starts a process:
|
|
||||||
#
|
|
||||||
# ~/.profile POSIX login shells, and `bash -lc` when no bash-specific
|
|
||||||
# profile exists.
|
|
||||||
# ~/.bash_profile A bash login shell reads the first of these that exists and
|
|
||||||
# ~/.bash_login then never reads ~/.profile. On a host with one of them,
|
|
||||||
# writing only ~/.profile is a silent no-op. Appended to when
|
|
||||||
# present, never created -- creating one would itself start
|
|
||||||
# shadowing ~/.profile for everything else the user has there.
|
|
||||||
# ~/.bashrc Interactive non-login shells. Debian's returns early when the
|
|
||||||
# shell is not interactive, so it cannot stand in for a profile.
|
|
||||||
# ~/.zshenv Every zsh invocation, including `ssh host cmd`. A remote
|
|
||||||
# non-interactive zsh reads neither ~/.zprofile nor ~/.zshrc,
|
|
||||||
# which is what the previous version of this function wrote.
|
|
||||||
# environment.d systemd --user units, which read no shell file at all. A
|
|
||||||
# Mosaic agent seat starts as a unit, so this one is the point.
|
|
||||||
persist_path_line() {
|
|
||||||
local dir="$1" line rc wrote=""
|
|
||||||
|
|
||||||
# This text is written into files that a future shell will execute, so a directory
|
|
||||||
# containing shell syntax would run there as code. Refuse rather than escape: such
|
|
||||||
# a path can only arrive through MOSAIC_NODE_HOME or MOSAIC_PREFIX, and a real
|
|
||||||
# install directory never needs these characters.
|
|
||||||
if [[ "$dir" =~ [\"\$\`\\] ]] || [[ "$dir" == *"'"* ]] || [[ "$dir" == *$'\n'* ]]; then
|
|
||||||
warn "Not adding ${dir} to PATH automatically: the path contains shell syntax."
|
|
||||||
dim " Put it on PATH by hand, or reinstall to a path without those characters."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
line="export PATH=\"${dir}:\$PATH\""
|
|
||||||
|
|
||||||
local files=("$HOME/.profile")
|
|
||||||
case "$(basename "${SHELL:-/bin/bash}")" in
|
|
||||||
zsh)
|
|
||||||
files+=("$HOME/.zshenv")
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
files+=("$HOME/.bashrc")
|
|
||||||
if [[ -f "$HOME/.bash_profile" ]]; then files+=("$HOME/.bash_profile"); fi
|
|
||||||
if [[ -f "$HOME/.bash_login" ]]; then files+=("$HOME/.bash_login"); fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
for rc in "${files[@]}"; do
|
|
||||||
# -x anchors the match to a whole line. Without it, a commented-out example of
|
|
||||||
# this same export counts as already present and the real entry never gets
|
|
||||||
# written -- the failure then looks like the installer simply did nothing.
|
|
||||||
if [[ -f "$rc" ]] && grep -Fqx "$line" "$rc"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
{
|
{
|
||||||
printf '\n# Added by the Mosaic Stack installer\n'
|
echo ""
|
||||||
printf '%s\n' "$line"
|
echo "# $label"
|
||||||
} >> "$rc"
|
echo "export PATH=\"$dir:\$PATH\""
|
||||||
wrote+="${wrote:+, }${rc}"
|
} >>"$profile"
|
||||||
done
|
ok "Added $dir to PATH in $profile"
|
||||||
|
return 0
|
||||||
# systemd --user units inherit from the user manager, not from any shell.
|
|
||||||
local envd="$HOME/.config/environment.d"
|
|
||||||
local envd_file="$envd/50-mosaic-path.conf"
|
|
||||||
local envd_line="PATH=${dir}:\${PATH}"
|
|
||||||
if mkdir -p "$envd" 2>/dev/null; then
|
|
||||||
if [[ ! -f "$envd_file" ]] || ! grep -Fqx "$envd_line" "$envd_file"; then
|
|
||||||
printf '%s\n' "$envd_line" >> "$envd_file"
|
|
||||||
wrote+="${wrote:+, }${envd_file}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$wrote" ]]; then
|
|
||||||
ok "Added ${dir} to PATH in ${wrote}"
|
|
||||||
dim " This shell: export PATH=\"${dir}:\$PATH\""
|
|
||||||
dim " systemd --user: systemctl --user daemon-reload (or log in again)"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Make the installed `mosaic` reachable, now and in the next shell. Warning about
|
# Persist $PREFIX/bin on PATH instead of only warning about it.
|
||||||
# this and moving on left a completed install whose CLI could not be found, which
|
#
|
||||||
# reads to an operator as a failed install.
|
# The warning it replaces was the last step of an otherwise successful install,
|
||||||
|
# so the installer reported success and left `mosaic: command not found` — an
|
||||||
|
# unattended install had no operator to read the advice and act on it.
|
||||||
ensure_prefix_on_path() {
|
ensure_prefix_on_path() {
|
||||||
persist_path_line "$PREFIX/bin"
|
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
|
||||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
return
|
||||||
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
|
fi
|
||||||
|
|
||||||
# A Node this installer put there previously, from an earlier run or another lane.
|
if path_entry_exists "$PREFIX/bin"; then
|
||||||
local candidate
|
warn "$PREFIX/bin is in your shell profile but not in this shell"
|
||||||
for candidate in "$NODE_HOME"/*/bin/node; do
|
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
|
||||||
[[ -x "$candidate" ]] || continue
|
return
|
||||||
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
|
fi
|
||||||
done
|
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
|
||||||
|
|
||||||
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() {
|
||||||
@@ -882,10 +531,8 @@ install_cli_from_source() {
|
|||||||
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
local cli_tgz gw_tgz
|
local cli_tgz gw_tgz
|
||||||
# An unanswerable lookup becomes an empty path, which the -f guards below report
|
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
|
||||||
# properly. Nothing destructive happens on this path, so failing soft is safe here.
|
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
|
||||||
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" || cli_tgz=""
|
|
||||||
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" || gw_tgz=""
|
|
||||||
|
|
||||||
if [[ ! -f "$cli_tgz" ]]; then
|
if [[ ! -f "$cli_tgz" ]]; then
|
||||||
fail "CLI tarball was not produced by pnpm pack."
|
fail "CLI tarball was not produced by pnpm pack."
|
||||||
@@ -950,28 +597,186 @@ install_next_cli_from_registry() {
|
|||||||
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── preflight ────────────────────────────────────────────────────────────────
|
# ─── 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_REQUIRED=20
|
NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
|
||||||
if [[ "$FLAG_NEXT" == "true" ]]; then
|
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
|
||||||
NODE_REQUIRED=22
|
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
|
||||||
fi
|
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
|
||||||
|
|
||||||
if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then
|
# Major version of the node at $1, or empty if it will not run.
|
||||||
# Neither lane installs anything, so neither one may install Node.
|
node_major_of() {
|
||||||
require_cmd node
|
local candidate="$1" version
|
||||||
require_cmd npm
|
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
|
||||||
NODE_MAJOR="$(node_major_of node)"
|
printf '%s' "${version%%.*}"
|
||||||
if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then
|
}
|
||||||
fail "Node.js >= ${NODE_REQUIRED} required (found $(node --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
|
||||||
|
|
||||||
|
# Linux here means glibc. Node's official linux-x64 build is dynamically
|
||||||
|
# linked against glibc, so on musl (Alpine) the binary will not exec — but it
|
||||||
|
# fails visibly: node_is_suitable rejects it and ensure_node exits with
|
||||||
|
# "install Node.js manually". No silent breakage, just a wasted download.
|
||||||
|
# A musl host needs the unofficial build, which is out of scope here.
|
||||||
|
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
|
||||||
|
|
||||||
|
# Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
|
||||||
|
# (the tarball matches the manifest), not AUTHENTICITY (the manifest is
|
||||||
|
# genuinely Node's). The only thing establishing that is TLS to
|
||||||
|
# $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
|
||||||
|
# keys and we do not check it, which is on par with nvm but means pointing
|
||||||
|
# MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
|
||||||
|
# Tracked as a hardening follow-up (raised by scooby in the #1229 review).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make the Mosaic-managed Node reachable from the next shell as well as this
|
||||||
|
# one. Measured on a greenfield canary run: without this the install finished
|
||||||
|
# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic`
|
||||||
|
# and then died on `env: 'node': No such file or directory` — the CLI is a Node
|
||||||
|
# script, so a CLI on PATH without its runtime is a successful install that
|
||||||
|
# produces a broken command.
|
||||||
|
persist_node_on_path() {
|
||||||
|
persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
persist_node_on_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
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
ensure_node "$NODE_REQUIRED"
|
require_cmd curl
|
||||||
# npm ships inside the Node tarball, so this only fails on a system Node that
|
require_cmd tar
|
||||||
# was packaged without it — which is worth saying out loud rather than dying later.
|
|
||||||
require_cmd npm
|
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
|
||||||
NODE_MAJOR="$(node_major_of node)"
|
# 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
|
||||||
|
persist_node_on_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_node
|
||||||
|
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
|
||||||
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1241,13 +1046,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
local base dir backup_path backup_val
|
local base dir backup_path backup_val
|
||||||
base="$(basename "$dest")"
|
base="$(basename "$dest")"
|
||||||
dir="$(dirname "$dest")"
|
dir="$(dirname "$dest")"
|
||||||
# Recording null here would tell a later uninstall that no backup exists, and
|
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
# it would then delete the destination instead of restoring it. An unanswerable
|
|
||||||
# lookup must stop the manifest, not guess at it.
|
|
||||||
if ! backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"; then
|
|
||||||
fail "Could not determine the backup state of ${dest}; refusing to write a manifest."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ -n "$backup_path" ]]; then
|
if [[ -n "$backup_path" ]]; then
|
||||||
backup_val="\"$backup_path\""
|
backup_val="\"$backup_path\""
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user