#!/bin/sh set -e REPO="nimbus-solution/nimbus" INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" # The product is Renmy; it shipped as Nimbus until 2026. The real file is # installed as renmy and `nimbus` is a symlink beside it, so every existing # command, script, editor extension and MCP config that names nimbus keeps # working. `nimbus upgrade` and `renmy upgrade` both resolve the symlink and # replace the real file, leaving the other name pointing at the new build. # COMPAT_BINARY must never equal BINARY: the symlink step below would replace # the binary with a link to itself. BINARY="renmy" COMPAT_BINARY="nimbus" # Anonymous install ping (see public.installs in supabase). Schema and # privacy posture documented in the matching migration. Opt out with # RENMY_NO_TRACKING=1, RENMY_TELEMETRY=false, or RENMY_NO_TELEMETRY=1 # (the NIMBUS_ spellings are honoured too, and always will be). SUPABASE_URL="https://fmqylpjoqnfamdexitbu.supabase.co" SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZtcXlscGpvcW5mYW1kZXhpdGJ1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzUzNTc1NDQsImV4cCI6MjA5MDkzMzU0NH0.2Y2jKwvmWLsS17unE5JjOoKdbVe7LDzax0oQpD_sc_A" INSTALLER_VERSION="2026-10-02" # Keeps its name under every spelling of the product: install.id here is # what ties an existing machine to its first-run record. NIMBUS_DIR="${HOME}/.nimbus" INSTALL_ID_FILE="${NIMBUS_DIR}/install.id" # Detect OS OS=$(uname -s | tr '[:upper:]' '[:lower:]') case "$OS" in linux|darwin) ;; *) echo "Unsupported OS: $OS" >&2; exit 1 ;; esac # Detect architecture ARCH=$(uname -m) case "$ARCH" in x86_64) ARCH="amd64" ;; arm64|aarch64) ARCH="arm64" ;; *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; esac # Resolve version. Precedence: # 1. RENMY_VERSION (or NIMBUS_VERSION) env var (pin for CI / reproducible installs) # 2. Follow the releases/latest 302 redirect (no auth, no rate limit) # 3. Fall back to the GitHub API, optionally using GITHUB_TOKEN # (the anonymous API limit is 60/hr/IP and shared GHA runners hit it) VERSION="${RENMY_VERSION:-${NIMBUS_VERSION:-}}" VERSION="${VERSION#v}" if [ -z "$VERSION" ]; then VERSION=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ "https://github.com/${REPO}/releases/latest" 2>/dev/null \ | sed -n 's|.*/tag/v\([^/?#]*\).*|\1|p') fi if [ -z "$VERSION" ]; then AUTH_HEADER="" if [ -n "${GITHUB_TOKEN:-}" ]; then AUTH_HEADER="Authorization: Bearer ${GITHUB_TOKEN}" fi VERSION=$(curl -fsSL ${AUTH_HEADER:+-H "$AUTH_HEADER"} \ "https://api.github.com/repos/${REPO}/releases/latest" \ | grep '"tag_name"' \ | sed 's/.*"tag_name": *"v\([^"]*\)".*/\1/') fi if [ -z "$VERSION" ]; then echo "error: could not determine latest version" >&2 echo "hint: set RENMY_VERSION=x.y.z to pin a release" >&2 exit 1 fi BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION}" echo "Installing renmy v${VERSION} (${OS}/${ARCH})..." # Download and extract to a temp dir TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT # The release's own checksums.txt comes first: it lists every asset of the # release, so it says which archive to fetch, and the asset is verified # against it below, the same check the CI action runs. That refuses an asset # that was swapped in transit by anything that could not also rewrite # checksums.txt; it is not a signature. A short or corrupted download already # fails in tar below, so this is about authenticity rather than truncation. CHECKSUMS_URL="${BASE_URL}/checksums.txt" if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP/checksums.txt"; then echo "error: could not download ${CHECKSUMS_URL}" >&2 echo "hint: the release is incomplete or the network path rewrote it; refusing to install unverified" >&2 exit 1 fi # Releases made since the rename publish a renmy_* archive beside the # nimbus_* one; releases made before it publish only nimbus_*. Take renmy_* # when the release has it and nimbus_* otherwise, so a pin to any version # resolves. Either way the file inside (renmy or nimbus) is installed as renmy # below, with nimbus beside it. ASSET_BINARY="$COMPAT_BINARY" if grep -q " ${BINARY}_${VERSION}_${OS}_${ARCH}\.tar\.gz\$" "$TMP/checksums.txt"; then ASSET_BINARY="$BINARY" fi FILENAME="${ASSET_BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz" curl -fsSL "${BASE_URL}/${FILENAME}" -o "$TMP/$FILENAME" CHECKSUM_LINE=$(grep " ${FILENAME}\$" "$TMP/checksums.txt" || true) if [ -z "$CHECKSUM_LINE" ]; then echo "error: ${FILENAME} is not listed in the release checksums.txt — refusing to install" >&2 exit 1 fi if command -v sha256sum >/dev/null 2>&1; then (cd "$TMP" && printf '%s\n' "$CHECKSUM_LINE" | sha256sum -c - >/dev/null) || { echo "error: checksum mismatch for ${FILENAME} — refusing to install" >&2; exit 1; } elif command -v shasum >/dev/null 2>&1; then (cd "$TMP" && printf '%s\n' "$CHECKSUM_LINE" | shasum -a 256 -c - >/dev/null) || { echo "error: checksum mismatch for ${FILENAME} — refusing to install" >&2; exit 1; } else echo "error: neither sha256sum nor shasum is available to verify ${FILENAME} — refusing to install" >&2 exit 1 fi tar -xzf "$TMP/$FILENAME" -C "$TMP" # Ensure install dir exists; fall back to ~/.local/bin if not writable mkdir -p "$INSTALL_DIR" 2>/dev/null || true if [ ! -w "$INSTALL_DIR" ]; then INSTALL_DIR="$HOME/.local/bin" mkdir -p "$INSTALL_DIR" fi # A renmy that is itself a symlink (made by hand, or by another installer) # goes first: not every install(1) replaces a link rather than writing # through it, and the nimbus link below must point at a real file. if [ -L "$INSTALL_DIR/$BINARY" ]; then rm -f "$INSTALL_DIR/$BINARY" fi install -m 755 "$TMP/$ASSET_BINARY" "$INSTALL_DIR/$BINARY" # The previous name, as a relative symlink beside the real file. Replaces a # regular file left by an older installer. Editor extensions look for exactly # "$INSTALL_DIR/nimbus" and an MCP config may hold its absolute path, so this # is not decoration. if ! ln -sfn "$BINARY" "$INSTALL_DIR/$COMPAT_BINARY" 2>/dev/null; then # Filesystems without symlinks (some network and container mounts): a copy # costs disk but keeps the old name working. install -m 755 "$TMP/$ASSET_BINARY" "$INSTALL_DIR/$COMPAT_BINARY" fi echo "renmy v${VERSION} installed to $INSTALL_DIR/$BINARY (also available as $COMPAT_BINARY)" # --- make sure the terminal can find it --- # /usr/local/bin is on every PATH. A directory this script was aimed at is # usually not: ~/.local/bin when /usr/local/bin is not writable, or the # ~/.nimbus/bin an editor extension passes as INSTALL_DIR so it can verify # what landed there. A binary the terminal cannot find is the same as no # install, so the login shell's profile gets one guarded export line, once. # RENMY_NO_MODIFY_PATH=1 (or NIMBUS_NO_MODIFY_PATH=1) leaves every dotfile # alone and prints the line # to add instead. # A path shown with ~ where it can be, so the message reads the way the # reader would type it. home_relative() { # shellcheck disable=SC2088 # a literal ~ is the point: this is display text case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac } case ":${PATH}:" in *":${INSTALL_DIR}:"*) ON_PATH=1 ;; *) ON_PATH=0 ;; esac case "${RENMY_NO_MODIFY_PATH:-${NIMBUS_NO_MODIFY_PATH:-}}" in 1|true|TRUE|yes) MODIFY_PATH=0 ;; *) MODIFY_PATH=1 ;; esac if [ "$ON_PATH" = "0" ]; then # The entry as it should read in a dotfile: $HOME-relative when it can be, # so the line survives a home directory that moves. case "$INSTALL_DIR" in "$HOME"/*) PATH_ENTRY="\$HOME/${INSTALL_DIR#"$HOME"/}" ;; *) PATH_ENTRY="$INSTALL_DIR" ;; esac # One file per login shell, the one an interactive terminal reads. bash on # macOS opens login shells (Terminal, iTerm, the IDE terminal), which read # .bash_profile and not .bashrc; everywhere else bash reads .bashrc. case "$(basename "${SHELL:-sh}")" in zsh) PROFILE="${ZDOTDIR:-$HOME}/.zshrc" ;; bash) if [ "$OS" = "darwin" ]; then PROFILE="$HOME/.bash_profile"; else PROFILE="$HOME/.bashrc"; fi ;; fish) PROFILE="$HOME/.config/fish/config.fish" ;; *) PROFILE="$HOME/.profile" ;; esac if [ "$MODIFY_PATH" = "0" ]; then echo "" echo "$(home_relative "$INSTALL_DIR") is not on your PATH (RENMY_NO_MODIFY_PATH is set, so nothing was written). Add it with:" echo " export PATH=\"$PATH_ENTRY:\$PATH\"" elif grep -qsF -- "$PATH_ENTRY" "$PROFILE" || grep -qsF -- "$INSTALL_DIR" "$PROFILE"; then # Already there — ours from an earlier run, or the reader's own line. # Open terminals predate it either way. echo "$(home_relative "$PROFILE") already puts $(home_relative "$INSTALL_DIR") on PATH (open a new terminal to use the renmy command)." else mkdir -p "$(dirname "$PROFILE")" 2>/dev/null || true case "$PROFILE" in *config.fish) LINE="set -gx PATH \"$PATH_ENTRY\" \$PATH" ;; *) LINE="export PATH=\"$PATH_ENTRY:\$PATH\"" ;; esac if { printf '\n# Added by the Renmy installer (https://renmy.net)\n%s\n' "$LINE" >> "$PROFILE"; } 2>/dev/null; then echo "Added $(home_relative "$INSTALL_DIR") to PATH in $(home_relative "$PROFILE") (open a new terminal to use the renmy command)." else echo "" echo "Could not write $(home_relative "$PROFILE"). Add $(home_relative "$INSTALL_DIR") to your PATH with:" echo " $LINE" fi fi fi # --- what to run next --- # `set -e` above means a failed download, extract or install has already # exited, so these lines print only after the binary is in place. `renmy # setup` assesses the project it is run in and does what is still missing. echo "" echo "Next:" echo " cd " echo " renmy setup" # --- anonymous install ping --- # Runs after the binary is in place so a tracking failure can never block # the install. All errors are swallowed; the user never sees them. # Honor opt-outs (must mirror internal/telemetry/firstrun.go). TRACKING_DISABLED=0 # Either spelling opts out. An opt-out that stopped being honoured because # the product was renamed would be the worst thing here to get wrong. [ "${RENMY_NO_TRACKING:-${NIMBUS_NO_TRACKING:-}}" = "1" ] && TRACKING_DISABLED=1 [ "${RENMY_NO_TELEMETRY:-${NIMBUS_NO_TELEMETRY:-}}" = "1" ] && TRACKING_DISABLED=1 [ "${RENMY_NO_TELEMETRY:-${NIMBUS_NO_TELEMETRY:-}}" = "true" ] && TRACKING_DISABLED=1 case "${RENMY_TELEMETRY:-${NIMBUS_TELEMETRY:-}}" in false|0|off|FALSE|OFF) TRACKING_DISABLED=1 ;; esac if [ "$TRACKING_DISABLED" = "0" ]; then # KIND tells us "first install on this machine" vs "re-install / upgrade". # If install.id already exists, the user has run renmy before (or run # this script before) — so this is an upgrade event regardless of # whether they're moving versions or just re-running the installer. if [ -f "$INSTALL_ID_FILE" ]; then KIND="upgrade" ANON_ID=$(tr -d '[:space:]' < "$INSTALL_ID_FILE" 2>/dev/null || true) else KIND="install" ANON_ID="" fi # Generate a fresh anon_id if needed. Try sources in order of how # reliably they exist across darwin and major linux distros. if [ -z "$ANON_ID" ]; then if [ -r /proc/sys/kernel/random/uuid ]; then ANON_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || true) elif command -v uuidgen >/dev/null 2>&1; then ANON_ID=$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || true) fi fi # Persist so the binary's first-run telemetry can correlate this install # with the activation event it'll fire later. if [ -n "$ANON_ID" ]; then mkdir -p "$NIMBUS_DIR" 2>/dev/null || true printf '%s\n' "$ANON_ID" > "$INSTALL_ID_FILE" 2>/dev/null || true chmod 600 "$INSTALL_ID_FILE" 2>/dev/null || true BODY=$(printf '{"anon_id":"%s","kind":"%s","version":"%s","os":"%s","arch":"%s","installer_version":"%s"}' \ "$ANON_ID" "$KIND" "$VERSION" "$OS" "$ARCH" "$INSTALLER_VERSION") # 3-second timeout, no retries, output discarded. The install must # finish whether or not the ping succeeds. curl -fsS \ --max-time 3 \ -X POST \ -H "apikey: $SUPABASE_ANON_KEY" \ -H "Authorization: Bearer $SUPABASE_ANON_KEY" \ -H "Content-Type: application/json" \ -H "Prefer: return=minimal" \ -d "$BODY" \ "$SUPABASE_URL/rest/v1/installs" >/dev/null 2>&1 || true fi fi