ci: verify and sync website installers (#80067)

* ci: verify and sync website installers

* test: fix pi runner boundary test type cast

* fix(installer): scope Windows legacy cleanup to git checkout

* ci: install curl for minimal install-cli smoke

* fix(installer): promote supported Linux node after install

* test(cli): align command hint expectations

* fix(installer): avoid shellcheck warning in node promotion

* fix(installer): sync Linux path hardening

* ci: raise build artifact testbox heap

* test(installer): align PowerShell installer tests
This commit is contained in:
Peter Steinberger
2026-05-09 23:48:49 -04:00
committed by GitHub
parent 71ebedee95
commit e60928d13c
14 changed files with 2144 additions and 517 deletions

View File

@@ -147,6 +147,8 @@ jobs:
- name: Build dist on cache miss
if: steps.dist-cache.outputs.cache-hit != 'true'
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
- name: Build Control UI on cache miss

View File

@@ -0,0 +1,188 @@
name: Website Installer Sync
on:
pull_request:
paths:
- scripts/install.sh
- scripts/install-cli.sh
- scripts/install.ps1
- scripts/install.cmd
- .github/workflows/website-installer-sync.yml
push:
branches: [main]
paths:
- scripts/install.sh
- scripts/install-cli.sh
- scripts/install.ps1
- scripts/install.cmd
- .github/workflows/website-installer-sync.yml
workflow_dispatch:
inputs:
sync_website:
description: Sync openclaw.ai after verification
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: website-installer-sync-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
static:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install ShellCheck
run: sudo apt-get update -y && sudo apt-get install -y shellcheck
- name: Shell syntax
run: bash -n scripts/install.sh scripts/install-cli.sh
- name: ShellCheck
run: shellcheck -e SC1091 scripts/install.sh scripts/install-cli.sh
- name: Installer help and dry-runs
run: |
bash scripts/install.sh --help >/tmp/install-help.txt
bash scripts/install.sh --dry-run --no-onboard --no-prompt
bash scripts/install-cli.sh --help >/tmp/install-cli-help.txt
- name: PowerShell syntax
shell: pwsh
run: |
$errors = $null
$null = [System.Management.Automation.PSParser]::Tokenize(
(Get-Content -Raw scripts/install.ps1),
[ref]$errors
)
if ($errors -and $errors.Count -gt 0) {
$errors | Format-List | Out-String | Write-Error
exit 1
}
linux-docker:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: install.sh in Docker
run: |
docker run --rm \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
node:24-bookworm-slim \
bash -lc 'bash /tmp/install.sh --no-prompt --no-onboard --version latest && openclaw --version'
- name: install-cli.sh in Docker
run: |
docker run --rm \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
node:24-bookworm-slim \
bash -lc 'apt-get update -y && apt-get install -y curl && bash /tmp/install-cli.sh --prefix /tmp/openclaw --no-onboard --version latest && /tmp/openclaw/bin/openclaw --version'
macos-installer:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: install.sh dry run
run: bash scripts/install.sh --dry-run --no-onboard --no-prompt
windows-installer:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: install.ps1 dry run
shell: pwsh
run: .\scripts\install.ps1 -DryRun -NoOnboard -InstallMethod npm
- name: install.cmd dry run
shell: cmd
run: set "OPENCLAW_INSTALL_PS1_URL=%GITHUB_WORKSPACE%\scripts\install.ps1" && .\scripts\install.cmd --dry-run --no-onboard --npm
sync-website:
needs: [static, linux-docker, macos-installer, windows-installer]
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && inputs.sync_website)
runs-on: ubuntu-24.04
steps:
- name: Checkout OpenClaw
uses: actions/checkout@v6
with:
path: openclaw
- name: Checkout openclaw.ai
uses: actions/checkout@v6
with:
repository: openclaw/openclaw.ai
token: ${{ secrets.OPENCLAW_GH_TOKEN }}
path: openclaw.ai
- name: Sync installer scripts
run: |
cp openclaw/scripts/install.sh openclaw.ai/public/install.sh
cp openclaw/scripts/install-cli.sh openclaw.ai/public/install-cli.sh
cp openclaw/scripts/install.ps1 openclaw.ai/public/install.ps1
cp openclaw/scripts/install.cmd openclaw.ai/public/install.cmd
chmod +x openclaw.ai/public/install.sh openclaw.ai/public/install-cli.sh
- name: Check for changes
id: changes
working-directory: openclaw.ai
run: |
if git diff --quiet -- public/install.sh public/install-cli.sh public/install.ps1 public/install.cmd; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.changes.outputs.changed == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install ShellCheck
if: steps.changes.outputs.changed == 'true'
run: sudo apt-get update -y && sudo apt-get install -y shellcheck
- name: Verify website with synced installers
if: steps.changes.outputs.changed == 'true'
working-directory: openclaw.ai
run: |
bash -n public/install.sh public/install-cli.sh
shellcheck -e SC1091 public/install.sh public/install-cli.sh
bun install --frozen-lockfile
bun run build
- name: Commit and push website sync
if: steps.changes.outputs.changed == 'true'
working-directory: openclaw.ai
run: |
git config user.name "openclaw-installer-sync[bot]"
git config user.email "openclaw-installer-sync[bot]@users.noreply.github.com"
git add public/install.sh public/install-cli.sh public/install.ps1 public/install.cmd
git commit -m "chore: sync installers from openclaw ${GITHUB_SHA::12}"
git push origin HEAD:main

