Compare commits

..
Author SHA1 Message Date
fred 07373ede4d docs(install): record the two trust/portability assumptions in install_node
ci/woodpecker/pr/ci Pipeline was successful
Comment-only, no behaviour change. Both raised by scooby in the #1229 review
as non-blocking findings worth writing down rather than fixing here.

F-A: the SHASUMS256.txt check gives integrity, not authenticity. TLS to
$NODE_DIST_BASE is the whole trust root, and MOSAIC_NODE_DIST_BASE widens it
to any mirror with no signature backstop. GPG-verifying SHASUMS256.txt.sig is
filed as its own follow-up so it gets its own review.

F-C: the uname map pulls the glibc build, so musl hosts fail — visibly, via
node_is_suitable, not silently.
2026-08-15 21:50:14 -05:00
fredandClaude Opus 5 fb5bb98a32 Revert "fix(installer): re-link runtime assets after the CLI stage"
ci/woodpecker/pr/ci Pipeline was canceled
This reverts 47e90767. I was wrong: the fix is correct about the cause and
makes the outcome worse.

The acceptance run passed everything I set out to check — greenfield canary
1125, --next --yes, no TTY, rc=0, node v22.23.2 + CLI 0.0.50-next.2413 from a
fresh login shell, and both enforcement hooks wired in ~/.claude/settings.json
where before they were stripped. Then `mosaic doctor` on that same host:

  [ERROR] Lease-enforcement hooks (mutator-gate.py, receipt-observer-client.py)
  are wired in ~/.claude/settings.json, but broker not healthy
  (checkBrokerSupervisorHealth() reports unhealthy). Every gated tool call will
  fail closed and BRICK this agent (see #869).

So the change takes a greenfield host from 'enforcement quietly off, agent
works' to 'enforcement wired, broker absent, agent bricks on the first gated
tool call'. The pre-existing behaviour reaches the safe state for the wrong
reason; this reaches the unsafe state for the right one. Safe-for-the-wrong-
reason still wins.

The real defect is underneath both, and it is not an ordering bug:

  mosaic __link-claude-settings ...   -> rc=0  (leaseEnforcementActivatable:
                                                 activatable, wire the hooks)
  mosaic doctor                       -> ERROR (checkBrokerSupervisorHealth:
                                                 unhealthy, hooks will brick)

Two capability checks, same host, opposite verdicts. And after a complete
install there is no broker supervisor to be healthy: no systemd --user unit
matching lease/broker, nothing under ~/.mosaic but the bootstrapped node, and
no lease or broker script in ~/.config/mosaic/tools/_scripts/. Lease
enforcement cannot be activated on a greenfield host at all, so
leaseEnforcementActivatable() returning true is the thing that is wrong.

Filing that separately. PR #1229 goes back to exactly the four commits scooby
reviewed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:42:20 -05:00
fredandClaude Opus 5 47e90767b7 fix(installer): re-link runtime assets after the CLI stage, so greenfield keeps its enforcement hooks
ci/woodpecker/pr/ci Pipeline was canceled
The framework's install.sh ends by running mosaic-link-runtime-assets, which
asks the `mosaic` CLI whether lease enforcement can be activated before
deciding whether to wire the #828 hooks into settings.json. Part 1 (framework)
runs before Part 2 (npm CLI), so on a first install there is no CLI to ask. The
script takes its fail-safe branch, prints a four-line ERROR, and writes
settings.json with mutator-gate.py and receipt-observer-client.py stripped out.

Measured on canary 1125, rolled back to greenfield, `--next --yes`, no TTY:

  framework template ~/.config/mosaic/runtime/claude/settings.json
    mutator-gate.py            1 occurrence
    receipt-observer-client.py 1 occurrence
  installed ~/.claude/settings.json after a clean rc=0 install
    mutator-gate.py            wired: False
    receipt-observer-client.py wired: False

So enforcement ends up off because of the order the two halves install in, not
because of anything about the host. Falsified by running the same script by
hand once the CLI existed: rc=0, both hooks wired: True. The guard's real
verdict on that host was 'activatable' the whole time.

This adds one more pass after Part 2. The script is idempotent (unchanged files
are skipped), so on an upgrade — CLI already present, first pass already
correct — it is a no-op. It deliberately does not pass
--allow-inactive-enforcement: Part 1 does not either, and a repair pass must
not be more permissive than the pass it corrects.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:38:11 -05:00
fred 00bc602f93 fix(installer): persist the bootstrapped Node on PATH, and stop duplicating PATH lines
ci/woodpecker/pr/ci Pipeline was successful
Two defects found by the second unattended greenfield run on canary (VMID 1125,
rolled back to its greenfield snapshot first).

1. ensure_node() exported the Mosaic-managed Node for the installer process and
   nothing wrote it down. The install finished rc=0, put $PREFIX/bin in
   ~/.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()
   now writes the runtime's bin dir to the same profile, from both the
   fresh-install and the already-installed-but-not-on-PATH branches.

2. The 'is it already in a shell rc file' guard was a single
   `grep -qslF "$dir" "${rc_files[@]}"` over four paths, most of which do
   not exist on a clean host. Handing grep a missing file makes the exit status
   implementation-defined: GNU grep 3.11 returns 0 when -q already 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 another PATH line, so
   every re-install grew the profile. Measured: 3 runs produced 3 duplicate
   entries; with the fix, 1.

   path_entry_exists() now tests each file for existence and greps it on its
   own, so the result does not depend on the grep implementation.

The profile-writing body is factored into persist_on_path(), shared by the CLI
prefix and the Node runtime, since both now need identical treatment.

Verified in a scratch $HOME: fresh write, idempotent across three runs, zsh
routes to .zshenv, an unwritable profile warns and survives set -e, and an
already-on-PATH prefix is a no-op that creates no file. Falsified by restoring
the multi-file grep: duplicates return.
2026-08-15 16:07:56 -05:00
fred d0c223bdf9 fix(wizard): write PATH to .profile/.zshenv, never .bashrc
ci/woodpecker/pr/ci Pipeline was successful
getShellProfilePath() preferred ~/.bashrc when it existed, and ~/.zshrc for
zsh. setupPath() in stages/finalize.ts appends the PATH export to whatever
it returns. Debian's default ~/.bashrc opens with

    case $- in *i*) ;; *) return;; esac

