From 2fbd4cdcbaec07e542f4d8310319c609b9123570 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:23:56 -0600 Subject: [PATCH] fix(installer): complete first-run onboarding (#101901) * fix(installer): complete first-run onboarding * docs(installer): document finalization paths * fix(installer): honor verify without tty * fix(installer): align config finalization --- docs/install/installer.md | 8 +- scripts/install.sh | 235 ++++++++++---------- test/scripts/install-sh.test.ts | 366 ++++++++++++++++++++++++++++++-- 3 files changed, 468 insertions(+), 141 deletions(-) diff --git a/docs/install/installer.md b/docs/install/installer.md index 62cdb86f7f8..4f7e5915fa4 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -85,10 +85,10 @@ Recommended for most interactive installs on macOS/Linux/WSL. - - Refreshes a loaded gateway service best-effort (`openclaw gateway install --force`, then restart) - - Runs `openclaw doctor --non-interactive` on upgrades and git installs (best effort) - - Attempts onboarding when appropriate (TTY available, onboarding not disabled, and bootstrap/config checks pass) - - Runs a post-install smoke verify when `--verify` is set + - Resolves the just-installed `openclaw` binary for follow-up commands + - For an unconfigured install, starts onboarding before doctor or gateway probes. With `--no-onboard` or no TTY, it prints the command to finish setup later. + - For a configured install, refreshes and restarts a loaded gateway service best-effort and runs doctor. Upgrades update plugins when possible, or print the manual command in a headless prompt-enabled run. + - When `--verify` runs, it checks the installed version and checks gateway health only after configuration exists. diff --git a/scripts/install.sh b/scripts/install.sh index 84e2930f62d..413049684bc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -72,6 +72,21 @@ resolve_openclaw_effective_home() { echo "$openclaw_home" } +resolve_openclaw_user_path() { + local input="$1" + local effective_home + effective_home="$(resolve_openclaw_effective_home)" + if [[ "$input" == "~" ]]; then + echo "$effective_home" + elif [[ "$input" == \~/* ]]; then + echo "${effective_home}${input:1}" + elif [[ "$input" == /* ]]; then + echo "$input" + else + echo "$PWD/$input" + fi +} + DOWNLOADER="" detect_downloader() { if command -v curl &> /dev/null; then @@ -2496,7 +2511,7 @@ warn_shell_path_missing_dir() { echo "" ui_info "PATH updated in ${rc}: added ${label} (${dir})" echo " New terminals pick this up automatically." - echo " For this shell, run: source ${rc}" + echo " For this shell, run: source ${rc}; hash -r" return 0 fi done @@ -2515,8 +2530,9 @@ openclaw_command_for_user() { return 0 fi - local claw_dir="${claw%/*}" - if [[ "$claw_dir" != "$claw" ]] && path_has_dir "$ORIGINAL_PATH" "$claw_dir"; then + local original_claw="" + original_claw="$(PATH="$ORIGINAL_PATH" type -P openclaw 2>/dev/null || true)" + if [[ "$original_claw" == "$claw" ]]; then echo "openclaw" return 0 fi @@ -2651,6 +2667,25 @@ resolve_openclaw_bin() { return 1 } +resolve_installed_openclaw_bin() { + local installed_bin="" + if [[ "$INSTALL_METHOD" == "git" ]]; then + installed_bin="$HOME/.local/bin/openclaw" + elif [[ "$INSTALL_METHOD" == "npm" ]]; then + local npm_bin="" + npm_bin="$(npm_global_bin_dir || true)" + if [[ -n "$npm_bin" ]]; then + installed_bin="${npm_bin}/openclaw" + fi + fi + + if [[ -n "$installed_bin" && -x "$installed_bin" ]]; then + echo "$installed_bin" + return 0 + fi + resolve_openclaw_bin +} + install_openclaw_from_git() { local repo_dir="$1" local repo_url="https://github.com/openclaw/openclaw.git" @@ -2888,68 +2923,32 @@ maybe_open_dashboard() { "$claw" dashboard || true } -resolve_workspace_dir() { - local profile="${OPENCLAW_PROFILE:-default}" +has_openclaw_config() { local effective_home effective_home="$(resolve_openclaw_effective_home)" - if [[ "${profile}" != "default" ]]; then - echo "${effective_home}/.openclaw/workspace-${profile}" - else - echo "${effective_home}/.openclaw/workspace" - fi -} - -run_bootstrap_onboarding_if_needed() { - if [[ "${NO_ONBOARD}" == "1" ]]; then + if [[ -n "${OPENCLAW_CONFIG_PATH:-}" ]]; then + local config_path + config_path="$(resolve_openclaw_user_path "$OPENCLAW_CONFIG_PATH")" + [[ -f "$config_path" ]] return fi - local effective_home - effective_home="$(resolve_openclaw_effective_home)" - local config_path="${OPENCLAW_CONFIG_PATH:-$effective_home/.openclaw/openclaw.json}" - local legacy_config_path="${HOME}/.openclaw/openclaw.json" - local legacy_clawdbot_path="${HOME}/.clawdbot/clawdbot.json" - if [[ -f "${config_path}" || -f "$effective_home/.clawdbot/clawdbot.json" ]]; then - return - fi - if [[ -z "${OPENCLAW_CONFIG_PATH:-}" && "${effective_home}" != "${HOME}" ]]; then - if [[ -f "$legacy_config_path" || -f "$legacy_clawdbot_path" ]]; then - return + if [[ -n "${OPENCLAW_STATE_DIR:-}" ]]; then + local state_dir + state_dir="$(resolve_openclaw_user_path "$OPENCLAW_STATE_DIR")" + if [[ -f "$state_dir/openclaw.json" || -f "$state_dir/clawdbot.json" ]]; then + return 0 fi + return 1 fi - local workspace - workspace="$(resolve_workspace_dir)" - local bootstrap="${workspace}/BOOTSTRAP.md" - - if [[ ! -f "${bootstrap}" ]]; then - return + if [[ -f "$effective_home/.openclaw/openclaw.json" || + -f "$effective_home/.openclaw/clawdbot.json" || + -f "$effective_home/.clawdbot/openclaw.json" || + -f "$effective_home/.clawdbot/clawdbot.json" ]]; then + return 0 fi - - if ! is_promptable; then - local user_claw - user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")" - ui_info "BOOTSTRAP.md found but no TTY; run ${user_claw} onboard to finish setup" - return - fi - - ui_info "BOOTSTRAP.md found; starting onboarding" - local claw="${OPENCLAW_BIN:-}" - if [[ -z "$claw" ]]; then - claw="$(resolve_openclaw_bin || true)" - fi - if [[ -z "$claw" ]]; then - ui_info "BOOTSTRAP.md found but openclaw not on PATH; skipping onboarding" - warn_openclaw_not_found - return - fi - - "$claw" onboard || { - local user_claw - user_claw="$(openclaw_command_for_user "$claw")" - ui_error "Onboarding failed; run ${user_claw} onboard to retry" - return - } + return 1 } load_install_version_helpers() { @@ -3060,7 +3059,9 @@ refresh_gateway_service_if_loaded() { if run_quiet_step "Restarting gateway service" "$claw" gateway restart; then ui_success "Gateway service restarted" else - ui_warn "Gateway service restart failed; continuing. Run: openclaw gateway restart" + local user_claw + user_claw="$(openclaw_command_for_user "$claw")" + ui_warn "Gateway service restart failed; continuing. Run: ${user_claw} gateway restart" return 0 fi @@ -3071,6 +3072,7 @@ verify_installation() { if [[ "${VERIFY_INSTALL}" != "1" ]]; then return 0 fi + local verify_gateway="${1:-true}" ui_stage "Verifying installation" local claw="${OPENCLAW_BIN:-}" @@ -3085,10 +3087,14 @@ verify_installation() { run_quiet_step "Checking OpenClaw version" "$claw" --version || return 1 - if is_gateway_daemon_loaded "$claw"; then + if [[ "$verify_gateway" != "true" ]]; then + ui_info "Setup not complete; skipping gateway service check" + elif is_gateway_daemon_loaded "$claw"; then run_quiet_step "Checking gateway service" "$claw" gateway status --deep || { + local user_claw + user_claw="$(openclaw_command_for_user "$claw")" ui_error "Install verify failed: gateway service unhealthy" - ui_info "Run: openclaw gateway status --deep" + ui_info "Run: ${user_claw} gateway status --deep" return 1 } else @@ -3163,7 +3169,6 @@ main() { is_upgrade=true fi local should_open_dashboard=false - local skip_onboard=false ui_stage "Preparing environment" @@ -3218,7 +3223,7 @@ main() { ui_stage "Finalizing setup" - OPENCLAW_BIN="$(resolve_openclaw_bin || true)" + OPENCLAW_BIN="$(resolve_installed_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. @@ -3233,21 +3238,11 @@ main() { fi fi - refresh_gateway_service_if_loaded - - # Step 6: Run doctor for migrations on upgrades and git installs - local run_doctor_after=false - if [[ "$is_upgrade" == "true" || "$INSTALL_METHOD" == "git" ]]; then - run_doctor_after=true + local config_present=false + if has_openclaw_config; then + config_present=true + refresh_gateway_service_if_loaded fi - if [[ "$run_doctor_after" == "true" ]]; then - if run_doctor; then - should_open_dashboard=true - fi - fi - - # Step 7: If BOOTSTRAP.md is still present in the workspace, resume onboarding - run_bootstrap_onboarding_if_needed local installed_version installed_version=$(resolve_openclaw_version) @@ -3304,17 +3299,46 @@ main() { echo "" if [[ "$INSTALL_METHOD" == "git" && -n "$final_git_dir" ]]; then + local user_claw + user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")" ui_section "Source install details" ui_kv "Checkout" "$final_git_dir" ui_kv "Wrapper" "$HOME/.local/bin/openclaw" - ui_kv "Update command" "openclaw update" + ui_kv "Update command" "${user_claw} update" ui_kv "Switch to npm" "curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method npm" + fi + + if [[ "$config_present" != "true" ]]; then + if [[ "$NO_ONBOARD" == "1" ]]; then + local user_claw + user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")" + ui_info "Skipping onboard (requested); run ${user_claw} onboard later" + else + ui_info "Starting setup" + echo "" + if is_promptable; then + local claw="${OPENCLAW_BIN:-}" + if [[ -z "$claw" ]]; then + claw="$(resolve_installed_openclaw_bin || true)" + fi + if [[ -z "$claw" ]]; then + ui_info "Skipping onboarding (openclaw not on PATH yet)" + warn_openclaw_not_found + return 0 + fi + exec /dev/null; then + if [[ "$config_present" == "true" ]]; then local claw="${OPENCLAW_BIN:-}" if [[ -z "$claw" ]]; then - claw="$(resolve_openclaw_bin || true)" + claw="$(resolve_installed_openclaw_bin || true)" fi if [[ -n "$claw" ]] && is_gateway_daemon_loaded "$claw"; then + local user_claw + user_claw="$(openclaw_command_for_user "$claw")" if [[ "$DRY_RUN" == "1" ]]; then - ui_info "Gateway daemon detected; would restart (openclaw daemon restart)" + ui_info "Gateway daemon detected; would restart (${user_claw} daemon restart)" else ui_info "Gateway daemon detected; restarting" if OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" daemon restart >/dev/null 2>&1; then ui_success "Gateway restarted" else - ui_warn "Gateway restart failed; try: openclaw daemon restart" + ui_warn "Gateway restart failed; try: ${user_claw} daemon restart" fi fi fi fi - if ! verify_installation; then + if ! verify_installation "$config_present"; then exit 1 fi diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 8d5205a1771..afe2d4f2047 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -718,7 +718,7 @@ NODE } }); - it("uses OPENCLAW_HOME for git and onboarding defaults", () => { + it("uses OPENCLAW_HOME for git defaults", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-home-")); const osHome = join(tmp, "os-home"); const openclawHome = join(tmp, "openclaw-home"); @@ -731,9 +731,7 @@ NODE [ `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, - 'printf "git=%s\\nworkspace=%s\\n" "$GIT_DIR" "$(resolve_workspace_dir)"', - "OPENCLAW_PROFILE=work", - 'printf "workspaceProfile=%s\\n" "$(resolve_workspace_dir)"', + 'printf "git=%s\\n" "$GIT_DIR"', ].join("\n"), { HOME: osHome, @@ -749,10 +747,6 @@ NODE expect(result?.status).toBe(0); const output = result?.stdout ?? ""; expect(output).toContain(`git=${join(openclawHome, "openclaw")}`); - expect(output).toContain(`workspace=${join(openclawHome, ".openclaw", "workspace")}`); - expect(output).toContain( - `workspaceProfile=${join(openclawHome, ".openclaw", "workspace-work")}`, - ); const mkdirParentIndex = script.indexOf('mkdir -p "$(dirname "$repo_dir")"'); const cloneIndex = script.indexOf( 'run_quiet_step "Cloning OpenClaw" git clone "$repo_url" "$repo_dir"', @@ -762,16 +756,14 @@ NODE expect(mkdirParentIndex).toBeLessThan(cloneIndex); }); - it("skips bootstrap onboarding when legacy HOME config exists with OPENCLAW_HOME", () => { + it("does not treat OS HOME config as active when OPENCLAW_HOME is set", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-legacy-config-")); const osHome = join(tmp, "os-home"); const openclawHome = join(tmp, "openclaw-home"); const legacyConfigDir = join(osHome, ".openclaw"); - const bootstrapDir = join(openclawHome, ".openclaw", "workspace"); mkdirSync(legacyConfigDir, { recursive: true }); - mkdirSync(bootstrapDir, { recursive: true }); + mkdirSync(openclawHome, { recursive: true }); writeFileSync(join(legacyConfigDir, "openclaw.json"), "{}\n"); - writeFileSync(join(bootstrapDir, "BOOTSTRAP.md"), "# bootstrap\n"); let result: ReturnType | undefined; try { @@ -779,8 +771,7 @@ NODE [ `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, - "NO_ONBOARD=0", - "run_bootstrap_onboarding_if_needed", + 'if has_openclaw_config; then printf "configured=1\\n"; else printf "configured=0\\n"; fi', ].join("\n"), { HOME: osHome, @@ -794,10 +785,244 @@ NODE } expect(result?.status).toBe(0); - expect(result?.stdout ?? "").not.toContain("BOOTSTRAP.md found"); + expect(result?.stdout).toContain("configured=0"); expect(result?.stderr ?? "").toBe(""); }); + it.each(["openclaw.json", "clawdbot.json"])( + "detects %s under OPENCLAW_STATE_DIR", + (configName) => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-state-config-")); + const stateDir = join(tmp, "state"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, configName), "{}\n"); + + let result: ReturnType | undefined; + try { + result = runInstallShell( + [ + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + 'if has_openclaw_config; then printf "configured=1\\n"; else printf "configured=0\\n"; fi', + ].join("\n"), + { + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_STATE_DIR: stateDir, + TERM: "dumb", + }, + ); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + expect(result?.stdout).toContain("configured=1"); + expect(result?.stderr ?? "").toBe(""); + }, + ); + + it("does not fall back to home config when OPENCLAW_STATE_DIR is set", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-state-override-")); + const home = join(tmp, "home"); + const stateDir = join(tmp, "state"); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(home, ".openclaw", "openclaw.json"), "{}\n"); + + let result: ReturnType | undefined; + try { + result = runInstallShell( + [ + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + 'if has_openclaw_config; then printf "configured=1\\n"; else printf "configured=0\\n"; fi', + ].join("\n"), + { + HOME: home, + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: stateDir, + TERM: "dumb", + }, + ); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + expect(result?.stdout).toContain("configured=0"); + expect(result?.stderr ?? "").toBe(""); + }); + + it.each([ + { + expected: /No TTY; run .*\/\.local\/bin\/openclaw onboard to finish setup/, + name: "starts setup", + noOnboard: 0, + }, + { + expected: /Skipping onboard .*run .*\/\.local\/bin\/openclaw onboard later/, + name: "honors --no-onboard", + noOnboard: 1, + }, + ])( + "$name for an unconfigured git install replacing an existing binary", + ({ expected, noOnboard }) => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + INSTALL_METHOD=git + GIT_DIR="$HOME/openclaw" + NO_ONBOARD=${noOnboard} + NO_PROMPT=1 + VERIFY_INSTALL=1 + OS=linux + + bootstrap_gum_temp() { :; } + print_installer_banner() { :; } + print_gum_status() { :; } + detect_os_or_die() { OS=linux; } + detect_openclaw_checkout() { return 1; } + show_install_plan() { :; } + check_existing_openclaw() { return 0; } + load_nvm_for_node_detection() { :; } + check_node() { return 0; } + activate_supported_node_on_path() { :; } + ensure_default_node_active_shell() { return 0; } + npm() { return 1; } + install_openclaw_from_git() { + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\\nexit 0\\n' > "$HOME/.local/bin/openclaw" + chmod +x "$HOME/.local/bin/openclaw" + export PATH="$HOME/.local/bin:$PATH" + } + resolve_openclaw_bin() { printf '%s\\n' "$HOME/.local/bin/openclaw"; } + warn_duplicate_openclaw_global_installs() { :; } + npm_global_bin_dir() { :; } + warn_shell_path_missing_dir() { :; } + refresh_gateway_service_if_loaded() { printf 'gateway-refresh-called\\n'; } + run_doctor() { + printf 'doctor-called\\n' + return 0 + } + resolve_openclaw_version() { printf 'test-version\\n'; } + is_gateway_daemon_loaded() { + printf 'gateway-probe-called\\n' + return 1 + } + maybe_open_dashboard() { :; } + show_footer_links() { :; } + + main + `); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("doctor-called"); + expect(result.stdout).not.toContain("gateway-refresh-called"); + expect(result.stdout).not.toContain("gateway-probe-called"); + expect(result.stdout).toMatch(/Update command:.*\/\.local\/bin\/openclaw update/); + expect(result.stdout).toMatch(expected); + }, + ); + + it("honors --verify for an unconfigured install without a TTY", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + INSTALL_METHOD=git + GIT_DIR="$HOME/openclaw" + NO_ONBOARD=0 + NO_PROMPT=1 + VERIFY_INSTALL=1 + OS=linux + + bootstrap_gum_temp() { :; } + print_installer_banner() { :; } + print_gum_status() { :; } + detect_os_or_die() { OS=linux; } + detect_openclaw_checkout() { return 1; } + show_install_plan() { :; } + check_existing_openclaw() { return 0; } + load_nvm_for_node_detection() { :; } + check_node() { return 0; } + activate_supported_node_on_path() { :; } + ensure_default_node_active_shell() { return 0; } + npm() { return 1; } + install_openclaw_from_git() { + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\\nexit 1\\n' > "$HOME/.local/bin/openclaw" + chmod +x "$HOME/.local/bin/openclaw" + export PATH="$HOME/.local/bin:$PATH" + } + resolve_openclaw_bin() { printf '%s\\n' "$HOME/.local/bin/openclaw"; } + warn_duplicate_openclaw_global_installs() { :; } + npm_global_bin_dir() { :; } + warn_shell_path_missing_dir() { :; } + refresh_gateway_service_if_loaded() { :; } + resolve_openclaw_version() { printf 'test-version\\n'; } + maybe_open_dashboard() { :; } + show_footer_links() { :; } + + main + `); + + expect(result.status).toBe(1); + expect(result.stdout).toMatch(/No TTY; run .*\/\.local\/bin\/openclaw onboard to finish setup/); + }); + + it("runs migration doctor for a configured upgrade without a TTY", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + INSTALL_METHOD=npm + NO_ONBOARD=0 + NO_PROMPT=0 + OS=linux + mkdir -p "$HOME/.openclaw" + printf '{}\\n' > "$HOME/.openclaw/openclaw.json" + + bootstrap_gum_temp() { :; } + print_installer_banner() { :; } + print_gum_status() { :; } + detect_os_or_die() { OS=linux; } + detect_openclaw_checkout() { return 1; } + show_install_plan() { :; } + check_existing_openclaw() { return 0; } + load_nvm_for_node_detection() { :; } + check_node() { return 0; } + activate_supported_node_on_path() { :; } + ensure_default_node_active_shell() { return 0; } + check_git() { return 0; } + fix_npm_permissions() { :; } + install_openclaw() { + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\\nexit 0\\n' > "$HOME/.local/bin/openclaw" + chmod +x "$HOME/.local/bin/openclaw" + export PATH="$HOME/.local/bin:$PATH" + } + resolve_openclaw_bin() { printf '%s\\n' "$HOME/.local/bin/openclaw"; } + warn_duplicate_openclaw_global_installs() { :; } + npm_global_bin_dir() { :; } + warn_shell_path_missing_dir() { :; } + refresh_gateway_service_if_loaded() { :; } + run_doctor() { + printf 'doctor-called\\n' + return 0 + } + resolve_openclaw_version() { printf 'test-version\\n'; } + is_gateway_daemon_loaded() { return 1; } + verify_installation() { return 0; } + maybe_open_dashboard() { printf 'dashboard-called\\n'; } + show_footer_links() { :; } + + main + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("doctor-called"); + expect(result.stdout).toContain("dashboard-called"); + }); + it("rejects OpenClaw GitHub source targets for npm installs", () => { const result = runInstallShell(` set -euo pipefail @@ -1357,12 +1582,17 @@ NODE it("uses a quoted absolute openclaw path in follow-up commands when npm bin is not on the original PATH", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-command-")); const npmBin = join(tmp, "npm bin"); + const staleBin = join(tmp, "stale-bin"); const visibleBin = join(tmp, "visible-bin"); mkdirSync(npmBin, { recursive: true }); + mkdirSync(staleBin, { recursive: true }); mkdirSync(visibleBin, { recursive: true }); const openclawBin = join(npmBin, "openclaw"); + const staleOpenclawBin = join(staleBin, "openclaw"); writeFileSync(openclawBin, "#!/bin/sh\nexit 0\n"); + writeFileSync(staleOpenclawBin, "#!/bin/sh\nexit 0\n"); chmodSync(openclawBin, 0o755); + chmodSync(staleOpenclawBin, 0o755); let result: ReturnType | undefined; try { @@ -1373,6 +1603,8 @@ NODE printf 'missing=%s\\n' "$(openclaw_command_for_user "${openclawBin}")" ORIGINAL_PATH=${JSON.stringify(`${npmBin}:${visibleBin}:/usr/bin:/bin`)} printf 'present=%s\\n' "$(openclaw_command_for_user "${openclawBin}")" + ORIGINAL_PATH=${JSON.stringify(`${staleBin}:${npmBin}:/usr/bin:/bin`)} + printf 'shadowed=%s\\n' "$(openclaw_command_for_user "${openclawBin}")" `); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -1381,6 +1613,105 @@ NODE expect(result?.status).toBe(0); expect(result?.stdout).toContain(`missing=${openclawBin.replace(/ /g, "\\ ")}`); expect(result?.stdout).toContain("present=openclaw"); + expect(result?.stdout).toContain(`shadowed=${openclawBin.replace(/ /g, "\\ ")}`); + }); + + it("prefers the binary owned by the completed install method over stale PATH entries", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-selected-bin-")); + const home = join(tmp, "home"); + const npmBin = join(tmp, "npm-bin"); + const staleBin = join(tmp, "stale-bin"); + const gitBin = join(home, ".local", "bin"); + mkdirSync(npmBin, { recursive: true }); + mkdirSync(staleBin, { recursive: true }); + mkdirSync(gitBin, { recursive: true }); + for (const bin of [ + join(npmBin, "openclaw"), + join(staleBin, "openclaw"), + join(gitBin, "openclaw"), + ]) { + writeFileSync(bin, "#!/bin/sh\nexit 0\n"); + chmodSync(bin, 0o755); + } + + let result: ReturnType | undefined; + try { + result = runInstallShell( + ` + set -euo pipefail + source "${SCRIPT_PATH}" + INSTALL_METHOD=git + printf 'git=%s\\n' "$(resolve_installed_openclaw_bin)" + INSTALL_METHOD=npm + npm_global_bin_dir() { printf '%s\\n' "${npmBin}"; } + printf 'npm=%s\\n' "$(resolve_installed_openclaw_bin)" + `, + { + HOME: home, + PATH: `${staleBin}:${process.env.PATH ?? ""}`, + }, + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + expect(result?.status).toBe(0); + expect(result?.stdout).toContain(`git=${join(gitBin, "openclaw")}`); + expect(result?.stdout).toContain(`npm=${join(npmBin, "openclaw")}`); + }); + + it("uses the selected binary in gateway recovery guidance", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-gateway-guidance-")); + const currentBin = join(tmp, "current bin"); + const staleBin = join(tmp, "stale-bin"); + mkdirSync(currentBin, { recursive: true }); + mkdirSync(staleBin, { recursive: true }); + const openclawBin = join(currentBin, "openclaw"); + writeFileSync(openclawBin, "#!/bin/sh\nexit 0\n"); + writeFileSync(join(staleBin, "openclaw"), "#!/bin/sh\nexit 0\n"); + chmodSync(openclawBin, 0o755); + chmodSync(join(staleBin, "openclaw"), 0o755); + + let result: ReturnType | undefined; + try { + result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + OPENCLAW_BIN=${JSON.stringify(openclawBin)} + ORIGINAL_PATH=${JSON.stringify(`${staleBin}:${currentBin}:/usr/bin:/bin`)} + VERIFY_INSTALL=1 + is_gateway_daemon_loaded() { return 0; } + run_quiet_step() { + case "$1" in + "Restarting gateway service"|"Checking gateway service") return 1 ;; + *) return 0 ;; + esac + } + refresh_gateway_service_if_loaded + verify_installation true || true + `); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + const quotedBin = openclawBin.replace(/ /g, "\\ "); + expect(result?.status).toBe(0); + expect(result?.stdout).toContain(`Run: ${quotedBin} gateway restart`); + expect(result?.stdout).toContain(`Run: ${quotedBin} gateway status --deep`); + }); + + it("refreshes the shell command cache after loading a persisted PATH update", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + printf 'export PATH="$HOME/.local/bin:$PATH"\\n' > "$HOME/.bashrc" + ORIGINAL_PATH="/usr/bin:/bin" + warn_shell_path_missing_dir "$HOME/.local/bin" "user-local bin dir" + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("For this shell, run: source "); + expect(result.stdout).toContain("; hash -r"); }); it("resolves requested git install versions to checkout refs", () => { @@ -1703,10 +2034,7 @@ describe("install.sh doctor cancellation and dashboard guard", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); it("guards every run_doctor caller against failure", () => { - // Both run_doctor call sites must guard the return value so a - // failed or cancelled doctor does not launch the dashboard. - // The upgrade path uses: if run_doctor; then should_open_dashboard=true; fi - // The existing-config path must also guard: if run_doctor; then ... + // A failed or cancelled doctor must not launch the dashboard. expect(script).toContain("if run_doctor; then"); // Ensure there is no bare "run_doctor" call followed by // "should_open_dashboard=true" without an if-guard