View File

@@ -35,7 +35,7 @@ const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
const WINDOWS_SCOPE_RE =
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/plugins\/import-specifier(?:\.test)?\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)$|test\/scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/ci\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/plugins\/import-specifier(?:\.test)?\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|install\.cmd|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js))$|test\/scripts\/(?:install-ps1|npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/ci\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
const WINDOWS_TEST_SCOPE_RE =
/^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/plugins\/import-specifier\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
const TEST_ONLY_PATH_RE =
@@ -47,7 +47,7 @@ const NATIVE_ONLY_RE =
const FAST_INSTALL_SMOKE_SCOPE_RE =
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
const FULL_INSTALL_SMOKE_SCOPE_RE =
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/install\.sh$|scripts\/test-install-sh-docker\.sh$|scripts\/docker\/|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|qr-import-docker\.sh|bun-global-install-smoke\.sh)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/install(?:-cli)?\.sh$|scripts\/install\.ps1$|scripts\/install\.cmd$|scripts\/test-install-sh-docker\.sh$|scripts\/docker\/|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|qr-import-docker\.sh|bun-global-install-smoke\.sh)$|\.github\/workflows\/(?:install-smoke|website-installer-sync)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE = /^src\/(?:channels|gateway|plugin-sdk|plugins)\//;
const NODE_FAST_PLUGIN_CONTRACT_SCOPE_RE =
/^(src\/plugins\/contracts\/(?:inventory\/bundled-capability-metadata|registry|tts-contract-suites)\.ts$|scripts\/test-projects(?:\.test-support)?\.mjs$|test\/scripts\/test-projects\.test\.ts$)/;

748
scripts/install-cli.sh Executable file
View File