so a line appended to the bottom of it never runs for 'bash -lc', for
systemd units, for 'ssh host cmd', or for any agent seat — precisely the
consumers that need the CLI. An install could print its summary and exit 0
while leaving 'mosaic: command not found'. .zshrc has the same problem:
zsh only reads it for interactive shells.

Now ~/.profile, which login shells read and which Debian's copy sources
.bashrc from for interactive shells, so one line covers both. For zsh the
always-sourced file is .zshenv. fish and PowerShell are unchanged.

__tests__/platform/detect.test.ts pins it, including a case asserting that
no shell resolves to an interactive-only rc file. Falsified by inverting
the fix: 5 failed / 1 passed; restored 6/6. Full package suite unchanged at
17 files / 4 tests failing, matching clean origin/next.
2026-08-15 15:54:10 -05:00
fred cc0d24d5c4 fix(installer): bootstrap Node.js on a greenfield host
tools/install.sh required node and npm and installed neither. Measured on a
snapshot-reverted Debian 13 image with no node, npm or git: the run stopped
at `require_cmd node` with "Required command not found: node", exit 1,
nothing installed, and no indication of how to proceed.

Adds ensure_node() to preflight. It fetches an official Node.js release into
$HOME/.mosaic/node, verifies it against that release's SHASUMS256.txt, and
refuses rather than degrades when the entry is missing or the checksum does
not match. sha256sum on Linux, shasum on macOS. .tar.gz over the smaller
.tar.xz because gzip is universally present and xz is not — a minimal image
is the case this exists to handle.

No-op when a suitable node is already on PATH, so it never fights an
operator's nvm/fnm/distro node. MOSAIC_SKIP_NODE_BOOTSTRAP=1 declines the
download and fails with instructions instead.

