mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* feat(browser): unify local Chrome setup across desktop and terminal Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * feat(browser): unify local Chrome setup across desktop and terminal OpenClaw-Publication: d19e865e-b5a0-4c70-8876-c1662f6e7ef2 Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * chore(linux): format Chrome setup fixture Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * feat(browser): keep desktop Chrome setup local and preserve pairing Delegate Windows registration to the shared native management owner, preserve released native bridge compatibility and saved launcher profiles, and integrate serialized desktop setup through isolated local runtimes. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): repair native setup CI contracts Keep Windows installer dependencies acyclic, validate Unicode within the package library target, and remove unused private exports. Require all eight packaged native-host proof cases and update lazy CLI inventory. Apply native Swift formatter diagnostics without changing behavior. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(cli): account for plugin-owned browser extension catalog Keep the core-only registration invariant aligned with the Browser plugin owner already exercised by its lazy registration tests. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(macos): retain released Chrome bridge request expectation Align the native bridge test with the shipped contract1 request retained by the canonical setup owner, and reject extra legacy payload fields. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(tui): give command handler harness a unique export Rename the shared TUI test helper and both consumers to avoid the Gateway placement harness export collision. No alias or guard waiver. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * chore(sdk): allow canonical browser config path resolver Apply the approved single public-export and callable allowance for resolveConfigPath. Preserve canonical pre-config path ownership and all other SDK surface checks. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): preserve desktop setup selection and supported actions Keep native automatic setup selector-free and resolve saved local browser selection through the canonical setup owner before installation. Respect Mac action advertisements and the released legacy install projection in the Apps card, and document the public config-path resolver contract. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(auth): retry model selection after concurrent credential refresh Adopt upstream PR #152426, commit32298b10f6, without changing its five source files. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test: preserve native setup selection and await dashboard document Match the selector-free native CLI arguments exactly and preserve a saved work-profile result. Wait through the existing document-readiness owner only at the quota test browser-proof boundary, after auth assertions. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(ui): avoid preloading already imported modules Remove exact direct static JavaScript imports from lazy preload tables using the emitted build graph. Preserve HTML, lazy-only JavaScript, CSS, and locale hints. Source-exact CI merge reproduction drops startup gzip from 363283 to 362968 bytes without changing budgets. Add a real emitted-bundle regression. Apply rustfmt layout to the native Chrome selector-free expected arguments. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix: preserve Chrome profiles and attachment follow-up branch binding Let the TUI canonical setup controller retain its saved browser profile and project only a bounded returned name. Align both native first-run fixture expectations with selector-free setup. Join pending chat history before the composer task handoff can expose an admitted attachment to restored-outbox delivery. Preserve idempotency, attachment custody, restored delivery semantics, and all existing assertions and timeouts. Add a deterministic regression reproduced on the exact failed CI merge and its main parent. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(state): adopt canonical worker-custody fixture repair Adopt src/plugin-state/plugin-state-worker.test.ts byte-for-byte from upstreambfec65a2a0(#152456). The former fixture held its late competing owner until after awaiting off-thread acquisition. Preserve that overlap, assert continued host authority checks and noncompletion, release custody, then assert the original result and persisted state. No production locking, guard, deadline or outcome assertion is relaxed. Both prior failures reproduced on the exact CI main parent with independently installed frozen dependencies and Node 24.19.0. All 12 repaired file tests, selected state-logging types, scoped typed lint and fresh P0-P2 review passed. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): retain saved Windows setup profiles Recover configured extension profiles through bounded serial read-only C# inspection. Select only independently validated current matching descriptors, confirm the selected generation before effects, and leave the single mutation under the existing C# owner. Preserve POSIX behavior, existing manual relay verification and explicit same-profile repair. Missing descriptors, runtime/origin drift and unknown or changing observations fail closed without automatic mutation. Keep raw management facts private and populate the existing browserProfile field only from validated binding metadata. No ABI, schema, SDK, configuration flag or registry/activation owner change. 29 actual CLI/controller/Windows-adapter boundary cases plus sibling coverage: 94 tests pass. Canonical changed checks, full production build and fresh independent P0-P2 review passed. Actual C# native proof remains separately coordinated. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): preserve saved profiles after POSIX bundle relocation Separate validated native registration ownership from supported origin-migration readiness. Recover the profile only after full private manifest and exact launcher validation; preserve the existing one-slot migration rule and all unsupported-origin, ACL and foreign-host refusals. Fail closed before selector-free installation when the saved selection cannot be proved. Extract the unchanged shared origin helpers into a cohesive sibling to satisfy the existing line-cap guard without waivers. Windows admission, ABI and selector behavior remain unchanged. Actual Linux/Darwin CLI-to-filesystem relocation regressions: 18 failures on original production, all 22 cases repaired. Preserve the private relay key and inode, config, Chrome preferences, work relay19444 and explicit-profile intent. 137 focused tests, eight real POSIX native-host E2E cases, canonical changed checks, full production build and fresh P0-P2 review passed. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(ui): retain input handoff through the shared outbox owner Remove the superseded pending-history no-yield workaround after main introduced foreground submission custody in the shared outbox owner. Restore chat-submit-guard.ts exactly to pinned main cc7 rather than retaining competing timing policies. Keep passive drains fenced while the input task yields. Preserve the retained history regression with explicit MessageChannel admission, no passive send before resume, and the same terminal leaf, idempotency key, attachment bytes and exactly-once assertions after completion. Original composed source fails all five focused cases; the repair passes 67 handoff/attachment cases and 20 real Chromium cases in the canonical secretless network-none runner. Canonical checks, UI build/performance and fresh P0-P2 review pass. No assertion, timeout, origin or proxy-policy weakening. Browser POSIX/Windows repairs remain byte-identical to accepted255f. The failed e40e CI receipts remain preserved; fresh exact-head CI and parent handoff are still required. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(tasks): preserve reads across native event finalization Hand joined event publication to its exact native successor after the native flow and observer publication frame completes. Keep worker settlement and cleanup, reversible claim transfer, current-authority and ABA checks, and post-commit delivery in their existing owners without replaying writes. Cover pre-result and readback finalization, native and reentrant successor chains, rollback, failed publication, delivery, and terminal activity cleanup. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(ui): retain rail baseline geometry on readiness failure Keep the exact existing readiness predicate, fixtures, case inventory, assertion and timeout. When the predicate is false, retain synthetic marker identity and numeric geometry so hosted CI can distinguish scroll, visibility and viewport failures. This is diagnostic evidence, not a repair or waiver of the unresolved rail failure. Local rootless browser infrastructure is unavailable; the existing hosted CI lane will verify the reviewed task-publication repair and collect meaningful rail evidence. Canonical changed checks and P0-P2 diagnostic review pass. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * docs(linux): describe saved-profile Chrome setup selection Match the selector-free adapter argument vector and its regression test. Address the fresh P3 review finding without changing runtime behavior. Markdown syntax and diff checks pass; the generic formatter excludes this subtree. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(transcripts): join configured startup before cleanup faults Observe and await the real startTranscripts promise through a narrow call-through spy while retaining the configured service entry point and real SQLite/provider work. Bind the await to the existing test lifetime instead of charging startup to the subsequent short active-map poll. Gate provider return after persisted utterance to prove readiness does not settle early; retain both missing/unreadable row injections and all cleanup, private-source, lifecycle-token and summary assertions. Cover real startup rejection explicitly. No production change, timeout increase, retries or broad module/storage mocks. The deterministic ordering boundary fails with the old fire-and-forget readiness and passes with the real promise join. Final 39 tests across 3 files, canonical changed checks and full-owner P0-P2 review pass. This does not recover whether the historical CI startup was late or rejected. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): preserve automatic desktop status inspection Restore read-only Device-page inspection for current and released Mac bridges while keeping installation and verification explicit. Preserve the native filesystem prerequisite proof, split installer repair tests within the existing line cap, and remove the superseded constant export. * fix(browser): preserve registered setup configuration Require canonical setup to match an owned launcher's effective state and config selection before installation or relay access. Preserve equivalent implicit/explicit default selections and the saved launch context. Recheck automatic profile selection before effects and the current manifest before publication through the existing registration owner. Keep manual install and relocation repair contracts unchanged. Cover mismatched configs, legacy selectors, equivalent defaults, selection drift, and actual bootstrap after refused setup. Restore the missing Command import in the existing Unix-only companion CLI test. Focused tests, types, lint, fresh review, clean package build and sealed Mac ARM64 runtime proof pass. The separate historical clock-jump CI failure has bounded replay evidence and remains documented without a speculative fix. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * fix(browser): keep setup registration types acyclic Move the private registration status contract beside its context policy and point both consumers at that owner. Remove the publication-module back-edge without keeping an unused compatibility export. The full architecture gate, extension production/test types, typed lint and fresh independent review pass. Node's transformed JavaScript is byte-identical for all three affected modules, so the existing runtime proof remains valid. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * test(linux): handle Chrome setup in desktop sharing fixture Recognize the exact automatic Chrome setup invocation and require its native no-respawn flag. Keep unknown-command rejection, selected-auth validation, process-group ownership and joined teardown assertions unchanged. The original fixture reproduces the CI rejection against the real Linux app. The repaired fixture passes all nine checks against that same binary, with five Chrome setup calls and five node starts and joined stops. Fresh review is clean; production app behavior is unchanged. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --------- Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1966 lines
67 KiB
Bash
Executable file
1966 lines
67 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
|
|
# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
|
|
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
|
|
if (return 0 2>/dev/null); then
|
|
printf '%s\n' 'Run this installer with /bin/bash on macOS instead of sourcing it.' >&2
|
|
return 1
|
|
fi
|
|
case "${BASH_SOURCE[0]:-}" in
|
|
""|bash|-bash|/dev/stdin)
|
|
# Bash reads piped scripts unbuffered; stdin now starts after this guard.
|
|
OPENCLAW_INSTALLER_REEXEC_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-installer.XXXXXX")" || exit 1
|
|
export OPENCLAW_INSTALLER_REEXEC_FILE
|
|
trap 'rm -f -- "$OPENCLAW_INSTALLER_REEXEC_FILE"' EXIT
|
|
{ printf '#!/bin/bash\n'; cat; } > "$OPENCLAW_INSTALLER_REEXEC_FILE" || exit 1
|
|
exec /bin/bash "$OPENCLAW_INSTALLER_REEXEC_FILE" "$@"
|
|
;;
|
|
*) exec /bin/bash "$0" "$@" ;;
|
|
esac
|
|
fi
|
|
|
|
set -euo pipefail
|
|
|
|
# BEGIN GENERATED UPDATE NETWORK BUDGET
|
|
# Source: src/infra/update-network-budget.ts; regenerate: node scripts/generate-update-network-budget.mjs
|
|
UPDATE_NETWORK_TIMEOUT_SECONDS=300
|
|
# END GENERATED UPDATE NETWORK BUDGET
|
|
|
|
# The re-executed shell has the script open, so unlink its private copy now.
|
|
if [[ -n "${OPENCLAW_INSTALLER_REEXEC_FILE:-}" && "${BASH_SOURCE[0]:-}" == "$OPENCLAW_INSTALLER_REEXEC_FILE" ]]; then
|
|
rm -f -- "$OPENCLAW_INSTALLER_REEXEC_FILE"
|
|
fi
|
|
unset OPENCLAW_INSTALLER_REEXEC_FILE
|
|
|
|
# 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.19.0"
|
|
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_24_VERSION="24.16.0"
|
|
MIN_NODE_26_VERSION="26.1.0"
|
|
SUPPORTED_NODE_VERSION_LABEL="Node 24.16.0+ or Node 26.1.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
|
|
NODE_ONLY=0
|
|
RUNTIME_ONLY=0
|
|
SET_NPM_PREFIX=0
|
|
PNPM_CMD=()
|
|
GIT_REF_KIND=""
|
|
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.19.0)
|
|
--node-only Install only a private Node runtime (no system package changes)
|
|
--runtime-only Install CLI runtime without Gateway probes, service changes, or onboarding
|
|
--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 connection and transfer stalls without a total download duration.
|
|
curl -fsSL --proto '=https' --tlsv1.2 \
|
|
--connect-timeout "$UPDATE_NETWORK_TIMEOUT_SECONDS" \
|
|
--speed-limit 1 --speed-time "$UPDATE_NETWORK_TIMEOUT_SECONDS" \
|
|
--retry 3 --retry-delay 1 --retry-connrefused \
|
|
-o "$output" "$url"
|
|
return
|
|
fi
|
|
wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout="$UPDATE_NETWORK_TIMEOUT_SECONDS" -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 step name legacy-submodule status start path "$legacy_dir"
|
|
log "Removing legacy submodule checkout: ${legacy_dir}"
|
|
rm -rf "$legacy_dir"
|
|
emit_json 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)"
|
|
}
|
|
|
|
JSON_STRING=""
|
|
quote_json_string() {
|
|
local value="${1:-}"
|
|
local char code escaped index
|
|
# Byte iteration escapes every C0 control without rewriting valid UTF-8 bytes.
|
|
# The final emitter can therefore guarantee one physical line per event.
|
|
local LC_ALL=C
|
|
|
|
JSON_STRING='"'
|
|
for ((index = 0; index < ${#value}; index++)); do
|
|
char="${value:index:1}"
|
|
case "$char" in
|
|
'"') JSON_STRING+='\"' ;;
|
|
\\) JSON_STRING+="\\\\" ;;
|
|
*)
|
|
printf -v code '%d' "'$char"
|
|
# Bash 3.2 reports high UTF-8 bytes as negative integers, not C0 controls.
|
|
if ((code >= 0 && code < 32)); then
|
|
printf -v escaped '\\u%04x' "$code"
|
|
JSON_STRING+="$escaped"
|
|
else
|
|
JSON_STRING+="$char"
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
JSON_STRING+='"'
|
|
}
|
|
|
|
emit_json() {
|
|
if [[ "$JSON" -ne 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local event="$1"
|
|
local key value output
|
|
shift
|
|
quote_json_string "$event"
|
|
output="{\"event\":${JSON_STRING}"
|
|
# `ok` is the installer's only non-string event field and belongs to done.
|
|
if [[ "$event" == "done" ]]; then
|
|
output+=',"ok":true'
|
|
fi
|
|
while [[ $# -gt 0 ]]; do
|
|
key="$1"
|
|
value="$2"
|
|
shift 2
|
|
quote_json_string "$key"
|
|
output+=",${JSON_STRING}:"
|
|
quote_json_string "$value"
|
|
output+="$JSON_STRING"
|
|
done
|
|
printf '%s}\n' "$output"
|
|
}
|
|
|
|
fail() {
|
|
local msg="$1"
|
|
emit_json error message "$msg"
|
|
log "ERROR: $msg"
|
|
exit 1
|
|
}
|
|
|
|
fail_freebsd_source_install() {
|
|
fail "Source/git installation is unsupported on FreeBSD. Use --install-method npm with a published version or compatible built .tgz package and the same --prefix. Keep pkg/Ports-managed installations with pkg or Ports."
|
|
}
|
|
|
|
prepare_tmpdir() {
|
|
local base tmp fallback=0
|
|
base="$(resolve_installer_path "${TMPDIR:-/tmp}")"
|
|
if ! tmp="$(mktemp -d "${base%/}/openclaw-install.XXXXXX" 2>/dev/null)"; then
|
|
if ! tmp="$(mktemp -d /tmp/openclaw-install.XXXXXX 2>/dev/null)"; then
|
|
fail "Cannot create a temporary directory. Check permissions for TMPDIR and /tmp, then retry setup."
|
|
fi
|
|
fallback=1
|
|
fi
|
|
TMPFILES+=("$tmp")
|
|
export TMPDIR="$tmp"
|
|
if [[ "$fallback" -eq 1 ]]; then
|
|
emit_json step name temporary-directory status warn reason using-fallback
|
|
log "Using a private directory under /tmp because TMPDIR is unavailable."
|
|
fi
|
|
}
|
|
|
|
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 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 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 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 step name git status ok
|
|
return
|
|
fi
|
|
|
|
emit_json 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
|
|
;;
|
|
freebsd)
|
|
fail "Git missing. Ask the system administrator to install it with pkg install git, then retry."
|
|
;;
|
|
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 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
|
|
;;
|
|
--runtime-only)
|
|
RUNTIME_ONLY=1
|
|
shift
|
|
;;
|
|
--node-only)
|
|
NODE_ONLY=1
|
|
shift
|
|
;;
|
|
--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" ;;
|
|
FreeBSD) echo "freebsd" ;;
|
|
*) 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 [[ "$os" == "linux" && "$arch" == "armv7l" ]]; then
|
|
fail "Linux ARMv7 is unsupported: official Node 24+ binaries are unavailable. Use a 64-bit OS on compatible hardware or another supported host."
|
|
fi
|
|
}
|
|
|
|
node_dir() {
|
|
echo "${PREFIX}/tools/node-v${NODE_VERSION}"
|
|
}
|
|
|
|
node_bin() {
|
|
echo "$(node_dir)/bin/node"
|
|
}
|
|
|
|
npm_bin() {
|
|
echo "$(node_dir)/bin/npm"
|
|
}
|
|
|
|
is_installer_node_bin() {
|
|
[[ "$1" -ef "$(node_dir)/bin" || "$1" -ef "${PREFIX}/tools/node/bin" ]]
|
|
}
|
|
|
|
command_path_without_node_prefix() {
|
|
local name="$1"
|
|
local exclude_active_runtime="${2:-0}"
|
|
local path_entry
|
|
local prefix_bin
|
|
local filtered_path=""
|
|
local separator=""
|
|
local -a path_entries=()
|
|
|
|
prefix_bin="$(node_dir)/bin"
|
|
# The extra delimiter preserves a trailing (or sole) empty cwd entry.
|
|
IFS=: read -r -a path_entries <<<"${PATH}:"
|
|
for path_entry in "${path_entries[@]}"; do
|
|
if [[ "$path_entry" == "$prefix_bin" ]] ||
|
|
{ [[ "$exclude_active_runtime" == "1" ]] && is_installer_node_bin "${path_entry:-.}"; }; then
|
|
continue
|
|
fi
|
|
filtered_path="${filtered_path}${separator}${path_entry}"
|
|
separator=":"
|
|
done
|
|
|
|
[[ -n "$separator" ]] || return 1
|
|
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
|
|
# PATH entries resolve from this cwd; published links must work from any cwd.
|
|
[[ "$node_path" == /* ]] || node_path="$PWD/$node_path"
|
|
[[ "$npm_path" == /* ]] || npm_path="$PWD/$npm_path"
|
|
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
|
|
# These optional tools cannot point through the alias we republish below.
|
|
resolved="$(command_path_without_node_prefix "$name" 1 || true)"
|
|
if [[ -n "$resolved" && "$resolved" != "${dir}/bin/${name}" ]]; then
|
|
[[ "$resolved" == /* ]] || resolved="$PWD/$resolved"
|
|
ln -sfn "$resolved" "${dir}/bin/${name}"
|
|
fi
|
|
done
|
|
ln -sfn "$dir" "${PREFIX}/tools/node"
|
|
}
|
|
|
|
linked_node_is_usable() {
|
|
local candidate_node="${1-$(node_bin)}"
|
|
local candidate_npm="${2-$(npm_bin)}"
|
|
local candidate_bin
|
|
local current_version
|
|
local required_version
|
|
|
|
if [[ ! -x "$candidate_node" || ! -x "$candidate_npm" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
current_version="$("$candidate_node" -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="${candidate_node%/*}"
|
|
if ! PATH="${candidate_bin}:${PATH}" "$candidate_npm" --version >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
"$candidate_node" -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)));
|
|
const text = "a\u0000b\u0000";
|
|
const bytes = Buffer.from(text, "utf8");
|
|
const json = JSON.stringify({ value: text });
|
|
db.exec("CREATE TABLE probe (text_value TEXT, blob_value BLOB, json_value TEXT)");
|
|
db.prepare("INSERT INTO probe VALUES (?, ?, ?)").run(text, bytes, json);
|
|
const row = db.prepare("SELECT text_value, blob_value, json_value FROM probe").get();
|
|
const textSafe = typeof row?.text_value === "string" && row.text_value.length === text.length && Buffer.from(row.text_value, "utf8").equals(bytes);
|
|
const blobSafe = row?.blob_value instanceof Uint8Array && Buffer.from(row.blob_value).equals(bytes);
|
|
const jsonSafe = row?.json_value === json && JSON.parse(row.json_value).value === text;
|
|
if (!textSafe) {
|
|
console.error("Node " + process.versions.node + ": node:sqlite truncates TEXT at embedded NUL (nodejs/node#61954); use 24.16+/26.1+ or a build with the fix");
|
|
} else if (!blobSafe || !jsonSafe) {
|
|
console.error("Node " + process.versions.node + ": node:sqlite NUL round-trip capability probe failed; use 24.16+/26.1+ or a build with the fix");
|
|
} else if (!safe) {
|
|
console.error("Node " + process.versions.node + ": SQLite " + value + " is not WAL-reset-safe");
|
|
}
|
|
if (!safe || !textSafe || !blobSafe || !jsonSafe) process.exitCode = 1;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
' --no-warnings >/dev/null
|
|
}
|
|
|
|
linked_node_sqlite_version() {
|
|
local candidate_node="${1-$(node_bin)}"
|
|
if [[ ! -x "$candidate_node" ]]; then
|
|
printf 'unavailable\n'
|
|
return
|
|
fi
|
|
local version
|
|
version="$("$candidate_node" -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))
|
|
}
|
|
|
|
parse_node_release_version() {
|
|
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_release_version_is_supported() {
|
|
parse_node_release_version "$1" || return 1
|
|
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 == 24)); then
|
|
semver_at_least "$version" "$MIN_NODE_24_VERSION"
|
|
return
|
|
fi
|
|
if ((major == 26)); then
|
|
semver_at_least "$version" "$MIN_NODE_26_VERSION"
|
|
return
|
|
fi
|
|
((major > 26))
|
|
}
|
|
|
|
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_24_VERSION"
|
|
}
|
|
|
|
try_link_usable_node_runtime_from_path() {
|
|
local path_entry
|
|
local -a path_entries=()
|
|
|
|
# The extra delimiter preserves a trailing (or sole) empty cwd entry.
|
|
IFS=: read -r -a path_entries <<<"${PATH}:"
|
|
for path_entry in "${path_entries[@]}"; do
|
|
if [[ -z "$path_entry" ]]; then
|
|
path_entry="."
|
|
fi
|
|
# Never publish links back into the runtime prefix being replaced.
|
|
if is_installer_node_bin "$path_entry"; then
|
|
continue
|
|
fi
|
|
if linked_node_is_usable "${path_entry}/node" "${path_entry}/npm"; then
|
|
link_node_runtime_paths "${path_entry}/node" "${path_entry}/npm"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
install_alpine_node() {
|
|
local installed_version
|
|
local required_version
|
|
local sqlite_version
|
|
|
|
emit_json 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 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
|
|
# Failed package prerequisites must leave the prefix's existing runtime intact.
|
|
if ! linked_node_is_usable "${APK_NODE_BIN_DIR}/node" "${APK_NODE_BIN_DIR}/npm"; then
|
|
installed_version="$("${APK_NODE_BIN_DIR}/node" -v 2>/dev/null || echo unknown)"
|
|
required_version="$(required_node_version)"
|
|
sqlite_version="$(linked_node_sqlite_version "${APK_NODE_BIN_DIR}/node")"
|
|
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
|
|
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
|
|
|
|
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
|
|
emit_json step name node status ok method apk version "$installed_version"
|
|
}
|
|
|
|
set_pnpm_cmd() {
|
|
PNPM_CMD=("$@")
|
|
}
|
|
|
|
run_pnpm() (
|
|
local repo_dir="$PWD"
|
|
if [[ "${1:-}" == "-C" ]]; then
|
|
repo_dir="$2"
|
|
shift 2
|
|
fi
|
|
cd "$repo_dir" || return 1
|
|
# Pin nested commands and inherited roots only for this child. Corepack's
|
|
# cold-cache prompt would otherwise wait invisibly in the version probe.
|
|
env COREPACK_ENABLE_DOWNLOAD_PROMPT=0 PATH="${PNPM_CMD[0]%/*}:$PATH" \
|
|
NPM_CONFIG_WORKSPACE_DIR="$PWD" npm_config_workspace_dir="$PWD" \
|
|
PNPM_CONFIG_LOCKFILE_DIR="$PWD" pnpm_config_lockfile_dir="$PWD" \
|
|
"${PNPM_CMD[@]}" "$@"
|
|
)
|
|
|
|
should_prefer_offline_pnpm_install() {
|
|
local project_dir="${1:-$PWD}"
|
|
[[ -z "${PNPM_CONFIG_PREFER_OFFLINE+x}" && -z "${pnpm_config_prefer_offline+x}" ]] || return 1
|
|
local configured=""
|
|
configured="$(run_pnpm -C "$project_dir" config get prefer-offline 2>/dev/null)" || return 1
|
|
[[ -z "$configured" || "$configured" == "undefined" || "$configured" == "null" ]]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
verify_git_rebase_recovery() {
|
|
local repo_dir="$1"
|
|
local expected_head="$2"
|
|
local expected_status="$3"
|
|
local git_dir
|
|
|
|
git_dir="$(git -C "$repo_dir" rev-parse --absolute-git-dir)" || return 1
|
|
if [[ -d "$git_dir/rebase-merge" || -d "$git_dir/rebase-apply" ]]; then
|
|
git -C "$repo_dir" rebase --abort >/dev/null 2>&1 || return 1
|
|
fi
|
|
|
|
[[ "$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)" == "$expected_head" ]] &&
|
|
[[ "$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all 2>/dev/null)" == "$expected_status" ]] &&
|
|
[[ ! -d "$git_dir/rebase-merge" && ! -d "$git_dir/rebase-apply" ]]
|
|
}
|
|
|
|
checkout_git_openclaw_ref() {
|
|
local repo_dir="$1"
|
|
local ref="$2"
|
|
local original_head=""
|
|
local original_status=""
|
|
local namespaces=(heads tags)
|
|
|
|
GIT_REF_KIND=""
|
|
|
|
if [[ -z "$ref" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
# Full commit IDs pin source bytes, even when a remote ref has the same name.
|
|
# Bundled/existing checkouts already have the object and need no remote lookup.
|
|
if [[ "$ref" =~ ^[[:xdigit:]]{40}$ ]]; then
|
|
if ! git -C "$repo_dir" cat-file -e "$ref" 2>/dev/null; then
|
|
git -C "$repo_dir" fetch --no-tags origin "$ref" ||
|
|
fail "Could not fetch requested git commit: ${ref}"
|
|
fi
|
|
git -C "$repo_dir" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null ||
|
|
fail "Requested git version is not a commit: ${ref}"
|
|
git -C "$repo_dir" checkout --detach "$ref"
|
|
GIT_REF_KIND="immutable"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$ref" == "main" ]]; then
|
|
git -C "$repo_dir" fetch --no-tags origin "refs/heads/main:refs/remotes/origin/main"
|
|
git -C "$repo_dir" checkout main
|
|
if [[ "$GIT_UPDATE" == "1" ]]; then
|
|
if ! original_head="$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)"; then
|
|
fail "Could not record repository state before updating from origin/main"
|
|
fi
|
|
if ! original_status="$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all 2>/dev/null)"; then
|
|
fail "Could not record repository state before updating from origin/main"
|
|
fi
|
|
if ! git -C "$repo_dir" rebase origin/main; then
|
|
if verify_git_rebase_recovery "$repo_dir" "$original_head" "$original_status"; then
|
|
fail "Could not update repository from origin/main; the checkout was restored to its pre-update state"
|
|
fi
|
|
fail "Could not update repository from origin/main; checkout recovery was not verified. Run git -C \"$repo_dir\" rebase --abort and inspect the checkout before retrying"
|
|
fi
|
|
fi
|
|
GIT_REF_KIND="moving"
|
|
return 0
|
|
fi
|
|
|
|
# Normalized release selectors prefer immutable tags. A same-name branch
|
|
# remains a fallback for operator-supplied v-prefixed branch names.
|
|
if [[ "$ref" == v[0-9]* ]]; then
|
|
namespaces=(tags heads)
|
|
fi
|
|
|
|
local namespace=""
|
|
local probe_status=0
|
|
for namespace in "${namespaces[@]}"; do
|
|
if git -C "$repo_dir" ls-remote --exit-code origin "refs/${namespace}/${ref}" >/dev/null 2>&1; then
|
|
if [[ "$namespace" == "heads" ]]; 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"
|
|
GIT_REF_KIND="moving"
|
|
else
|
|
git -C "$repo_dir" fetch --no-tags origin "refs/tags/${ref}:refs/tags/${ref}"
|
|
git -C "$repo_dir" rev-parse --verify --quiet "refs/tags/${ref}^{commit}" >/dev/null ||
|
|
fail "Requested git version is not a commit: ${ref}"
|
|
git -C "$repo_dir" checkout --detach "refs/tags/${ref}"
|
|
GIT_REF_KIND="immutable"
|
|
fi
|
|
return 0
|
|
else
|
|
probe_status=$?
|
|
fi
|
|
(( probe_status == 2 )) || fail "Could not resolve requested git ref: ${ref}"
|
|
done
|
|
|
|
fail "Requested git version not found: ${ref}"
|
|
}
|
|
|
|
git_install_lockfile_flag() {
|
|
if [[ "$1" == "moving" ]]; then
|
|
echo "--no-frozen-lockfile"
|
|
else
|
|
echo "--frozen-lockfile"
|
|
fi
|
|
}
|
|
|
|
repo_pnpm_spec() {
|
|
local repo_dir="$1"
|
|
local package_json="${repo_dir}/package.json"
|
|
|
|
if [[ ! -f "$package_json" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
"$(node_bin)" -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (typeof pkg.packageManager === "string") process.stdout.write(pkg.packageManager);' "$package_json"
|
|
}
|
|
|
|
|
|
install_node() {
|
|
# Packaging provisions each requested architecture in a fresh private prefix.
|
|
# It must execute that Node (Rosetta for x64 on ARM), never link the host runtime.
|
|
local os="$1"
|
|
local arch="$2"
|
|
local url
|
|
local tmp
|
|
local dir
|
|
local base_url
|
|
local tarball
|
|
local expected_sha
|
|
local actual_sha
|
|
|
|
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" == "freebsd" ]]; then
|
|
if [[ "$NODE_ONLY" -eq 1 ]]; then
|
|
fail "Private Node.js recovery is unavailable on FreeBSD. Update Node.js and npm with pkg, then retry."
|
|
fi
|
|
local system_node system_npm installed_version
|
|
system_node="$(command_path_without_node_prefix node || true)"
|
|
system_npm="$(command_path_without_node_prefix npm || true)"
|
|
emit_json step name node status start method system
|
|
# FreeBSD has no official Node binary archive. Validate the package-owned
|
|
# runtime before publishing links so a failed prerequisite leaves the CLI intact.
|
|
if ! linked_node_is_usable "$system_node" "$system_npm"; then
|
|
fail "FreeBSD requires ${SUPPORTED_NODE_VERSION_LABEL}, working npm, and WAL-reset-safe SQLite. Ask the system administrator to install or update node24 and npm-node24 with pkg, then retry with node and npm on PATH."
|
|
fi
|
|
system_node="$("$system_node" -p 'process.execPath')"
|
|
system_npm="$("$system_node" -p 'require("node:fs").realpathSync(process.argv[1])' "$system_npm")"
|
|
link_node_runtime_paths "$system_node" "$system_npm"
|
|
installed_version="$("$(node_bin)" -v)"
|
|
emit_json step name node status ok method system version "$installed_version"
|
|
return
|
|
fi
|
|
|
|
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
|
|
ln -sfn "$dir" "${PREFIX}/tools/node"
|
|
emit_json step name node status skip path "$dir"
|
|
return
|
|
fi
|
|
|
|
emit_json step name node status start version "$NODE_VERSION"
|
|
log "Installing Node ${NODE_VERSION} (user-space)..."
|
|
|
|
mkdir -p "${PREFIX}/tools"
|
|
# Darwin's default mktemp location can ignore TMPDIR; use the prepared path explicitly.
|
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-node.XXXXXX")"
|
|
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"
|
|
|
|
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.19.0 (or newer)"
|
|
fi
|
|
# Existing CLI wrappers use this alias; activate only a runtime that can start.
|
|
ln -sfn "$dir" "${PREFIX}/tools/node"
|
|
emit_json step name node status ok version "$NODE_VERSION"
|
|
}
|
|
|
|
ensure_pnpm() {
|
|
local repo_dir="${1:-$PWD}"
|
|
local spec version pnpm_dir corepack_cmd="" npm_cmd lifecycle_arg selected_version
|
|
spec="$(repo_pnpm_spec "$repo_dir" || true)"
|
|
[[ "$spec" == pnpm@* ]] || spec="pnpm@12.4.0"
|
|
version="${spec#pnpm@}"
|
|
version="${version%%+*}"
|
|
pnpm_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pnpm.XXXXXX")" || return 1
|
|
TMPFILES+=("$pnpm_dir")
|
|
if [[ -x "$(node_dir)/bin/corepack" ]]; then
|
|
corepack_cmd="$(node_dir)/bin/corepack"
|
|
else
|
|
corepack_cmd="$(command -v corepack || true)"
|
|
fi
|
|
if [[ -n "$corepack_cmd" ]]; then
|
|
emit_json step name pnpm status start method corepack
|
|
log "Selecting repo pnpm ${version} via Corepack..."
|
|
set_pnpm_cmd "$pnpm_dir/pnpm"
|
|
if "$corepack_cmd" enable --install-directory "$pnpm_dir" pnpm &&
|
|
selected_version="$(run_pnpm -C "$repo_dir" --version 2>/dev/null)" &&
|
|
[[ "$selected_version" == "$version" ]]; then
|
|
emit_json step name pnpm status ok
|
|
return 0
|
|
fi
|
|
log "Corepack could not provision pnpm; falling back to npm."
|
|
fi
|
|
|
|
emit_json step name pnpm status start method npm
|
|
log "Installing pnpm ${version} via npm..."
|
|
npm_cmd="$(npm_bin)"
|
|
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "pnpm@${version}" "$repo_dir" "pnpm@${version}")" || return 1
|
|
# The explicit npm prefix owns this executable; never rediscover ambient pnpm.
|
|
"$npm_cmd" install -g --prefix "$pnpm_dir/npm" "pnpm@${version}" ${lifecycle_arg:+"$lifecycle_arg"} || return 1
|
|
set_pnpm_cmd "$pnpm_dir/npm/bin/pnpm"
|
|
if [[ ! -x "${PNPM_CMD[0]}" ]] || ! selected_version="$(run_pnpm -C "$repo_dir" --version 2>/dev/null)" || [[ "$selected_version" != "$version" ]]; then
|
|
fail "Could not provision pnpm ${version} for ${repo_dir}"
|
|
fi
|
|
emit_json step name pnpm status ok
|
|
}
|
|
|
|
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 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 project_dir="${3:-}"
|
|
local raw=""
|
|
local file=""
|
|
local -a files=()
|
|
|
|
if [[ -n "$project_dir" ]]; then
|
|
files+=("${project_dir}/.npmrc")
|
|
fi
|
|
|
|
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}" exact_identity="${4:-}" version="" output=""
|
|
if ! version="$("$npm_cmd" --version 2>/dev/null)"; then
|
|
log "ERROR: unable to determine npm version; no package changes were made"
|
|
return 1
|
|
fi
|
|
output="$("$(node_bin)" - "$version" "$spec" "$npm_cwd" "$exact_identity" <<'NODE'
|
|
const path = require("node:path");
|
|
const [versionOutput, spec, cwd, exactIdentity] = process.argv.slice(2);
|
|
const version = versionOutput.trim().split(/\r?\n/).at(-1) ?? "";
|
|
const parsed = version.match(/^[vV]?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
|
|
const fail = (message) => { process.stderr.write(`${message}\n`); process.exit(1); };
|
|
if (!parsed) fail("Unable to determine npm version; no package changes were made.");
|
|
if (+parsed[1] < 12 && (+parsed[1] !== 11 || +parsed[2] < 16)) process.exit(0);
|
|
const normalized = spec.trim();
|
|
const unaliased = normalized.toLowerCase().startsWith("openclaw@") ? normalized.slice(9).trim() : normalized;
|
|
const explicit = (value) => /\.(?:tgz|tar\.gz)$/i.test(value) || value.includes("://") || value.includes("#") || /^(?:file|github|git\+(?:ssh|https|http|file)|npm):/i.test(value);
|
|
let identity = !normalized || explicit(normalized) || explicit(unaliased) || /^\.{1,2}(?:[\\/]|$)/.test(unaliased) || path.isAbsolute(normalized) || path.isAbsolute(unaliased) ? unaliased : "openclaw";
|
|
const alias = /^npm:/i.test(identity);
|
|
if (alias) identity = /^npm:(@[^/]+\/[^@]+|[^@]+?)(?:@.*)?$/i.exec(identity)?.[1] ?? "";
|
|
const filePrefix = /^file:/i.test(identity) ? "file:" : "";
|
|
const archivePath = identity.slice(filePrefix.length);
|
|
const gitShorthand = !/^~[\\/]/.test(identity) && /^[^./@\s:#][^/\s:@#]*\/[^/\s:@#]+(?:#[\s\S]*)?$/.test(identity);
|
|
const localArchive = !alias && !gitShorthand && /\.(?:tgz|tar\.gz|tar)$/i.test(archivePath) && (filePrefix || path.isAbsolute(archivePath) || !/^[a-z][a-z0-9+.-]*:/i.test(archivePath));
|
|
let absoluteArchive = "";
|
|
if (localArchive) {
|
|
const npmPath = process.platform === "win32" ? archivePath.replaceAll("\\", "/") : archivePath;
|
|
// Escape raw paths before URL normalization so literal %, #, and ? retain their identity.
|
|
let fileUrl = `file:${encodeURI(npmPath).replace(/[?#]/g, encodeURIComponent)}`;
|
|
fileUrl = fileUrl.replace(/^file:\/\/(?=[^/])/, "file:/").replace(/^file:\/{1,3}(?=\.\.?(?:\/|$))/, "file:");
|
|
const specPath = decodeURIComponent(new URL(fileUrl).pathname);
|
|
let resolvedPath = decodeURIComponent(new URL(fileUrl, `${require("node:url").pathToFileURL(path.resolve(cwd || process.cwd())).href}/`).pathname);
|
|
if (process.platform === "win32") resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, "$1");
|
|
absoluteArchive = /^\/~(?:\/|$)/.test(specPath) ? path.resolve(require("node:os").homedir(), specPath.slice(3)) : path.resolve(cwd || process.cwd(), resolvedPath);
|
|
}
|
|
// Tarballs match the absolute npm resolved identity; directory links accept relative paths.
|
|
// Keep the npm 11 comma-path identity: its advisory/strict decision stays npm-owned.
|
|
if (absoluteArchive && (+parsed[1] >= 12 || !absoluteArchive.includes(","))) identity = `${filePrefix}${absoluteArchive}`;
|
|
else {
|
|
const relative = cwd && path.isAbsolute(identity) ? path.relative(cwd, identity) || "." : "";
|
|
if (relative) identity = path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`;
|
|
}
|
|
if (exactIdentity) identity = exactIdentity;
|
|
if (!identity || identity.includes(",")) fail(`npm cannot allow lifecycle scripts for install target '${spec}'; use a package URL or local path without commas.`);
|
|
process.stdout.write(`--allow-scripts=${identity}\n`);
|
|
NODE
|
|
)" || return 1
|
|
printf '%s' "$output"
|
|
}
|
|
|
|
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
|
|
if [[ "$(os_detect)" == "freebsd" ]]; then
|
|
fail_freebsd_source_install
|
|
fi
|
|
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
|
|
# || true: a failed npm view must reach the explicit fail below instead
|
|
# of dying silently through set -e with no error event.
|
|
resolved_requested="$(resolve_npm_openclaw_version "$requested" || true)"
|
|
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 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 lifecycle_pending legacy_install_guard
|
|
installed_entry="$(node_dir)/lib/node_modules/openclaw/dist/entry.js"
|
|
lifecycle_pending="$(node_dir)/lib/node_modules/openclaw/.openclaw-lifecycle-pending"
|
|
legacy_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 "$lifecycle_pending" || -e "$legacy_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 "$lifecycle_pending" || -e "$legacy_install_guard" ]]; then
|
|
emit_json 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 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 "${TMPDIR:-/tmp}/openclaw-workspace.XXXXXX")"
|
|
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")
|
|
|
|
# Blobless partial clone: the dev checkout only needs current files plus pullable
|
|
# history refs; full multi-gigabyte blob history would dominate install time.
|
|
git clone --filter=blob:none "$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 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 step name git-tools status start
|
|
ensure_git
|
|
emit_json 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 step name git-clone status start
|
|
clone_git_checkout_transactionally "$repo_url" "$repo_dir"
|
|
emit_json 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 step name git-clone status start
|
|
clone_git_checkout_transactionally "$repo_url" "$repo_dir"
|
|
emit_json 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 step name git-update status start
|
|
fi
|
|
checkout_git_openclaw_ref "$repo_dir" "$git_ref"
|
|
if [[ "$fresh_checkout" -eq 0 ]]; then
|
|
emit_json step name git-update status ok
|
|
fi
|
|
else
|
|
log "Repo is dirty; skipping git checkout/update"
|
|
emit_json step name git-update status warn reason dirty
|
|
if git -C "$repo_dir" symbolic-ref --quiet HEAD >/dev/null; then
|
|
GIT_REF_KIND="moving"
|
|
else
|
|
GIT_REF_KIND="immutable"
|
|
fi
|
|
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"
|
|
ensure_pnpm "$repo_dir"
|
|
|
|
local install_lockfile_flag
|
|
install_lockfile_flag="$(git_install_lockfile_flag "$GIT_REF_KIND")"
|
|
local -a pnpm_prefer_offline_args=()
|
|
if should_prefer_offline_pnpm_install "$repo_dir"; then
|
|
pnpm_prefer_offline_args=(--prefer-offline)
|
|
fi
|
|
emit_json step name dependencies status start
|
|
CI="${CI:-true}" run_pnpm -C "$repo_dir" install ${pnpm_prefer_offline_args[@]+"${pnpm_prefer_offline_args[@]}"} "$install_lockfile_flag"
|
|
emit_json step name dependencies status ok
|
|
|
|
emit_json 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 step name control-ui status warn
|
|
else
|
|
emit_json step name control-ui status ok
|
|
fi
|
|
emit_json step name cli-build status start
|
|
run_pnpm -C "$repo_dir" build
|
|
emit_json 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 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=""
|
|
# Unlike daemon status, gateway status reports service.loaded during pending migrations.
|
|
status_json="$("$claw" gateway status --json 2>/dev/null || true)"
|
|
if [[ -z "$status_json" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
# Managed installs must parse with their provisioned Node even when the system has none.
|
|
local node_bin="${PREFIX}/tools/node/bin/node"
|
|
if [[ ! -x "$node_bin" ]]; then
|
|
if command -v node >/dev/null 2>&1; then
|
|
node_bin="$(command -v node)"
|
|
else
|
|
# Approximate POSIX-safe fallback when neither managed nor system Node is available.
|
|
printf '%s\n' "$status_json" | grep -Eq '"loaded"[[:space:]]*:[[:space:]]*true'
|
|
return
|
|
fi
|
|
fi
|
|
|
|
printf '%s' "$status_json" | "$node_bin" -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" refresh_output
|
|
if [[ ! -x "$claw" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if ! is_gateway_daemon_loaded "$claw"; then
|
|
emit_json step name gateway-service status skip reason not-loaded
|
|
return 0
|
|
fi
|
|
|
|
emit_json step name gateway-service status start
|
|
log "Refreshing loaded gateway service..."
|
|
|
|
if ! refresh_output="$({ set +x; "$claw" gateway install --force; } 2>&1 | sed -n -e 's/^Replacing unsupported Gateway service Node .*; refreshing the install\.$/node-runtime-replaced/p' -e 's/^Replacing missing Gateway service Node .*; refreshing the install\.$/node-runtime-replaced/p' -e 's/.*SERVICE_DEFINITION_SEALED:.*/ask the privileged deployment owner to manually repair it/p' -e 's/.*SERVICE_DEFINITION_UNKNOWN:.*/inspect service-definition access and manually repair it/p')"; then
|
|
refresh_output="$(printf '%s\n' "$refresh_output" | sed '/^node-runtime-replaced$/d')"
|
|
if [[ -n "$refresh_output" ]]; then
|
|
emit_json step name gateway-service status warn reason definition-mutation-denied
|
|
printf '%s\n' "Code installed; gateway service definition left unchanged; ${refresh_output}." >&2
|
|
printf '%s\n' "Run openclaw gateway status --deep, verify the installation owner, and restart it manually if needed." >&2
|
|
return 0
|
|
fi
|
|
emit_json step name gateway-service status warn reason install-failed
|
|
log "Warning: gateway service refresh failed; continuing."
|
|
return 0
|
|
fi
|
|
if [[ "$refresh_output" == *node-runtime-replaced* ]]; then
|
|
printf '%s\n' "Gateway service Node runtime replaced." >&2
|
|
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 step name gateway-service status ok
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
# Reject unsupported source installs before changing runtime links or checkouts.
|
|
# Node-only recovery owns its separate platform refusal and ignores the method.
|
|
if [[ "$NODE_ONLY" -eq 0 && "$INSTALL_METHOD" == "git" && "$(os_detect)" == "freebsd" ]]; then
|
|
fail_freebsd_source_install
|
|
fi
|
|
PREFIX="$(resolve_installer_path "$PREFIX")"
|
|
local original_tmpdir="${TMPDIR-}" original_tmpdir_set="${TMPDIR+x}"
|
|
local TMPDIR="$original_tmpdir"
|
|
prepare_tmpdir
|
|
if [[ "$NODE_ONLY" -eq 1 ]]; then
|
|
if is_musl_linux; then
|
|
fail "Private Node.js recovery is unavailable on musl Linux; update Node.js with your system package manager."
|
|
fi
|
|
install_node "$(os_detect)" "$(arch_detect)"
|
|
return
|
|
fi
|
|
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 "$(os_detect)" "$(arch_detect)"
|
|
if [[ "$INSTALL_METHOD" == "git" ]]; then
|
|
install_openclaw_from_git "$GIT_DIR"
|
|
elif [[ "$INSTALL_METHOD" == "npm" ]]; then
|
|
if [[ "$RUNTIME_ONLY" -eq 0 ]]; then
|
|
ensure_git
|
|
fi
|
|
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
|
|
|
|
# Services and onboarding outlive staging; never persist its temporary path.
|
|
if [[ "$original_tmpdir_set" == x ]]; then
|
|
export TMPDIR="$original_tmpdir"
|
|
else
|
|
unset TMPDIR
|
|
fi
|
|
if [[ "$RUNTIME_ONLY" -eq 1 ]]; then
|
|
emit_json step name gateway-service status skip reason runtime-only
|
|
else
|
|
refresh_gateway_service_if_loaded
|
|
fi
|
|
emit_json "done" version "$installed_version"
|
|
log "OpenClaw installed (${installed_version})."
|
|
|
|
if [[ "$RUN_ONBOARD" -eq 1 && "$RUNTIME_ONLY" -eq 0 ]]; then
|
|
"${PREFIX}/bin/openclaw" onboard
|
|
fi
|
|
}
|
|
|
|
if [[ "${OPENCLAW_INSTALL_CLI_SH_NO_RUN:-0}" != "1" ]]; then
|
|
main "$@"
|
|
fi
|