diff --git a/.github/workflows/ci-build-artifacts-testbox.yml b/.github/workflows/ci-build-artifacts-testbox.yml index 3fdd0eca0b92..9affb0485b44 100644 --- a/.github/workflows/ci-build-artifacts-testbox.yml +++ b/.github/workflows/ci-build-artifacts-testbox.yml @@ -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 diff --git a/.github/workflows/website-installer-sync.yml b/.github/workflows/website-installer-sync.yml new file mode 100644 index 000000000000..cccea90a5f4b --- /dev/null +++ b/.github/workflows/website-installer-sync.yml @@ -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 diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 35bd17a9bcf0..382c0b6a6a79 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -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$)/; diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh new file mode 100755 index 000000000000..5c008944817e --- /dev/null +++ b/scripts/install-cli.sh @@ -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 ] [--version ] [--node-version ] [--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 < 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 Checkout directory (default: ~/openclaw) + --version OpenClaw version (default: latest) + --node-version 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| + 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" </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" <"$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" </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 diff --git a/scripts/install.cmd b/scripts/install.cmd new file mode 100644 index 000000000000..e685df40806c --- /dev/null +++ b/scripts/install.cmd @@ -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 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 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index da6cf73edd93..5b44720870b7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,12 +1,12 @@ -# OpenClaw Installer for Windows (PowerShell) -# Usage: iwr -useb https://openclaw.ai/install.ps1 | iex -# Or: & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard +# OpenClaw Installer for Windows +# Usage: powershell -c "irm https://openclaw.ai/install.ps1 | iex" +# powershell -c "& ([scriptblock]::Create((irm https://openclaw.ai/install.ps1))) -Tag beta -NoOnboard -DryRun" param( + [string]$Tag = "latest", [ValidateSet("npm", "git")] [string]$InstallMethod = "npm", - [string]$Tag = "latest", - [string]$GitDir = "$env:USERPROFILE\openclaw", + [string]$GitDir, [switch]$NoOnboard, [switch]$NoGitUpdate, [switch]$DryRun @@ -14,393 +14,6 @@ param( $ErrorActionPreference = "Stop" -# Colors -$ACCENT = "`e[38;2;255;77;77m" # coral-bright -$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright -$WARN = "`e[38;2;255;176;32m" # amber -$ERROR_COLOR = "`e[38;2;230;57;70m" # coral-mid -$MUTED = "`e[38;2;90;100;128m" # text-muted -$NC = "`e[0m" # No Color - -function Write-Host { - param([string]$Message, [string]$Level = "info") - $msg = switch ($Level) { - "success" { "$SUCCESS✓$NC $Message" } - "warn" { "$WARN!$NC $Message" } - "error" { "$ERROR_COLOR✗$NC $Message" } - default { "$MUTED·$NC $Message" } - } - Microsoft.PowerShell.Utility\Write-Host $msg -} - -function Write-Banner { - Write-Host "" - Write-Host "${ACCENT} 🦞 OpenClaw Installer$NC" -Level info - Write-Host "${MUTED} All your chats, one OpenClaw.$NC" -Level info - Write-Host "" -} - -function Get-ExecutionPolicyStatus { - $policy = Get-ExecutionPolicy - if ($policy -eq "Restricted" -or $policy -eq "AllSigned") { - return @{ Blocked = $true; Policy = $policy } - } - return @{ Blocked = $false; Policy = $policy } -} - -function Test-Admin { - $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Ensure-ExecutionPolicy { - $status = Get-ExecutionPolicyStatus - if ($status.Blocked) { - Write-Host "PowerShell execution policy is set to: $($status.Policy)" -Level warn - Write-Host "This prevents scripts like npm.ps1 from running." -Level warn - Write-Host "" - - # Try to set execution policy for current process - try { - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -ErrorAction Stop - Write-Host "Set execution policy to RemoteSigned for current process" -Level success - return $true - } catch { - Write-Host "Could not automatically set execution policy" -Level error - Write-Host "" - Write-Host "To fix this, run:" -Level info - Write-Host " Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process" -Level info - Write-Host "" - Write-Host "Or run PowerShell as Administrator and execute:" -Level info - Write-Host " Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine" -Level info - return $false - } - } - return $true -} - -function Get-NodeVersion { - try { - $version = node --version 2>$null - if ($version) { - return $version -replace '^v', '' - } - } catch { } - return $null -} - -function Get-NpmVersion { - try { - $version = npm --version 2>$null - if ($version) { - return $version - } - } catch { } - return $null -} - -function Install-Node { - Write-Host "Node.js not found" -Level info - Write-Host "Installing Node.js..." -Level info - - # Try winget first - if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Host " Using winget..." -Level info - try { - winget install OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null - # Refresh PATH - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - Write-Host " Node.js installed via winget" -Level success - return $true - } catch { - Write-Host " Winget install failed: $_" -Level warn - } - } - - # Try chocolatey - if (Get-Command choco -ErrorAction SilentlyContinue) { - Write-Host " Using chocolatey..." -Level info - try { - choco install nodejs-lts -y 2>&1 | Out-Null - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - Write-Host " Node.js installed via chocolatey" -Level success - return $true - } catch { - Write-Host " Chocolatey install failed: $_" -Level warn - } - } - - # Try scoop - if (Get-Command scoop -ErrorAction SilentlyContinue) { - Write-Host " Using scoop..." -Level info - try { - scoop install nodejs-lts 2>&1 | Out-Null - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - Write-Host " Node.js installed via scoop" -Level success - return $true - } catch { - Write-Host " Scoop install failed: $_" -Level warn - } - } - - Write-Host "Could not install Node.js automatically" -Level error - Write-Host "Please install Node.js 22+ manually from: https://nodejs.org" -Level info - return $false -} - -function Ensure-Node { - $nodeVersion = Get-NodeVersion - if ($nodeVersion) { - $major = [int]($nodeVersion -split '\.')[0] - if ($major -ge 22) { - Write-Host "Node.js v$nodeVersion found" -Level success - return $true - } - Write-Host "Node.js v$nodeVersion found, but need v22+" -Level warn - } - return Install-Node -} - -function Get-GitVersion { - try { - $version = git --version 2>$null - if ($version) { - return $version - } - } catch { } - return $null -} - -function Install-Git { - Write-Host "Git not found" -Level info - - if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Host " Installing Git via winget..." -Level info - try { - winget install Git.Git --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - Write-Host " Git installed" -Level success - return $true - } catch { - Write-Host " Winget install failed" -Level warn - } - } - - Write-Host "Please install Git for Windows from: https://git-scm.com" -Level error - return $false -} - -function Ensure-Git { - $gitVersion = Get-GitVersion - if ($gitVersion) { - Write-Host "$gitVersion found" -Level success - return $true - } - return Install-Git -} - -function Read-TrimmedFileText { - param([string]$Path) - - if (!(Test-Path -LiteralPath $Path)) { - return "" - } - - return ((Get-Content -LiteralPath $Path -Raw) -replace "(\r?\n)+$", "") -} - -function ConvertTo-PowerShellSingleQuotedLiteral { - param([string]$Value) - - return "'" + ($Value -replace "'", "''") + "'" -} - -function Invoke-NativeCommandCapture { - param( - [Parameter(Mandatory = $true)] - [string]$FilePath, - [string[]]$Arguments = @(), - [string]$WorkingDirectory = "" - ) - - $stdoutPath = [System.IO.Path]::GetTempFileName() - $stderrPath = [System.IO.Path]::GetTempFileName() - - try { - $startFilePath = $FilePath - $startArguments = $Arguments - - if ($FilePath -match '(?i)\.(cmd|bat)$') { - # Start-Process cannot directly redirect stdio for command shims like - # npm.cmd. Run them inside a nested PowerShell so the shim executes - # normally while stdout/stderr still flow back to these temp files. - $commandParts = @( - ConvertTo-PowerShellSingleQuotedLiteral -Value $FilePath - ) - foreach ($argument in $Arguments) { - $commandParts += ConvertTo-PowerShellSingleQuotedLiteral -Value $argument - } - $commandScript = "& " + ($commandParts -join " ") + "`nexit `$LASTEXITCODE" - $startFilePath = "powershell.exe" - $startArguments = @( - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - $commandScript - ) - } - - $startProcessArgs = @{ - FilePath = $startFilePath - ArgumentList = $startArguments - Wait = $true - PassThru = $true - RedirectStandardOutput = $stdoutPath - RedirectStandardError = $stderrPath - } - if (![string]::IsNullOrWhiteSpace($WorkingDirectory)) { - $startProcessArgs.WorkingDirectory = $WorkingDirectory - } - - $process = Start-Process @startProcessArgs - - return @{ - ExitCode = $process.ExitCode - Stdout = Read-TrimmedFileText -Path $stdoutPath - Stderr = Read-TrimmedFileText -Path $stderrPath - } - } finally { - Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue - } -} - -function Get-NpmWorkingDirectory { - $workingDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-installer" - New-Item -ItemType Directory -Path $workingDirectory -Force | Out-Null - return $workingDirectory -} - -function Install-OpenClawNpm { - param([string]$Target = "latest") - - $installSpec = Resolve-PackageInstallSpec -Target $Target - - Write-Host "Installing OpenClaw ($installSpec)..." -Level info - - try { - # Run npm out-of-process so warning chatter on stderr does not get - # promoted into a terminating PowerShell error while the install succeeds. - $installResult = Invoke-NativeCommandCapture -FilePath "npm.cmd" -Arguments @( - "install", - "-g", - $installSpec, - "--no-fund", - "--no-audit" - ) -WorkingDirectory (Get-NpmWorkingDirectory) - if ($installResult.Stdout) { - Microsoft.PowerShell.Utility\Write-Host $installResult.Stdout - } - if ($installResult.Stderr) { - Microsoft.PowerShell.Utility\Write-Host $installResult.Stderr - } - if ($installResult.ExitCode -ne 0) { - Write-Host "npm install failed with exit code $($installResult.ExitCode)" -Level error - return $false - } - Write-Host "OpenClaw installed" -Level success - return $true - } catch { - Write-Host "npm install failed: $_" -Level error - return $false - } -} - -function Install-OpenClawGit { - param([string]$RepoDir, [switch]$Update) - - Write-Host "Installing OpenClaw from git..." -Level info - - if (!(Test-Path $RepoDir)) { - Write-Host " Cloning repository..." -Level info - git clone https://github.com/openclaw/openclaw.git $RepoDir 2>&1 - } elseif ($Update) { - Write-Host " Updating repository..." -Level info - git -C $RepoDir pull --rebase 2>&1 - } - - # Install pnpm if not present - if (!(Get-Command pnpm -ErrorAction SilentlyContinue)) { - Write-Host " Installing pnpm..." -Level info - npm install -g pnpm 2>&1 - } - - # Install dependencies - Write-Host " Installing dependencies..." -Level info - pnpm install --dir $RepoDir 2>&1 - - # Build - Write-Host " Building..." -Level info - pnpm --dir $RepoDir build 2>&1 - - # Create wrapper - $wrapperDir = "$env:USERPROFILE\.local\bin" - if (!(Test-Path $wrapperDir)) { - New-Item -ItemType Directory -Path $wrapperDir -Force | Out-Null - } - - $entryPath = Join-Path $RepoDir "dist\entry.js" - @" -@echo off -node "$entryPath" %* -"@ | Out-File -FilePath "$wrapperDir\openclaw.cmd" -Encoding ASCII -Force - Add-ToPath -Path $wrapperDir - - Write-Host "OpenClaw installed" -Level success - return $true -} - -function Test-ExplicitPackageInstallSpec { - param([string]$Target) - - if ([string]::IsNullOrWhiteSpace($Target)) { - return $false - } - - return $Target.Contains("://") -or - $Target.Contains("#") -or - $Target -match '^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):' -} - -function Resolve-PackageInstallSpec { - param([string]$Target = "latest") - - $trimmed = $Target.Trim() - if ([string]::IsNullOrWhiteSpace($trimmed)) { - return "openclaw@latest" - } - if ($trimmed.ToLowerInvariant() -eq "main") { - return "github:openclaw/openclaw#main" - } - if (Test-ExplicitPackageInstallSpec -Target $trimmed) { - return $trimmed - } - return "openclaw@$trimmed" -} - -function Add-ToPath { - param([string]$Path) - - $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") - if ($currentPath -notlike "*$Path*") { - [Environment]::SetEnvironmentVariable("Path", "$currentPath;$Path", "User") - Write-Host "Added $Path to user PATH" -Level info - } -} - $script:InstallExitCode = 0 function Fail-Install { @@ -424,78 +37,827 @@ function Complete-Install { throw "OpenClaw installation failed with exit code $($script:InstallExitCode)." } -# Main -function Main { - Write-Banner - - Write-Host "Windows detected" -Level success - - # Check and handle execution policy FIRST, before any npm calls - if (!(Ensure-ExecutionPolicy)) { - Write-Host "" - Write-Host "Installation cannot continue due to execution policy restrictions" -Level error - return (Fail-Install) +Write-Host "" +Write-Host " OpenClaw Installer" -ForegroundColor Cyan +Write-Host "" + +# Check if running in PowerShell +if ($PSVersionTable.PSVersion.Major -lt 5) { + Write-Host "Error: PowerShell 5+ required" -ForegroundColor Red + Complete-Install -Succeeded:$false + return +} + +Write-Host "[OK] Windows detected" -ForegroundColor Green + +if (-not $PSBoundParameters.ContainsKey("InstallMethod")) { + if (-not [string]::IsNullOrWhiteSpace($env:OPENCLAW_INSTALL_METHOD)) { + $InstallMethod = $env:OPENCLAW_INSTALL_METHOD } - - if (!(Ensure-Node)) { - return (Fail-Install) +} +if (-not $PSBoundParameters.ContainsKey("GitDir")) { + if (-not [string]::IsNullOrWhiteSpace($env:OPENCLAW_GIT_DIR)) { + $GitDir = $env:OPENCLAW_GIT_DIR } - - if ($InstallMethod -eq "git") { - if (!(Ensure-Git)) { - return (Fail-Install) - } - - if ($DryRun) { - Write-Host "[DRY RUN] Would install OpenClaw from git to $GitDir" -Level info - } else { - try { - npm uninstall -g openclaw 2>$null | Out-Null - } catch { } - if (!(Install-OpenClawGit -RepoDir $GitDir -Update:(-not $NoGitUpdate))) { - return (Fail-Install) +} +if (-not $PSBoundParameters.ContainsKey("NoOnboard")) { + if ($env:OPENCLAW_NO_ONBOARD -eq "1") { + $NoOnboard = $true + } +} +if (-not $PSBoundParameters.ContainsKey("NoGitUpdate")) { + if ($env:OPENCLAW_GIT_UPDATE -eq "0") { + $NoGitUpdate = $true + } +} +if (-not $PSBoundParameters.ContainsKey("DryRun")) { + if ($env:OPENCLAW_DRY_RUN -eq "1") { + $DryRun = $true + } +} + +if ([string]::IsNullOrWhiteSpace($GitDir)) { + $userHome = [Environment]::GetFolderPath("UserProfile") + $GitDir = (Join-Path $userHome "openclaw") +} + +# Check for Node.js +function Check-Node { + try { + $nodeVersion = (node -v 2>$null) + if ($nodeVersion) { + $version = [int]($nodeVersion -replace 'v(\d+)\..*', '$1') + if ($version -ge 22) { + Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green + return $true + } else { + Write-Host "[!] Node.js $nodeVersion found, but v22+ required" -ForegroundColor Yellow + return $false } } + } catch { + Write-Host "[!] Node.js not found" -ForegroundColor Yellow + return $false + } + return $false +} + +# Install Node.js +function Install-Node { + Write-Host "[*] Installing Node.js..." -ForegroundColor Yellow + + # Try winget first (Windows 11 / Windows 10 with App Installer) + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host " Using winget..." -ForegroundColor Gray + winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements + + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + if (Check-Node) { + Write-Host "[OK] Node.js installed via winget" -ForegroundColor Green + return $true + } + Write-Host "[!] winget completed, but Node.js is still unavailable in this shell" -ForegroundColor Yellow + Write-Host "Restart PowerShell and re-run the installer if Node.js was installed successfully." -ForegroundColor Yellow + return $false + } + + # Try Chocolatey + if (Get-Command choco -ErrorAction SilentlyContinue) { + Write-Host " Using Chocolatey..." -ForegroundColor Gray + choco install nodejs-lts -y + + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + Write-Host "[OK] Node.js installed via Chocolatey" -ForegroundColor Green + return $true + } + + # Try Scoop + if (Get-Command scoop -ErrorAction SilentlyContinue) { + Write-Host " Using Scoop..." -ForegroundColor Gray + scoop install nodejs-lts + Write-Host "[OK] Node.js installed via Scoop" -ForegroundColor Green + return $true + } + + # Manual download fallback + Write-Host "" + Write-Host "Error: Could not find a package manager (winget, choco, or scoop)" -ForegroundColor Red + Write-Host "" + Write-Host "Please install Node.js 22+ manually:" -ForegroundColor Yellow + Write-Host " https://nodejs.org/en/download/" -ForegroundColor Cyan + Write-Host "" + Write-Host "Or install winget (App Installer) from the Microsoft Store." -ForegroundColor Gray + return $false +} + +# Check for existing OpenClaw installation +function Check-ExistingOpenClaw { + if (Get-OpenClawCommandPath) { + Write-Host "[*] Existing OpenClaw installation detected" -ForegroundColor Yellow + return $true + } + return $false +} + +function Check-Git { + try { + $null = Get-Command git -ErrorAction Stop + return $true + } catch { + return $false + } +} + +function Add-ToProcessPath { + param( + [Parameter(Mandatory = $true)] + [string]$PathEntry + ) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return + } + + $currentEntries = @($env:Path -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($currentEntries | Where-Object { $_ -ieq $PathEntry }) { + return + } + + $env:Path = "$PathEntry;$env:Path" +} + +function Get-PortableGitRoot { + $base = Join-Path $env:LOCALAPPDATA "OpenClaw\deps" + return (Join-Path $base "portable-git") +} + +function Get-PortableGitCommandPath { + $root = Get-PortableGitRoot + foreach ($candidate in @( + (Join-Path $root "mingw64\bin\git.exe"), + (Join-Path $root "cmd\git.exe"), + (Join-Path $root "bin\git.exe"), + (Join-Path $root "git.exe") + )) { + if (Test-Path $candidate) { + return $candidate + } + } + return $null +} + +function Use-PortableGitIfPresent { + $gitExe = Get-PortableGitCommandPath + if (-not $gitExe) { + return $false + } + + $portableRoot = Get-PortableGitRoot + foreach ($pathEntry in @( + (Join-Path $portableRoot "mingw64\bin"), + (Join-Path $portableRoot "usr\bin"), + (Split-Path -Parent $gitExe) + )) { + if (Test-Path $pathEntry) { + Add-ToProcessPath $pathEntry + } + } + if (Check-Git) { + return $true + } + return $false +} + +function Resolve-PortableGitDownload { + $releaseApi = "https://api.github.com/repos/git-for-windows/git/releases/latest" + $headers = @{ + "User-Agent" = "openclaw-installer" + "Accept" = "application/vnd.github+json" + } + $release = Invoke-RestMethod -Uri $releaseApi -Headers $headers + if (-not $release -or -not $release.assets) { + throw "Could not resolve latest git-for-windows release metadata." + } + + $asset = $release.assets | + Where-Object { $_.name -match '^MinGit-.*-64-bit\.zip$' -and $_.name -notmatch 'busybox' } | + Select-Object -First 1 + + if (-not $asset) { + throw "Could not find a MinGit zip asset in the latest git-for-windows release." + } + + return @{ + Tag = $release.tag_name + Name = $asset.name + Url = $asset.browser_download_url + } +} + +function Install-PortableGit { + if (Use-PortableGitIfPresent) { + $portableVersion = (& git --version 2>$null) + if ($portableVersion) { + Write-Host "[OK] User-local Git already available: $portableVersion" -ForegroundColor Green + } + return + } + + Write-Host "[*] Git not found; bootstrapping user-local portable Git..." -ForegroundColor Yellow + + $download = Resolve-PortableGitDownload + $portableRoot = Get-PortableGitRoot + $portableParent = Split-Path -Parent $portableRoot + $tmpZip = Join-Path $env:TEMP $download.Name + $tmpExtract = Join-Path $env:TEMP ("openclaw-portable-git-" + [guid]::NewGuid().ToString("N")) + + New-Item -ItemType Directory -Force -Path $portableParent | Out-Null + if (Test-Path $portableRoot) { + Remove-Item -Recurse -Force $portableRoot + } + if (Test-Path $tmpExtract) { + Remove-Item -Recurse -Force $tmpExtract + } + New-Item -ItemType Directory -Force -Path $tmpExtract | Out-Null + + try { + Write-Host " Downloading $($download.Tag)..." -ForegroundColor Gray + Invoke-WebRequest -Uri $download.Url -OutFile $tmpZip + Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force + Move-Item -Path (Join-Path $tmpExtract "*") -Destination $portableRoot -Force + } finally { + if (Test-Path $tmpZip) { + Remove-Item -Force $tmpZip + } + if (Test-Path $tmpExtract) { + Remove-Item -Recurse -Force $tmpExtract + } + } + + if (-not (Use-PortableGitIfPresent)) { + throw "Portable Git bootstrap completed, but git is still unavailable." + } + + $portableVersion = (& git --version 2>$null) + Write-Host "[OK] User-local Git ready: $portableVersion" -ForegroundColor Green +} + +function Ensure-Git { + if (Check-Git) { return $true } + if (Use-PortableGitIfPresent) { return $true } + try { + Install-PortableGit + if (Check-Git) { + return $true + } + } catch { + Write-Host "[!] Portable Git bootstrap failed: $($_.Exception.Message)" -ForegroundColor Yellow + } + + Write-Host "" + Write-Host "Error: Git is required to install OpenClaw." -ForegroundColor Red + Write-Host "Auto-bootstrap of user-local Git did not succeed." -ForegroundColor Yellow + Write-Host "Install Git for Windows manually, then re-run this installer:" -ForegroundColor Yellow + Write-Host " https://git-scm.com/download/win" -ForegroundColor Cyan + return $false +} + +function Get-OpenClawCommandPath { + $openclawCmd = Get-Command openclaw.cmd -ErrorAction SilentlyContinue + if ($openclawCmd -and $openclawCmd.Source) { + return $openclawCmd.Source + } + + $openclaw = Get-Command openclaw -ErrorAction SilentlyContinue + if ($openclaw -and $openclaw.Source) { + return $openclaw.Source + } + + return $null +} + +function Invoke-OpenClawCommand { + param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + $commandPath = Get-OpenClawCommandPath + if (-not $commandPath) { + throw "openclaw command not found on PATH." + } + + & $commandPath @Arguments +} + +function Resolve-CommandPath { + param( + [Parameter(Mandatory = $true)] + [string[]]$Candidates + ) + + foreach ($candidate in $Candidates) { + $command = Get-Command $candidate -ErrorAction SilentlyContinue + if ($command -and $command.Source) { + return $command.Source + } + } + + return $null +} + +function Get-NpmCommandPath { + $path = Resolve-CommandPath -Candidates @("npm.cmd", "npm.exe", "npm") + if (-not $path) { + throw "npm not found on PATH." + } + return $path +} + +function Get-CorepackCommandPath { + return (Resolve-CommandPath -Candidates @("corepack.cmd", "corepack.exe", "corepack")) +} + +function Get-PnpmCommandPath { + return (Resolve-CommandPath -Candidates @("pnpm.cmd", "pnpm.exe", "pnpm")) +} + +function Get-NpmGlobalBinCandidates { + param( + [string]$NpmPrefix + ) + + $candidates = @() + if (-not [string]::IsNullOrWhiteSpace($NpmPrefix)) { + $candidates += $NpmPrefix + $candidates += (Join-Path $NpmPrefix "bin") + } + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + $candidates += (Join-Path $env:APPDATA "npm") + } + + return $candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique +} + +function Ensure-OpenClawOnPath { + if (Get-OpenClawCommandPath) { + return $true + } + + $npmPrefix = $null + try { + $npmPrefix = (& (Get-NpmCommandPath) config get prefix 2>$null).Trim() + } catch { + $npmPrefix = $null + } + + $npmBins = Get-NpmGlobalBinCandidates -NpmPrefix $npmPrefix + foreach ($npmBin in $npmBins) { + if (-not (Test-Path (Join-Path $npmBin "openclaw.cmd"))) { + continue + } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not ($userPath -split ";" | Where-Object { $_ -ieq $npmBin })) { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$npmBin", "User") + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + Write-Host "[!] Added $npmBin to user PATH (restart terminal if command not found)" -ForegroundColor Yellow + } + return $true + } + + Write-Host "[!] openclaw is not on PATH yet." -ForegroundColor Yellow + Write-Host "Restart PowerShell or add the npm global install folder to PATH." -ForegroundColor Yellow + if ($npmBins.Count -gt 0) { + Write-Host "Expected path (one of):" -ForegroundColor Gray + foreach ($npmBin in $npmBins) { + Write-Host " $npmBin" -ForegroundColor Cyan + } } else { - # npm method - if (!(Ensure-Git)) { - Write-Host "Git is required for npm installs. Please install Git and try again." -Level warn - } - - if ($DryRun) { - Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info - } else { - $gitWrapper = "$env:USERPROFILE\.local\bin\openclaw.cmd" - if (Test-Path $gitWrapper) { - Remove-Item -Force $gitWrapper - Write-Host "Removed git wrapper (switching to npm)" -Level info - } - if (!(Install-OpenClawNpm -Target $Tag)) { - return (Fail-Install) + Write-Host "Hint: run \"npm config get prefix\" to find your npm global path." -ForegroundColor Gray + } + return $false +} + +function Ensure-Pnpm { + if (Get-PnpmCommandPath) { + return + } + $corepackCommand = Get-CorepackCommandPath + if ($corepackCommand) { + try { + & $corepackCommand enable | Out-Null + & $corepackCommand prepare pnpm@latest --activate | Out-Null + if (Get-PnpmCommandPath) { + Write-Host "[OK] pnpm installed via corepack" -ForegroundColor Green + return } + } catch { + # fallthrough to npm install } } - - # Try to add npm global bin to PATH + Write-Host "[*] Installing pnpm..." -ForegroundColor Yellow + $prevScriptShell = $env:NPM_CONFIG_SCRIPT_SHELL + $env:NPM_CONFIG_SCRIPT_SHELL = "cmd.exe" try { - $prefixResult = Invoke-NativeCommandCapture -FilePath "npm.cmd" -Arguments @( - "config", - "get", - "prefix" - ) -WorkingDirectory (Get-NpmWorkingDirectory) - $npmPrefix = $prefixResult.Stdout - if ($prefixResult.ExitCode -eq 0 -and $npmPrefix) { - Add-ToPath -Path "$npmPrefix" - } - } catch { } - - if (!$NoOnboard -and !$DryRun) { - Write-Host "" - Write-Host "Run 'openclaw onboard' to complete setup" -Level info + & (Get-NpmCommandPath) install -g pnpm + } finally { + $env:NPM_CONFIG_SCRIPT_SHELL = $prevScriptShell } - + Write-Host "[OK] pnpm installed" -ForegroundColor Green +} + +# Install OpenClaw +function Resolve-NpmOpenClawInstallSpec { + param( + [string]$PackageName, + [string]$RequestedTag + ) + + if ([string]::IsNullOrWhiteSpace($RequestedTag)) { + return "$PackageName@latest" + } + + $trimmedTag = $RequestedTag.Trim() + if ( + $trimmedTag -match '^(https?|file):' -or + $trimmedTag -match '^(git\+|github:)' -or + $trimmedTag -match '^[A-Za-z]:[\\/]' -or + $trimmedTag -match '^\\\\' -or + $trimmedTag -match '^\.\.?[\\/]' -or + $trimmedTag -match '\.tgz($|[?#])' + ) { + return $trimmedTag + } + + return "$PackageName@$trimmedTag" +} + +function Install-OpenClaw { + if ([string]::IsNullOrWhiteSpace($Tag)) { + $Tag = "latest" + } + if (-not (Ensure-Git)) { + return $false + } + + # Use openclaw package for beta, openclaw for stable + $packageName = "openclaw" + if ($Tag -eq "beta" -or $Tag -match "^beta\.") { + $packageName = "openclaw" + } + $installSpec = Resolve-NpmOpenClawInstallSpec -PackageName $packageName -RequestedTag $Tag + Write-Host "[*] Installing OpenClaw ($installSpec)..." -ForegroundColor Yellow + $prevLogLevel = $env:NPM_CONFIG_LOGLEVEL + $prevUpdateNotifier = $env:NPM_CONFIG_UPDATE_NOTIFIER + $prevFund = $env:NPM_CONFIG_FUND + $prevAudit = $env:NPM_CONFIG_AUDIT + $prevScriptShell = $env:NPM_CONFIG_SCRIPT_SHELL + $prevNodeLlamaSkipDownload = $env:NODE_LLAMA_CPP_SKIP_DOWNLOAD + $env:NPM_CONFIG_LOGLEVEL = "error" + $env:NPM_CONFIG_UPDATE_NOTIFIER = "false" + $env:NPM_CONFIG_FUND = "false" + $env:NPM_CONFIG_AUDIT = "false" + $env:NPM_CONFIG_SCRIPT_SHELL = "cmd.exe" + $env:NODE_LLAMA_CPP_SKIP_DOWNLOAD = "1" + try { + $npmOutput = & (Get-NpmCommandPath) install -g "$installSpec" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "[!] npm install failed" -ForegroundColor Red + if ($npmOutput -match "spawn git" -or $npmOutput -match "ENOENT.*git") { + Write-Host "Error: git is missing from PATH." -ForegroundColor Red + Write-Host "Install Git for Windows, then reopen PowerShell and retry:" -ForegroundColor Yellow + Write-Host " https://git-scm.com/download/win" -ForegroundColor Cyan + } else { + Write-Host "Re-run with verbose output to see the full error:" -ForegroundColor Yellow + Write-Host ' powershell -c "irm https://openclaw.ai/install.ps1 | iex"' -ForegroundColor Cyan + } + $npmOutput | ForEach-Object { Write-Host $_ } + return $false + } + } finally { + $env:NPM_CONFIG_LOGLEVEL = $prevLogLevel + $env:NPM_CONFIG_UPDATE_NOTIFIER = $prevUpdateNotifier + $env:NPM_CONFIG_FUND = $prevFund + $env:NPM_CONFIG_AUDIT = $prevAudit + $env:NPM_CONFIG_SCRIPT_SHELL = $prevScriptShell + $env:NODE_LLAMA_CPP_SKIP_DOWNLOAD = $prevNodeLlamaSkipDownload + } + Write-Host "[OK] OpenClaw installed" -ForegroundColor Green + return $true +} + +# Install OpenClaw from GitHub +function Install-OpenClawFromGit { + param( + [string]$RepoDir, + [switch]$SkipUpdate + ) + if (-not (Ensure-Git)) { + return $false + } + Ensure-Pnpm + + $repoUrl = "https://github.com/openclaw/openclaw.git" + Write-Host "[*] Installing OpenClaw from GitHub ($repoUrl)..." -ForegroundColor Yellow + + if (-not (Test-Path $RepoDir)) { + git clone $repoUrl $RepoDir + } + + if (-not $SkipUpdate) { + if (-not (git -C $RepoDir status --porcelain 2>$null)) { + git -C $RepoDir pull --rebase 2>$null + } else { + Write-Host "[!] Repo is dirty; skipping git pull" -ForegroundColor Yellow + } + } else { + Write-Host "[!] Git update disabled; skipping git pull" -ForegroundColor Yellow + } + + Remove-LegacySubmodule -RepoDir $RepoDir + + $prevPnpmScriptShell = $env:NPM_CONFIG_SCRIPT_SHELL + $pnpmCommand = Get-PnpmCommandPath + if (-not $pnpmCommand) { + throw "pnpm not found after installation." + } + $env:NPM_CONFIG_SCRIPT_SHELL = "cmd.exe" + try { + & $pnpmCommand -C $RepoDir install + if (-not (& $pnpmCommand -C $RepoDir ui:build)) { + Write-Host "[!] UI build failed; continuing (CLI may still work)" -ForegroundColor Yellow + } + & $pnpmCommand -C $RepoDir build + } finally { + $env:NPM_CONFIG_SCRIPT_SHELL = $prevPnpmScriptShell + } + + $binDir = Join-Path $env:USERPROFILE ".local\\bin" + if (-not (Test-Path $binDir)) { + New-Item -ItemType Directory -Force -Path $binDir | Out-Null + } + $cmdPath = Join-Path $binDir "openclaw.cmd" + $cmdContents = "@echo off`r`nnode ""$RepoDir\\dist\\entry.js"" %*`r`n" + Set-Content -Path $cmdPath -Value $cmdContents -NoNewline + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not ($userPath -split ";" | Where-Object { $_ -ieq $binDir })) { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$binDir", "User") + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + Write-Host "[!] Added $binDir to user PATH (restart terminal if command not found)" -ForegroundColor Yellow + } + + Write-Host "[OK] OpenClaw wrapper installed to $cmdPath" -ForegroundColor Green + Write-Host "[i] This checkout uses pnpm. For deps, run: pnpm install (avoid npm install in the repo)." -ForegroundColor Gray + return $true +} + +# Run doctor for migrations (safe, non-interactive) +function Run-Doctor { + Write-Host "[*] Running doctor to migrate settings..." -ForegroundColor Yellow + try { + Invoke-OpenClawCommand doctor --non-interactive + } catch { + # Ignore errors from doctor + } + Write-Host "[OK] Migration complete" -ForegroundColor Green +} + +function Test-GatewayServiceLoaded { + try { + $statusJson = (Invoke-OpenClawCommand daemon status --json 2>$null) + if ([string]::IsNullOrWhiteSpace($statusJson)) { + return $false + } + $parsed = $statusJson | ConvertFrom-Json + if ($parsed -and $parsed.service -and $parsed.service.loaded) { + return $true + } + } catch { + return $false + } + return $false +} + +function Refresh-GatewayServiceIfLoaded { + if (-not (Get-OpenClawCommandPath)) { + return + } + if (-not (Test-GatewayServiceLoaded)) { + return + } + + Write-Host "[*] Refreshing loaded gateway service..." -ForegroundColor Yellow + try { + Invoke-OpenClawCommand gateway install --force | Out-Null + } catch { + Write-Host "[!] Gateway service refresh failed; continuing." -ForegroundColor Yellow + return + } + + try { + Invoke-OpenClawCommand gateway restart | Out-Null + Invoke-OpenClawCommand gateway status --json | Out-Null + Write-Host "[OK] Gateway service refreshed" -ForegroundColor Green + } catch { + Write-Host "[!] Gateway service restart failed; continuing." -ForegroundColor Yellow + } +} + +function Get-LegacyRepoDir { + if (-not [string]::IsNullOrWhiteSpace($env:OPENCLAW_GIT_DIR)) { + return $env:OPENCLAW_GIT_DIR + } + $userHome = [Environment]::GetFolderPath("UserProfile") + return (Join-Path $userHome "openclaw") +} + +function Remove-LegacySubmodule { + param( + [string]$RepoDir + ) + if ([string]::IsNullOrWhiteSpace($RepoDir)) { + $RepoDir = Get-LegacyRepoDir + } + $legacyDir = Join-Path $RepoDir "Peekaboo" + if (Test-Path $legacyDir) { + Write-Host "[!] Removing legacy submodule checkout: $legacyDir" -ForegroundColor Yellow + Remove-Item -Recurse -Force $legacyDir + } +} + +# Main installation flow +function Main { + if ($InstallMethod -ne "npm" -and $InstallMethod -ne "git") { + Write-Host "Error: invalid -InstallMethod (use npm or git)." -ForegroundColor Red + return (Fail-Install -Code 2) + } + + if ($DryRun) { + Write-Host "[OK] Dry run" -ForegroundColor Green + Write-Host "[OK] Install method: $InstallMethod" -ForegroundColor Green + if ($InstallMethod -eq "git") { + Write-Host "[OK] Git dir: $GitDir" -ForegroundColor Green + if ($NoGitUpdate) { + Write-Host "[OK] Git update: disabled" -ForegroundColor Green + } else { + Write-Host "[OK] Git update: enabled" -ForegroundColor Green + } + } + if ($NoOnboard) { + Write-Host "[OK] Onboard: skipped" -ForegroundColor Green + } + return $true + } + + # Check for existing installation + $isUpgrade = Check-ExistingOpenClaw + + # Step 1: Node.js + if (-not (Check-Node)) { + if (-not (Install-Node)) { + return (Fail-Install) + } + + # Verify installation + if (-not (Check-Node)) { + Write-Host "" + Write-Host "Error: Node.js installation may require a terminal restart" -ForegroundColor Red + Write-Host "Please close this terminal, open a new one, and run this installer again." -ForegroundColor Yellow + return (Fail-Install) + } + } + + $finalGitDir = $null + + # Step 2: OpenClaw + if ($InstallMethod -eq "git") { + try { + $npmCommand = Get-NpmCommandPath + if ($npmCommand) { + & $npmCommand uninstall -g openclaw 2>$null | Out-Null + Write-Host "[OK] Removed npm global install if present" -ForegroundColor Green + } + } catch { } + $finalGitDir = $GitDir + if (-not (Install-OpenClawFromGit -RepoDir $GitDir -SkipUpdate:$NoGitUpdate)) { + return (Fail-Install) + } + } else { + $gitWrapper = Join-Path (Join-Path $env:USERPROFILE ".local\\bin") "openclaw.cmd" + if (Test-Path $gitWrapper) { + Remove-Item -Force $gitWrapper + Write-Host "[OK] Removed git wrapper (switching to npm)" -ForegroundColor Green + } + if (-not (Install-OpenClaw)) { + return (Fail-Install) + } + } + + if (-not (Ensure-OpenClawOnPath)) { + Write-Host "Install completed, but OpenClaw is not on PATH yet." -ForegroundColor Yellow + Write-Host "Open a new terminal, then run: openclaw doctor" -ForegroundColor Cyan + return + } + + Refresh-GatewayServiceIfLoaded + + # Step 3: Run doctor for migrations if upgrading or git install + if ($isUpgrade -or $InstallMethod -eq "git") { + Run-Doctor + } + + $installedVersion = $null + try { + $installedVersion = (Invoke-OpenClawCommand --version 2>$null).Trim() + } catch { + $installedVersion = $null + } + if (-not $installedVersion) { + try { + $npmList = & (Get-NpmCommandPath) list -g --depth 0 --json 2>$null | ConvertFrom-Json + if ($npmList -and $npmList.dependencies -and $npmList.dependencies.openclaw -and $npmList.dependencies.openclaw.version) { + $installedVersion = $npmList.dependencies.openclaw.version + } + } catch { + $installedVersion = $null + } + } + Write-Host "" - Write-Host "🦞 OpenClaw installed successfully!" -Level success + if ($installedVersion) { + Write-Host "OpenClaw installed successfully ($installedVersion)!" -ForegroundColor Green + } else { + Write-Host "OpenClaw installed successfully!" -ForegroundColor Green + } + Write-Host "" + if ($isUpgrade) { + $updateMessages = @( + "Leveled up! New skills unlocked. You're welcome.", + "Fresh code, same lobster. Miss me?", + "Back and better. Did you even notice I was gone?", + "Update complete. I learned some new tricks while I was out.", + "Upgraded! Now with 23% more sass.", + "I've evolved. Try to keep up.", + "New version, who dis? Oh right, still me but shinier.", + "Patched, polished, and ready to pinch. Let's go.", + "The lobster has molted. Harder shell, sharper claws.", + "Update done! Check the changelog or just trust me, it's good.", + "Reborn from the boiling waters of npm. Stronger now.", + "I went away and came back smarter. You should try it sometime.", + "Update complete. The bugs feared me, so they left.", + "New version installed. Old version sends its regards.", + "Firmware fresh. Brain wrinkles: increased.", + "I've seen things you wouldn't believe. Anyway, I'm updated.", + "Back online. The changelog is long but our friendship is longer.", + "Upgraded! Peter fixed stuff. Blame him if it breaks.", + "Molting complete. Please don't look at my soft shell phase.", + "Version bump! Same chaos energy, fewer crashes (probably)." + ) + Write-Host (Get-Random -InputObject $updateMessages) -ForegroundColor Gray + Write-Host "" + } else { + $completionMessages = @( + "Ahh nice, I like it here. Got any snacks? ", + "Home sweet home. Don't worry, I won't rearrange the furniture.", + "I'm in. Let's cause some responsible chaos.", + "Installation complete. Your productivity is about to get weird.", + "Settled in. Time to automate your life whether you're ready or not.", + "Cozy. I've already read your calendar. We need to talk.", + "Finally unpacked. Now point me at your problems.", + "cracks claws Alright, what are we building?", + "The lobster has landed. Your terminal will never be the same.", + "All done! I promise to only judge your code a little bit." + ) + Write-Host (Get-Random -InputObject $completionMessages) -ForegroundColor Gray + Write-Host "" + } + + if ($InstallMethod -eq "git") { + Write-Host "Source checkout: $finalGitDir" -ForegroundColor Cyan + Write-Host "Wrapper: $env:USERPROFILE\\.local\\bin\\openclaw.cmd" -ForegroundColor Cyan + Write-Host "" + } + + if ($isUpgrade) { + Write-Host "Upgrade complete. Run " -NoNewline + Write-Host "openclaw doctor" -ForegroundColor Cyan -NoNewline + Write-Host " to check for additional migrations." + } else { + if ($NoOnboard) { + Write-Host "Skipping onboard (requested). Run " -NoNewline + Write-Host "openclaw onboard" -ForegroundColor Cyan -NoNewline + Write-Host " later." + } else { + Write-Host "Starting setup..." -ForegroundColor Cyan + Write-Host "" + Invoke-OpenClawCommand onboard + } + } + return $true } diff --git a/scripts/install.sh b/scripts/install.sh index abb62dab53b2..38bced676f52 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 07c6a5d70d9b..6cd6bfc0adab 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -179,7 +179,7 @@ describe("normalizeMessagesForLlmBoundary", () => { const output = normalizeMessagesForLlmBoundary( input as Parameters[0], - ) as Array>; + ) as unknown as Array>; expect(output[0]?.content).toEqual([ { diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 9e049badbd5a..1c7d0c30242a 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -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.', ); }); }); diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index 96b4d587979f..d884a128953a 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -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(); diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index 7ee84caef0fd..4c545948282c 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -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, diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index a4b9e24aae2f..f2f23c66ee3b 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -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", diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 56fa3e1a7af2..8a498ca50862 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -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 | 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"); diff --git a/test/scripts/website-installer-sync-workflow.test.ts b/test/scripts/website-installer-sync-workflow.test.ts new file mode 100644 index 000000000000..34b6765a5758 --- /dev/null +++ b/test/scripts/website-installer-sync-workflow.test.ts @@ -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"); + }); +});