From db0864ad416374dcb81fb66e561708bda9b2831a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 06:49:08 +0100 Subject: [PATCH] fix(installer): warn about duplicate global installs --- CHANGELOG.md | 1 + scripts/install.sh | 143 ++++++++++++++++++++++++++++++++ test/scripts/install-sh.test.ts | 44 ++++++++++ 3 files changed, 188 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a8bb0f136..d4677c6d094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io. - Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj. - CLI/Volta: respawn raw `openclaw` CLI runs through the named `node` shim when the current Node executable resolves to `volta-shim`, avoiding direct shim execution failures in non-interactive shells. Fixes #68672. Thanks @sanchezm86. +- Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio. - Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg. - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. diff --git a/scripts/install.sh b/scripts/install.sh index ae378ccf1dc..67c33544e9f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1825,6 +1825,148 @@ npm_global_bin_dir() { return 1 } +canonicalize_dir() { + local dir="$1" + if [[ -z "$dir" || ! -d "$dir" ]]; then + return 1 + fi + (cd "$dir" 2>/dev/null && pwd -P) || return 1 +} + +openclaw_package_version() { + local package_json="$1" + if [[ ! -f "$package_json" ]]; then + echo "unknown" + return 0 + fi + + local version="" + if command -v node >/dev/null 2>&1; then + version="$(node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(pkg.version || "unknown"));' "$package_json" 2>/dev/null || true)" + fi + if [[ -z "$version" ]]; then + version="$(sed -n -E 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -n1)" + fi + echo "${version:-unknown}" +} + +emit_npm_root_candidate() { + local root="${1%/}" + if [[ -n "$root" && "$root" == /* ]]; then + echo "$root" + fi +} + +collect_openclaw_npm_root_candidates() { + local root="" + root="$(npm root -g 2>/dev/null || true)" + emit_npm_root_candidate "$root" + + local npm_cmd="" + while IFS= read -r npm_cmd; do + [[ -n "$npm_cmd" ]] || continue + root="$("$npm_cmd" root -g 2>/dev/null || true)" + emit_npm_root_candidate "$root" + done < <(type -aP npm 2>/dev/null | awk '!seen[$0]++' || true) + + local extra_root="" + local old_ifs="$IFS" + IFS=":" + for extra_root in ${OPENCLAW_INSTALL_EXTRA_NPM_ROOTS:-}; do + emit_npm_root_candidate "$extra_root" + done + IFS="$old_ifs" + + emit_npm_root_candidate "/opt/homebrew/lib/node_modules" + emit_npm_root_candidate "/usr/local/lib/node_modules" + emit_npm_root_candidate "/usr/lib/node_modules" + + local manager_dir="" + local candidate="" + for manager_dir in "${NVM_DIR:-}" "$HOME/.nvm"; do + [[ -n "$manager_dir" && -d "$manager_dir" ]] || continue + for candidate in "$manager_dir"/versions/node/*/lib/node_modules; do + [[ -d "$candidate" ]] && emit_npm_root_candidate "$candidate" + done + done + + for manager_dir in "${FNM_DIR:-}" "$HOME/.fnm" "$HOME/.local/share/fnm"; do + [[ -n "$manager_dir" && -d "$manager_dir" ]] || continue + for candidate in "$manager_dir"/node-versions/*/installation/lib/node_modules; do + [[ -d "$candidate" ]] && emit_npm_root_candidate "$candidate" + done + done + + for manager_dir in "${VOLTA_HOME:-}" "$HOME/.volta"; do + [[ -n "$manager_dir" && -d "$manager_dir" ]] || continue + for candidate in "$manager_dir"/tools/image/node/*/lib/node_modules; do + [[ -d "$candidate" ]] && emit_npm_root_candidate "$candidate" + done + done +} + +find_openclaw_global_installs() { + local seen="|" + local npm_root="" + while IFS= read -r npm_root; do + [[ -n "$npm_root" ]] || continue + local package_dir="${npm_root%/}/openclaw" + local package_json="${package_dir}/package.json" + [[ -f "$package_json" ]] || continue + + local real_package_dir="" + real_package_dir="$(canonicalize_dir "$package_dir" || true)" + [[ -n "$real_package_dir" ]] || real_package_dir="$package_dir" + case "$seen" in + *"|${real_package_dir}|"*) continue ;; + esac + seen="${seen}${real_package_dir}|" + + local version="" + version="$(openclaw_package_version "$package_json")" + printf '%s\t%s\t%s\n' "$version" "$real_package_dir" "$npm_root" + done < <(collect_openclaw_npm_root_candidates) +} + +warn_duplicate_openclaw_global_installs() { + local installs=() + local line="" + while IFS= read -r line; do + [[ -n "$line" ]] && installs+=("$line") + done < <(find_openclaw_global_installs) + + if [[ "${#installs[@]}" -le 1 ]]; then + return 0 + fi + + ui_warn "Multiple OpenClaw global installs detected" + echo " Different Node/npm environments can run different OpenClaw versions." + + local active_node active_npm active_openclaw + active_node="$(command -v node 2>/dev/null || true)" + active_npm="$(command -v npm 2>/dev/null || true)" + active_openclaw="${OPENCLAW_BIN:-}" + if [[ -z "$active_openclaw" ]]; then + active_openclaw="$(type -P openclaw 2>/dev/null || true)" + fi + echo -e " Active node: ${INFO}${active_node:-none}${NC}" + echo -e " Active npm: ${INFO}${active_npm:-none}${NC}" + echo -e " Active openclaw: ${INFO}${active_openclaw:-none}${NC}" + echo "" + echo " Found installs:" + + local install version package_dir npm_root + for install in "${installs[@]}"; do + IFS=$'\t' read -r version package_dir npm_root <<< "$install" + echo -e " - ${INFO}${version:-unknown}${NC} ${package_dir}" + echo -e " npm root: ${MUTED}${npm_root}${NC}" + done + + echo "" + echo " Keep one install source, then remove stale installs with that environment's npm:" + echo " npm uninstall -g openclaw" +} + refresh_shell_command_cache() { hash -r 2>/dev/null || true } @@ -2435,6 +2577,7 @@ main() { ui_stage "Finalizing setup" OPENCLAW_BIN="$(resolve_openclaw_bin || true)" + warn_duplicate_openclaw_global_installs || true # PATH warning: installs can succeed while the user's login shell still lacks npm's global bin dir. local npm_bin="" diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index c1d91290a2b..d91dd0e62e3 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -240,3 +240,47 @@ describe("install.sh macOS Homebrew Node behavior", () => { } }); }); + +describe("install.sh duplicate OpenClaw install detection", () => { + it("warns with concrete package paths and versions for duplicate npm roots", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + root="$(mktemp -d)" + trap 'rm -rf "$root"' EXIT + mkdir -p "$root/brew/openclaw" "$root/fnm/openclaw" + printf '{"version":"2026.3.7"}\\n' > "$root/brew/openclaw/package.json" + printf '{"version":"2026.3.1"}\\n' > "$root/fnm/openclaw/package.json" + collect_openclaw_npm_root_candidates() { printf '%s\\n' "$root/brew" "$root/fnm"; } + OPENCLAW_BIN="$root/fnm/.bin/openclaw" + ui_warn() { echo "WARN: $*"; } + warn_duplicate_openclaw_global_installs + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Multiple OpenClaw global installs detected"); + expect(result.stdout).toContain("2026.3.7"); + expect(result.stdout).toContain("2026.3.1"); + expect(result.stdout).toContain("/brew/openclaw"); + expect(result.stdout).toContain("/fnm/openclaw"); + expect(result.stdout).toContain("Active openclaw:"); + expect(result.stdout).toContain("npm uninstall -g openclaw"); + }); + + it("stays quiet when only one OpenClaw npm root exists", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + root="$(mktemp -d)" + trap 'rm -rf "$root"' EXIT + mkdir -p "$root/only/openclaw" + printf '{"version":"2026.3.7"}\\n' > "$root/only/openclaw/package.json" + collect_openclaw_npm_root_candidates() { printf '%s\\n' "$root/only"; } + ui_warn() { echo "WARN: $*"; } + warn_duplicate_openclaw_global_installs + `); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("Multiple OpenClaw global installs detected"); + }); +});