diff --git a/packages/mosaic/__tests__/platform/detect.test.ts b/packages/mosaic/__tests__/platform/detect.test.ts new file mode 100644 index 00000000..540f0e8a --- /dev/null +++ b/packages/mosaic/__tests__/platform/detect.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/mosaic/src/platform/detect.ts b/packages/mosaic/src/platform/detect.ts index 59f48173..4abb3129 100644 --- a/packages/mosaic/src/platform/detect.ts +++ b/packages/mosaic/src/platform/detect.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, platform } from 'node:os'; @@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null { const shell = detectShell(); 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': { const zdotdir = process.env['ZDOTDIR'] ?? home; - return join(zdotdir, '.zshrc'); + return join(zdotdir, '.zshenv'); } - case 'bash': { - const bashrc = join(home, '.bashrc'); - if (existsSync(bashrc)) return bashrc; + case 'bash': return join(home, '.profile'); - } case 'fish': return join(home, '.config', 'fish', 'config.fish'); default: diff --git a/tools/install.sh b/tools/install.sh index 46c54f34..bd76d265 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -309,6 +309,87 @@ require_cmd() { fi } +# True if any shell rc file already puts $1 on PATH. +# +# Each file is tested for existence first and grepped one at a time, rather than +# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file +# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q +# 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 +# line on every single install. +path_entry_exists() { + local dir="$1" rc_file + for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do + if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then + return 0 + fi + done + return 1 +} + +# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this +# process. An `export` here reaches only the installer; every directory the +# 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 path_entry_exists "$dir"; then + return 0 + fi + + if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then + profile="$HOME/.zshenv" + else + profile="$HOME/.profile" + fi + + # Probe writability in a subshell. A redirection failure on a special built-in + # aborts the shell it runs in, so it has to be a child; and the redirection on + # the subshell is what silences the "Permission denied" the shell would + # otherwise print ahead of our own message. + if ! ( : >>"$profile" ) 2>/dev/null; then + warn "$dir is not on your PATH and $profile could not be written" + dim " Add to your shell rc: export PATH=\"$dir:\$PATH\"" + return 1 + fi + + { + echo "" + echo "# $label" + echo "export PATH=\"$dir:\$PATH\"" + } >>"$profile" + ok "Added $dir to PATH in $profile" + return 0 +} + +# Persist $PREFIX/bin on PATH instead of only warning about it. +# +# 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() { + if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then + return + fi + + if path_entry_exists "$PREFIX/bin"; then + warn "$PREFIX/bin is in your shell profile but not in this shell" + elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then + return + fi + dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)" +} + installed_cli_version() { local json json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true @@ -516,8 +597,175 @@ install_next_cli_from_registry() { ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" } +# ─── node bootstrap ─────────────────────────────────────────────────────────── +# +# Nothing on a greenfield host installs Node.js, yet this installer and the CLI +# it installs both hard-require it. Measured on a clean Debian 13 image: the +# installer stopped at `require_cmd node` with "Required command not found" and +# nothing was installed, with no hint of how to proceed. +# +# Inlined rather than factored into a sibling file on purpose: this script is +# fetched standalone by curl and has nothing to source. +# +# No-op when a suitable node is already on PATH, so it never fights an +# operator's nvm/fnm/distro node. + +NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}" +NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}" +NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}" +NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}" + +# Major version of the node at $1, or empty if it will not run. +node_major_of() { + local candidate="$1" version + version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0 + printf '%s' "${version%%.*}" +} + +node_is_suitable() { + local major + major="$(node_major_of "$1")" + [[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]] +} + +install_node() { + local node_os node_arch tarball release_url work_dir extracted target node_bin + + case "$(uname -s)" in + Linux) node_os="linux" ;; + Darwin) node_os="darwin" ;; + *) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;; + esac + + # 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 + fi + + require_cmd curl + require_cmd tar + + # sha256sum on Linux, shasum on macOS. Verification is not optional: without a + # checksum this would install an unauthenticated runtime. + if command -v sha256sum &>/dev/null; then + verify_sha256() { sha256sum -c --status "$1"; } + elif command -v shasum &>/dev/null; then + verify_sha256() { shasum -a 256 -c --status "$1"; } + else + fail "sha256sum or shasum required to verify the Node.js download" + exit 1 + fi + + if ! install_node; then + fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run." + exit 1 + fi + persist_node_on_path +} + # ─── preflight ──────────────────────────────────────────────────────────────── +ensure_node require_cmd node require_cmd npm @@ -682,11 +930,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ensure_monorepo install_cli_from_source - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path elif is_next_registry_lane; then info "Next mode — trying fast npm @next install from ${REGISTRY}…" if install_next_cli_from_registry; then @@ -699,11 +943,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path else if [[ -z "$LATEST" ]]; then warn "Could not reach registry at $REGISTRY — skipping npm CLI." @@ -721,11 +961,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)." fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path fi fi