Files
openclaw/scripts/install-cli.sh
T
Peter Steinberger 7bc994aee8 fix(install): avoid success after incomplete lifecycle changes (#125992)
* fix(install): make lifecycle mutations transactional

Standalone installers now apply npm-version-aware lifecycle approval. Updates verify and repair the installation before reporting success and preserve the prior install owner during method switches. Uninstall now exits nonzero when requested cleanup is only partially completed. Plugin update behavior is unchanged.

Closes #125925

* test(uninstall): assert aggregated live-owner failure

* fix(install): satisfy standalone shell checks

* fix(update): scan PATH for prior Git wrapper

* test(hooks): await Gmail watcher descendant exit

* fix(install): verify Windows npm candidate

* fix(ci): normalize package acceptance version

* fix(update): preserve staged local package links

* test(update): fold staged symlink coverage

* fix(update): retire every legacy Git wrapper

* test(docs): align consolidated ownership checks
2026-08-18 20:50:15 -07:00

1724 lines
53 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# OpenClaw CLI installer (non-interactive, no onboarding)
# Usage: curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install-cli.sh | bash -s -- [--json] [--prefix <path>] [--version <ver>] [--node-version <ver>] [--onboard]
ensure_home_env() {
if [[ -n "${HOME:-}" && "${HOME}" != "/" && -d "${HOME}" ]]; then
return 0
fi
local user_name=""
local home_dir=""
user_name="$(id -un 2>/dev/null || true)"
if [[ -n "$user_name" ]]; then
if command -v getent >/dev/null 2>&1; then
home_dir="$(getent passwd "$user_name" 2>/dev/null | awk -F: '{print $6; exit}' || true)"
fi
if [[ -z "$home_dir" && "$(uname -s 2>/dev/null || true)" == "Darwin" ]] && command -v dscl >/dev/null 2>&1; then
home_dir="$(dscl . -read "/Users/${user_name}" NFSHomeDirectory 2>/dev/null | awk '{print $2; exit}' || true)"
fi
fi
if [[ -n "$home_dir" && "$home_dir" != "/" && -d "$home_dir" ]]; then
export HOME="$home_dir"
fi
}
ensure_home_env
# Track temp paths so fail/exit paths do not leak mktemp dirs/files.
# Register paths in the caller: command substitutions run in a subshell, so
# array mutations inside a helper would not reach this shell.
TMPFILES=()
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
cleanup_tmpfiles() {
if [[ -n "$WRAPPER_BACKUP_PATH" && ( -e "$WRAPPER_BACKUP_PATH" || -L "$WRAPPER_BACKUP_PATH" ) ]]; then
rm -f "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
mv "$WRAPPER_BACKUP_PATH" "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
fi
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
done
}
trap cleanup_tmpfiles EXIT
resolve_home_path() {
local input="$1"
case "$input" in
\~) echo "$HOME" ;;
\~/*) echo "${HOME}${input:1}" ;;
*) echo "$input" ;;
esac
}
INSTALLER_CWD="$(pwd -P)"
resolve_installer_path() {
local input
input="$(resolve_home_path "$1")"
case "$input" in
"") echo "" ;;
/*) echo "$input" ;;
*) echo "${INSTALLER_CWD}/${input}" ;;
esac
}
OPENCLAW_EFFECTIVE_HOME="$(resolve_home_path "${OPENCLAW_HOME:-$HOME}")"
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
REQUIRED_COMPATIBLE_VERSION=""
DEFAULT_NODE_VERSION="24.15.0"
ARMV7_DEFAULT_NODE_VERSION="22.22.3"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-${DEFAULT_NODE_VERSION}}"
NODE_VERSION_REQUESTED=0
if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then
NODE_VERSION_REQUESTED=1
fi
MIN_NODE_22_VERSION="22.22.3"
MIN_NODE_24_VERSION="24.15.0"
MIN_NODE_25_VERSION="25.9.0"
SUPPORTED_NODE_VERSION_LABEL="Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+"
NODE_RELEASE_VERSION_CORE=""
APK_NODE_BIN_DIR="/usr/bin"
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
GIT_DIR="${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}"
GIT_UPDATE="${OPENCLAW_GIT_UPDATE:-1}"
JSON=0
RUN_ONBOARD=0
SET_NPM_PREFIX=0
PNPM_CMD=()
FRESH_GIT_MIN_FREE_KIB=$((6 * 1024 * 1024))
print_usage() {
cat <<EOF
Usage: install-cli.sh [options]
--json Emit NDJSON events (no human output)
--prefix <path> Install prefix (default: ~/.openclaw; use \$OPENCLAW_PREFIX to override)
--install-method, --method npm|git Install via npm (default) or from a git checkout
--npm Shortcut for --install-method npm
--git, --github Shortcut for --install-method git
--git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
--version <ver> OpenClaw version (default: latest)
--compatible-with <ver> Refuse a CLI that cannot modify config written by <ver>
--node-version <ver> Node version (default: 24.15.0; 22.22.3 on Linux ARMv7)
--onboard Run "openclaw onboard" after install
--no-onboard Skip onboarding (default)
--set-npm-prefix Force npm prefix to ~/.npm-global if current prefix is not writable (Linux)
Environment variables:
OPENCLAW_NPM_LOGLEVEL=error|warn|notice Default: error (hide npm deprecation noise)
OPENCLAW_INSTALL_METHOD=git|npm
OPENCLAW_HOME=...
OPENCLAW_PREFIX=...
OPENCLAW_VERSION=latest|next|<semver>
OPENCLAW_GIT_DIR=...
OPENCLAW_GIT_UPDATE=0|1
EOF
}
log() {
if [[ "$JSON" -eq 0 ]]; then
echo "$@"
fi
}
DOWNLOADER=""
detect_downloader() {
if command -v curl >/dev/null 2>&1; then
DOWNLOADER="curl"
return 0
fi
if command -v wget >/dev/null 2>&1; then
DOWNLOADER="wget"
return 0
fi
fail "Missing downloader (curl or wget required)"
}
download_file() {
local url="$1"
local output="$2"
if [[ -z "$DOWNLOADER" ]]; then
detect_downloader
fi
if [[ "$DOWNLOADER" == "curl" ]]; then
# Bound post-connect stalls without imposing a total download duration.
curl -fsSL --proto '=https' --tlsv1.2 \
--speed-limit 1 --speed-time 30 \
--retry 3 --retry-delay 1 --retry-connrefused \
-o "$output" "$url"
return
fi
wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
}
cleanup_legacy_submodules() {
local repo_dir="${1:-${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}}"
local legacy_dir="${repo_dir}/Peekaboo"
if [[ -d "$legacy_dir" ]]; then
emit_json "{\"event\":\"step\",\"name\":\"legacy-submodule\",\"status\":\"start\",\"path\":\"${legacy_dir//\"/\\\"}\"}"
log "Removing legacy submodule checkout: ${legacy_dir}"
rm -rf "$legacy_dir"
emit_json "{\"event\":\"step\",\"name\":\"legacy-submodule\",\"status\":\"ok\",\"path\":\"${legacy_dir//\"/\\\"}\"}"
fi
}
sha256_file() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" | awk '{print $1}'
return 0
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" | awk '{print $1}'
return 0
fi
if command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$file" | awk '{print $NF}'
return 0
fi
fail "Missing sha256 tool (need sha256sum, shasum, or openssl)"
}
emit_json() {
if [[ "$JSON" -eq 1 ]]; then
printf '%s\n' "$1"
fi
}
fail() {
local msg="$1"
emit_json "{\"event\":\"error\",\"message\":\"${msg//\"/\\\"}\"}"
log "ERROR: $msg"
exit 1
}
require_bin() {
local name="$1"
if ! command -v "$name" >/dev/null 2>&1; then
fail "Missing required binary: $name"
fi
}
available_disk_kib() {
local target="$1"
df -Pk "$target" 2>/dev/null | awk 'NR == 2 { print $4; exit }' || true
}
preflight_fresh_git_disk_space() {
local repo_dir="$1"
local ancestor
local available_kib
local available_gib
if [[ -d "$repo_dir/.git" ]]; then
return 0
fi
emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"start\"}"
ancestor="$repo_dir"
while [[ ! -e "$ancestor" ]]; do
local parent
parent="$(dirname "$ancestor")"
if [[ "$parent" == "$ancestor" ]]; then
break
fi
ancestor="$parent"
done
if [[ ! -d "$ancestor" ]]; then
ancestor="$(dirname "$ancestor")"
fi
available_kib="$(available_disk_kib "$ancestor")"
if [[ ! "$available_kib" =~ ^[0-9]+$ ]]; then
emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"warn\",\"reason\":\"unreadable\"}"
return 0
fi
if ((available_kib < FRESH_GIT_MIN_FREE_KIB)); then
available_gib="$(awk -v kib="$available_kib" 'BEGIN { printf "%.1f", kib / 1048576 }')"
fail "Fresh Git installs require at least 6 GiB of free disk space; only ${available_gib} GiB is available. Free disk space and retry."
fi
emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"ok\"}"
}
has_sudo() {
command -v sudo >/dev/null 2>&1
}
is_root() {
[[ "$(id -u)" -eq 0 ]]
}
ensure_git() {
if command -v git >/dev/null 2>&1; then
emit_json '{"event":"step","name":"git","status":"ok"}'
return
fi
emit_json '{"event":"step","name":"git","status":"start"}'
log "Installing Git (required for npm installs)..."
case "$(os_detect)" in
linux)
if command -v apt-get >/dev/null 2>&1; then
if is_root; then
apt-get update -y
apt-get install -y git
elif has_sudo; then
sudo apt-get update -y
sudo apt-get install -y git
else
fail "Git missing and sudo unavailable. Install git and retry."
fi
elif command -v dnf >/dev/null 2>&1; then
if is_root; then
dnf install -y git
elif has_sudo; then
sudo dnf install -y git
else
fail "Git missing and sudo unavailable. Install git and retry."
fi
elif command -v yum >/dev/null 2>&1; then
if is_root; then
yum install -y git
elif has_sudo; then
sudo yum install -y git
else
fail "Git missing and sudo unavailable. Install git and retry."
fi
elif command -v apk >/dev/null 2>&1; then
if is_root; then
apk add --no-cache git
elif has_sudo; then
sudo apk add --no-cache git
else
fail "Git missing and sudo unavailable. Install git and retry."
fi
else
fail "Git missing and package manager not found. Install git and retry."
fi
;;
darwin)
if command -v brew >/dev/null 2>&1; then
brew install git
else
fail "Git missing. Install Xcode Command Line Tools or Homebrew Git, then retry."
fi
;;
esac
if ! command -v git >/dev/null 2>&1; then
fail "Git install failed. Install git manually and retry."
fi
emit_json '{"event":"step","name":"git","status":"ok"}'
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON=1
shift
;;
--prefix)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
PREFIX="$2"
shift 2
;;
--version)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
OPENCLAW_VERSION="$2"
shift 2
;;
--compatible-with)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
REQUIRED_COMPATIBLE_VERSION="$2"
shift 2
;;
--node-version)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
NODE_VERSION="$2"
NODE_VERSION_REQUESTED=1
shift 2
;;
--install-method|--method)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
INSTALL_METHOD="$2"
shift 2
;;
--npm)
INSTALL_METHOD="npm"
shift
;;
--git|--github)
INSTALL_METHOD="git"
shift
;;
--git-dir|--dir)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
GIT_DIR="$2"
shift 2
;;
--no-git-update)
GIT_UPDATE=0
shift
;;
--onboard)
RUN_ONBOARD=1
shift
;;
--no-onboard)
RUN_ONBOARD=0
shift
;;
--help|-h)
print_usage
exit 0
;;
--set-npm-prefix)
SET_NPM_PREFIX=1
shift
;;
*)
fail "Unknown option: $1"
;;
esac
done
}
os_detect() {
local os
os="$(uname -s)"
case "$os" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) fail "Unsupported OS: $os" ;;
esac
}
arch_detect() {
local arch
arch="$(uname -m)"
case "$arch" in
arm64|aarch64) echo "arm64" ;;
armv7|armv7l) echo "armv7l" ;;
x86_64|amd64) echo "x64" ;;
*) fail "Unsupported architecture: $arch" ;;
esac
}
select_node_version_for_platform() {
local os="$1"
local arch="$2"
if [[ "$NODE_VERSION_REQUESTED" == "0" && "$os" == "linux" && "$arch" == "armv7l" ]]; then
NODE_VERSION="$ARMV7_DEFAULT_NODE_VERSION"
fi
if [[ "$os" == "linux" && "$arch" == "armv7l" && "${NODE_VERSION%%.*}" != "22" ]]; then
fail "Linux ARMv7 requires Node 22.22.3+ because official Node 24+ binaries are unavailable; use --node-version 22.22.3."
fi
}
node_dir() {
echo "${PREFIX}/tools/node-v${NODE_VERSION}"
}
node_bin() {
echo "$(node_dir)/bin/node"
}
npm_bin() {
echo "$(node_dir)/bin/npm"
}
command_path_without_node_prefix() {
local name="$1"
local path_entry
local prefix_bin
local filtered_path=""
local separator=""
local -a path_entries=()
prefix_bin="$(node_dir)/bin"
IFS=: read -r -a path_entries <<<"$PATH"
for path_entry in "${path_entries[@]}"; do
if [[ "$path_entry" == "$prefix_bin" ]]; then
continue
fi
filtered_path="${filtered_path}${separator}${path_entry}"
separator=":"
done
PATH="$filtered_path" command -v "$name" 2>/dev/null
}
is_musl_linux() {
if [[ "$(os_detect)" != "linux" ]]; then
return 1
fi
if [[ -f /etc/alpine-release ]]; then
return 0
fi
ldd --version 2>&1 | grep -qi musl
}
link_node_runtime_paths() {
local node_path="$1"
local npm_path="$2"
local dir
local runtime_bin
local resolved
dir="$(node_dir)"
runtime_bin="${node_path%/*}"
mkdir -p "${dir}/bin" "${PREFIX}/tools"
ln -sfn "$node_path" "${dir}/bin/node"
ln -sfn "$npm_path" "${dir}/bin/npm"
for name in npx corepack; do
if [[ -x "${runtime_bin}/${name}" ]]; then
ln -sfn "${runtime_bin}/${name}" "${dir}/bin/${name}"
continue
fi
resolved="$(command_path_without_node_prefix "$name" || true)"
if [[ -n "$resolved" && "$resolved" != "${dir}/bin/${name}" ]]; then
ln -sfn "$resolved" "${dir}/bin/${name}"
fi
done
ln -sfn "$dir" "${PREFIX}/tools/node"
}
linked_node_is_usable() {
local candidate_bin
local current_version
local required_version
if [[ ! -x "$(node_bin)" || ! -x "$(npm_bin)" ]]; then
return 1
fi
current_version="$("$(node_bin)" -v 2>/dev/null || echo "")"
required_version="$(required_node_version)"
if ! node_release_version_is_supported "$current_version"; then
return 1
fi
if ! semver_at_least "$NODE_RELEASE_VERSION_CORE" "$required_version"; then
return 1
fi
candidate_bin="$(node_dir)/bin"
if ! PATH="${candidate_bin}:${PATH}" "$(npm_bin)" --version >/dev/null 2>&1; then
return 1
fi
"$(node_bin)" -e '
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
const value = db.prepare("SELECT sqlite_version() AS version").get()?.version;
const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null;
const major = Number(match?.[1]);
const minor = Number(match?.[2]);
const patch = Number(match?.[3]);
const safe =
major > 3 ||
(major === 3 &&
(minor > 51 ||
(minor === 51 && patch >= 3) ||
(minor === 50 && patch >= 7) ||
(minor === 44 && patch >= 6)));
if (!safe) process.exitCode = 1;
} finally {
db.close();
}
' >/dev/null 2>&1
}
linked_node_sqlite_version() {
if [[ ! -x "$(node_bin)" ]]; then
printf 'unavailable\n'
return
fi
local version
version="$("$(node_bin)" -e '
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? "unknown"));
} finally {
db.close();
}
' 2>/dev/null || true)"
printf '%s\n' "${version:-unavailable}"
}
semver_at_least() {
local version="${1#v}"
local required="${2#v}"
local version_major version_minor version_patch
local required_major required_minor required_patch
IFS=. read -r version_major version_minor version_patch <<<"$version"
IFS=. read -r required_major required_minor required_patch <<<"$required"
version_minor="${version_minor:-0}"
version_patch="${version_patch:-0}"
required_minor="${required_minor:-0}"
required_patch="${required_patch:-0}"
for part in "$version_major" "$version_minor" "$version_patch" "$required_major" "$required_minor" "$required_patch"; do
if [[ ! "$part" =~ ^[0-9]+$ ]]; then
return 1
fi
done
if ((version_major != required_major)); then
((version_major > required_major))
return
fi
if ((version_minor != required_minor)); then
((version_minor > required_minor))
return
fi
((version_patch >= required_patch))
}
node_release_version_is_supported() {
local version="$1"
local major minor patch
NODE_RELEASE_VERSION_CORE=""
while [[ "$version" == [[:space:]]* ]]; do version="${version#?}"; done
while [[ "$version" == *[[:space:]] ]]; do version="${version%?}"; done
if [[ ! "$version" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
return 1
fi
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
for part in "$major" "$minor" "$patch"; do
if ((${#part} > 16)) || ((${#part} == 16 && 10#$part > 9007199254740991)); then
return 1
fi
done
NODE_RELEASE_VERSION_CORE="${major}.${minor}.${patch}"
node_version_is_supported "$NODE_RELEASE_VERSION_CORE"
}
# Download labels are plain numeric Node distribution versions. Installed
# runtimes use node_release_version_is_supported, which accepts canonical
# release labels with a leading v or build metadata.
node_version_is_supported() {
local version="${1#v}"
local major minor patch
IFS=. read -r major minor patch <<<"$version"
minor="${minor:-0}"
patch="${patch:-0}"
for part in "$major" "$minor" "$patch"; do
if [[ ! "$part" =~ ^[0-9]+$ ]]; then
return 1
fi
done
if ((major == 22)); then
semver_at_least "$version" "$MIN_NODE_22_VERSION"
return
fi
if ((major == 24)); then
semver_at_least "$version" "$MIN_NODE_24_VERSION"
return
fi
if ((major == 25)); then
semver_at_least "$version" "$MIN_NODE_25_VERSION"
return
fi
((major > 25))
}
required_node_version() {
if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && node_version_is_supported "$NODE_VERSION"; then
printf '%s\n' "$NODE_VERSION"
return
fi
printf '%s\n' "$MIN_NODE_22_VERSION"
}
try_link_usable_node_runtime_from_path() {
local path_entry
local prefix_bin
local -a path_entries=()
prefix_bin="$(node_dir)/bin"
IFS=: read -r -a path_entries <<<"$PATH"
for path_entry in "${path_entries[@]}"; do
if [[ -z "$path_entry" ]]; then
path_entry="."
fi
if [[ "$path_entry" == "$prefix_bin" ]]; then
continue
fi
if [[ -x "${path_entry}/node" && -x "${path_entry}/npm" ]]; then
link_node_runtime_paths "${path_entry}/node" "${path_entry}/npm"
if linked_node_is_usable; then
return 0
fi
fi
done
return 1
}
install_alpine_node() {
local installed_version
local required_version
local sqlite_version
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}"
if try_link_usable_node_runtime_from_path; then
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"method\":\"system\",\"version\":\"${installed_version}\"}"
return
fi
log "Installing Node via apk (Alpine Linux detected)..."
if is_root; then
apk add --no-cache nodejs npm
elif has_sudo; then
sudo apk add --no-cache nodejs npm
else
fail "Alpine Linux detected, but Node musl tarballs are unavailable and sudo is unavailable. Install nodejs and npm with apk, then retry."
fi
if [[ -x "${APK_NODE_BIN_DIR}/node" && -x "${APK_NODE_BIN_DIR}/npm" ]]; then
link_node_runtime_paths "${APK_NODE_BIN_DIR}/node" "${APK_NODE_BIN_DIR}/npm"
elif ! try_link_usable_node_runtime_from_path; then
fail "apk Node install failed. Install nodejs and npm manually, then retry."
fi
if ! linked_node_is_usable; then
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
required_version="$(required_node_version)"
sqlite_version="$(linked_node_sqlite_version)"
fail "Alpine Node package must provide Node >= ${required_version} with WAL-reset-safe SQLite 3.51.3+, 3.50.7+ within 3.50.x, or 3.44.6+ within 3.44.x; found Node ${installed_version}, SQLite ${sqlite_version}."
fi
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"method\":\"apk\",\"version\":\"${installed_version}\"}"
}
set_pnpm_cmd() {
PNPM_CMD=("$@")
}
pnpm_cmd_is_ready() {
if [[ ${#PNPM_CMD[@]} -eq 0 ]]; then
return 1
fi
"${PNPM_CMD[@]}" --version >/dev/null 2>&1
}
detect_pnpm_cmd() {
if [[ -x "${PREFIX}/bin/pnpm" ]]; then
set_pnpm_cmd "${PREFIX}/bin/pnpm"
return 0
fi
if command -v pnpm >/dev/null 2>&1; then
set_pnpm_cmd pnpm
return 0
fi
if [[ -x "$(node_dir)/bin/corepack" ]] && "$(node_dir)/bin/corepack" pnpm --version >/dev/null 2>&1; then
set_pnpm_cmd "$(node_dir)/bin/corepack" pnpm
return 0
fi
return 1
}
ensure_pnpm_binary_for_scripts() {
if command -v pnpm >/dev/null 2>&1; then
return 0
fi
if [[ ${#PNPM_CMD[@]} -eq 2 && "${PNPM_CMD[1]}" == "pnpm" ]] && [[ "$(basename "${PNPM_CMD[0]}")" == "corepack" ]]; then
mkdir -p "${PREFIX}/bin"
cat > "${PREFIX}/bin/pnpm" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PNPM_CMD[0]}" pnpm "\$@"
EOF
chmod +x "${PREFIX}/bin/pnpm"
export PATH="${PREFIX}/bin:${PATH}"
hash -r 2>/dev/null || true
fi
if command -v pnpm >/dev/null 2>&1; then
return 0
fi
fail "pnpm command not available on PATH"
}
run_pnpm() {
if [[ ${#PNPM_CMD[@]} -eq 2 && "${PNPM_CMD[1]}" == "pnpm" ]] && [[ "${1:-}" == "-C" && -n "${2:-}" ]]; then
local repo_dir="$2"
shift 2
if ! (cd "$repo_dir" && "${PNPM_CMD[@]}" --version >/dev/null 2>&1); then
ensure_pnpm
fi
(cd "$repo_dir" && "${PNPM_CMD[@]}" "$@")
return
fi
if ! pnpm_cmd_is_ready; then
ensure_pnpm
fi
"${PNPM_CMD[@]}" "$@"
}
to_lowercase_ascii() {
printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
}
is_openclaw_source_package_install_spec() {
local value="${1:-}"
local normalized_value=""
normalized_value="$(to_lowercase_ascii "$value")"
normalized_value="${normalized_value#openclaw@}"
[[ "$normalized_value" == "main" ]] && return 0
[[ "$normalized_value" =~ ^github:openclaw/openclaw($|[#/]) ]] && return 0
normalized_value="${normalized_value#git+}"
[[ "$normalized_value" =~ ^https?://github\.com/openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
[[ "$normalized_value" =~ ^ssh://git@github\.com[:/]openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
[[ "$normalized_value" =~ ^git://github\.com/openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
[[ "$normalized_value" =~ ^git@github\.com:openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
return 1
}
openclaw_version_is_compatible_with() {
local candidate="$1"
local config_writer="$2"
"$(node_bin)" - "$candidate" "$config_writer" <<'NODE'
const candidateRaw = process.argv[2];
const writerRaw = process.argv[3];
function parse(raw) {
let value = String(raw ?? "").trim();
const legacyBeta = /^([vV]?\d+\.\d+\.\d+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(value);
if (legacyBeta) {
value = `${legacyBeta[1]}-beta${legacyBeta[2] ? `.${legacyBeta[2]}` : ""}`;
}
const match = /^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(value);
if (!match) return null;
const parseIdentifiers = (rawIdentifiers) => {
if (!rawIdentifiers) return [];
const identifiers = rawIdentifiers.split(".");
if (identifiers.some((identifier) => identifier.length === 0)) return null;
return identifiers.map((identifier) => {
if (!/^\d+$/.test(identifier)) return identifier;
if (identifier.length > 1 && identifier.startsWith("0")) return null;
return Number(identifier);
});
};
const prerelease = parseIdentifiers(match[4]);
const build = parseIdentifiers(match[5]);
if (!prerelease || !build || prerelease.includes(null) || build.includes(null)) return null;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease,
build,
};
}
function compareIdentifiers(left, right) {
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const a = left[index];
const b = right[index];
if (a === undefined) return -1;
if (b === undefined) return 1;
if (a === b) continue;
if (typeof a === "number" && typeof b === "number") return a < b ? -1 : 1;
if (typeof a === "number") return -1;
if (typeof b === "number") return 1;
return a < b ? -1 : 1;
}
return 0;
}
function isCorrection(version) {
return version.prerelease.length === 1 && typeof version.prerelease[0] === "number";
}
function comparable(version) {
if (!isCorrection(version)) return version;
return { ...version, prerelease: [], build: [version.prerelease[0]] };
}
function compare(leftRaw, rightRaw) {
const left = comparable(leftRaw);
const right = comparable(rightRaw);
for (let index = 0; index < left.core.length; index += 1) {
if (left.core[index] !== right.core[index]) {
return left.core[index] < right.core[index] ? -1 : 1;
}
}
if (left.prerelease.length === 0 && right.prerelease.length > 0) return 1;
if (right.prerelease.length === 0 && left.prerelease.length > 0) return -1;
const prereleaseOrder = compareIdentifiers(left.prerelease, right.prerelease);
return prereleaseOrder === 0 ? compareIdentifiers(left.build, right.build) : prereleaseOrder;
}
const candidate = parse(candidateRaw);
const writer = parse(writerRaw);
if (!candidate || !writer) process.exit(2);
const sameCore = candidate.core.every((part, index) => part === writer.core[index]);
if (sameCore && (writer.prerelease.length === 0 || isCorrection(writer))) process.exit(0);
process.exit(compare(candidate, writer) < 0 ? 1 : 0);
NODE
}
require_openclaw_version_compatible() {
local candidate="$1"
local config_writer="${REQUIRED_COMPATIBLE_VERSION:-}"
if [[ -z "$config_writer" ]]; then
return 0
fi
if openclaw_version_is_compatible_with "$candidate" "$config_writer"; then
return 0
fi
local status="$?"
if [[ "$status" -eq 2 ]]; then
fail "Cannot compare resolved OpenClaw version '${candidate}' with config writer '${config_writer}'."
fi
fail "OpenClaw ${candidate} is older than config writer ${config_writer}. Choose a newer CLI channel or retry after the channel is updated."
}
resolve_npm_openclaw_version() {
local requested="$1"
"$(npm_bin)" view "openclaw@${requested}" version 2>/dev/null | awk 'NF { value = $0 } END { print value }'
}
resolve_git_checkout_openclaw_version() {
local repo_dir="$1"
"$(node_bin)" -e '
const fs = require("node:fs");
const path = require("node:path");
const value = JSON.parse(fs.readFileSync(path.join(process.argv[1], "package.json"), "utf8")).version;
if (typeof value !== "string" || value.trim() === "") process.exit(1);
process.stdout.write(value.trim());
' "$repo_dir"
}
resolve_git_openclaw_ref() {
local requested="${OPENCLAW_VERSION:-latest}"
local resolved_version=""
case "$requested" in
""|latest)
resolved_version="$("$(npm_bin)" view "openclaw" "dist-tags.${requested:-latest}" 2>/dev/null || true)"
if [[ -n "$resolved_version" ]]; then
echo "v${resolved_version}"
return 0
fi
echo "main"
return 0
;;
next|beta)
resolved_version="$("$(npm_bin)" view "openclaw" "dist-tags.${requested:-latest}" 2>/dev/null || true)"
if [[ -n "$resolved_version" ]]; then
echo "v${resolved_version}"
return 0
fi
echo "$requested"
return 0
;;
main)
echo "main"
return 0
;;
v[0-9]*)
echo "$requested"
return 0
;;
[0-9]*.[0-9]*.[0-9]*)
echo "v${requested}"
return 0
;;
*)
echo "$requested"
return 0
;;
esac
}
checkout_git_openclaw_ref() {
local repo_dir="$1"
local ref="$2"
if [[ -z "$ref" ]]; then
return 0
fi
if [[ "$ref" == "main" ]]; then
git -C "$repo_dir" fetch --no-tags origin main
git -C "$repo_dir" checkout main
if [[ "$GIT_UPDATE" == "1" ]]; then
git -C "$repo_dir" pull --rebase --no-tags || true
fi
return 0
fi
if git -C "$repo_dir" ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then
git -C "$repo_dir" fetch --no-tags origin "refs/heads/${ref}:refs/remotes/origin/${ref}"
git -C "$repo_dir" checkout -B "$ref" "origin/$ref"
if [[ "$GIT_UPDATE" == "1" ]]; then
git -C "$repo_dir" pull --rebase --no-tags || true
fi
return 0
fi
git -C "$repo_dir" fetch --tags origin
if git -C "$repo_dir" rev-parse --verify --quiet "refs/tags/${ref}^{commit}" >/dev/null; then
git -C "$repo_dir" checkout --detach "$ref"
return 0
fi
if git -C "$repo_dir" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then
git -C "$repo_dir" checkout --detach "$ref"
return 0
fi
fail "Requested git version not found: ${ref}"
}
git_install_lockfile_flag() {
local repo_dir="$1"
local ref="$2"
if [[ "$ref" == "main" ]] || git -C "$repo_dir" ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then
echo "--no-frozen-lockfile"
return 0
fi
echo "--frozen-lockfile"
}
repo_pnpm_spec() {
local repo_dir="$1"
local package_json="${repo_dir}/package.json"
if [[ ! -f "$package_json" ]]; then
return 1
fi
sed -n -E 's/^[[:space:]]*"packageManager"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -n1
}
activate_repo_pnpm_version() {
local repo_dir="$1"
local spec
local version
local corepack_cmd=""
spec="$(repo_pnpm_spec "$repo_dir" || true)"
if [[ "$spec" != pnpm@* ]]; then
return 0
fi
version="${spec#pnpm@}"
version="${version%%+*}"
if [[ -z "$version" ]]; then
return 0
fi
if [[ -x "$(node_dir)/bin/corepack" ]]; then
corepack_cmd="$(node_dir)/bin/corepack"
elif command -v corepack >/dev/null 2>&1; then
corepack_cmd="$(command -v corepack)"
fi
if [[ -n "$corepack_cmd" ]]; then
log "Activating repo pnpm ${version}"
"$corepack_cmd" prepare "pnpm@${version}" --activate >/dev/null 2>&1 || true
if [[ "$(cd "$repo_dir" && "$corepack_cmd" pnpm --version 2>/dev/null || true)" == "$version" ]]; then
set_pnpm_cmd "$corepack_cmd" pnpm
return 0
fi
detect_pnpm_cmd || true
fi
}
install_node() {
local os
local arch
local url
local tmp
local dir
local base_url
local tarball
local expected_sha
local actual_sha
os="$(os_detect)"
arch="$(arch_detect)"
select_node_version_for_platform "$os" "$arch"
if ! node_version_is_supported "$NODE_VERSION"; then
fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}."
fi
dir="$(node_dir)"
if [[ "$os" == "linux" ]] && command -v apk >/dev/null 2>&1 && is_musl_linux; then
install_alpine_node
return
fi
if linked_node_is_usable; then
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"skip\",\"path\":\"${dir//\"/\\\\\\\"}\"}"
return
fi
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"version\":\"${NODE_VERSION}\"}"
log "Installing Node ${NODE_VERSION} (user-space)..."
mkdir -p "${PREFIX}/tools"
tmp="$(mktemp -d)"
TMPFILES+=("$tmp")
base_url="https://nodejs.org/dist/v${NODE_VERSION}"
tarball="node-v${NODE_VERSION}-${os}-${arch}.tar.gz"
url="${base_url}/${tarball}"
detect_downloader
require_bin tar
download_file "${base_url}/SHASUMS256.txt" "$tmp/SHASUMS256.txt"
expected_sha="$(grep " ${tarball}$" "$tmp/SHASUMS256.txt" | awk '{print $1}' | head -n 1 || true)"
if [[ -z "${expected_sha}" ]]; then
fail "Failed to resolve Node shasum for ${tarball}"
fi
download_file "$url" "$tmp/node.tgz"
actual_sha="$(sha256_file "$tmp/node.tgz")"
if [[ "$actual_sha" != "$expected_sha" ]]; then
fail "Node tarball sha256 mismatch for ${tarball} (expected ${expected_sha}, got ${actual_sha})"
fi
rm -rf "$dir"
mkdir -p "$dir"
tar -xzf "$tmp/node.tgz" -C "$dir" --strip-components=1
rm -rf "$tmp"
ln -sfn "$dir" "${PREFIX}/tools/node"
if ! linked_node_is_usable; then
local installed_version
local required_version
local sqlite_version
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
required_version="$(required_node_version)"
sqlite_version="$(linked_node_sqlite_version)"
fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with WAL-reset-safe SQLite; found Node ${installed_version}, SQLite ${sqlite_version}. Re-run with --node-version 24.15.0 (or newer)"
fi
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"version\":\"${NODE_VERSION}\"}"
}
ensure_pnpm() {
if detect_pnpm_cmd && pnpm_cmd_is_ready; then
local current_version
current_version="$("${PNPM_CMD[@]}" --version 2>/dev/null || true)"
if [[ "$current_version" =~ ^11\. ]]; then
return 0
fi
log "Found pnpm ${current_version:-unknown}; upgrading to pnpm@11..."
fi
if [[ -x "$(node_dir)/bin/corepack" ]]; then
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"start\",\"method\":\"corepack\"}"
log "Installing pnpm via Corepack..."
"$(node_dir)/bin/corepack" enable >/dev/null 2>&1 || true
"$(node_dir)/bin/corepack" prepare pnpm@11 --activate
if detect_pnpm_cmd && pnpm_cmd_is_ready && [[ "$("${PNPM_CMD[@]}" --version 2>/dev/null || true)" =~ ^11\. ]]; then
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"ok\"}"
return 0
fi
fi
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"start\",\"method\":\"npm\"}"
log "Installing pnpm via npm..."
"$(npm_bin)" install -g --prefix "$PREFIX" pnpm@11
detect_pnpm_cmd || true
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"ok\"}"
return 0
}
fix_npm_prefix_if_needed() {
# only meaningful on Linux, non-root installs
if [[ "$(os_detect)" != "linux" ]]; then
return
fi
local prefix
prefix="$("$(npm_bin)" config get prefix 2>/dev/null || true)"
if [[ -z "$prefix" ]]; then
return
fi
if [[ -w "$prefix" || -w "${prefix}/lib" ]]; then
return
fi
local target="${HOME}/.npm-global"
mkdir -p "$target"
"$(npm_bin)" config set prefix "$target"
local path_line="export PATH=\\\"${target}/bin:\\$PATH\\\""
for rc in "${HOME}/.bashrc" "${HOME}/.zshrc"; do
if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then
echo "$path_line" >> "$rc"
fi
done
export PATH="${target}/bin:${PATH}"
emit_json "{\"event\":\"step\",\"name\":\"npm-prefix\",\"status\":\"ok\",\"prefix\":\"${target//\"/\\\"}\"}"
log "Configured npm prefix to ${target}"
}
resolve_npm_config_path() {
local raw="$1"
if [[ -z "$raw" || "$raw" == "null" || "$raw" == "undefined" ]]; then
return 1
fi
if [[ "$raw" == \~/* && -n "${HOME:-}" ]]; then
printf '%s\n' "${HOME}/${raw#"~/"}"
return 0
fi
if [[ "$raw" == "\${HOME}/"* && -n "${HOME:-}" ]]; then
printf '%s\n' "${HOME}/${raw#"\${HOME}/"}"
return 0
fi
printf '%s\n' "$raw"
}
npm_config_file_has_key() {
local file="$1"
local key="$2"
[[ -f "$file" ]] || return 1
grep -Eiq "^[[:space:]]*${key}[[:space:]]*=" "$file"
}
npm_command_path() {
local npm_cmd="$1"
local npm_path="$npm_cmd"
if [[ "$npm_path" != */* ]]; then
npm_path="$(command -v "$npm_cmd" 2>/dev/null)" || return 1
fi
if command -v node >/dev/null 2>&1; then
node -e 'const fs = require("node:fs"); console.log(fs.realpathSync(process.argv[1]));' "$npm_path" 2>/dev/null && return 0
fi
printf '%s\n' "$npm_path"
}
npm_builtin_config_path() {
local npm_cmd="$1"
local npm_path
npm_path="$(npm_command_path "$npm_cmd")" || return 1
local npm_root
npm_root="$(cd "$(dirname "$npm_path")/.." >/dev/null 2>&1 && pwd -P)" || return 1
printf '%s\n' "${npm_root}/npmrc"
}
npm_config_has_raw_key() {
local npm_cmd="$1"
local key="$2"
local raw=""
local file=""
local -a files=()
raw="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}"
if [[ -n "$raw" ]]; then
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
elif [[ -n "${HOME:-}" ]]; then
files+=("${HOME}/.npmrc")
fi
raw="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}"
if [[ -n "$raw" ]]; then
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
fi
raw="$(env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" config get globalconfig --global 2>/dev/null || true)"
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
file="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
for file in "${files[@]}"; do
if npm_config_file_has_key "$file" "$key"; then
return 0
fi
done
return 1
}
npm_lifecycle_allow_arg() {
local npm_cmd="$1" spec="$2" npm_cwd="${3:-$PWD}" version=""
version="$("$npm_cmd" --version 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
if [[ ! "$version" =~ ^[vV]?([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
log "ERROR: unable to determine npm version; no package changes were made"
return 1
fi
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
if (( major < 12 && (major != 11 || minor < 16) )); then return 0; fi
local identity="$spec" normalized=""
normalized="$(to_lowercase_ascii "$identity")"
if [[ "$normalized" == openclaw@* ]]; then identity="${identity#*@}"; normalized="$(to_lowercase_ascii "$identity")"; fi
if [[ "$normalized" == npm:* ]]; then
local alias_target="${identity#*:}"
if [[ "$alias_target" == @*/*@* ]]; then identity="${alias_target%@*}"
elif [[ "$alias_target" == *@* ]]; then identity="${alias_target%%@*}"
else identity="$alias_target"; fi
elif [[ "$identity" != *"://"* && "$identity" != /* && "$identity" != ./* && "$identity" != ../* && ! "$identity" =~ ^(file|github|git\+|npm): && ! "$identity" =~ \.(tgz|tar\.gz)$ ]]; then
identity="openclaw"
fi
if [[ "$identity" == /* ]]; then
# shellcheck disable=SC2016 # JavaScript source must not expand in the installer shell.
identity="$("$(node_bin)" -e '
const path = require("node:path");
const relative = path.relative(process.argv[1], process.argv[2]) || ".";
process.stdout.write(path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`);
' "$npm_cwd" "$identity")" || return 1
fi
[[ -n "$identity" && "$identity" != *,* ]] || { log "ERROR: npm cannot allow lifecycle scripts for ${spec}"; return 1; }
printf '%s\n' "--allow-scripts=${identity}"
}
publish_executable_wrapper() {
local target="$1" target_dir="" temp="" backup=""
target_dir="${target%/*}"
mkdir -p "$target_dir"
temp="$(mktemp "${target_dir}/.openclaw-wrapper.XXXXXX")" || return 1
TMPFILES+=("$temp")
cat > "$temp"
chmod +x "$temp"
if [[ -z "$WRAPPER_BACKUP_PATH" && ( -e "$target" || -L "$target" ) ]]; then
backup="$(mktemp "${target}.backup.XXXXXX")" || return 1
rm -f "$backup" || return 1
mv "$target" "$backup" || return 1
WRAPPER_BACKUP_TARGET="$target"
WRAPPER_BACKUP_PATH="$backup"
fi
mv -f "$temp" "$target"
}
commit_wrapper_backup() {
[[ -z "$WRAPPER_BACKUP_PATH" ]] || rm -f "$WRAPPER_BACKUP_PATH" || return 1
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
}
install_openclaw() {
local requested="${OPENCLAW_VERSION:-latest}"
if is_openclaw_source_package_install_spec "$requested"; then
fail "npm installs do not support OpenClaw GitHub source targets like '${requested}'. Use --install-method git --version main, latest, beta, an exact version, or a built .tgz package."
fi
local freshness_flag="--min-release-age=0"
local min_release_age=""
min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$(npm_bin)" config get min-release-age --global 2>/dev/null || true)"
if npm_config_has_raw_key "$(npm_bin)" "min-release-age"; then
freshness_flag="--min-release-age=0"
elif [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then
local before_value=""
before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" config get before --global 2>/dev/null || true)"
if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then
freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')"
fi
fi
local npm_args=(
--loglevel "$NPM_LOGLEVEL"
--no-fund
--no-audit
"$freshness_flag"
)
local resolved_requested="$requested"
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
resolved_requested="$(resolve_npm_openclaw_version "$requested")"
if [[ -z "$resolved_requested" ]]; then
fail "Could not resolve OpenClaw ${requested} before compatibility checking."
fi
require_openclaw_version_compatible "$resolved_requested"
fi
local install_spec="openclaw@${resolved_requested}"
if [[ "$resolved_requested" == *"://"* || "$resolved_requested" == /* || "$resolved_requested" == ./* || "$resolved_requested" == ../* || "$resolved_requested" =~ ^(file|github|git\+|npm): || "$resolved_requested" =~ \.(tgz|tar\.gz)$ ]]; then
install_spec="$resolved_requested"
fi
local npm_cmd="" lifecycle_arg=""
npm_cmd="$(npm_bin)"
local npm_cwd="$PWD"
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$install_spec" "$npm_cwd")" || return 1
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"version\":\"${requested}\"}"
log "Installing OpenClaw (${requested})..."
if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
fix_npm_prefix_if_needed
fi
local installed_entry install_guard
installed_entry="$(node_dir)/lib/node_modules/openclaw/dist/entry.js"
install_guard="$(node_dir)/lib/node_modules/openclaw/dist/openclaw-install-guard"
local npm_install_args=(install -g --prefix "$(node_dir)" "${npm_args[@]}")
[[ -z "$lifecycle_arg" ]] || npm_install_args+=("$lifecycle_arg")
npm_install_args+=("$install_spec")
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; then
log "npm install openclaw@${resolved_requested} did not produce a usable package; retrying once"
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; then
emit_json '{"event":"error","message":"npm install did not produce a usable OpenClaw package"}'
log "ERROR: npm install did not produce a usable OpenClaw package"
return 1
fi
fi
mkdir -p "${PREFIX}/bin"
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "$(node_dir)/lib/node_modules/openclaw/dist/entry.js" "\$@"
EOF
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"version\":\"${requested}\"}"
}
ensure_pnpm_git_prepare_allowlist() {
local repo_dir="$1"
local workspace_file="${repo_dir}/pnpm-workspace.yaml"
local dep="@tloncorp/api"
local tmp
if [[ -f "$workspace_file" ]] && ! grep -Fq "\"${dep}\"" "$workspace_file" && ! grep -Fq "${dep}:" "$workspace_file" && ! grep -Fq -- "- ${dep}" "$workspace_file"; then
tmp="$(mktemp)"
TMPFILES+=("$tmp")
if grep -q '^allowBuilds:[[:space:]]*$' "$workspace_file"; then
awk -v dep="$dep" '
BEGIN { inserted = 0 }
{
print
if (!inserted && $0 ~ /^allowBuilds:[[:space:]]*$/) {
print " \"" dep "\": true"
inserted = 1
}
}
' "$workspace_file" >"$tmp"
else
cat "$workspace_file" >"$tmp"
printf '\nallowBuilds:\n "%s": true\n' "$dep" >>"$tmp"
fi
mv "$tmp" "$workspace_file"
elif [[ ! -f "$workspace_file" ]]; then
printf 'allowBuilds:\n "%s": true\n' "$dep" >"$workspace_file"
fi
log "Updated pnpm allowlist for git-hosted build dependency: ${dep}"
}
clone_git_checkout_transactionally() {
local repo_url="$1"
local repo_dir="$2"
local parent_dir staging_dir clone_status=0 preserve_repo_dir=0
parent_dir="$(dirname "$repo_dir")"
mkdir -p "$parent_dir"
parent_dir="$(cd "$parent_dir" && pwd -P)"
if [[ -d "$repo_dir" && -z "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then
preserve_repo_dir=1
repo_dir="$(cd "$repo_dir" && pwd -P)"
staging_dir="$(mktemp -d "${repo_dir}/.openclaw-clone.XXXXXX")"
else
repo_dir="${parent_dir}/$(basename "$repo_dir")"
staging_dir="$(mktemp -d "${parent_dir}/.openclaw-clone.XXXXXX")"
fi
TMPFILES+=("$staging_dir")
git clone "$repo_url" "$staging_dir" || clone_status=$?
if [[ "$clone_status" -ne 0 ]]; then
return "$clone_status"
fi
if ! node - "$staging_dir" "$repo_dir" "$preserve_repo_dir" <<'NODE'
const fs = require("node:fs");
const [source, target, preserveTarget] = process.argv.slice(2);
if (preserveTarget === "0") {
try {
fs.lstatSync(target);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
fs.renameSync(source, target);
process.exit(0);
}
throw new Error(`Git install dir appeared while cloning: ${target}`);
}
const expected = preserveTarget === "1" ? [source.slice(source.lastIndexOf("/") + 1)] : [];
if (!fs.statSync(target).isDirectory() || fs.readdirSync(target).sort().join("\0") !== expected.sort().join("\0")) {
throw new Error(`Git install dir appeared while cloning: ${target}`);
}
const entries = fs.readdirSync(source).sort((a, b) => (a === ".git" ? 1 : b === ".git" ? -1 : 0));
const moved = [];
try {
for (const entry of entries) {
fs.renameSync(`${source}/${entry}`, `${target}/${entry}`);
moved.push(entry);
}
fs.rmdirSync(source);
} catch (error) {
const rollbackErrors = [];
for (const entry of moved.reverse()) {
try {
fs.renameSync(`${target}/${entry}`, `${source}/${entry}`);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length > 0) {
let recovery = source;
try {
recovery = `${source}.recovery`;
fs.renameSync(source, recovery);
} catch (recoveryError) {
rollbackErrors.push(recoveryError);
recovery = source;
}
throw new AggregateError(
[error, ...rollbackErrors],
`Could not publish or fully roll back the cloned checkout at ${target}; recovery files remain at ${recovery}`,
);
}
throw error;
}
NODE
then
fail "Could not publish the cloned checkout: ${repo_dir}. Inspect the destination for partial files, move it or choose another --git-dir, then retry."
fi
}
install_openclaw_from_git() {
local repo_dir="$1"
local repo_url="https://github.com/openclaw/openclaw.git"
local fresh_checkout=0
if [[ -z "$repo_dir" ]]; then
fail "Git install dir cannot be empty"
fi
mkdir -p "$(dirname "$repo_dir")"
if [[ -d "$repo_dir" ]]; then
repo_dir="$(cd "$repo_dir" && pwd -P)"
else
repo_dir="$(cd "$(dirname "$repo_dir")" && pwd -P)/$(basename "$repo_dir")"
fi
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"method\":\"git\",\"repo\":\"${repo_url//\"/\\\"}\"}"
if [[ -d "$repo_dir/.git" ]]; then
log "Installing Openclaw from git checkout: ${repo_dir}"
else
log "Installing Openclaw from GitHub (${repo_url})..."
fi
emit_json '{"event":"step","name":"git-tools","status":"start"}'
ensure_git
ensure_pnpm
ensure_pnpm_binary_for_scripts
emit_json '{"event":"step","name":"git-tools","status":"ok"}'
if [[ -d "$repo_dir/.git" ]] &&
! git --git-dir="$repo_dir/.git" --work-tree="$repo_dir" rev-parse --verify --quiet 'HEAD^{commit}' >/dev/null 2>&1; then
fail "Git checkout has no commit: ${repo_dir}. Move or remove this incomplete checkout, then retry."
fi
if [[ -d "$repo_dir/.git" ]]; then
:
elif [[ -d "$repo_dir" ]]; then
if [[ -z "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then
emit_json '{"event":"step","name":"git-clone","status":"start"}'
clone_git_checkout_transactionally "$repo_url" "$repo_dir"
emit_json '{"event":"step","name":"git-clone","status":"ok"}'
fresh_checkout=1
else
fail "Git install dir exists but is not a git repo: ${repo_dir}"
fi
else
emit_json '{"event":"step","name":"git-clone","status":"start"}'
clone_git_checkout_transactionally "$repo_url" "$repo_dir"
emit_json '{"event":"step","name":"git-clone","status":"ok"}'
fresh_checkout=1
fi
local git_ref
git_ref="$(resolve_git_openclaw_ref)"
if [[ -z "$(git -C "$repo_dir" status --porcelain 2>/dev/null || true)" ]]; then
log "Using git ref: ${git_ref}"
if [[ "$fresh_checkout" -eq 0 ]]; then
emit_json '{"event":"step","name":"git-update","status":"start"}'
fi
checkout_git_openclaw_ref "$repo_dir" "$git_ref"
if [[ "$fresh_checkout" -eq 0 ]]; then
emit_json '{"event":"step","name":"git-update","status":"ok"}'
fi
else
log "Repo is dirty; skipping git checkout/update"
emit_json '{"event":"step","name":"git-update","status":"warn","reason":"dirty"}'
fi
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
local resolved_version
resolved_version="$(resolve_git_checkout_openclaw_version "$repo_dir" 2>/dev/null || true)"
if [[ -z "$resolved_version" ]]; then
fail "Could not resolve the Git checkout version before compatibility checking."
fi
require_openclaw_version_compatible "$resolved_version"
fi
cleanup_legacy_submodules "$repo_dir"
ensure_pnpm_git_prepare_allowlist "$repo_dir"
activate_repo_pnpm_version "$repo_dir"
local install_lockfile_flag
install_lockfile_flag="$(git_install_lockfile_flag "$repo_dir" "$git_ref")"
emit_json '{"event":"step","name":"dependencies","status":"start"}'
CI="${CI:-true}" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"
emit_json '{"event":"step","name":"dependencies","status":"ok"}'
emit_json '{"event":"step","name":"control-ui","status":"start"}'
if ! run_pnpm -C "$repo_dir" ui:build; then
log "UI build failed; continuing (CLI may still work)"
emit_json '{"event":"step","name":"control-ui","status":"warn"}'
else
emit_json '{"event":"step","name":"control-ui","status":"ok"}'
fi
emit_json '{"event":"step","name":"cli-build","status":"start"}'
run_pnpm -C "$repo_dir" build
emit_json '{"event":"step","name":"cli-build","status":"ok"}'
mkdir -p "${PREFIX}/bin"
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "${repo_dir}/dist/entry.js" "\$@"
EOF
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"method\":\"git\"}"
}
is_gateway_daemon_loaded() {
local claw="$1"
if [[ -z "$claw" || ! -x "$claw" ]]; then
return 1
fi
local status_json=""
status_json="$("$claw" daemon status --json 2>/dev/null || true)"
if [[ -z "$status_json" ]]; then
return 1
fi
printf '%s' "$status_json" | node -e '
const fs = require("fs");
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) process.exit(1);
try {
const data = JSON.parse(raw);
process.exit(data?.service?.loaded ? 0 : 1);
} catch {
process.exit(1);
}
' >/dev/null 2>&1
}
refresh_gateway_service_if_loaded() {
local claw="${PREFIX}/bin/openclaw"
if [[ ! -x "$claw" ]]; then
return 0
fi
if ! is_gateway_daemon_loaded "$claw"; then
emit_json '{"event":"step","name":"gateway-service","status":"skip","reason":"not-loaded"}'
return 0
fi
emit_json '{"event":"step","name":"gateway-service","status":"start"}'
log "Refreshing loaded gateway service..."
if ! "$claw" gateway install --force >/dev/null 2>&1; then
emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"install-failed"}'
log "Warning: gateway service refresh failed; continuing."
return 0
fi
# `gateway install --force` activates the replacement service. A second
# restart can kill startup migrations and strand their lock until expiry.
"$claw" gateway status --probe --json >/dev/null 2>&1 || true
emit_json '{"event":"step","name":"gateway-service","status":"ok"}'
}
main() {
parse_args "$@"
PREFIX="$(resolve_installer_path "$PREFIX")"
GIT_DIR="$(resolve_installer_path "$GIT_DIR")"
if [[ "${OPENCLAW_NO_ONBOARD:-0}" == "1" ]]; then
RUN_ONBOARD=0
fi
if [[ "$INSTALL_METHOD" == "git" ]]; then
preflight_fresh_git_disk_space "$GIT_DIR"
fi
select_node_version_for_platform "$(os_detect)" "$(arch_detect)"
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
export PATH
install_node
if [[ "$INSTALL_METHOD" == "git" ]]; then
install_openclaw_from_git "$GIT_DIR"
elif [[ "$INSTALL_METHOD" == "npm" ]]; then
ensure_git
if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
fix_npm_prefix_if_needed
fi
install_openclaw
else
fail "Unknown install method: ${INSTALL_METHOD} (use npm or git)"
fi
local installed_version
if ! installed_version="$("${PREFIX}/bin/openclaw" --version 2>/dev/null | head -n 1 | tr -d '\r')" ||
[[ -z "$installed_version" ]]; then
fail "Installed OpenClaw CLI did not return a version successfully from ${PREFIX}/bin/openclaw."
fi
commit_wrapper_backup
refresh_gateway_service_if_loaded
emit_json "{\"event\":\"done\",\"ok\":true,\"version\":\"${installed_version//\"/\\\"}\"}"
log "OpenClaw installed (${installed_version})."
if [[ "$RUN_ONBOARD" -eq 1 ]]; then
"${PREFIX}/bin/openclaw" onboard
fi
}
if [[ "${OPENCLAW_INSTALL_CLI_SH_NO_RUN:-0}" != "1" ]]; then
main "$@"
fi