From d5b41ca65d256af22e8fef2e48ea181452d061c8 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Fri, 15 May 2026 10:03:42 +0800 Subject: [PATCH] fix(install): use nvm toolchain when PATH still resolves stale Node When nvm activates a supported Node version but `node` on PATH remains older, pin NODE_CMD/NPM_CMD/NPX_CMD to NVM_BIN for the rest of the installer run. Adds a Linux integration test for the stale-resolution scenario. Co-authored-by: Cursor --- scripts/install.sh | 110 ++++++++--- .../test_browser_runtime_configuration.py | 4 +- tests/scripts/test_install_script_sources.py | 177 +++++++++++++++++- 3 files changed, 262 insertions(+), 29 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 0c6710f7..8781db29 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -24,6 +24,9 @@ NODEJS_MANUAL_DOWNLOAD_URL="${FLOCKS_NODEJS_MANUAL_DOWNLOAD_URL:-https://nodejs. NVM_INSTALL_SCRIPT_URL="${FLOCKS_NVM_INSTALL_SCRIPT_URL:-https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh}" NVM_GITEE_REPO_URL="${FLOCKS_NVM_GITEE_REPO_URL:-https://gitee.com/mirrors/nvm.git}" NVM_GITEE_RAW_URL_PREFIX="${FLOCKS_NVM_GITEE_RAW_URL_PREFIX:-https://gitee.com/mirrors/nvm/raw}" +NODE_CMD="node" +NPM_CMD="npm" +NPX_CMD="npx" info() { printf '[flocks] %s\n' "$1" @@ -54,6 +57,30 @@ clear_command_cache() { hash -r 2>/dev/null || true } +command_exists() { + local cmd="$1" + [[ -n "$cmd" ]] || return 1 + + if [[ "$cmd" == */* ]]; then + [[ -x "$cmd" ]] + return + fi + + command -v "$cmd" >/dev/null 2>&1 +} + +node_command_available() { + command_exists "$NODE_CMD" +} + +npm_command_available() { + command_exists "$NPM_CMD" +} + +npx_command_available() { + command_exists "$NPX_CMD" +} + is_zh_install() { [[ "$INSTALL_LANGUAGE" == zh* || "$INSTALL_LANGUAGE" == cn* ]] } @@ -290,9 +317,9 @@ refresh_path() { append_path "$HOME/.bun/bin" append_path "$HOME/.npm-global/bin" - if has_cmd npm; then + if npm_command_available; then local npm_prefix - npm_prefix="$(npm config get prefix 2>/dev/null | tr -d '\r' || true)" + npm_prefix="$(get_npm_prefix || true)" if [[ -n "$npm_prefix" && "$npm_prefix" != "undefined" && "$npm_prefix" != "null" ]]; then append_path "$npm_prefix" append_path "$npm_prefix/bin" @@ -301,12 +328,12 @@ refresh_path() { } get_npm_prefix() { - if ! has_cmd npm; then + if ! npm_command_available; then return 1 fi local npm_prefix - npm_prefix="$(npm config get prefix 2>/dev/null | tr -d '\r' || true)" + npm_prefix="$("$NPM_CMD" config get prefix 2>/dev/null | tr -d '\r' || true)" if [[ -z "$npm_prefix" || "$npm_prefix" == "undefined" || "$npm_prefix" == "null" ]]; then return 1 fi @@ -378,25 +405,50 @@ parse_args() { done } -get_node_major_version() { - if ! has_cmd node; then - return 1 - fi - +get_node_major_version_from_cmd() { + local node_cmd="$1" local version - version="$(node -v 2>/dev/null | tr -d '\r' || true)" + command_exists "$node_cmd" || return 1 + + version="$("$node_cmd" -v 2>/dev/null | tr -d '\r' || true)" version="${version#v}" version="${version%%.*}" [[ "$version" =~ ^[0-9]+$ ]] || return 1 printf '%s' "$version" } -node_version_satisfies_requirement() { +get_node_major_version() { + get_node_major_version_from_cmd "$NODE_CMD" +} + +node_command_satisfies_requirement() { + local node_cmd="$1" local major - major="$(get_node_major_version)" || return 1 + major="$(get_node_major_version_from_cmd "$node_cmd")" || return 1 [[ "$major" -ge "$MIN_NODE_MAJOR" ]] } +node_version_satisfies_requirement() { + node_command_satisfies_requirement "$NODE_CMD" +} + +set_node_toolchain_from_bin_dir() { + local bin_dir="$1" + [[ -n "$bin_dir" && -d "$bin_dir" ]] || return 1 + [[ -x "$bin_dir/node" && -x "$bin_dir/npm" ]] || return 1 + + NODE_CMD="$bin_dir/node" + NPM_CMD="$bin_dir/npm" + if [[ -x "$bin_dir/npx" ]]; then + NPX_CMD="$bin_dir/npx" + else + NPX_CMD="npx" + fi + + append_path "$bin_dir" + clear_command_cache +} + load_nvm() { export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" [[ -s "$NVM_DIR/nvm.sh" ]] || return 1 @@ -489,10 +541,20 @@ install_nodejs_with_nvm() { fi clear_command_cache - if ! node_version_satisfies_requirement; then - warn "nvm activated Node.js ${MIN_NODE_MAJOR}, but the current shell still resolves a different node binary. Open a new shell and retry.$(nodejs_manual_download_hint)" - return 1 + if node_command_satisfies_requirement "node" && npm_command_available; then + return 0 fi + + if [[ -n "${NVM_BIN:-}" ]] \ + && command_exists "$NVM_BIN/npm" \ + && node_command_satisfies_requirement "$NVM_BIN/node"; then + set_node_toolchain_from_bin_dir "$NVM_BIN" + warn "nvm activated Node.js ${MIN_NODE_MAJOR}, but the current shell still resolves a different node binary. Continuing with the nvm-managed toolchain for this installer run. Open a new shell afterwards.$(nodejs_manual_download_hint)" + return 0 + fi + + warn "nvm activated Node.js ${MIN_NODE_MAJOR}, but the nvm-managed runtime is still not usable in this shell.$(nodejs_manual_download_hint)" + return 1 } install_nodejs_macos() { @@ -578,11 +640,11 @@ install_nodejs_linux() { } ensure_npm_installed() { - if has_cmd npm && node_version_satisfies_requirement; then + if npm_command_available && node_version_satisfies_requirement; then return fi - if has_cmd node; then + if node_command_available; then local current_major current_major="$(get_node_major_version || true)" if [[ -n "$current_major" ]]; then @@ -605,12 +667,12 @@ ensure_npm_installed() { esac refresh_path - has_cmd npm || fail "Node.js (including npm) was installed, but npm is still not available. Check PATH and retry.$(nodejs_manual_download_hint)" + npm_command_available || fail "Node.js (including npm) was installed, but npm is still not available. Check PATH and retry.$(nodejs_manual_download_hint)" node_version_satisfies_requirement || fail "Detected Node.js version is too old. This project requires Node.js ${MIN_NODE_MAJOR}+.$(nodejs_manual_download_hint)" } ensure_npm_global_prefix_writable() { - has_cmd npm || fail "npm was not found. Install Node.js 22+ (including npm) and retry.$(nodejs_manual_download_hint)" + npm_command_available || fail "npm was not found. Install Node.js 22+ (including npm) and retry.$(nodejs_manual_download_hint)" local npm_prefix target_dir user_prefix npm_prefix="$(get_npm_prefix || true)" @@ -627,7 +689,7 @@ ensure_npm_global_prefix_writable() { user_prefix="$HOME/.npm-global" info "Global npm directory is not writable. Switching to user prefix: $user_prefix" mkdir -p "$user_prefix" - npm config set prefix "$user_prefix" + "$NPM_CMD" config set prefix "$user_prefix" refresh_path } @@ -944,7 +1006,7 @@ resolve_chrome_for_testing_path_from_dir() { install_chrome_for_testing() { local browser_dir browser_path="" install_status - if ! has_cmd npx; then + if ! npx_command_available; then if is_zh_install; then warn "未找到 npx,跳过浏览器安装;这不影响 Flocks 启动,可稍后重新安装。" else @@ -965,7 +1027,7 @@ install_chrome_for_testing() { fi set +e - npm_config_registry="$NPM_REGISTRY" npx --yes @puppeteer/browsers install chrome@stable --path "$browser_dir" 1>&2 + npm_config_registry="$NPM_REGISTRY" "$NPX_CMD" --yes @puppeteer/browsers install chrome@stable --path "$browser_dir" 1>&2 install_status=$? set -e @@ -1021,7 +1083,7 @@ install_agent_browser() { if ! has_cmd agent-browser; then ensure_npm_global_prefix_writable info "Installing the agent-browser CLI..." - npm_config_registry="$NPM_REGISTRY" npm install --global agent-browser + npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install --global agent-browser refresh_path ensure_agent_browser_user_path_if_needed has_cmd agent-browser || fail "agent-browser finished installing, but it is still not available. Check PATH and retry." @@ -1066,7 +1128,7 @@ main() { fi ( cd "$ROOT_DIR/webui" - npm_config_registry="$NPM_REGISTRY" npm install + npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install ) if [[ "$INSTALL_TUI" -eq 1 ]]; then diff --git a/tests/scripts/test_browser_runtime_configuration.py b/tests/scripts/test_browser_runtime_configuration.py index 92cd1738..7e4d840b 100644 --- a/tests/scripts/test_browser_runtime_configuration.py +++ b/tests/scripts/test_browser_runtime_configuration.py @@ -12,10 +12,10 @@ def test_bash_installer_prefers_explicit_browser_configuration() -> None: assert "AGENT_BROWSER_EXECUTABLE_PATH" in script assert "get_chrome_for_testing_dir()" in script assert "resolve_chrome_for_testing_path_from_dir()" in script - assert 'npx --yes @puppeteer/browsers install chrome@stable --path "$browser_dir"' in script + assert '"$NPX_CMD" --yes @puppeteer/browsers install chrome@stable --path "$browser_dir"' in script assert 'Downloading Chrome for Testing.' in script assert 'If browser installation fails, Flocks can still start and you can reinstall it later.' in script - assert 'npm_config_registry="$NPM_REGISTRY" npx --yes @puppeteer/browsers install chrome@stable --path "$browser_dir" 1>&2' in script + assert 'npm_config_registry="$NPM_REGISTRY" "$NPX_CMD" --yes @puppeteer/browsers install chrome@stable --path "$browser_dir" 1>&2' in script assert '"$browser_dir"/**/"Google Chrome for Testing"' in script assert '"$browser_dir"/**/chrome.exe' in script assert '"$browser_dir"/**/chrome' in script diff --git a/tests/scripts/test_install_script_sources.py b/tests/scripts/test_install_script_sources.py index 03aa3adc..ad979c17 100644 --- a/tests/scripts/test_install_script_sources.py +++ b/tests/scripts/test_install_script_sources.py @@ -148,9 +148,13 @@ def test_main_bash_installer_uses_configured_default_sources_without_probing() - assert '使用 uv 官方回退安装脚本: $UV_INSTALL_SH_SECONDARY_FALLBACK_URL' in script assert 'pick_fastest_url' not in script assert 'Probing PyPI and npm registries to choose the faster source' not in script - assert 'npm_config_registry="$NPM_REGISTRY" npm install' in script - assert 'npm_config_registry="$NPM_REGISTRY" npx --yes @puppeteer/browsers install chrome@stable --path "$browser_dir"' in script - assert 'npm_config_registry="$NPM_REGISTRY" npm install --global agent-browser' in script + assert 'NODE_CMD="node"' in script + assert 'NPM_CMD="npm"' in script + assert 'NPX_CMD="npx"' in script + assert 'set_node_toolchain_from_bin_dir()' in script + assert 'npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install' in script + assert 'npm_config_registry="$NPM_REGISTRY" "$NPX_CMD" --yes @puppeteer/browsers install chrome@stable --path "$browser_dir"' in script + assert 'npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install --global agent-browser' in script assert "FLOCKS_NODEJS_MANUAL_DOWNLOAD_URL" in script assert "https://nodejs.org/en/download" in script assert "nodejs_manual_download_hint" in script @@ -858,6 +862,173 @@ def test_main_bash_installer_prefers_nvm_on_linux_before_package_manager() -> No assert result.returncode == 0, output +def test_main_bash_installer_keeps_using_nvm_toolchain_when_shell_node_resolution_stays_stale_on_linux() -> None: + script = (SCRIPT_DIR / "install.sh").read_text(encoding="utf-8") + script_without_main = re.sub(r'\nmain "\$@"\s*$', "\n", script) + test_script = script_without_main + textwrap.dedent( + r""" + + export HOME="$(mktemp -d)" + unset NVM_DIR + export TEST_LOG="$HOME/install-node.log" + + info() { + printf 'INFO:%s\n' "$1" >> "$TEST_LOG" + } + + warn() { + printf 'WARN:%s\n' "$1" >> "$TEST_LOG" + } + + fail() { + printf 'FAIL:%s\n' "$1" >&2 + exit 1 + } + + has_cmd() { + case "$1" in + curl|pacman) + return 0 + ;; + *) + command -v "$1" >/dev/null 2>&1 + ;; + esac + } + + node() { + printf 'v18.20.8\n' + } + + npm() { + if [[ "${1:-}" == "-v" ]]; then + printf '9.9.9\n' + return 0 + fi + + if [[ "${1:-}" == "config" && "${2:-}" == "get" && "${3:-}" == "prefix" ]]; then + printf '/usr/local\n' + return 0 + fi + + printf 'stale npm\n' + } + + curl() { + local output_file="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) + output_file="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + cat > "$output_file" <<'EOF' + mkdir -p "$HOME/.nvm" + cat > "$HOME/.nvm/nvm.sh" <<'EOS' + nvm() { + printf '%s\n' "$*" >> "$HOME/nvm-commands.log" + if [[ "$1" == "install" ]]; then + mkdir -p "$HOME/.nvm/versions/node/v22.22.2/bin" + cat > "$HOME/.nvm/versions/node/v22.22.2/bin/node" <<'EON' + #!/usr/bin/env bash + printf 'v22.22.2\n' + EON + cat > "$HOME/.nvm/versions/node/v22.22.2/bin/npm" <<'EON' + #!/usr/bin/env bash + if [[ "${1:-}" == "-v" ]]; then + printf '10.9.7\n' + exit 0 + fi + if [[ "${1:-}" == "config" && "${2:-}" == "get" && "${3:-}" == "prefix" ]]; then + printf '%s\n' "$HOME/.nvm/versions/node/v22.22.2" + exit 0 + fi + printf 'nvm npm\n' + EON + cat > "$HOME/.nvm/versions/node/v22.22.2/bin/npx" <<'EON' + #!/usr/bin/env bash + printf 'nvm npx\n' + EON + chmod +x "$HOME/.nvm/versions/node/v22.22.2/bin/node" \ + "$HOME/.nvm/versions/node/v22.22.2/bin/npm" \ + "$HOME/.nvm/versions/node/v22.22.2/bin/npx" + return 0 + fi + if [[ "$1" == "use" ]]; then + export NVM_BIN="$HOME/.nvm/versions/node/v22.22.2/bin" + return 0 + fi + return 0 + } + EOS + EOF + } + + run_with_privilege() { + printf '%s\n' "$*" >> "$HOME/pkg-commands.log" + return 0 + } + + install_nodejs_linux + + stale_node_version="$(node -v)" + selected_node_version="$("$NODE_CMD" -v)" + selected_npm_version="$("$NPM_CMD" -v)" + selected_npm_prefix="$("$NPM_CMD" config get prefix)" + install_log="$(<"$TEST_LOG")" + + [[ "$stale_node_version" == "v18.20.8" ]] || { + printf 'stale shell node unexpectedly changed: %s\n' "$stale_node_version" >&2 + exit 1 + } + [[ "$selected_node_version" == "v22.22.2" ]] || { + printf 'installer did not select the nvm node runtime: %s\n' "$selected_node_version" >&2 + exit 1 + } + [[ "$selected_npm_version" == "10.9.7" ]] || { + printf 'installer did not select the nvm npm runtime: %s\n' "$selected_npm_version" >&2 + exit 1 + } + [[ "$selected_npm_prefix" == "$HOME/.nvm/versions/node/v22.22.2" ]] || { + printf 'unexpected nvm npm prefix: %s\n' "$selected_npm_prefix" >&2 + exit 1 + } + [[ "$NODE_CMD" == "$HOME/.nvm/versions/node/v22.22.2/bin/node" ]] || { + printf 'NODE_CMD did not pin to the nvm runtime: %s\n' "$NODE_CMD" >&2 + exit 1 + } + [[ "$NPM_CMD" == "$HOME/.nvm/versions/node/v22.22.2/bin/npm" ]] || { + printf 'NPM_CMD did not pin to the nvm runtime: %s\n' "$NPM_CMD" >&2 + exit 1 + } + [[ "$install_log" == *"WARN:nvm activated Node.js 22, but the current shell still resolves a different node binary. Continuing with the nvm-managed toolchain for this installer run. Open a new shell afterwards."* ]] || { + printf 'stale-shell warning missing: %s\n' "$install_log" >&2 + exit 1 + } + [[ ! -f "$HOME/pkg-commands.log" ]] || { + printf 'package manager fallback should not run when nvm runtime is usable: %s\n' "$(<"$HOME/pkg-commands.log")" >&2 + exit 1 + } + """ + ) + + result = subprocess.run( + ["bash", "-c", test_script], + check=False, + capture_output=True, + text=True, + ) + + output = f"{result.stdout}\n{result.stderr}" + assert result.returncode == 0, output + + def test_main_bash_installer_falls_back_to_package_manager_when_nvm_fails_on_linux() -> None: script = (SCRIPT_DIR / "install.sh").read_text(encoding="utf-8") script_without_main = re.sub(r'\nmain "\$@"\s*$', "\n", script)