diff --git a/bootstrap.sh b/bootstrap.sh index d953c1e..035fc5e 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -34,6 +34,9 @@ fi # Runs before bin/ so the kind install gate can see the rootful daemon. "$SCRIPT_DIR/docker/install.sh" "$SCRIPT_DIR/bin/install.sh" +# Provides npx, which opencode needs to spawn the npm-based MCP servers in +# mcp-config/. Without it every MCP server silently fails to start. +"$SCRIPT_DIR/node/install.sh" "$SCRIPT_DIR/ansible/install.sh" # Runs last: needs rootless Docker already up (started at boot) so it can # delegate cgroups to it. No-ops where that does not apply. diff --git a/node/install.sh b/node/install.sh new file mode 100755 index 0000000..255ce2c --- /dev/null +++ b/node/install.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env sh +# node/install.sh — install Node.js (and with it npx) into userland. +# +# WHY: the MCP servers in mcp-config/opencode.gateway.json are npm packages and +# opencode spawns them as `npx -y @`. Without npx on PATH every +# MCP server fails to start with "command not found" — and opencode reports that +# only as a dead server, so it is easy to misread as a broken config. The MCP +# ecosystem is overwhelmingly npx-based, so this also unblocks future servers. +# +# Node is a tree (bin/node + lib/node_modules/npm), not a single binary, so it +# cannot use bin/install.sh's install_tarball_bin. It follows the ansible module +# instead: unpack under ~/.local/opt and symlink the CLIs into ~/.local/bin. +# Everything is under $HOME — no sudo, and it survives a Kasm redeploy. +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REPO_ROOT="$(dirname -- "$SCRIPT_DIR")" + +# shellcheck source=../lib/fetch.sh +. "$REPO_ROOT/lib/fetch.sh" + +say() { printf '%s\n' "$*" >&2; } + +# Pinned LTS. Bump deliberately: npx executes whatever the registry serves, so +# the runtime is part of the supply chain that sees your Gitea/Proxmox tokens. +NODE_VERSION="v24.18.0" + +PREFIX="$HOME/.local/opt/node" +BIN_DIR="$HOME/.local/bin" +NODE_CLIS="node npm npx" + +case "$(fetch_arch)" in + x86_64) NODE_ARCH=x64 ;; + aarch64) NODE_ARCH=arm64 ;; + *) say "node: unsupported arch $(fetch_arch)"; exit 1 ;; +esac + +# Verify it RUNS, not just that the file exists: a Kasm redeploy can leave a +# present-but-broken tree behind (same failure the ansible venv guards against). +if [ "$("$PREFIX/bin/node" --version 2>/dev/null || true)" = "$NODE_VERSION" ]; then + say "node $NODE_VERSION already present at $PREFIX." +else + TARBALL="node-$NODE_VERSION-linux-$NODE_ARCH.tar.gz" + URL="https://nodejs.org/dist/$NODE_VERSION/$TARBALL" + TMP="$(mktemp -d)" + say "Installing node $NODE_VERSION ($NODE_ARCH) into $PREFIX..." + _fetch_dl "$URL" "$TMP/node.tar.gz" + rm -rf "$PREFIX" + mkdir -p "$PREFIX" + # The archive has a single top-level dir; strip it so bin/ lands at $PREFIX/bin. + tar -xzf "$TMP/node.tar.gz" -C "$PREFIX" --strip-components=1 + rm -rf "$TMP" + say "Installed node $NODE_VERSION." +fi + +# Symlink the CLIs into ~/.local/bin (on PATH via zsh/zshrc). npx must be here: +# opencode spawns MCP servers with a bare `npx`, resolved from PATH. +mkdir -p "$BIN_DIR" +for cli in $NODE_CLIS; do + [ -x "$PREFIX/bin/$cli" ] && ln -sf "$PREFIX/bin/$cli" "$BIN_DIR/$cli" +done +say "Linked node CLIs ($NODE_CLIS) into $BIN_DIR."