diff --git a/Dockerfile b/Dockerfile index fa97f83323a..a022f90f225 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,9 @@ # Opt-in extension dependencies at build time (space-separated directory names). # Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . # -# Multi-stage build produces a minimal runtime image without build tools, -# source code, or Bun. Works with Docker, Buildx, and Podman. +# Multi-stage build keeps source code and Bun out of the runtime image while +# still allowing optional runtime tooling for Docker-hosted workflows. +# Works with Docker, Buildx, and Podman. # The ext-deps stage extracts only the package.json files we need from # extensions/, so the main build layer is not invalidated by unrelated # extension source changes. @@ -55,6 +56,13 @@ ENV PATH="/root/.bun/bin:${PATH}" RUN corepack enable +ENV PNPM_HOME=/home/node/.local/share/pnpm +ENV NPM_CONFIG_PREFIX=/home/node/.npm-global +ENV GOPATH=/home/node/go +ENV PATH="/usr/local/go/bin:${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${PATH}" +RUN mkdir -p "${PNPM_HOME}" "${NPM_CONFIG_PREFIX}/bin" "${GOPATH}/bin" && \ + chown -R node:node /home/node/.local /home/node/.npm-global /home/node/go + WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ @@ -126,17 +134,19 @@ LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \ org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image" WORKDIR /app - -# Install system utilities present in bookworm but missing in bookworm-slim. -# On the full bookworm image these are already installed (apt-get is a no-op). -RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - procps hostname curl git lsof openssl - +ENV PNPM_HOME=/home/node/.local/share/pnpm +ENV NPM_CONFIG_PREFIX=/home/node/.npm-global +ENV COREPACK_HOME=/usr/local/share/corepack +ENV GOPATH=/home/node/go +ENV HOMEBREW_PREFIX=/home/linuxbrew/.linuxbrew +ENV HOMEBREW_CELLAR=/home/linuxbrew/.linuxbrew/Cellar +ENV HOMEBREW_REPOSITORY=/home/linuxbrew/.linuxbrew/Homebrew +ENV PATH="/usr/local/go/bin:${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:${PATH}" RUN chown node:node /app +RUN mkdir -p /home/node/.cache "${PNPM_HOME}" "${NPM_CONFIG_PREFIX}/bin" "${COREPACK_HOME}" \ + "${GOPATH}/bin" "${HOMEBREW_REPOSITORY}" "${HOMEBREW_CELLAR}" "${HOMEBREW_PREFIX}/bin" && \ + chown -R node:node /home/node/.cache /home/node/.local /home/node/.npm-global /home/node/go /home/linuxbrew "${COREPACK_HOME}" +RUN corepack enable COPY --from=runtime-assets --chown=node:node /app/dist ./dist COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules @@ -167,15 +177,28 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \ done && \ chmod -R a+rX "$COREPACK_HOME" -# Install additional system packages needed by your skills or extensions. -# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . +# Install baseline system packages needed by the slim runtime and common +# Docker workflows. Extra packages can still be layered in via +# OPENCLAW_DOCKER_APT_PACKAGES without reinstalling duplicates. ARG OPENCLAW_DOCKER_APT_PACKAGES="" RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ - if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \ - fi + set -eux; \ + BASE_APT_PACKAGES="\ +cron gosu \ +git curl wget ca-certificates jq unzip ripgrep procps hostname openssl lsof file \ +python3 python3-pip python3-venv \ +xvfb xauth \ +libgbm1 libnss3 libasound2 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libxss1 libgtk-3-0"; \ + EXTRA_APT_PACKAGES=""; \ + for pkg in $OPENCLAW_DOCKER_APT_PACKAGES; do \ + case " ${BASE_APT_PACKAGES} " in \ + *" ${pkg} "*) ;; \ + *) EXTRA_APT_PACKAGES="${EXTRA_APT_PACKAGES} ${pkg}" ;; \ + esac; \ + done; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ${BASE_APT_PACKAGES} ${EXTRA_APT_PACKAGES} # Optionally install Chromium and Xvfb for browser automation. # Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... @@ -185,14 +208,67 @@ ARG OPENCLAW_INSTALL_BROWSER="" RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \ mkdir -p /home/node/.cache/ms-playwright && \ PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \ node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \ chown -R node:node /home/node/.cache/ms-playwright; \ fi +# ---- Install Go (official) ---- +# Fetch the latest stable version from go.dev and install the correct arch. +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) GOARCH=amd64 ;; \ + arm64) GOARCH=arm64 ;; \ + *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + GOVERSION="$(curl -fsSL 'https://go.dev/dl/?mode=json' | jq -r 'map(select(.stable==true)) | .[0].version')" ; \ + echo "Installing Go ${GOVERSION} for linux-${GOARCH}"; \ + curl -fsSL "https://go.dev/dl/${GOVERSION}.linux-${GOARCH}.tar.gz" -o /tmp/go.tgz; \ + rm -rf /usr/local/go; \ + tar -C /usr/local -xzf /tmp/go.tgz; \ + rm -f /tmp/go.tgz; \ + /usr/local/go/bin/go version + +# Ensure Go is first in PATH (no old go ahead of it) +ENV PATH="/usr/local/go/bin:${PATH}" + +# ---- Install gog (gogcli) ---- +# Pin version by setting GOGCLI_TAG at build time. +# Default stays pinned for reproducible CI builds. +ARG GOGCLI_TAG=v0.11.0 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) GOGARCH=amd64 ;; \ + arm64) GOGARCH=arm64 ;; \ + *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + tag="$GOGCLI_TAG"; \ + if [ "$tag" = "latest" ]; then \ + tag="$(curl -fsSI -H 'User-Agent: openclaw-docker-build' https://github.com/steipete/gogcli/releases/latest | awk 'tolower($1)==\"location:\" {print $2}' | tr -d '\r' | awk -F/ '{print $NF}' | tail -n1)"; \ + if [ -z "$tag" ]; then \ + echo "WARN: Failed to resolve gogcli latest release tag; falling back to v0.11.0" >&2; \ + tag="v0.11.0"; \ + fi; \ + fi; \ + ver="${tag#v}"; \ + url="https://github.com/steipete/gogcli/releases/download/$tag/gogcli_${ver}_linux_${GOGARCH}.tar.gz"; \ + echo "Downloading: $url"; \ + curl -fsSL "$url" -o /tmp/gogcli.tgz; \ + tar -xzf /tmp/gogcli.tgz -C /tmp; \ + install -m 0755 /tmp/gog /usr/local/bin/gog; \ + rm -f /tmp/gog /tmp/gogcli.tgz; \ + gog --help >/dev/null + +# Install Linuxbrew in a node-writable prefix so brew installs work at runtime. +RUN set -eux; \ + curl -fsSL https://github.com/Homebrew/brew/tarball/master | tar xz --strip-components=1 -C "${HOMEBREW_REPOSITORY}"; \ + ln -sf ../Homebrew/bin/brew "${HOMEBREW_PREFIX}/bin/brew"; \ + chown -R node:node /home/linuxbrew +RUN gosu node brew --version >/dev/null + # Optionally install Docker CLI for sandbox container management. # Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ... # Adds ~50MB. Only the CLI is installed โ€” no Docker daemon. @@ -225,6 +301,16 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar docker-ce-cli docker-compose-plugin; \ fi +COPY --from=build --chown=node:node /app/scripts/docker ./scripts/docker +# Normalize extension paths so plugin safety checks do not reject +# world-writable directories inherited from source file modes. +RUN for dir in /app/extensions /app/.agent /app/.agents; do \ + if [ -d "$dir" ]; then \ + find "$dir" -type d -exec chmod 755 {} +; \ + find "$dir" -type f -exec chmod 644 {} +; \ + fi; \ + done +RUN chmod +x scripts/docker/gateway-entrypoint.sh # Expose the CLI binary without requiring npm global writes as non-root. RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ && chmod 755 /app/openclaw.mjs diff --git a/docker-compose.yml b/docker-compose.yml index 0a55b342e92..8fafe7fb212 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: openclaw-gateway: image: ${OPENCLAW_IMAGE:-openclaw:local} + user: root + entrypoint: ["/app/scripts/docker/gateway-entrypoint.sh"] environment: HOME: /home/node TERM: xterm-256color @@ -11,6 +13,7 @@ services: CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} TZ: ${OPENCLAW_TZ:-UTC} volumes: + - ./.env:/app/.env:ro - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace ## Uncomment the lines below to enable sandbox isolation @@ -68,6 +71,7 @@ services: CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} TZ: ${OPENCLAW_TZ:-UTC} volumes: + - ./.env:/app/.env:ro - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace stdin_open: true diff --git a/docker-setup.sh b/docker-setup.sh old mode 100755 new mode 100644 diff --git a/docs/install/docker.md b/docs/install/docker.md index ce78993e737..95369bba8c4 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -50,7 +50,6 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va The setup script runs onboarding automatically. It will: - - prompt for provider API keys - generate a gateway token and write it to `.env` - start the gateway via Docker Compose @@ -108,15 +107,121 @@ include it with `-f docker-compose.yml -f docker-compose.extra.yml`. The setup script accepts these optional environment variables: -| Variable | Purpose | -| ------------------------------ | ---------------------------------------------------------------- | -| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | -| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | -| `OPENCLAW_EXTENSIONS` | Pre-install extension deps at build time (space-separated names) | -| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | -| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | -| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | -| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | +| Variable | Purpose | +| ------------------------------------ | ---------------------------------------------------------------- | +| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | +| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | +| `OPENCLAW_INSTALL_BROWSER` | Install Chromium and Playwright deps during the image build | +| `OPENCLAW_EXTENSIONS` | Pre-install extension deps at build time (space-separated names) | +| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | +| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | +| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | +| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | +| `OPENCLAW_INSTALL_DOCKER_CLI` | Install Docker CLI in the image (auto-set for sandbox bootstrap) | +| `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS` | Allow trusted private-network `ws://` client targets | +| `OPENCLAW_TZ` | Set container timezone (for example `America/Los_Angeles`) | + +### Use a remote image (skip local build) + +Use image name `ghcr.io/openclaw/openclaw` (not similarly named Docker Hub +images). + +Common tags: + +- `main` โ€” latest build from `main` +- `` โ€” release tag builds (for example `2026.3.12`) +- `latest` โ€” latest stable release tag + +```bash +export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest" +./scripts/docker/setup.sh +``` + +### Extra mounts (optional) + +Set `OPENCLAW_EXTRA_MOUNTS` to add host directories to both containers: + +```bash +export OPENCLAW_EXTRA_MOUNTS="$HOME/.codex:/home/node/.codex:ro,$HOME/github:/home/node/github:rw" +./scripts/docker/setup.sh +``` + +Notes: + +- Paths must be shared with Docker Desktop on macOS/Windows. +- Each entry must be `source:target[:options]` with no spaces or control characters. +- Rerun the setup script after changing the value so `docker-compose.extra.yml` + is regenerated. + +### Persist the entire container home (optional) + +Use `OPENCLAW_HOME_VOLUME` if you want `/home/node` to survive container +recreation: + +```bash +export OPENCLAW_HOME_VOLUME="openclaw_home" +./scripts/docker/setup.sh +``` + +### Install extra apt packages (optional) + +`OPENCLAW_DOCKER_APT_PACKAGES` installs additional Debian packages at build time: + +```bash +export OPENCLAW_DOCKER_APT_PACKAGES="ffmpeg build-essential" +./scripts/docker/setup.sh +``` + +The Docker image already includes a baseline for common container workflows: +`cron`, `gosu`, `pnpm`, non-root `npm -g`, `go install`, Linuxbrew, and browser +runtime libraries. Add only the extra packages your environment needs. + +### Install browser dependencies at build time (optional) + +If you want Chromium baked into the image instead of downloading it later: + +```bash +export OPENCLAW_INSTALL_BROWSER=1 +./scripts/docker/setup.sh +``` + +This installs Chromium through Playwright during the image build and stores it +under `/home/node/.cache/ms-playwright`. + +### Pre-install extension dependencies (optional) + +Extensions with their own `package.json` can be baked into the image: + +```bash +export OPENCLAW_EXTENSIONS="diagnostics-otel matrix" +./scripts/docker/setup.sh +``` + +### Keep macOS awake (optional) + +Docker itself does not reliably keep macOS awake. If the gateway must stay +online for Slack, webhooks, or scheduled jobs: + +```bash +scripts/openclaw-keepawake.sh on +scripts/openclaw-keepawake.sh status +scripts/openclaw-keepawake.sh off +``` + +By default this uses `caffeinate -imsu`, which allows the displays to sleep. +Set `OPENCLAW_KEEPAWAKE_FLAGS=-dimsu` if you also want to keep the displays on. + +### Base image metadata + +The main Docker image uses `node:24-bookworm` and publishes OCI base-image +annotations including: + +- `org.opencontainers.image.base.name` +- `org.opencontainers.image.base.digest` +- `org.opencontainers.image.source` +- `org.opencontainers.image.documentation` + +Reference: [OCI image annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md) ### Health checks @@ -279,6 +384,89 @@ See the [`ClawDock` Helper README](https://github.com/openclaw/openclaw/blob/mai +## Backup and migration (Intel Mac to Apple Silicon) + +For low-disruption host migration, move OpenClaw data and config, then rebuild +the Docker image natively on the new machine. + +Use: + +- `scripts/migrate/backup-openclaw.sh` on the source host +- `scripts/migrate/restore-openclaw.sh` on the target host + +### 1) Create a backup on the source host + +From the repo root: + +```bash +scripts/migrate/backup-openclaw.sh +``` + +The archive includes: + +- OpenClaw config dir (`OPENCLAW_CONFIG_DIR` or `~/.openclaw`) +- OpenClaw workspace dir (`OPENCLAW_WORKSPACE_DIR` or `~/.openclaw/workspace`) +- `.env` and Docker setup files from the repo root +- metadata and an internal checksum manifest + +Output files: + +- `backups/openclaw-backup-.tar.gz` +- `backups/openclaw-backup-.tar.gz.sha256` + +Optional path overrides: + +```bash +scripts/migrate/backup-openclaw.sh \ + --config-dir "$HOME/.openclaw" \ + --workspace-dir "$HOME/.openclaw/workspace" \ + --output-dir "$HOME/openclaw-backups" +``` + +### 2) Transfer the archive to the target host + +Copy the archive and checksum file to the new machine using your normal secure +transfer method. + +### 3) Restore on the target host + +From the repo root on the target host: + +```bash +scripts/migrate/restore-openclaw.sh --archive /path/to/openclaw-backup-.tar.gz +``` + +Default restore behavior: + +- verifies archive checksums +- stops `openclaw-gateway` before restore +- snapshots current config and workspace as `.pre-restore-` +- restores config and workspace from backup +- writes the backup env file as `.env.from-backup` for review + +To overwrite `.env` directly: + +```bash +scripts/migrate/restore-openclaw.sh \ + --archive /path/to/openclaw-backup-.tar.gz \ + --apply-env +``` + +### 4) Rebuild and validate on the target architecture + +Always rebuild on Apple Silicon: + +```bash +docker compose up -d --build --force-recreate openclaw-gateway +docker compose run --rm openclaw-cli health +docker compose run --rm openclaw-cli channels status --probe +``` + +### Architecture migration note + +Do not carry over architecture-specific binary caches from x86 to arm hosts. +Rebuild containers and reinstall native toolchains on the target host. + ### Running on a VPS? See [Hetzner (Docker VPS)](/install/hetzner) and diff --git a/scripts/docker/gateway-entrypoint.sh b/scripts/docker/gateway-entrypoint.sh new file mode 100644 index 00000000000..9d59ec70e89 --- /dev/null +++ b/scripts/docker/gateway-entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "gateway-entrypoint requires root (set docker compose service user: root)" >&2 + exit 1 +fi + +if [ "${OPENCLAW_ENABLE_CRON:-1}" = "1" ] && command -v cron >/dev/null 2>&1; then + # Start cron daemon once; jobs are configured by user crontabs via `crontab`. + if ! pgrep -x cron >/dev/null 2>&1; then + cron + fi +fi + +ensure_node_writable_dir() { + local dir="$1" + mkdir -p "$dir" + chown -R node:node "$dir" +} + +export COREPACK_HOME="${COREPACK_HOME:-/home/node/.cache/node/corepack}" +ensure_node_writable_dir /home/node/.cache +ensure_node_writable_dir "${PNPM_HOME:-/home/node/.local/share/pnpm}" +ensure_node_writable_dir "${NPM_CONFIG_PREFIX:-/home/node/.npm-global}" +ensure_node_writable_dir "${GOPATH:-/home/node/go}" + +if command -v gosu >/dev/null 2>&1; then + exec gosu node "$@" +fi + +exec su -s /bin/sh node -c "$*" diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index cfa6fd4046e..e60d91da66b 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -235,6 +235,7 @@ export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" export OPENCLAW_IMAGE="$IMAGE_NAME" export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}" +export OPENCLAW_INSTALL_BROWSER="${OPENCLAW_INSTALL_BROWSER:-}" export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}" export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" @@ -427,6 +428,7 @@ upsert_env "$ENV_FILE" \ DOCKER_GID \ OPENCLAW_INSTALL_DOCKER_CLI \ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ + OPENCLAW_INSTALL_BROWSER \ OPENCLAW_TZ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then @@ -435,6 +437,7 @@ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ --build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \ --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \ + --build-arg "OPENCLAW_INSTALL_BROWSER=${OPENCLAW_INSTALL_BROWSER}" \ -t "$IMAGE_NAME" \ -f "$ROOT_DIR/Dockerfile" \ "$ROOT_DIR" diff --git a/scripts/migrate/backup-openclaw.sh b/scripts/migrate/backup-openclaw.sh new file mode 100755 index 00000000000..c1d6383aaca --- /dev/null +++ b/scripts/migrate/backup-openclaw.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/migrate/backup-openclaw.sh [options] + +Options: + --repo-root OpenClaw repo root (default: current repo) + --env-file Env file to include (default: /.env) + --config-dir OpenClaw config dir (default: env or ~/.openclaw) + --workspace-dir OpenClaw workspace dir (default: env or ~/.openclaw/workspace) + --output-dir Output directory for backup archive (default: /backups) + --name Backup name prefix (default: openclaw-backup-) + -h, --help Show this help +EOF +} + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +strip_quotes() { + local value="$1" + if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" +} + +env_value_from_file() { + local file="$1" + local key="$2" + [[ -f "$file" ]] || return 0 + local line + line="$(grep -E "^(export[[:space:]]+)?${key}=" "$file" | tail -n 1 || true)" + [[ -n "$line" ]] || return 0 + line="${line#export }" + local value="${line#*=}" + strip_quotes "$value" +} + +resolve_abs_path() { + local p="$1" + python3 - "$p" <<'PY' +import os +import sys + +path = sys.argv[1] +print(os.path.abspath(os.path.expanduser(path))) +PY +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPO_ROOT="$ROOT_DIR" +ENV_FILE="$ROOT_DIR/.env" +CONFIG_DIR="" +WORKSPACE_DIR="" +OUTPUT_DIR="$ROOT_DIR/backups" +BACKUP_NAME="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-root) + REPO_ROOT="$2" + shift 2 + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + --config-dir) + CONFIG_DIR="$2" + shift 2 + ;; + --workspace-dir) + WORKSPACE_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --name) + BACKUP_NAME="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +require_cmd tar +require_cmd rsync +require_cmd shasum +require_cmd python3 +require_cmd date +require_cmd uname + +REPO_ROOT="$(resolve_abs_path "$REPO_ROOT")" +ENV_FILE="$(resolve_abs_path "$ENV_FILE")" +OUTPUT_DIR="$(resolve_abs_path "$OUTPUT_DIR")" + +if [[ -z "$CONFIG_DIR" ]]; then + CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_CONFIG_DIR)}" +fi +if [[ -z "$WORKSPACE_DIR" ]]; then + WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_WORKSPACE_DIR)}" +fi + +CONFIG_DIR="${CONFIG_DIR:-$HOME/.openclaw}" +WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +CONFIG_DIR="$(resolve_abs_path "$CONFIG_DIR")" +WORKSPACE_DIR="$(resolve_abs_path "$WORKSPACE_DIR")" + +[[ -d "$CONFIG_DIR" ]] || fail "Config directory does not exist: $CONFIG_DIR" +[[ -d "$WORKSPACE_DIR" ]] || fail "Workspace directory does not exist: $WORKSPACE_DIR" +[[ -d "$REPO_ROOT" ]] || fail "Repo root does not exist: $REPO_ROOT" + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_NAME="${BACKUP_NAME:-openclaw-backup-${timestamp}}" +mkdir -p "$OUTPUT_DIR" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +stage="$tmpdir/stage" +mkdir -p "$stage/payload/config" "$stage/payload/workspace" "$stage/payload/repo" "$stage/meta" + +echo "==> Copying config directory" +rsync -a "$CONFIG_DIR/" "$stage/payload/config/" + +echo "==> Copying workspace directory" +rsync -a "$WORKSPACE_DIR/" "$stage/payload/workspace/" + +if [[ -f "$ENV_FILE" ]]; then + echo "==> Including env file: $ENV_FILE" + cp "$ENV_FILE" "$stage/payload/repo/.env" +fi + +for file in docker-compose.yml docker-compose.extra.yml Dockerfile docker-setup.sh; do + if [[ -f "$REPO_ROOT/$file" ]]; then + cp "$REPO_ROOT/$file" "$stage/payload/repo/$file" + fi +done + +{ + echo "timestamp_utc=$timestamp" + echo "source_host=$(hostname -s || hostname)" + echo "source_arch=$(uname -m)" + echo "source_os=$(uname -s)" + echo "repo_root=$REPO_ROOT" + echo "config_dir=$CONFIG_DIR" + echo "workspace_dir=$WORKSPACE_DIR" +} >"$stage/meta/backup.env" + +if command -v docker >/dev/null 2>&1; then + { + echo "# docker version" + docker version --format '{{.Server.Version}}' 2>/dev/null || true + echo + echo "# docker compose ps" + docker compose -f "$REPO_ROOT/docker-compose.yml" ps 2>/dev/null || true + } >"$stage/meta/docker.txt" +fi + +if command -v git >/dev/null 2>&1 && [[ -d "$REPO_ROOT/.git" ]]; then + { + echo "branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)" + echo "commit=$(git -C "$REPO_ROOT" rev-parse HEAD)" + echo + echo "# status" + git -C "$REPO_ROOT" status --short + } >"$stage/meta/git.txt" +fi + +( + cd "$stage" + find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 shasum -a 256 > SHA256SUMS +) + +archive_path="$OUTPUT_DIR/${BACKUP_NAME}.tar.gz" +( + cd "$stage" + tar -czf "$archive_path" . +) +shasum -a 256 "$archive_path" > "${archive_path}.sha256" + +echo +echo "Backup created:" +echo " $archive_path" +echo " ${archive_path}.sha256" +echo +echo "Next step on target host:" +echo " scripts/migrate/restore-openclaw.sh --archive \"$archive_path\"" diff --git a/scripts/migrate/restore-openclaw.sh b/scripts/migrate/restore-openclaw.sh new file mode 100755 index 00000000000..9853e93af9a --- /dev/null +++ b/scripts/migrate/restore-openclaw.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/migrate/restore-openclaw.sh --archive [options] + +Options: + --archive Backup archive created by backup-openclaw.sh (required) + --repo-root OpenClaw repo root (default: current repo) + --env-file Env file path (default: /.env) + --config-dir OpenClaw config dir (default: env or ~/.openclaw) + --workspace-dir OpenClaw workspace dir (default: env or ~/.openclaw/workspace) + --apply-env Overwrite --env-file with backup .env (default: false) + --no-stop Do not stop gateway container before restore + -h, --help Show this help +EOF +} + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +strip_quotes() { + local value="$1" + if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" +} + +env_value_from_file() { + local file="$1" + local key="$2" + [[ -f "$file" ]] || return 0 + local line + line="$(grep -E "^(export[[:space:]]+)?${key}=" "$file" | tail -n 1 || true)" + [[ -n "$line" ]] || return 0 + line="${line#export }" + local value="${line#*=}" + strip_quotes "$value" +} + +resolve_abs_path() { + local p="$1" + python3 - "$p" <<'PY' +import os +import sys + +path = sys.argv[1] +print(os.path.abspath(os.path.expanduser(path))) +PY +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPO_ROOT="$ROOT_DIR" +ENV_FILE="$ROOT_DIR/.env" +ARCHIVE_PATH="" +CONFIG_DIR="" +WORKSPACE_DIR="" +APPLY_ENV=0 +STOP_FIRST=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --archive) + ARCHIVE_PATH="$2" + shift 2 + ;; + --repo-root) + REPO_ROOT="$2" + shift 2 + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + --config-dir) + CONFIG_DIR="$2" + shift 2 + ;; + --workspace-dir) + WORKSPACE_DIR="$2" + shift 2 + ;; + --apply-env) + APPLY_ENV=1 + shift + ;; + --no-stop) + STOP_FIRST=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +[[ -n "$ARCHIVE_PATH" ]] || fail "--archive is required" + +require_cmd tar +require_cmd rsync +require_cmd shasum +require_cmd python3 +require_cmd date + +ARCHIVE_PATH="$(resolve_abs_path "$ARCHIVE_PATH")" +REPO_ROOT="$(resolve_abs_path "$REPO_ROOT")" +ENV_FILE="$(resolve_abs_path "$ENV_FILE")" + +[[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH" +[[ -d "$REPO_ROOT" ]] || fail "Repo root does not exist: $REPO_ROOT" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +echo "==> Extracting archive" +tar -xzf "$ARCHIVE_PATH" -C "$tmpdir" + +[[ -f "$tmpdir/SHA256SUMS" ]] || fail "Archive missing SHA256SUMS" +( + cd "$tmpdir" + shasum -a 256 -c SHA256SUMS +) + +if [[ -z "$CONFIG_DIR" ]]; then + CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_CONFIG_DIR)}" +fi +if [[ -z "$WORKSPACE_DIR" ]]; then + WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_WORKSPACE_DIR)}" +fi + +CONFIG_DIR="${CONFIG_DIR:-$HOME/.openclaw}" +WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +CONFIG_DIR="$(resolve_abs_path "$CONFIG_DIR")" +WORKSPACE_DIR="$(resolve_abs_path "$WORKSPACE_DIR")" + +if [[ $STOP_FIRST -eq 1 ]] && command -v docker >/dev/null 2>&1; then + echo "==> Stopping gateway container" + docker compose -f "$REPO_ROOT/docker-compose.yml" stop openclaw-gateway >/dev/null 2>&1 || true +fi + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$(dirname "$CONFIG_DIR")" "$(dirname "$WORKSPACE_DIR")" + +if [[ -d "$CONFIG_DIR" ]]; then + mv "$CONFIG_DIR" "${CONFIG_DIR}.pre-restore-${timestamp}" +fi +if [[ -d "$WORKSPACE_DIR" ]]; then + mv "$WORKSPACE_DIR" "${WORKSPACE_DIR}.pre-restore-${timestamp}" +fi + +mkdir -p "$CONFIG_DIR" "$WORKSPACE_DIR" + +echo "==> Restoring config" +rsync -a "$tmpdir/payload/config/" "$CONFIG_DIR/" + +echo "==> Restoring workspace" +rsync -a "$tmpdir/payload/workspace/" "$WORKSPACE_DIR/" + +if [[ -f "$tmpdir/payload/repo/.env" ]]; then + if [[ $APPLY_ENV -eq 1 ]]; then + mkdir -p "$(dirname "$ENV_FILE")" + if [[ -f "$ENV_FILE" ]]; then + cp "$ENV_FILE" "${ENV_FILE}.pre-restore-${timestamp}" + fi + cp "$tmpdir/payload/repo/.env" "$ENV_FILE" + echo "==> Applied backed up env file to $ENV_FILE" + else + cp "$tmpdir/payload/repo/.env" "${ENV_FILE}.from-backup" + echo "==> Wrote env candidate to ${ENV_FILE}.from-backup" + fi +fi + +source_arch="$(grep -E '^source_arch=' "$tmpdir/meta/backup.env" | cut -d= -f2- || true)" +target_arch="$(uname -m)" +if [[ -n "$source_arch" && "$source_arch" != "$target_arch" ]]; then + echo + echo "NOTE: source arch (${source_arch}) differs from target arch (${target_arch})." + echo "Rebuild the Docker image on this host; do not reuse old binary caches or volumes." +fi + +echo +echo "Restore completed." +echo "Next steps:" +echo " 1) docker compose -f \"$REPO_ROOT/docker-compose.yml\" up -d --build --force-recreate openclaw-gateway" +echo " 2) docker compose -f \"$REPO_ROOT/docker-compose.yml\" run --rm openclaw-cli health" +echo " 3) docker compose -f \"$REPO_ROOT/docker-compose.yml\" run --rm openclaw-cli channels status --probe" diff --git a/scripts/openclaw-keepawake.sh b/scripts/openclaw-keepawake.sh new file mode 100755 index 00000000000..72bfab5a1c8 --- /dev/null +++ b/scripts/openclaw-keepawake.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +PID_FILE="${OPENCLAW_KEEPAWAKE_PID_FILE:-$HOME/.openclaw/run/keepawake.pid}" +KEEPAWAKE_FLAGS="${OPENCLAW_KEEPAWAKE_FLAGS:--imsu}" + +usage() { + cat <<'USAGE' +Usage: scripts/openclaw-keepawake.sh + +Keeps macOS awake using `caffeinate` for long-running OpenClaw Docker sessions. + +Commands: + on Start keep-awake background process + off Stop keep-awake background process + status Show current keep-awake status + restart Restart keep-awake background process + +Environment: + OPENCLAW_KEEPAWAKE_FLAGS caffeinate flags (default: -imsu) + Use -dimsu to keep displays awake too. +USAGE +} + +ensure_caffeinate() { + if ! command -v caffeinate >/dev/null 2>&1; then + echo "caffeinate not found (this helper is for macOS)." >&2 + exit 1 + fi +} + +read_pid() { + if [[ -f "$PID_FILE" ]]; then + tr -d '[:space:]' <"$PID_FILE" + fi +} + +is_caffeinate_pid() { + local pid="$1" + [[ -n "$pid" ]] || return 1 + kill -0 "$pid" >/dev/null 2>&1 || return 1 + local comm + comm="$(ps -p "$pid" -o comm= 2>/dev/null | tr -d '[:space:]')" + [[ "$comm" == "caffeinate" ]] +} + +start_awake() { + ensure_caffeinate + mkdir -p "$(dirname "$PID_FILE")" + + local pid + pid="$(read_pid || true)" + if is_caffeinate_pid "$pid"; then + echo "keep-awake already on (pid $pid)" + return 0 + fi + + local -a flags + # Allow override for special cases (for example keeping displays on). + read -r -a flags <<<"$KEEPAWAKE_FLAGS" + caffeinate "${flags[@]}" >/dev/null 2>&1 & + pid="$!" + echo "$pid" >"$PID_FILE" + echo "keep-awake on (pid $pid, flags: $KEEPAWAKE_FLAGS)" +} + +stop_awake() { + local pid + pid="$(read_pid || true)" + if [[ -z "$pid" ]]; then + echo "keep-awake already off" + return 0 + fi + + if is_caffeinate_pid "$pid"; then + kill "$pid" >/dev/null 2>&1 || true + echo "keep-awake off (stopped pid $pid)" + else + echo "keep-awake off (stale pid file removed)" + fi + rm -f "$PID_FILE" +} + +status_awake() { + local pid + pid="$(read_pid || true)" + if is_caffeinate_pid "$pid"; then + echo "keep-awake is on (pid $pid)" + else + echo "keep-awake is off" + if [[ -n "$pid" ]]; then + rm -f "$PID_FILE" + fi + fi +} + +cmd="${1:-}" +case "$cmd" in +on) + start_awake + ;; +off) + stop_awake + ;; +status) + status_awake + ;; +restart) + stop_awake + start_awake + ;; +*) + usage + exit 2 + ;; +esac diff --git a/skills/summarize/SKILL.md b/skills/summarize/SKILL.md index 0f42e0bfe45..32c8dbc2cd9 100644 --- a/skills/summarize/SKILL.md +++ b/skills/summarize/SKILL.md @@ -7,12 +7,14 @@ metadata: "openclaw": { "emoji": "๐Ÿงพ", + "os": ["darwin"], "requires": { "bins": ["summarize"] }, "install": [ { "id": "brew", "kind": "brew", + "os": ["darwin"], "formula": "steipete/tap/summarize", "bins": ["summarize"], "label": "Install summarize (brew)", diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index 5232d1349a6..4a7677fd01e 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -1,5 +1,5 @@ import "./isolated-agent.mocks.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; @@ -11,6 +11,7 @@ import { withTempCronHome, writeSessionStoreEntries, } from "./isolated-agent.test-harness.js"; +import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; import type { CronJob } from "./types.js"; const withTempHome = withTempCronHome; @@ -126,11 +127,16 @@ async function expectInvalidModel(home: string, model: string) { describe("cron model formatting and precedence edge cases", () => { beforeEach(() => { + setupIsolatedAgentTurnMocks({ fast: true }); vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + // ------ provider/model string splitting ------ describe("parseModelRef formatting", () => { diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 2570a8ed9dc..a55cca93ae4 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -27,6 +27,7 @@ describe("Dockerfile", () => { const dockerfile = await readFile(dockerfilePath, "utf8"); const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); const browserArgIndex = dockerfile.indexOf("ARG OPENCLAW_INSTALL_BROWSER"); + const baseAptPackagesMatch = dockerfile.match(/BASE_APT_PACKAGES="([\s\S]*?)";/); expect(installIndex).toBeGreaterThan(-1); expect(browserArgIndex).toBeGreaterThan(-1); @@ -34,7 +35,9 @@ describe("Dockerfile", () => { expect(dockerfile).toContain( "node /app/node_modules/playwright-core/cli.js install --with-deps chromium", ); - expect(dockerfile).toContain("apt-get install -y --no-install-recommends xvfb"); + expect(baseAptPackagesMatch).not.toBeNull(); + expect(baseAptPackagesMatch?.[1]).toContain("xvfb"); + expect(dockerfile).toContain("apt-get install -y --no-install-recommends ${BASE_APT_PACKAGES}"); }); it("prunes runtime dependencies after the build stage", async () => { diff --git a/src/plugin-sdk/index.bundle.test.ts b/src/plugin-sdk/index.bundle.test.ts index 1f3afc8ab3a..ac61433eb26 100644 --- a/src/plugin-sdk/index.bundle.test.ts +++ b/src/plugin-sdk/index.bundle.test.ts @@ -1,22 +1,14 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { - buildPluginSdkEntrySources, - buildPluginSdkPackageExports, - buildPluginSdkSpecifiers, - pluginSdkEntrypoints, -} from "./entrypoints.js"; +import { buildPluginSdkPackageExports, buildPluginSdkSpecifiers } from "./entrypoints.js"; const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); const execFileAsync = promisify(execFile); -const require = createRequire(import.meta.url); -const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href; describe("plugin-sdk bundled exports", () => { it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => { @@ -24,24 +16,9 @@ describe("plugin-sdk bundled exports", () => { const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-")); try { - const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs"); - await fs.writeFile( - buildScriptPath, - `import { build } from ${JSON.stringify(tsdownModuleUrl)}; -await build(${JSON.stringify({ - clean: true, - config: false, - dts: false, - entry: buildPluginSdkEntrySources(), - env: { NODE_ENV: "production" }, - fixedExtension: false, - logLevel: "error", - outDir, - platform: "node", - })}); -`, - ); - await execFileAsync(process.execPath, [buildScriptPath], { + // Reuse the repo's tsdown config so plugin-sdk bundle checks stay aligned + // with the production build graph and singleton boundaries. + await execFileAsync(process.execPath, ["scripts/tsdown-build.mjs", "--outDir", outDir], { cwd: process.cwd(), }); await fs.symlink( @@ -50,17 +27,12 @@ await build(${JSON.stringify({ "dir", ); - for (const entry of pluginSdkEntrypoints) { - const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); - expect(module).toBeTypeOf("object"); - } - const packageDir = path.join(fixtureDir, "openclaw"); const consumerDir = path.join(fixtureDir, "consumer"); const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs"); - await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); - await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.symlink(outDir, path.join(packageDir, "dist"), "dir"); // Mirror the installed package layout so subpaths can resolve root deps. await fs.symlink( path.join(process.cwd(), "node_modules"), diff --git a/src/secrets/runtime.integration.test.ts b/src/secrets/runtime.integration.test.ts index f39607cbe80..8c16ca0761f 100644 --- a/src/secrets/runtime.integration.test.ts +++ b/src/secrets/runtime.integration.test.ts @@ -289,6 +289,7 @@ describe("secrets runtime snapshot integration", () => { plugins: { entries: { google: { + enabled: true, config: { webSearch: { apiKey: { diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 5f608e4b9a2..1d5c2605980 100644 --- a/test/git-hooks-pre-commit.test.ts +++ b/test/git-hooks-pre-commit.test.ts @@ -37,6 +37,11 @@ describe("git-hooks/pre-commit (integration)", () => { path.join(process.cwd(), "git-hooks", "pre-commit"), path.join(dir, "git-hooks", "pre-commit"), ); + writeFileSync( + path.join(dir, "package.json"), + `${JSON.stringify({ name: "openclaw-pre-commit-test", private: true }, null, 2)}\n`, + "utf8", + ); writeFileSync( path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"), "#!/usr/bin/env bash\nexit 0\n", @@ -56,6 +61,9 @@ describe("git-hooks/pre-commit (integration)", () => { // The hook ends with `pnpm check`, but this fixture is only exercising staged-file handling. // Stub pnpm too so Windows CI does not invoke a real package-manager command in the temp repo. writeExecutable(fakeBinDir, "pnpm", "#!/usr/bin/env bash\nexit 0\n"); + writeFileSync(path.join(fakeBinDir, "pnpm.cmd"), "@echo off\r\nexit /b 0\r\n", { + encoding: "utf8", + }); // Create an untracked file that should NOT be staged by the hook. writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8");