@@ -0,0 +1,748 @@
#!/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
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-22.22.0}"
SHARP_IGNORE_GLOBAL_LIBVIPS="${SHARP_IGNORE_GLOBAL_LIBVIPS:-1}"
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
GIT_DIR="${OPENCLAW_GIT_DIR:-${HOME}/openclaw}"
GIT_UPDATE="${OPENCLAW_GIT_UPDATE:-1}"
JSON=0
RUN_ONBOARD=0
SET_NPM_PREFIX=0
PNPM_CMD=()
print_usage() {
cat <<EOF
Usage: install-cli.sh [options]
--json Emit NDJSON events (no human output)
--prefix <path> Install prefix (default: ~/.openclaw)
--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)
--version <ver> OpenClaw version (default: latest)
--node-version <ver> Node version (default: 22.22.0)
--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:
SHARP_IGNORE_GLOBAL_LIBVIPS=0|1 Default: 1 (avoid sharp building against global libvips)
OPENCLAW_NPM_LOGLEVEL=error|warn|notice Default: error (hide npm deprecation noise)
OPENCLAW_INSTALL_METHOD=git|npm
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
curl -fsSL --proto '=https' --tlsv1.2 --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:-${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
}
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
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)
PREFIX="$2"
shift 2
;;
--version)
OPENCLAW_VERSION="$2"
shift 2
;;
--node-version)
NODE_VERSION="$2"
shift 2
;;
--install-method|--method)
INSTALL_METHOD="$2"
shift 2
;;
--npm)
INSTALL_METHOD="npm"
shift
;;
--git|--github)
INSTALL_METHOD="git"
shift
;;
--git-dir|--dir)
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" ;;
x86_64|amd64) echo "x64" ;;
*) fail "Unsupported architecture: $arch" ;;
esac
}
node_dir() {
echo "${PREFIX}/tools/node-v${NODE_VERSION}"
}
node_bin() {
echo "$(node_dir)/bin/node"
}
npm_bin() {
echo "$(node_dir)/bin/npm"
}
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_is_ready; then
ensure_pnpm
fi
"${PNPM_CMD[@]}" "$@"
}
install_node() {
local os
local arch
local url
local tmp
local dir
local current_major
local base_url
local tarball
local expected_sha
local actual_sha
os="$(os_detect)"
arch="$(arch_detect)"
dir="$(node_dir)"
if [[ -x "$(node_bin)" ]]; then
current_major="$("$(node_bin)" -v 2>/dev/null | tr -d 'v' | cut -d'.' -f1 || echo "")"
if [[ -n "$current_major" && "$current_major" -ge 22 ]]; then
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"skip\",\"path\":\"${dir//\"/\\\\\\\"}\"}"
return
fi
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)"
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 ! "$(node_bin)" -e "require('node:sqlite')" >/dev/null 2>&1; then
fail "Installed Node ${NODE_VERSION} is missing node:sqlite; re-run with --node-version 22.22.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" =~ ^10\. ]]; then
return 0
fi
log "Found pnpm ${current_version:-unknown}; upgrading to pnpm@10..."
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@10 --activate
if detect_pnpm_cmd && pnpm_cmd_is_ready && [[ "$("${PNPM_CMD[@]}" --version 2>/dev/null || true)" =~ ^10\. ]]; 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..."
SHARP_IGNORE_GLOBAL_LIBVIPS="$SHARP_IGNORE_GLOBAL_LIBVIPS" "$(npm_bin)" install -g --prefix "$PREFIX" pnpm@10
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}"
}
install_openclaw() {
local requested="${OPENCLAW_VERSION:-latest}"
local npm_args=(
--loglevel "$NPM_LOGLEVEL"
--no-fund
--no-audit
)
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
if [[ "${requested}" == "latest" ]]; then
if ! SHARP_IGNORE_GLOBAL_LIBVIPS="$SHARP_IGNORE_GLOBAL_LIBVIPS" "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@latest"; then
log "npm install openclaw@latest failed; retrying openclaw@next"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"retry\",\"version\":\"next\"}"
SHARP_IGNORE_GLOBAL_LIBVIPS="$SHARP_IGNORE_GLOBAL_LIBVIPS" "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@next"
requested="next"
fi
else
SHARP_IGNORE_GLOBAL_LIBVIPS="$SHARP_IGNORE_GLOBAL_LIBVIPS" "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${requested}"
fi
mkdir -p "${PREFIX}/bin"
rm -f "${PREFIX}/bin/openclaw"
cat > "${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
chmod +x "${PREFIX}/bin/openclaw"
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 package_file="${repo_dir}/package.json"
local dep="@tloncorp/api"
local tmp
if [[ -f "$workspace_file" ]] && ! grep -Fq "\"${dep}\"" "$workspace_file" && ! grep -Fq -- "- ${dep}" "$workspace_file"; then
tmp="$(mktemp)"
if grep -q '^onlyBuiltDependencies:[[:space:]]*$' "$workspace_file"; then
awk -v dep="$dep" '
BEGIN { inserted = 0 }
{
print
if (!inserted && $0 ~ /^onlyBuiltDependencies:[[:space:]]*$/) {
print " - \"" dep "\""
inserted = 1
}
}
' "$workspace_file" >"$tmp"
else
cat "$workspace_file" >"$tmp"
printf '\nonlyBuiltDependencies:\n - "%s"\n' "$dep" >>"$tmp"
fi
mv "$tmp" "$workspace_file"
fi
if [[ -f "$package_file" ]]; then
"$(node_bin)" - "$package_file" "$dep" <<'EOF'
const fs = require("node:fs");
const [packageFile, dep] = process.argv.slice(2);
const data = JSON.parse(fs.readFileSync(packageFile, "utf8"));
const list = data.pnpm?.onlyBuiltDependencies;
if (Array.isArray(list)) {
if (!list.includes(dep)) {
list.unshift(dep);
fs.writeFileSync(packageFile, `${JSON.stringify(data, null, 2)}\n`);
}
process.exit(0);
}
if (!data.pnpm || typeof data.pnpm !== "object") {
data.pnpm = {};
}
data.pnpm.onlyBuiltDependencies = [dep];
fs.writeFileSync(packageFile, `${JSON.stringify(data, null, 2)}\n`);
EOF
fi
log "Updated pnpm allowlist for git-hosted build dependency: ${dep}"
}
install_openclaw_from_git() {
local repo_dir="$1"
local repo_url="https://github.com/openclaw/openclaw.git"
if [[ -z "$repo_dir" ]]; then
fail "Git install dir cannot be empty"
fi
if [[ "$repo_dir" != /* ]]; then
repo_dir="$(pwd)/$repo_dir"
fi
mkdir -p "$(dirname "$repo_dir")"
repo_dir="$(cd "$(dirname "$repo_dir")" && pwd)/$(basename "$repo_dir")"
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
ensure_git
ensure_pnpm
ensure_pnpm_binary_for_scripts
if [[ -d "$repo_dir/.git" ]]; then
:
elif [[ -d "$repo_dir" ]]; then
if [[ -z "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then
git clone "$repo_url" "$repo_dir"
else
fail "Git install dir exists but is not a git repo: ${repo_dir}"
fi
else
git clone "$repo_url" "$repo_dir"
fi
if [[ "$GIT_UPDATE" == "1" ]]; then
if [[ -z "$(git -C "$repo_dir" status --porcelain 2>/dev/null || true)" ]]; then
git -C "$repo_dir" pull --rebase || true
else
log "Repo is dirty; skipping git pull"
fi
fi
cleanup_legacy_submodules "$repo_dir"
ensure_pnpm_git_prepare_allowlist "$repo_dir"
SHARP_IGNORE_GLOBAL_LIBVIPS="$SHARP_IGNORE_GLOBAL_LIBVIPS" run_pnpm -C "$repo_dir" install
if ! run_pnpm -C "$repo_dir" ui:build; then
log "UI build failed; continuing (CLI may still work)"
fi
run_pnpm -C "$repo_dir" build
mkdir -p "${PREFIX}/bin"
cat > "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "${repo_dir}/dist/entry.js" "\$@"
EOF
chmod +x "${PREFIX}/bin/openclaw"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"method\":\"git\"}"
}
resolve_openclaw_version() {
local version=""
if [[ -x "${PREFIX}/bin/openclaw" ]]; then
version="$("${PREFIX}/bin/openclaw" --version 2>/dev/null | head -n 1 | tr -d '\r')"
fi
echo "$version"
}
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
if ! "$claw" gateway restart >/dev/null 2>&1; then
emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"restart-failed"}'
log "Warning: gateway service restart failed; continuing."
return 0
fi
"$claw" gateway status --probe --json >/dev/null 2>&1 || true
emit_json '{"event":"step","name":"gateway-service","status":"ok"}'
}
main() {
parse_args "$@"
if [[ "${OPENCLAW_NO_ONBOARD:-0}" == "1" ]]; then
RUN_ONBOARD=0
fi
cleanup_legacy_submodules
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
refresh_gateway_service_if_loaded
local installed_version
installed_version="$(resolve_openclaw_version)"
if [[ -n "$installed_version" ]]; then
emit_json "{\"event\":\"done\",\"ok\":true,\"version\":\"${installed_version//\"/\\\"}\"}"
log "OpenClaw installed (${installed_version})."
else
emit_json "{\"event\":\"done\",\"ok\":true}"
log "OpenClaw installed."
fi
if [[ "$RUN_ONBOARD" -eq 1 ]]; then
"${PREFIX}/bin/openclaw" onboard
fi
}
if [[ "${OPENCLAW_INSTALL_CLI_SH_NO_RUN:-0}" != "1" ]]; then
main "$@"
fi

102
scripts/install.cmd Normal file
View File

@@ -0,0 +1,102 @@
@echo off
setlocal enabledelayedexpansion
REM OpenClaw Windows CMD installer
REM Usage:
REM curl -fsSL https://openclaw.ai/install.cmd -o install.cmd && install.cmd --no-onboard && del install.cmd
set "TAG=latest"
set "INSTALL_METHOD=npm"
set "NO_ONBOARD=0"
set "NO_GIT_UPDATE=0"
set "DRY_RUN=0"
set "TAG_SET=0"
set "INSTALL_PS1_URL="
:parse_args
if "%~1"=="" goto :args_done
if /i "%~1"=="--help" goto :usage
if /i "%~1"=="--git" set "INSTALL_METHOD=git"
if /i "%~1"=="--npm" set "INSTALL_METHOD=npm"
if /i "%~1"=="--no-onboard" set "NO_ONBOARD=1"
if /i "%~1"=="--no-git-update" set "NO_GIT_UPDATE=1"
if /i "%~1"=="--dry-run" set "DRY_RUN=1"
if /i "%~1"=="--tag" (
if not "%~2"=="" (
set "TAG=%~2"
set "TAG_SET=1"
shift
)
shift
goto :parse_args
)
set "ARG=%~1"
if not "%ARG%"=="" (
if not "%ARG:~0,1%"=="-" (
if "%TAG_SET%"=="0" (
set "TAG=%ARG%"
set "TAG_SET=1"
)
)
)
shift
goto :parse_args
:args_done
curl --version >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo curl is required but not available. Please install curl or use PowerShell installer. >&2
exit /b 1
)
powershell -NoProfile -Command "$PSVersionTable.PSVersion.Major" >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo PowerShell is required but not available. Use install.ps1 directly or install PowerShell. >&2
exit /b 1
)
set "TMP=%TEMP%\openclaw-install.ps1"
REM TMP may include spaces; always quote "%TMP%" when used.
if not "%OPENCLAW_INSTALL_PS1_URL%"=="" set "INSTALL_PS1_URL=%OPENCLAW_INSTALL_PS1_URL%"
if "%INSTALL_PS1_URL%"=="" set "INSTALL_PS1_URL=https://openclaw.ai/install.ps1"
if exist "%INSTALL_PS1_URL%" (
copy /Y "%INSTALL_PS1_URL%" "%TMP%" >nul
) else (
curl -fsSL "%INSTALL_PS1_URL%" -o "%TMP%"
)
if %ERRORLEVEL% neq 0 (
echo Failed to download install.ps1 >&2
exit /b 1
)
set "PS_ARGS=-Tag ""%TAG%"" -InstallMethod ""%INSTALL_METHOD%"""
if "%NO_ONBOARD%"=="1" set "PS_ARGS=%PS_ARGS% -NoOnboard"
if "%NO_GIT_UPDATE%"=="1" set "PS_ARGS=%PS_ARGS% -NoGitUpdate"
if "%DRY_RUN%"=="1" set "PS_ARGS=%PS_ARGS% -DryRun"
if "%DRY_RUN%"=="1" echo [OK] Dry run ^(delegating to install.ps1^)
powershell -NoProfile -ExecutionPolicy Bypass -File "%TMP%" %PS_ARGS%
set "RESULT=%ERRORLEVEL%"
del /f "%TMP%" >nul 2>&1
if %RESULT% neq 0 exit /b %RESULT%
exit /b 0
:usage
echo Usage: install.cmd [options] [tag]
echo.
echo Options:
echo --git Install from git checkout
echo --npm Install via npm ^(default^)
echo --tag <ver> Tag/version to install ^(default: latest^)
echo --no-onboard Skip onboarding
echo --no-git-update Skip git pull for existing checkout
echo --dry-run Print what would happen ^(no changes^)
exit /b 0

File diff suppressed because it is too large Load Diff

View File

@@ -1275,12 +1275,13 @@ install_homebrew() {
}
# Check Node.js version
parse_node_version_components() {
if ! command -v node &> /dev/null; then
parse_node_version_components_for_binary() {
local node_bin="${1:-node}"
if ! command -v "$node_bin" &> /dev/null && [[ ! -x "$node_bin" ]]; then
return 1
fi
local version major minor
version="$(node -v 2>/dev/null || true)"
version="$("$node_bin" -v 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
minor="${version#v}"
@@ -1297,6 +1298,13 @@ parse_node_version_components() {
return 0
}
parse_node_version_components() {
if ! command -v node &> /dev/null; then
return 1
fi
parse_node_version_components_for_binary node
}
node_major_version() {
local version_components major minor
version_components="$(parse_node_version_components || true)"
@@ -1324,6 +1332,83 @@ node_is_at_least_required() {
return 1
}
node_binary_is_at_least_required() {
local node_bin="$1"
local version_components major minor
version_components="$(parse_node_version_components_for_binary "$node_bin" || true)"
read -r major minor <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then
return 1
fi
if [[ "$major" -gt "$NODE_MIN_MAJOR" ]]; then
return 0
fi
if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then
return 0
fi
return 1
}
prepend_path_dir() {
local dir="${1%/}"
if [[ -z "$dir" || ! -d "$dir" ]]; then
return 1
fi
local current=":${PATH:-}:"
current="${current//:${dir}:/:}"
current="${current#:}"
current="${current%:}"
if [[ -n "$current" ]]; then
export PATH="${dir}:${current}"
else
export PATH="${dir}"
fi
refresh_shell_command_cache
}
promote_supported_node_binary() {
local candidates=()
local candidate dir seen_dirs=":"
while IFS= read -r candidate; do
candidates+=("$candidate")
done < <(type -P -a node 2>/dev/null || true)
candidates+=(
"/usr/bin/node"
"/usr/local/bin/node"
"/opt/homebrew/bin/node"
"/opt/homebrew/opt/node@${NODE_DEFAULT_MAJOR}/bin/node"
"/usr/local/opt/node@${NODE_DEFAULT_MAJOR}/bin/node"
)
for candidate in "${candidates[@]}"; do
if [[ -z "$candidate" || ! -x "$candidate" ]]; then
continue
fi
if dir="$(cd "$(dirname "$candidate")" && pwd 2>/dev/null)"; then
:
else
dir=""
fi
if [[ -z "$dir" || "$seen_dirs" == *":$dir:"* ]]; then
continue
fi
seen_dirs="${seen_dirs}${dir}:"
if node_binary_is_at_least_required "$candidate"; then
prepend_path_dir "$dir" || continue
ui_info "Using Node.js runtime at ${candidate}"
return 0
fi
done
return 1
}
activate_supported_node_on_path() {
promote_supported_node_binary "$@"
}
print_active_node_paths() {
if ! command -v node &> /dev/null; then
return 1
@@ -1378,7 +1463,12 @@ ensure_macos_default_node_active() {
return 1
}
ensure_macos_node22_active() {
ensure_macos_default_node_active "$@"
}
ensure_default_node_active_shell() {
promote_supported_node_binary || true
if node_is_at_least_required; then
return 0
fi
@@ -1426,7 +1516,7 @@ load_nvm_for_node_detection() {
fi
export NVM_DIR="$nvm_dir"
# shellcheck disable=SC1090
# shellcheck disable=SC1090,SC1091
. "$NVM_DIR/nvm.sh" --no-use >/dev/null 2>&1 || . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true
if command -v nvm >/dev/null 2>&1; then
nvm use default --silent >/dev/null 2>&1 || nvm use node --silent >/dev/null 2>&1 || true
@@ -1487,6 +1577,7 @@ install_node() {
else
run_quiet_step "Installing Node.js" sudo pacman -Sy --noconfirm nodejs npm
fi
promote_supported_node_binary || true
ui_success "Node.js v${NODE_DEFAULT_MAJOR} installed"
print_active_node_paths || true
return 0
@@ -1532,6 +1623,7 @@ install_node() {
exit 1
fi
promote_supported_node_binary || true
ui_success "Node.js v${NODE_DEFAULT_MAJOR} installed"
print_active_node_paths || true
fi
@@ -1642,11 +1734,18 @@ fix_npm_permissions() {
# shellcheck disable=SC2016
local path_line='export PATH="$HOME/.npm-global/bin:$PATH"'
local wrote_rc=0
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then
echo "$path_line" >> "$rc"
if [[ -f "$rc" ]]; then
if ! grep -q ".npm-global" "$rc"; then
echo "$path_line" >> "$rc"
fi
wrote_rc=1
fi
done
if [[ "$wrote_rc" -eq 0 ]]; then
printf '%s\n' "$path_line" >> "$HOME/.bashrc"
fi
export PATH="$HOME/.npm-global/bin:$PATH"
ui_success "npm configured for user installs"
@@ -2350,10 +2449,15 @@ load_install_version_helpers() {
if [[ -z "$source_path" || ! -f "$source_path" ]]; then
return 0
fi
script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null || true)"
if script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null)"; then
:
else
script_dir=""
fi
helper_path="${script_dir}/docker/install-sh-common/version-parse.sh"
if [[ -n "$script_dir" && -r "$helper_path" ]]; then
# shellcheck source=docker/install-sh-common/version-parse.sh
# shellcheck disable=SC1091
source "$helper_path"
fi
}

View File

@@ -179,7 +179,7 @@ describe("normalizeMessagesForLlmBoundary", () => {
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
) as Array<Record<string, unknown>>;
) as unknown as Array<Record<string, unknown>>;
expect(output[0]?.content).toEqual([
{

View File

@@ -353,7 +353,7 @@ describe("channel-auth", () => {
});
await expect(runChannelLogin({ channel: "whatsapp" }, runtime)).rejects.toThrow(
'Channel "whatsapp" does not support login. Run openclaw channels status --channel whatsapp to inspect supported actions.',
'Channel "whatsapp" does not support login. Run `openclaw channels status --channel whatsapp` to inspect supported actions.',
);
});
@@ -564,7 +564,7 @@ describe("channel-auth", () => {
});
await expect(runChannelLogout({ channel: "whatsapp" }, runtime)).rejects.toThrow(
'Channel "whatsapp" does not support logout. Run openclaw channels status --channel whatsapp to inspect supported actions.',
'Channel "whatsapp" does not support logout. Run `openclaw channels status --channel whatsapp` to inspect supported actions.',
);
});
});

View File

@@ -189,7 +189,7 @@ describe("plugins cli policy mutations", () => {
);
expect(runtimeErrors).toContain(
"Plugin not found: missing-plugin. Run openclaw plugins list to see installed plugins, or openclaw plugins search missing-plugin to look for installable plugins.",
"Plugin not found: missing-plugin. Run `openclaw plugins list` to see installed plugins, or `openclaw plugins search missing-plugin` to look for installable plugins.",
);
expect(enablePluginInConfig).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();

View File

@@ -300,6 +300,24 @@ describe("detectChangedScope", () => {
runChangedSmoke: false,
runControlUiI18n: false,
});
expect(detectChangedScope(["scripts/install.ps1"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: true,
runSkillsPython: false,
runChangedSmoke: true,
runControlUiI18n: false,
});
expect(detectChangedScope(["scripts/install.cmd"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: true,
runSkillsPython: false,
runChangedSmoke: true,
runControlUiI18n: false,
});
});
it("runs changed-smoke for install and packaging surfaces", () => {
@@ -312,6 +330,15 @@ describe("detectChangedScope", () => {
runChangedSmoke: true,
runControlUiI18n: false,
});
expect(detectChangedScope(["scripts/install-cli.sh"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: false,
runSkillsPython: false,
runChangedSmoke: true,
runControlUiI18n: false,
});
expect(detectChangedScope([bundledPluginFile("matrix", "package.json")])).toEqual({
runNode: true,
runMacos: false,
@@ -456,6 +483,18 @@ describe("detectChangedScope", () => {
runFastInstallSmoke: true,
runFullInstallSmoke: true,
});
expect(detectInstallSmokeScope(["scripts/install-cli.sh"])).toEqual({
runFastInstallSmoke: true,
runFullInstallSmoke: true,
});
expect(detectInstallSmokeScope(["scripts/install.ps1"])).toEqual({
runFastInstallSmoke: true,
runFullInstallSmoke: true,
});
expect(detectInstallSmokeScope(["scripts/install.cmd"])).toEqual({
runFastInstallSmoke: true,
runFullInstallSmoke: true,
});
expect(detectInstallSmokeScope(["Dockerfile"])).toEqual({
runFastInstallSmoke: true,
runFullInstallSmoke: true,

View File

@@ -47,7 +47,8 @@ function createFailingNodeFixture(source: string): string {
"",
"function Write-Banner { }",
"function Ensure-ExecutionPolicy { return $true }",
"function Ensure-Node { return $false }",
"function Check-Node { return $false }",
"function Install-Node { return $false }",
"",
"$mainResults = @(Main)",
"$installSucceeded = $mainResults.Count -gt 0 -and $mainResults[-1] -eq $true",
@@ -81,45 +82,26 @@ describe("install.ps1 failure handling", () => {
expect(completeInstallBody).toMatch(/\bthrow "OpenClaw installation failed with exit code/);
});
it("runs npm capture commands from a writable installer temp directory", () => {
const nativeCaptureBody = extractFunctionBody(source, "Invoke-NativeCommandCapture");
const npmInstallBody = extractFunctionBody(source, "Install-OpenClawNpm");
const mainBody = extractFunctionBody(source, "Main");
expect(source).toContain("function Get-NpmWorkingDirectory {");
expect(nativeCaptureBody).toContain('[string]$WorkingDirectory = ""');
expect(nativeCaptureBody).toContain("$startProcessArgs.WorkingDirectory = $WorkingDirectory");
expect(npmInstallBody).toContain("-WorkingDirectory (Get-NpmWorkingDirectory)");
expect(mainBody).toContain("-WorkingDirectory (Get-NpmWorkingDirectory)");
it("runs npm install through the resolved command with quiet CI defaults", () => {
const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw");
expect(npmInstallBody).toContain("$npmOutput = & (Get-NpmCommandPath) install -g");
expect(npmInstallBody).toContain('$env:NPM_CONFIG_LOGLEVEL = "error"');
expect(npmInstallBody).toContain('$env:NPM_CONFIG_UPDATE_NOTIFIER = "false"');
expect(npmInstallBody).toContain('$env:NPM_CONFIG_FUND = "false"');
expect(npmInstallBody).toContain('$env:NPM_CONFIG_AUDIT = "false"');
expect(npmInstallBody).toContain('$env:NPM_CONFIG_SCRIPT_SHELL = "cmd.exe"');
expect(npmInstallBody).toContain('$env:NODE_LLAMA_CPP_SKIP_DOWNLOAD = "1"');
expect(npmInstallBody).toContain("$env:NPM_CONFIG_LOGLEVEL = $prevLogLevel");
expect(npmInstallBody).toContain(
"$env:NODE_LLAMA_CPP_SKIP_DOWNLOAD = $prevNodeLlamaSkipDownload",
);
});
runIfPowerShell("creates a temp npm working directory", () => {
const tempDir = harness.createTempDir("openclaw-install-ps1-");
const scriptPath = join(tempDir, "install.ps1");
const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, "");
writeFileSync(
scriptPath,
[
scriptWithoutEntryPoint,
"",
"$result = Get-NpmWorkingDirectory",
'if (!(Test-Path -LiteralPath $result)) { throw "missing $result" }',
'if ($result -notmatch "openclaw-installer") { throw "unexpected $result" }',
"",
].join("\n"),
);
chmodSync(scriptPath, 0o755);
const result = runPowerShell([
"-NoLogo",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
]);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
it("cleans legacy git submodules only from the selected git checkout", () => {
const gitInstallBody = extractFunctionBody(source, "Install-OpenClawFromGit");
const mainBody = extractFunctionBody(source, "Main");
expect(gitInstallBody).toContain("Remove-LegacySubmodule -RepoDir $RepoDir");
expect(mainBody).not.toContain("Remove-LegacySubmodule");
});
runIfPowerShell("exits non-zero when run as a script file", () => {
@@ -172,12 +154,13 @@ describe("install.ps1 failure handling", () => {
"",
"function Write-Banner { }",
"function Ensure-ExecutionPolicy { return $true }",
"function Ensure-Node { return $true }",
"function Check-Node { return $true }",
"function Check-ExistingOpenClaw { return $false }",
"function Add-ToPath { param([string]$Path) }",
"function Invoke-NativeCommandCapture {",
" param([string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory = '')",
" return @{ ExitCode = 0; Stdout = 'npm stdout'; Stderr = 'npm stderr' }",
"}",
"function Install-OpenClaw { Write-Output 'npm stdout'; return $true }",
"function Ensure-OpenClawOnPath { return $true }",
"function Refresh-GatewayServiceIfLoaded { }",
"function Invoke-OpenClawCommand { return 'OpenClaw test-version' }",
"$NoOnboard = $true",
"$result = Main",
"if ($result -is [array]) { throw 'Main returned an array' }",
@@ -211,18 +194,16 @@ describe("install.ps1 failure handling", () => {
"",
"function Write-Banner { }",
"function Ensure-ExecutionPolicy { return $true }",
"function Ensure-Node { return $true }",
"function Ensure-Git { return $true }",
"function Check-Node { return $true }",
"function Check-ExistingOpenClaw { return $false }",
"function Add-ToPath { param([string]$Path) }",
"function Install-OpenClawNpm {",
" param([string]$Target = 'latest')",
"function Install-OpenClaw {",
" Write-Output 'native chatter'",
" return $true",
"}",
"function Invoke-NativeCommandCapture {",
" param([string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory = '')",
" return @{ ExitCode = 0; Stdout = 'npm prefix'; Stderr = '' }",
"}",
"function Ensure-OpenClawOnPath { return $true }",
"function Refresh-GatewayServiceIfLoaded { }",
"function Invoke-OpenClawCommand { return 'OpenClaw test-version' }",
"$NoOnboard = $true",
"$mainResults = @(Main)",
"$installSucceeded = $mainResults.Count -gt 0 -and $mainResults[-1] -eq $true",

View File

@@ -109,6 +109,52 @@ describe("install.sh", () => {
expect(output).toContain("version=v22.22.1");
});
it("promotes a supported Linux Node binary over stale PATH entries", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-promote-"));
const staleBin = join(tmp, "usr-local-bin");
const supportedBin = join(tmp, "usr-bin");
mkdirSync(staleBin, { recursive: true });
mkdirSync(supportedBin, { recursive: true });
const staleNode = join(staleBin, "node");
const supportedNode = join(supportedBin, "node");
writeFileSync(staleNode, "#!/bin/sh\necho v20.20.0\n");
writeFileSync(supportedNode, "#!/bin/sh\necho v22.22.0\n");
chmodSync(staleNode, 0o755);
chmodSync(supportedNode, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"set +e",
"OS=linux",
"promote_supported_node_binary",
"promote_status=$?",
"ensure_default_node_active_shell",
"active_status=$?",
'printf "promote=%s\\nactive=%s\\npath=%s\\nversion=%s\\n" "$promote_status" "$active_status" "$(command -v node)" "$(node -v)"',
"exit $active_status",
].join("\n"),
{
PATH: `${staleBin}:${supportedBin}:/usr/bin:/bin`,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const output = result?.stdout ?? "";
expect(output).toContain("promote=0");
expect(output).toContain("active=0");
expect(output).toContain(`path=${supportedNode}`);
expect(output).toContain("version=v22.22.0");
});
it("warns before redirecting an unwritable npm prefix", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-prefix-"));
const home = join(tmp, "home");

View File

@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const { detectInstallSmokeScope } = (await import("../../scripts/ci-changed-scope.mjs")) as {
detectInstallSmokeScope: (paths: string[]) => {
runFastInstallSmoke: boolean;
runFullInstallSmoke: boolean;
};
};
const WORKFLOW_PATH = ".github/workflows/website-installer-sync.yml";
describe("website installer sync workflow", () => {
const workflow = readFileSync(WORKFLOW_PATH, "utf8");
it("treats all website installer scripts as OpenClaw-owned inputs", () => {
for (const path of [
"scripts/install.sh",
"scripts/install-cli.sh",
"scripts/install.ps1",
"scripts/install.cmd",
]) {
expect(workflow).toContain(path);
expect(detectInstallSmokeScope([path]).runFullInstallSmoke).toBe(true);
}
});
it("verifies installers on Linux Docker plus native macOS and Windows runners", () => {
expect(workflow).toContain("linux-docker:");
expect(workflow).toContain("docker run --rm");
expect(workflow).toContain("bash /tmp/install.sh --no-prompt --no-onboard");
expect(workflow).toContain("bash /tmp/install-cli.sh --prefix /tmp/openclaw");
expect(workflow).toContain("macos-installer:");
expect(workflow).toContain("runs-on: macos-latest");
expect(workflow).toContain("windows-installer:");
expect(workflow).toContain("runs-on: windows-latest");
expect(workflow).toContain(".\\scripts\\install.ps1 -DryRun");
expect(workflow).toContain("OPENCLAW_INSTALL_PS1_URL=%GITHUB_WORKSPACE%\\scripts\\install.ps1");
expect(workflow).toContain(".\\scripts\\install.cmd --dry-run");
});
it("syncs verified scripts to openclaw.ai only after all installer checks pass", () => {
expect(workflow).toContain("needs: [static, linux-docker, macos-installer, windows-installer]");
expect(workflow).toContain("repository: openclaw/openclaw.ai");
expect(workflow).toContain("token: ${{ secrets.OPENCLAW_GH_TOKEN }}");
expect(workflow).toContain("cp openclaw/scripts/install.sh openclaw.ai/public/install.sh");
expect(workflow).toContain(
"cp openclaw/scripts/install-cli.sh openclaw.ai/public/install-cli.sh",
);
expect(workflow).toContain("cp openclaw/scripts/install.ps1 openclaw.ai/public/install.ps1");
expect(workflow).toContain("cp openclaw/scripts/install.cmd openclaw.ai/public/install.cmd");
expect(workflow).toContain("bun run build");
expect(workflow).toContain("git push origin HEAD:main");
});
});