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
This commit is contained in:
Jason (Json) 2026-07-07 18:23:56 -06:00 committed by GitHub
parent 23cf16a7d5
commit 2fbd4cdcba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 468 additions and 141 deletions

View file

@ -85,10 +85,10 @@ Recommended for most interactive installs on macOS/Linux/WSL.
</Step>
<Step title="Post-install tasks">
- 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.
</Step>
</Steps>

View file

@ -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/tty
exec "$claw" onboard
fi
local user_claw
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "No TTY; run ${user_claw} onboard to finish setup"
fi
elif [[ "$is_upgrade" == "true" ]]; then
ui_info "Upgrade complete"
if has_controlling_tty || [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
local claw="${OPENCLAW_BIN:-}"
if [[ -z "$claw" ]]; then
claw="$(resolve_openclaw_bin || true)"
claw="$(resolve_installed_openclaw_bin || true)"
fi
if [[ -z "$claw" ]]; then
ui_info "Skipping doctor (openclaw not on PATH yet)"
@ -3345,75 +3369,50 @@ main() {
doctor_ok=1
fi
if (( doctor_ok )); then
should_open_dashboard=true
ui_info "Updating plugins"
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" plugins update --all || true
else
ui_warn "Doctor failed; skipping plugin updates"
fi
else
if run_doctor; then
should_open_dashboard=true
fi
local user_claw
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "No TTY; run ${user_claw} doctor and ${user_claw} plugins update --all manually"
ui_info "No TTY; run ${user_claw} plugins update --all manually"
fi
else
if [[ "$NO_ONBOARD" == "1" || "$skip_onboard" == "true" ]]; then
local user_claw
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "Skipping onboard (requested); run ${user_claw} onboard later"
else
local effective_home
effective_home="$(resolve_openclaw_effective_home)"
local config_path="${OPENCLAW_CONFIG_PATH:-$effective_home/.openclaw/openclaw.json}"
if [[ -f "${config_path}" || -f "$effective_home/.clawdbot/clawdbot.json" ]]; then
ui_info "Config already present; running doctor"
if run_doctor; then
should_open_dashboard=true
fi
ui_info "Config already present; skipping onboarding"
skip_onboard=true
fi
ui_info "Starting setup"
echo ""
if is_promptable; then
local claw="${OPENCLAW_BIN:-}"
if [[ -z "$claw" ]]; then
claw="$(resolve_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/tty
exec "$claw" onboard
fi
local user_claw
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "No TTY; run ${user_claw} onboard to finish setup"
return 0
ui_info "Config already present; running doctor"
if run_doctor; then
should_open_dashboard=true
fi
ui_info "Config already present; skipping onboarding"
fi
if command -v openclaw &> /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

View file

@ -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<typeof runInstallShell> | 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<typeof runInstallShell> | 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<typeof runInstallShell> | 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<typeof runInstallShell> | 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<typeof runInstallShell> | 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<typeof runInstallShell> | 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