Inlined rather than factored into a sibling file because this script is
fetched standalone by curl and has nothing to source.
2026-08-15 15:54:09 -05:00
fred 40fecd4d38 fix(installer): put $PREFIX/bin on PATH instead of warning about it
The three duplicated PATH blocks in tools/install.sh only warned, so an
unattended install finished with rc=0 and left `mosaic: command not found`
— there was no operator to read the advice and act on it. Measured on a
greenfield Debian 13 sandbox: `--next --yes` installed
@mosaicstack/[email protected] successfully and the CLI was still
unreachable.

Replaces all three copies with one ensure_prefix_on_path helper that
appends the export to ~/.profile (~/.zshenv under zsh) and is a no-op when
the prefix is already on PATH or already in a shell profile.

Not ~/.bashrc: Debian's default .bashrc returns early for non-interactive
shells, so a line appended there is unreachable to `bash -lc`, systemd
units and agent seats — the consumers that need the CLI.
2026-08-15 15:47:30 -05:00
7 changed files with 340 additions and 1066 deletions
+1 -1
View File
@@ -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');
});
});
+8 -6
View File
@@ -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:
-137
View File
@@ -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"
+2 -6
View File
@@ -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
-460
View File
@@ -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"
+240 -441
View File
@@ -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.
if [[ ! -f "$manifest" ]]; then
fail "No checksum manifest was downloaded for ${file}"
return 1
fi
# Compare filenames exactly rather than `grep " ${file}$"`. A Node tarball name is
# mostly dots, and in a regex a dot matches any character -- so a manifest line for
# 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 # Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
# the manifest is not trustworthy, and picking either one is a decision this code # non-interactive shells, so a PATH line appended to the bottom of it is
# has no basis to make. # unreachable to `bash -lc`, to systemd units, and to every agent seat — the
while IFS= read -r line || [[ -n "$line" ]]; do # exact consumers that need these binaries. ~/.profile is read by login shells
name="${line#* }" # and Debian's .profile sources .bashrc for interactive ones, so a single line
[[ "$name" == "$file" ]] || continue # there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
expected="${line%% *}" #
matched=$(( matched + 1 )) # $1 = directory to add, $2 = label for the comment line.
done < "$manifest" # Returns 1 (having warned) if the profile could not be written.
persist_on_path() {
local dir="$1" label="$2" profile
if [[ "$matched" -eq 0 ]]; then if path_entry_exists "$dir"; then
fail "No checksum published for ${file}" return 0
return 1
fi fi
if [[ "$matched" -gt 1 ]]; then
fail "Checksum manifest lists ${file} ${matched} times; refusing to guess." if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
return 1 profile="$HOME/.zshenv"
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}"
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
# Major version of the node at $1, or empty if it will not run.
node_major_of() {
local candidate="$1" version
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
printf '%s' "${version%%.*}"
}
node_is_suitable() {
local major
major="$(node_major_of "$1")"
[[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
}
install_node() {
local node_os node_arch tarball release_url work_dir extracted target node_bin
case "$(uname -s)" in
Linux) node_os="linux" ;;
Darwin) node_os="darwin" ;;
*) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# 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 fi
if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then # Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
# Neither lane installs anything, so neither one may install Node. # (the tarball matches the manifest), not AUTHENTICITY (the manifest is
require_cmd node # genuinely Node's). The only thing establishing that is TLS to
require_cmd npm # $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
NODE_MAJOR="$(node_major_of node)" # keys and we do not check it, which is on par with nvm but means pointing
if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then # MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
fail "Node.js >= ${NODE_REQUIRED} required (found $(node --version))" # 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
require_cmd curl
require_cmd tar
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
# checksum this would install an unauthenticated runtime.
if command -v sha256sum &>/dev/null; then
verify_sha256() { sha256sum -c --status "$1"; }
elif command -v shasum &>/dev/null; then
verify_sha256() { shasum -a 256 -c --status "$1"; }
else else
ensure_node "$NODE_REQUIRED" fail "sha256sum or shasum required to verify the Node.js download"
# npm ships inside the Node tarball, so this only fails on a system Node that exit 1
# was packaged without it — which is worth saying out loud rather than dying later. 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 require_cmd npm
NODE_MAJOR="$(node_major_of node)"
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