#!/bin/sh # ============================================================================ # openclaw-env — Node.js 环境自动检测/下载 + OpenClaw 安装 # 用法: # openclaw-env setup — 完整安装 (Node.js + pnpm + OpenClaw) # openclaw-env check — 检查环境状态 # openclaw-env upgrade — 升级 OpenClaw 到最新版 # openclaw-env node — 仅下载/更新 Node.js # openclaw-env versions — 输出发布通道、上游版本与已验证版本 (JSON) # 环境变量: # OC_CHANNEL — 发布通道: extended-stable (默认) / latest # OC_VERSION — 锁定精确版本 (如 2026.9.36);历史值 stable/latest 分别映射到对应通道 # OC_ALLOW_UNVERIFIED — 1 = 允许安装上游已发布但未经本插件验证的版本 # 通道与已验证版本记录在 /usr/share/openclaw/openclaw-compat.json # ============================================================================ set -e # ── Node.js 版本策略 ── # 24.22.2 (24 LTS) 同时满足 OpenClaw 2026.3.x ~ 2026.10.x 全部 engines.node, # 因此升级时先换 Node、回滚到旧版 OpenClaw 时也无需降级 Node。 NODE_VERSION_V2="24.22.2" # 默认使用 V2 版本 (可通过 NODE_VERSION 环境变量覆盖) NODE_VERSION="${NODE_VERSION:-${NODE_VERSION_V2}}" # 默认通道 (extended-stable) 的已验证版本,需与 openclaw-compat.json 保持一致 (测试会校验) OC_TESTED_VERSION="2026.9.36" # 已验证版本 engines.node 的最低要求。 # 安装成功后还会从 package.json 再做一次强校验。 OC_NODE_MIN_VERSION="${OC_NODE_MIN_VERSION:-24.22.1}" # 支持底线: 兼容矩阵内最低版本 (2026.9.1) 的要求,已安装包的 engines 不能把要求拉低到它以下 OC_NODE_FLOOR_VERSION="22.22.3" # 用户可通过 OC_VERSION 环境变量覆盖安装版本 OC_VERSION="${OC_VERSION:-}" OC_CHANNEL="${OC_CHANNEL:-$(uci -q get openclaw.main.channel 2>/dev/null || true)}" OC_ALLOW_UNVERIFIED="${OC_ALLOW_UNVERIFIED:-0}" _script_dir="$(CDPATH= cd -- "$(dirname "$0")" 2>/dev/null && pwd || echo "")" if [ -r /usr/libexec/openclaw-paths.sh ]; then . /usr/libexec/openclaw-paths.sh elif [ -n "$_script_dir" ] && [ -r "${_script_dir}/../libexec/openclaw-paths.sh" ]; then . "${_script_dir}/../libexec/openclaw-paths.sh" elif [ -r ./root/usr/libexec/openclaw-paths.sh ]; then . ./root/usr/libexec/openclaw-paths.sh fi if [ -r /usr/libexec/openclaw-node.sh ]; then . /usr/libexec/openclaw-node.sh elif [ -n "$_script_dir" ] && [ -r "${_script_dir}/../libexec/openclaw-node.sh" ]; then . "${_script_dir}/../libexec/openclaw-node.sh" elif [ -r ./root/usr/libexec/openclaw-node.sh ]; then . ./root/usr/libexec/openclaw-node.sh fi # ── 安装路径 (支持自定义) ── # UCI 公开字段继续使用 openclaw.main.install_path,语义为“基础目录”。 # 兼容用户误填 /mnt/data/openclaw:统一剥离末尾 openclaw,避免得到 # /mnt/data/openclaw/openclaw 这种双拼路径。 OC_CONFIGURED_PATH="${OC_INSTALL_PATH:-$(uci -q get openclaw.main.install_path 2>/dev/null || echo '/opt')}" if command -v oc_load_paths >/dev/null 2>&1; then if ! oc_load_paths "$OC_CONFIGURED_PATH"; then echo " [✗] 安装路径无效: $OC_CONFIGURED_PATH" echo " [!] 请使用绝对路径,例如 /opt、/mnt/data;不要使用相对路径、空格或 shell 特殊字符。" exit 1 fi OC_BASE_PATH="$OPENCLAW_INSTALL_PATH" OC_INSTALL_PATH="$OC_ROOT" else OC_BASE_PATH="${OC_CONFIGURED_PATH%/}" OC_INSTALL_PATH="${OC_BASE_PATH}/openclaw" NODE_BASE="${OC_INSTALL_PATH}/node" OC_GLOBAL="${OC_INSTALL_PATH}/global" OC_DATA="${OC_INSTALL_PATH}/data" CONFIG_FILE="${OC_DATA}/.openclaw/openclaw.json" fi [ -n "${NODE_BASE:-}" ] || NODE_BASE="${OC_INSTALL_PATH}/node" [ -n "${OC_GLOBAL:-}" ] || OC_GLOBAL="${OC_INSTALL_PATH}/global" [ -n "${OC_DATA:-}" ] || OC_DATA="${OC_INSTALL_PATH}/data" [ -n "${CONFIG_FILE:-}" ] || CONFIG_FILE="${OC_DATA}/.openclaw/openclaw.json" OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-${OC_DATA}/.openclaw}" OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-${CONFIG_FILE}}" export OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH NODE_BASE OC_GLOBAL OC_DATA CONFIG_FILE # ── OverlayFS 兼容性修复 ── # iStoreOS/OpenWrt 上 Docker 的 bind mount (/overlay/upper/opt/docker) # 会导致 OverlayFS 合并视图中 /opt 完全不可写 (mkdir 报 "Directory not empty")。 # 解决方案: 将 /overlay/upper/opt bind mount 到 /opt,绕过 OverlayFS 冲突。 # 注意: 仅当基础路径为 /opt 时才需要此修复 _oc_fix_opt() { # 如果基础路径不是 /opt,无需修复 [ "$OC_BASE_PATH" != "/opt" ] && return 0 # 如果 /opt 可正常写入,无需修复 if mkdir -p /opt/openclaw/.probe 2>/dev/null; then rmdir /opt/openclaw/.probe 2>/dev/null return 0 fi # /opt 不可写且 overlay upper 层存在 — 执行 bind mount 修复 if [ -d /overlay/upper/opt ]; then # 确保 upper 层有 openclaw 目录 mkdir -p /overlay/upper/opt/openclaw 2>/dev/null # 绑定挂载 upper 层的 /opt 到合并视图的 /opt mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0 fi return 1 } _oc_fix_opt NODE_BIN="${NODE_BASE}/bin/node" NPM_BIN="${NODE_BASE}/bin/npm" PNPM_BIN="${OC_GLOBAL}/bin/pnpm" # Node.js 官方镜像 + musl 非官方构建与自托管兜底 NODE_MIRROR="${NODE_MIRROR:-https://nodejs.org/dist}" NODE_MIRROR_CN="https://npmmirror.com/mirrors/node" NODE_MUSL_MIRROR="https://unofficial-builds.nodejs.org/download/release" # 项目自托管 musl Node.js 兜底资产 NODE_SELF_HOST="https://github.com/10000ge10000/luci-app-openclaw/releases/download/node-bins" OC_PACKAGE_CONTRACT="/usr/share/openclaw/openclaw-package-contract.js" if [ ! -f "$OC_PACKAGE_CONTRACT" ]; then _script_dir="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" if [ -f "${_script_dir}/../share/openclaw/openclaw-package-contract.js" ]; then OC_PACKAGE_CONTRACT="${_script_dir}/../share/openclaw/openclaw-package-contract.js" elif [ -f "./root/usr/share/openclaw/openclaw-package-contract.js" ]; then OC_PACKAGE_CONTRACT="./root/usr/share/openclaw/openclaw-package-contract.js" fi fi OC_COMPAT_FILE="$(dirname "$OC_PACKAGE_CONTRACT")/openclaw-compat.json" export OC_COMPAT_FILE OC_ALLOW_UNVERIFIED export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:$PATH" export NODE_ICU_DATA="${NODE_BASE}/share/icu" log_info() { echo " [✓] $1"; } log_warn() { echo " [!] $1"; } log_error() { echo " [✗] $1"; } # 安全创建目录 (会在 _oc_fix_opt 修复 /opt 后使用标准路径) ensure_mkdir() { local target="$1" [ -d "$target" ] && return 0 if ! mkdir -p "$target" 2>/dev/null; then log_error "无法创建目录: $target" local probe_parent="/" command -v oc_find_existing_path >/dev/null 2>&1 && probe_parent=$(oc_find_existing_path "$target") log_error "请检查挂载点是否只读、overlay 是否已满,以及 ${probe_parent} 是否可写。" [ "$OC_BASE_PATH" = "/opt" ] && log_error "如果安装了 Docker,可能需要手动执行: mount --bind /overlay/upper/opt /opt" return 1 fi } ensure_openclaw_user() { # OpenWrt 默认没有 useradd/adduser 的完整实现,所以沿用 uci-defaults 的轻量写法。 # 这里在安装链路再兜底一次,避免用户删除账号后微信插件安装或 Gateway 启动失败。 if id openclaw >/dev/null 2>&1 || grep -q '^openclaw:' /etc/passwd 2>/dev/null; then return 0 fi local oc_uid=1000 while grep -q "^[^:]*:x:${oc_uid}:" /etc/passwd 2>/dev/null; do oc_uid=$((oc_uid + 1)) done local oc_gid="$oc_uid" while grep -q "^[^:]*:x:${oc_gid}:" /etc/group 2>/dev/null; do oc_gid=$((oc_gid + 1)) done echo "openclaw:x:${oc_uid}:${oc_gid}:openclaw:${OC_DATA}:/bin/false" >> /etc/passwd echo 'openclaw:x:0:0:99999:7:::' >> /etc/shadow echo "openclaw:x:${oc_gid}:" >> /etc/group log_info "已创建 openclaw 系统用户 (uid=${oc_uid}, gid=${oc_gid})" } probe_install_root() { if command -v oc_probe_writable_root >/dev/null 2>&1; then if ! oc_probe_writable_root "$OC_BASE_PATH"; then log_error "安装路径不可写: $OC_BASE_PATH" log_error "常见原因: overlay 已满、文件系统只读、挂载点未挂载或权限不正确。" local check_path="/" command -v oc_find_existing_path >/dev/null 2>&1 && check_path=$(oc_find_existing_path "$OC_BASE_PATH") df -h "$check_path" 2>/dev/null | tail -1 || true exit 1 fi fi } cleanup_partial_install() { [ -n "${OC_STAGE_DIR:-}" ] && rm -rf "$OC_STAGE_DIR" 2>/dev/null || true # 只在确认目标是规范化后的 openclaw 运行根目录时清理,避免误删用户目录。 if command -v oc_safe_openclaw_root >/dev/null 2>&1 && oc_safe_openclaw_root "$OC_INSTALL_PATH"; then if [ "${OC_SETUP_FRESH_ROOT:-0}" = "1" ]; then log_warn "安装失败,清理本次新建的运行目录: $OC_INSTALL_PATH" rm -rf "$OC_INSTALL_PATH" 2>/dev/null || true [ -d /overlay/upper ] && rm -rf "/overlay/upper${OC_INSTALL_PATH}" 2>/dev/null || true else log_warn "安装失败,检测到已有运行目录,已保留: $OC_INSTALL_PATH" fi fi } # 检测 C 运行时类型 (glibc vs musl) detect_libc() { if ldd --version 2>&1 | grep -qi musl; then echo "musl" elif [ -f /lib/ld-musl-*.so.1 ] 2>/dev/null; then echo "musl" elif [ -f /etc/openwrt_release ] || grep -qi "openwrt\|istoreos\|lede" /etc/os-release 2>/dev/null; then echo "musl" else echo "glibc" fi } # 在所有可能的位置查找 openclaw 入口文件 # pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw find_oc_entry() { local base_global="${1:-$OC_GLOBAL}" local search_dirs="${base_global}/lib/node_modules/openclaw ${base_global}/node_modules/openclaw ${NODE_BASE}/lib/node_modules/openclaw" # 添加 pnpm 版本化目录 (如 $base_global/5/node_modules/openclaw) for ver_dir in "${base_global}"/*/node_modules/openclaw; do [ -d "$ver_dir" ] 2>/dev/null && search_dirs="$search_dirs $ver_dir" done local d echo "$search_dirs" | while read -r d; do [ -z "$d" ] && continue if [ -f "${d}/openclaw.mjs" ]; then echo "${d}/openclaw.mjs" return elif [ -f "${d}/dist/cli.js" ]; then echo "${d}/dist/cli.js" return fi done } detect_oc_node_requirement() { local oc_entry pkg_json required oc_entry=$(find_oc_entry) [ -n "$oc_entry" ] || return 1 pkg_json="$(dirname "$oc_entry")/package.json" [ -f "$pkg_json" ] || return 1 [ -x "$NODE_BIN" ] || return 1 required=$(OC_PKG_JSON="$pkg_json" "$NODE_BIN" -e " const fs=require('fs'); try{ const pkg=JSON.parse(fs.readFileSync(process.env.OC_PKG_JSON,'utf8')); const spec=(pkg.engines&&pkg.engines.node)||''; const m=String(spec).match(/([0-9]+\\.[0-9]+\\.[0-9]+)/); if(m) process.stdout.write(m[1]); }catch(e){} " 2>/dev/null) [ -n "$required" ] || return 1 printf '%s\n' "$required" } assert_node_runtime() { # 已安装 OpenClaw 时以它自己的 engines 为准 (后面还有完整范围校验), # 否则存量或预放置的 Node 22 + OpenClaw 2026.9.1 会被新版默认下限误拒 (#113); # 但不能低于支持底线,防止异常包用宽松的 engines 绕过版本检查。 # OC_NODE_MIN_VERSION 只在尚未安装 OpenClaw 时生效。 local required="$OC_NODE_MIN_VERSION" local from_pkg from_pkg=$(detect_oc_node_requirement 2>/dev/null || true) if [ -n "$from_pkg" ]; then required="$OC_NODE_FLOOR_VERSION" if command -v oc_node_version_ge >/dev/null 2>&1 && oc_node_version_ge "$from_pkg" "$OC_NODE_FLOOR_VERSION"; then required="$from_pkg" fi fi if command -v oc_assert_node_min_version >/dev/null 2>&1; then oc_assert_node_min_version "$NODE_BIN" "$required" || exit 1 fi # 契约校验 helper 与 Node 运行时必须可用,缺失时直接失败关闭 if [ ! -f "$OC_PACKAGE_CONTRACT" ]; then log_error "契约校验 helper 缺失 ($OC_PACKAGE_CONTRACT),运行时强校验失败关闭" exit 1 fi if [ ! -x "$NODE_BIN" ]; then log_error "Node.js 运行时未就绪 ($NODE_BIN),强校验失败关闭" exit 1 fi local engine_spec=">=24.22.1 <25 || >=26.12.1" local oc_entry pkg_json oc_entry=$(find_oc_entry 2>/dev/null || true) if [ -n "$oc_entry" ]; then pkg_json="$(dirname "$oc_entry")/package.json" if [ -f "$pkg_json" ]; then local detected_spec detected_spec=$("$NODE_BIN" -e "try{const p=require(process.argv[1]);process.stdout.write((p.engines&&p.engines.node)||'')}catch(e){}" "$pkg_json" 2>/dev/null || true) [ -n "$detected_spec" ] && engine_spec="$detected_spec" fi fi if ! "$NODE_BIN" "$OC_PACKAGE_CONTRACT" validate-runtime "$NODE_BIN" "$engine_spec"; then log_error "Node.js 运行时未满足 engines.node 契约 (${engine_spec}),强校验失败关闭" exit 1 fi # OpenClaw CLI 依赖 Unicode property escapes(例如 \p{Extended_Pictographic})。 # ARM64 musl 自托管 Node 包必须带上 NODE_ICU_DATA,否则用户直接执行 # `openclaw status` 等子命令会在 CLI 入口阶段抛 SyntaxError。 if ! NODE_ICU_DATA="${NODE_BASE}/share/icu" "$NODE_BIN" -e "new RegExp('\\\\p{Extended_Pictographic}','u')" >/dev/null 2>&1; then log_error "Node.js ICU/Unicode 正则能力异常,请重新安装 Node.js 运行时" exit 1 fi } install_openclaw_cli_wrapper() { local oc_entry oc_entry=$(find_oc_entry) [ -n "$oc_entry" ] || return 1 ensure_mkdir "$OC_GLOBAL/bin" # npm 默认生成的 openclaw 可能是指向 openclaw.mjs 的 symlink。 # 写 wrapper 前必须先 unlink,否则重定向会顺着 symlink 覆盖真实 CLI 入口。 rm -f "$OC_GLOBAL/bin/openclaw" 2>/dev/null || true # 用 : "${VAR:=default}" 而非 export VAR=... 赋默认值。 # # 历史实现无条件 export,把 OpenClaw 官方文档承诺的 per-instance 环境变量 # (OPENCLAW_HOME / OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH) 全部覆盖, # 连 `openclaw --profile ` 也随之失效 —— profile 会解析回同一份 # 生产配置。后果是无法做隔离的写入测试,也无法按官方文档跑多实例网关。 # # 现改为"仅当调用方未设置时才填默认值",默认行为不变,但允许覆盖。 cat > "$OC_GLOBAL/bin/openclaw" << EOF #!/bin/sh # luci-app-openclaw 生成的 OpenClaw CLI 包装器。 # 只在调用方未提供时设置默认值,保留官方的 per-instance / --profile 隔离能力。 : "\${HOME:=${OC_DATA}}" : "\${OPENCLAW_HOME:=${OC_DATA}}" : "\${OPENCLAW_STATE_DIR:=\${OPENCLAW_HOME}/.openclaw}" : "\${OPENCLAW_CONFIG_PATH:=\${OPENCLAW_STATE_DIR}/openclaw.json}" : "\${NODE_ICU_DATA:=${NODE_BASE}/share/icu}" export HOME OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH NODE_ICU_DATA export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:\$PATH" exec "${NODE_BIN}" "${oc_entry}" "\$@" EOF chmod +x "$OC_GLOBAL/bin/openclaw" 2>/dev/null || true } detect_arch() { local arch arch=$(uname -m) case "$arch" in x86_64) echo "linux-x64" ;; aarch64) echo "linux-arm64" ;; armv7l|armv6l) log_error "不支持的 CPU 架构: $arch" log_error "Node.js v22+ 不提供 32 位 ARM 预编译包。" log_error "建议: 使用 aarch64 (ARM64) 版本的固件,或使用 x86_64 设备。" exit 1 ;; *) log_error "不支持的 CPU 架构: $arch (仅支持 x86_64/aarch64)" exit 1 ;; esac } # 计算文件 sha256 (优先 sha256sum,兜底 openssl;openssl-util 是本包硬依赖) oc_sum256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}' elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' else return 1 fi } # 校验 Node tarball 完整性: # - 官方 / unofficial-builds / npmmirror 镜像: 拉取同目录 SHASUMS256.txt 比对 # - 自托管 node-bins: 下载同名 .sha256 边车文件比对 # - 缺失、版本不符、哈希不可验证明确失败 (fail-closed),不得忽略校验 verify_node_tarball() { local file="$1" mirror_url="$2" filename filename=$(basename "$mirror_url") local actual actual=$(oc_sum256 "$file") || { log_error "无可用 sha256 工具,无法校验文件哈希"; return 1; } local expected="" if [ "${mirror_url#*node-bins}" != "$mirror_url" ]; then # 自托管 Release 资产: 下载 .sha256 边车文件 local fetch_sidecar=0 if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 10 --max-time 30 -o "${file}.sha256" "${mirror_url}.sha256" 2>/dev/null && fetch_sidecar=1 || true elif command -v wget >/dev/null 2>&1; then wget -q --timeout=10 -O "${file}.sha256" "${mirror_url}.sha256" 2>/dev/null && fetch_sidecar=1 || true fi if [ "$fetch_sidecar" -eq 1 ] && [ -s "${file}.sha256" ]; then expected=$(awk '{print $1}' "${file}.sha256" 2>/dev/null) rm -f "${file}.sha256" fi else # 官方系镜像: 同版本目录下的 SHASUMS256.txt local shasum_url="${mirror_url%/*}/SHASUMS256.txt" local fetch_sums=0 if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 10 --max-time 30 -o "${file}.sums" "$shasum_url" 2>/dev/null && fetch_sums=1 || true elif command -v wget >/dev/null 2>&1; then wget -q --timeout=10 -O "${file}.sums" "$shasum_url" 2>/dev/null && fetch_sums=1 || true fi if [ "$fetch_sums" -eq 1 ] && [ -s "${file}.sums" ]; then expected=$(awk -v f="$filename" '$2 == f {print $1}' "${file}.sums" 2>/dev/null) rm -f "${file}.sums" fi fi if [ -z "$expected" ]; then log_error "未获取到 ${filename} 的有效 SHA256 校验和,哈希不可验证" return 1 fi if [ "$actual" = "$expected" ]; then log_info "SHA256 校验通过" return 0 fi log_error "SHA256 校验失败: ${filename} (实际: ${actual}, 期望: ${expected})" return 1 } # npm 自检 (#109) # Node.js 运行时目录中的 npm 文件不完整时 (解压中断、磁盘写满、被部分覆盖), # npm 会以 "Class extends value undefined is not a constructor or null" 之类的 # 内部错误退出。仅凭 node --version 无法发现这种损坏。 NPM_SELFCHECK_OUTPUT="" npm_selfcheck() { NPM_SELFCHECK_OUTPUT="" if [ ! -x "$NPM_BIN" ]; then NPM_SELFCHECK_OUTPUT="npm 可执行文件不存在: $NPM_BIN" return 1 fi local out rc=0 out=$("$NPM_BIN" --version 2>&1) || rc=$? if [ "$rc" -eq 0 ]; then case "$out" in [0-9]*) return 0 ;; esac fi NPM_SELFCHECK_OUTPUT=$(printf '%s\n' "$out" | tail -n 5) return 1 } npm_runtime_broken_hint() { log_error "Node.js 运行时自带的 npm 无法运行,通常是运行时文件损坏或不完整:" printf '%s\n' "$NPM_SELFCHECK_OUTPUT" | sed 's/^/ /' log_error "修复方法: 在 LuCI → 服务 → OpenClaw 点击「安装运行环境」,会重新下载 Node.js 运行时;配置与数据不受影响" log_error "命令行等价操作: openclaw-env setup" } # ── 发布通道与目标版本解析 ── # 上游已发布版本来自 npm dist-tags (走用户配置的 npm registry,可使用镜像); # 本插件已验证版本来自 openclaw-compat.json。两者分开记录,默认只安装已验证版本, # 上游出现更新版本时只提示,不会自动升级到未验证版本。 OC_UPSTREAM_TAGS_JSON="" fetch_upstream_disttags() { OC_UPSTREAM_TAGS_JSON="" [ -x "$NPM_BIN" ] || return 1 local out out=$("$NPM_BIN" view openclaw dist-tags --json --fetch-timeout=20000 --fetch-retries=1 2>/dev/null) || return 1 case "$out" in *"{"*) OC_UPSTREAM_TAGS_JSON="$out"; return 0 ;; esac return 1 } upstream_disttag() { local tag="$1" [ -n "$OC_UPSTREAM_TAGS_JSON" ] || fetch_upstream_disttags || return 1 printf '%s' "$OC_UPSTREAM_TAGS_JSON" | "$NODE_BIN" -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{try{const v=JSON.parse(s)[process.argv[1]];if(typeof v==='string'&&/^\d+\.\d+\.\d+$/.test(v))process.stdout.write(v)}catch(e){}})" "$tag" } compat_default_channel() { "$NODE_BIN" -e "try{process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).defaultChannel)}catch(e){process.exit(1)}" "$OC_COMPAT_FILE" 2>/dev/null } # 结果写入 OC_TARGET_VER / OC_TARGET_CHANNEL / OC_TARGET_VERIFIED (verified|unverified) # OC_TARGET_NEWER (newer|same) / OC_TARGET_UPSTREAM resolve_target_version() { local channel="$OC_CHANNEL" pin="" allow="" case "$OC_VERSION" in ""|stable) ;; latest|extended-stable) channel="$OC_VERSION" ;; *) pin="$OC_VERSION" ;; esac [ -n "$pin" ] || pin="$(uci -q get openclaw.main.pin_version 2>/dev/null || true)" [ "$OC_ALLOW_UNVERIFIED" = "1" ] && allow="--allow-unverified" local upstream="" if [ -z "$pin" ]; then local ch="$channel" [ -n "$ch" ] && [ "$ch" != "stable" ] || ch=$(compat_default_channel || true) upstream=$(upstream_disttag "$ch" 2>/dev/null || true) [ -n "$upstream" ] || log_warn "无法查询上游 ${ch} 通道版本 (npm registry 不可达),按已验证版本安装" fi local out out=$("$NODE_BIN" "$OC_PACKAGE_CONTRACT" resolve-target ${channel:+--channel "$channel"} ${pin:+--pin "$pin"} ${upstream:+--upstream "$upstream"} $allow) || return 1 # shellcheck disable=SC2086 set -- $out OC_TARGET_VER="$1" OC_TARGET_CHANNEL="$2" OC_TARGET_VERIFIED="$3" OC_TARGET_NEWER="$4" OC_TARGET_UPSTREAM="$5" [ "$OC_TARGET_UPSTREAM" = "-" ] && OC_TARGET_UPSTREAM="" return 0 } print_target_summary() { if [ "$OC_TARGET_VERIFIED" = "verified" ]; then log_info "目标版本: v${OC_TARGET_VER} (${OC_TARGET_CHANNEL},本插件已验证)" else log_warn "目标版本: v${OC_TARGET_VER} (${OC_TARGET_CHANNEL},未经本插件验证,按用户明确选择安装)" fi if [ "$OC_TARGET_NEWER" = "newer" ] && [ "$OC_TARGET_VERIFIED" = "verified" ]; then log_warn "上游 ${OC_TARGET_CHANNEL} 通道已发布 v${OC_TARGET_UPSTREAM},尚未经本插件验证,本次仍安装已验证版本" fi } # 正在使用的微信插件版本;未安装或未启用时输出为空 wechat_installed_version() { local state="${OC_DATA}/.openclaw" pkg [ -f "$state/openclaw.json" ] && [ -x "$NODE_BIN" ] || return 0 "$NODE_BIN" -e " const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')); const on=(c.channels&&c.channels['openclaw-weixin']&&c.channels['openclaw-weixin'].enabled===true)|| (c.plugins&&c.plugins.entries&&c.plugins.entries['openclaw-weixin']&&c.plugins.entries['openclaw-weixin'].enabled===true); process.exit(on?0:1);" "$state/openclaw.json" >/dev/null 2>&1 || return 0 pkg=$(find "$state/npm/projects" "$state/extensions/openclaw-weixin" -type f \ \( -path '*/@tencent-weixin/openclaw-weixin/package.json' -o -path '*/extensions/openclaw-weixin/package.json' \) 2>/dev/null | head -n 1) [ -n "$pkg" ] || return 0 "$NODE_BIN" -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version" "$pkg" 2>/dev/null || true } # 机器可读的版本信息,供 LuCI 展示「上游已发布」与「本插件已验证」 do_versions() { local installed="" entry entry=$(find_oc_entry 2>/dev/null || true) if [ -n "$entry" ] && [ -f "$(dirname "$entry")/package.json" ] && [ -x "$NODE_BIN" ]; then installed=$("$NODE_BIN" -e "try{process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version)}catch(e){}" "$(dirname "$entry")/package.json" 2>/dev/null || true) fi if [ ! -x "$NODE_BIN" ] || [ ! -f "$OC_PACKAGE_CONTRACT" ]; then printf '{"installed":"%s","node":"","nodeRecommended":"%s","channels":{},"upstreamOk":false}\n' "$installed" "$NODE_VERSION" return 0 fi local tags="" [ "${1:-}" = "--offline" ] || { fetch_upstream_disttags && tags="$OC_UPSTREAM_TAGS_JSON"; } OC_V_INSTALLED="$installed" OC_V_TAGS="$tags" OC_V_CHANNEL="$OC_CHANNEL" \ OC_V_PIN="$(uci -q get openclaw.main.pin_version 2>/dev/null || true)" \ OC_V_NODE="$("$NODE_BIN" --version 2>/dev/null | sed 's/^v//')" \ "$NODE_BIN" -e " const c=require(process.argv[1]); const s=c.channelSummary(); let tags={};try{tags=JSON.parse(process.env.OC_V_TAGS||'{}')}catch(e){} const out={installed:process.env.OC_V_INSTALLED||'',node:process.env.OC_V_NODE||'',nodeRecommended:s.node, defaultChannel:s.defaultChannel,channel:process.env.OC_V_CHANNEL||s.defaultChannel,pin:process.env.OC_V_PIN||'', upstreamOk:!!process.env.OC_V_TAGS,channels:{}}; for(const [n,ch] of Object.entries(s.channels)){ const up=typeof tags[n]==='string'?tags[n]:''; const pu=c.parseStrictSemver(up),pv=c.parseStrictSemver(ch.verified); out.channels[n]=Object.assign({},ch,{upstream:up,upstreamNewer:!!(pu&&pv&&c.compareSemver(pu,pv)>0)}); } const iv=c.parseStrictSemver(out.installed); out.installedVerified=!!(iv&&c.loadCompat().versions[out.installed]); process.stdout.write(JSON.stringify(out)+'\n'); " "$OC_PACKAGE_CONTRACT" } download_node() { local node_ver="$1" local node_arch node_arch=$(detect_arch) local libc_type libc_type=$(detect_libc) # 如果已存在且版本兼容, 跳过 (npm 自检失败时强制重新下载以修复运行时) if [ -x "$NODE_BIN" ]; then local current_ver current_ver=$("$NODE_BIN" --version 2>/dev/null | sed 's/^v//') local version_ok=0 if [ "$current_ver" = "$node_ver" ]; then version_ok=1 elif command -v oc_node_version_ge >/dev/null 2>&1 && oc_node_version_ge "$current_ver" "$node_ver"; then version_ok=1 fi if [ "$version_ok" -eq 1 ]; then if npm_selfcheck; then log_info "Node.js v${current_ver} 已安装 (>= v${node_ver}), 跳过下载" assert_node_runtime return 0 fi log_warn "Node.js v${current_ver} 已安装,但 npm 自检失败,将重新下载运行时修复:" printf '%s\n' "$NPM_SELFCHECK_OUTPUT" | sed 's/^/ /' else log_warn "当前 Node.js v${current_ver}, 将更新到 v${node_ver}" fi fi # ── 构建下载 URL 列表 (musl 资产名精确固定) ── local mirror_list="" local musl_tarball="node-v${node_ver}-${node_arch}-musl.tar.xz" local glibc_tarball="node-v${node_ver}-${node_arch}.tar.xz" if [ "$libc_type" = "musl" ]; then echo "" echo "=== 下载 Node.js v${node_ver} (${node_arch}, musl libc) ===" # x64 与 arm64 musl 均优先官方/社区 unofficial-builds,其次 npmmirror,最后 fallback 到项目自托管资产 mirror_list="${NODE_MUSL_MIRROR}/v${node_ver}/${musl_tarball}" mirror_list="$mirror_list ${NODE_MIRROR_CN}/v${node_ver}/${musl_tarball}" mirror_list="$mirror_list ${NODE_SELF_HOST}/${musl_tarball}" else echo "" echo "=== 下载 Node.js v${node_ver} (${node_arch}, glibc) ===" mirror_list="${NODE_MIRROR}/v${node_ver}/${glibc_tarball}" mirror_list="$mirror_list ${NODE_MIRROR_CN}/v${node_ver}/${glibc_tarball}" fi # ── 逐个尝试下载 ── local downloaded=0 local dl_tmp_dir dl_tmp_dir=$(mktemp -d /tmp/openclaw-node-XXXXXX 2>/dev/null || mktemp -d 2>/dev/null || echo "/tmp/openclaw-node-$$-${RANDOM:-0}") ensure_mkdir "$dl_tmp_dir" local tmp_file="${dl_tmp_dir}/node-v${node_ver}.tar.xz" local attempt=0 local total=0 for _ in $mirror_list; do total=$((total + 1)); done for mirror_url in $mirror_list; do attempt=$((attempt + 1)) echo " 正在从 ${mirror_url} 下载... (${attempt}/${total})" rm -f "$tmp_file" local dl_ok=0 if command -v curl >/dev/null 2>&1; then if curl -fSL --connect-timeout 15 --max-time 300 -o "$tmp_file" "$mirror_url" 2>/dev/null; then dl_ok=1 fi elif command -v wget >/dev/null 2>&1; then if wget -q --timeout=15 -O "$tmp_file" "$mirror_url" 2>/dev/null; then dl_ok=1 fi fi if [ "$dl_ok" -eq 0 ] || [ ! -f "$tmp_file" ]; then log_warn "从 ${mirror_url} 下载失败 (网络错误或 HTTP 404),尝试备用镜像..." rm -f "$tmp_file" continue fi # 校验文件大小 (Node.js xz 压缩包至少 5MB; Alpine 精简包约 12MB, 官方完整包约 30MB) local fsize fsize=$(wc -c < "$tmp_file" 2>/dev/null || echo 0) if [ "$fsize" -le 5000000 ]; then log_warn "下载文件大小异常 (${fsize} bytes),当前镜像产物不完整,淘汰当前源" rm -f "$tmp_file" continue fi if ! verify_node_tarball "$tmp_file" "$mirror_url"; then log_warn "文件 SHA256 校验失败或校验清单缺失,淘汰当前镜像源: ${mirror_url}" rm -f "$tmp_file" continue fi downloaded=1 break done if [ "$downloaded" -ne 1 ]; then log_error "Node.js 运行时所有候选镜像源均下载或校验失败 (候选源已耗尽)" if [ "$libc_type" = "musl" ]; then log_error "当前设备为 ${node_arch} musl 架构,候选源均下载或校验失败" log_error "候选镜像包括 unofficial-builds、npmmirror 以及自托管 assets" fi rm -rf "$dl_tmp_dir" 2>/dev/null || true return 1 2>/dev/null || exit 1 fi # 解压 echo " 正在解压到 ${NODE_BASE}..." # 先解压到临时目录并确认完整,再替换 NODE_BASE。 # 这样即使 tar/xz 缺失或压缩包损坏,也不会删除已有 Node.js。 local tmp_extract="${dl_tmp_dir}/extract" rm -rf "$tmp_extract" 2>/dev/null ensure_mkdir "$tmp_extract" if ! tar xf "$tmp_file" -C "$tmp_extract" 2>/dev/null; then log_error "Node.js 解压失败,请确认已安装 GNU tar(opkg install tar)且下载文件完整" rm -rf "$dl_tmp_dir" 2>/dev/null || true return 1 2>/dev/null || exit 1 fi # 找顶层目录 (node-vX.X.X-linux-xxx) local top_dir top_dir=$(ls "$tmp_extract" 2>/dev/null | head -1) if [ -z "$top_dir" ] || [ ! -d "$tmp_extract/$top_dir" ]; then log_error "解压后未找到顶层目录,安装失败" rm -rf "$dl_tmp_dir" 2>/dev/null || true return 1 2>/dev/null || exit 1 fi # 校验解压出的产物名称是否与请求版本及架构精确一致 local expected_top="node-v${node_ver}-${node_arch}" [ "$libc_type" = "musl" ] && expected_top="node-v${node_ver}-${node_arch}-musl" if [ "$top_dir" != "$expected_top" ]; then log_error "构建产物顶层目录名称不符: 期望 ${expected_top}, 实际 ${top_dir}" rm -rf "$dl_tmp_dir" 2>/dev/null || true return 1 2>/dev/null || exit 1 fi # OverlayFS 兼容: rm -rf 后可能因 whiteout 导致 mkdir 失败 # 只有在临时目录已解压成功后才替换正式 Node 目录。 rm -rf "$NODE_BASE" 2>/dev/null if [ -d /overlay/upper ]; then rm -rf "/overlay/upper${NODE_BASE}" 2>/dev/null fi ensure_mkdir "$NODE_BASE" cp -a "$tmp_extract/$top_dir/." "$NODE_BASE/" rm -rf "$dl_tmp_dir" 2>/dev/null || true # 验证 if [ -x "$NODE_BIN" ]; then local installed_ver installed_ver=$("$NODE_BIN" --version 2>/dev/null | sed 's/^v//' || echo "unknown") if [ "$installed_ver" != "$node_ver" ]; then log_error "Node.js 安装版本不匹配: 期望 v${node_ver}, 实际 v${installed_ver}" return 1 2>/dev/null || exit 1 fi local installed_arch installed_arch=$("$NODE_BIN" -p "process.arch" 2>/dev/null || echo "unknown") local expected_arch="x64" [ "$node_arch" = "linux-arm64" ] && expected_arch="arm64" if [ "$installed_arch" != "$expected_arch" ]; then log_error "Node.js 架构不匹配: 期望 ${expected_arch}, 实际 ${installed_arch}" return 1 2>/dev/null || exit 1 fi log_info "Node.js v${installed_ver} (${installed_arch}) 安装成功" assert_node_runtime else log_error "Node.js 安装验证失败: ${NODE_BIN} 不可执行" return 1 2>/dev/null || exit 1 fi } install_pnpm() { echo "" echo "=== 安装 pnpm ===" if [ -x "$PNPM_BIN" ]; then log_info "pnpm 已安装: $($PNPM_BIN --version 2>/dev/null)" return 0 fi # 使用 npm 安装 pnpm 到全局目录 ensure_mkdir "$OC_GLOBAL" "$NPM_BIN" install -g pnpm --prefix="$OC_GLOBAL" 2>/dev/null if [ -x "$OC_GLOBAL/bin/pnpm" ]; then PNPM_BIN="$OC_GLOBAL/bin/pnpm" log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功" elif [ -x "$NODE_BASE/bin/pnpm" ]; then PNPM_BIN="$NODE_BASE/bin/pnpm" log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功" else log_warn "pnpm 安装失败, 将使用 npm 作为回退" fi } validate_openclaw_manifest() { local pkg_target="$1" target_ver="$2" local pkg_json="" local pkg_dir="" if [ -d "$pkg_target" ]; then pkg_dir="$pkg_target" pkg_json="${pkg_dir}/package.json" else pkg_json="$pkg_target" pkg_dir="$(dirname "$pkg_json")" fi if [ ! -f "$pkg_json" ]; then log_error "未找到 OpenClaw package.json: $pkg_json" return 1 fi # 校验 helper 和 Node 运行时必须可用,缺失直接失败关闭 if [ ! -f "$OC_PACKAGE_CONTRACT" ]; then log_error "契约校验 helper 缺失 ($OC_PACKAGE_CONTRACT),无法验证包清单" return 1 fi if [ ! -x "$NODE_BIN" ]; then log_error "Node.js 运行时未就绪 ($NODE_BIN),无法执行契约校验" return 1 fi # 已验证版本按兼容矩阵逐项校验 (含 lifecycle 脚本文件存在性); # 未验证版本仅在 OC_ALLOW_UNVERIFIED=1 时放行 (零执行静态预检) echo " 正在校验 OpenClaw 包清单不可变契约 (静态预检)..." if ! "$NODE_BIN" "$OC_PACKAGE_CONTRACT" validate-manifest "$pkg_json" "$target_ver"; then log_error "OpenClaw 包清单契约静态校验失败" return 1 fi return 0 } # 修正插件历史写入、新版 OpenClaw 不再接受的配置字段 (解析失败时不写入,返回非零) run_config_fixups() { local cfg="$1" local fixups="$(dirname "$OC_PACKAGE_CONTRACT")/oc-config-fixups.js" [ -f "$cfg" ] || return 0 [ -f "$fixups" ] && [ -x "$NODE_BIN" ] || return 0 local out rc=0 out=$("$NODE_BIN" "$fixups" "$cfg" 2>&1) || rc=$? [ -n "$out" ] && printf '%s\n' "$out" | sed 's/^/ /' chown openclaw:openclaw "$cfg" 2>/dev/null || true return "$rc" } # 已验证版本: tarball sha512 必须与兼容矩阵一致,防止镜像源或缓存被篡改 validate_openclaw_tarball() { local tarball="$1" target_ver="$2" if ! "$NODE_BIN" "$OC_PACKAGE_CONTRACT" validate-integrity "$tarball" "$target_ver"; then log_error "OpenClaw 安装包完整性校验失败,可能是镜像源返回了被篡改或损坏的文件" return 1 fi return 0 } execute_openclaw_lifecycle() { local pkg_dir="$1" target_ver="$2" local pkg_json="${pkg_dir}/package.json" if [ ! -f "$pkg_json" ]; then log_error "未找到 OpenClaw package.json: $pkg_json" return 1 fi # 校验 helper 和 Node 运行时必须可用,缺失直接失败关闭 if [ ! -f "$OC_PACKAGE_CONTRACT" ]; then log_error "契约校验 helper 缺失 ($OC_PACKAGE_CONTRACT),无法验证包清单" return 1 fi if [ ! -x "$NODE_BIN" ]; then log_error "Node.js 运行时未就绪 ($NODE_BIN),无法执行契约校验" return 1 fi if command -v validate_openclaw_manifest >/dev/null 2>&1; then if ! validate_openclaw_manifest "$pkg_dir" "$target_ver"; then return 1 fi else echo " 正在校验 OpenClaw 包清单不可变契约..." if ! "$NODE_BIN" "$OC_PACKAGE_CONTRACT" validate-manifest "$pkg_json" "$target_ver"; then log_error "OpenClaw 包清单契约校验失败,终止安装" return 1 fi fi # 只执行契约批准的 lifecycle 脚本 (受控环境: cwd=pkg_dir, npm_config_ignore_scripts=true)。 # 脚本列表来自兼容矩阵;未验证版本的命令也必须与已验证版本逐字一致,否则上一步已失败关闭。 local vetted if ! vetted=$("$NODE_BIN" "$OC_PACKAGE_CONTRACT" get-vetted-scripts "$pkg_json" "$target_ver"); then log_error "无法获取经审核的 lifecycle 脚本列表" return 1 fi local script for script in $vetted; do case "$script" in scripts/*.mjs) ;; *) log_error "拒绝执行非白名单路径的脚本: $script"; return 1 ;; esac if [ ! -f "${pkg_dir}/${script}" ]; then log_error "白名单 lifecycle 脚本文件不存在: ${pkg_dir}/${script}" return 1 fi log_info "显式执行已校验的 OpenClaw lifecycle 脚本: ${script} (锁定 npm_config_ignore_scripts=true)" if ! ( cd "$pkg_dir" && npm_config_ignore_scripts=true "$NODE_BIN" "$script" ); then log_error "OpenClaw lifecycle 脚本执行失败 (${script},退出码非零)" return 1 fi done log_info "OpenClaw 已校验 lifecycle 脚本执行完成" if [ "${OC_TARGET_VERIFIED:-verified}" != "verified" ]; then log_warn "当前安装版本 (${target_ver}) 未列入本插件已验证兼容矩阵,不属于受支持的稳定配置" fi return 0 } install_openclaw() { echo "" echo "=== 安装 OpenClaw ===" # 确定安装版本: 通道 / 锁定版本 → 精确版本 (默认只装本插件已验证的版本) if ! resolve_target_version; then log_error "无法确定安装目标版本,安装终止" exit 1 fi print_target_summary local target_ver="$OC_TARGET_VER" local oc_pkg="openclaw@${OC_TARGET_VER}" # 检查 git 是否可用 (openclaw 部分依赖可能使用 git:// 协议) if ! command -v git >/dev/null 2>&1; then log_warn "未检测到 git,正在尝试安装..." opkg update >/dev/null 2>&1 opkg install git git-http 2>&1 | tail -3 || true if command -v git >/dev/null 2>&1; then log_info "git 安装成功" else log_warn "git 安装失败,将尝试无 git 模式安装" fi fi # 必须要求 helper 和 npm 可用 if [ ! -x "$NPM_BIN" ]; then log_error "npm 可执行文件不可用 ($NPM_BIN),无法安装 OpenClaw" exit 1 fi if [ ! -x "$NODE_BIN" ]; then log_error "Node.js 可执行文件不可用 ($NODE_BIN),无法安装 OpenClaw" exit 1 fi if [ ! -f "$OC_PACKAGE_CONTRACT" ]; then log_error "契约校验 helper 缺失 ($OC_PACKAGE_CONTRACT),安装终止 (失败关闭)" exit 1 fi local stage_dir stage_dir=$(mktemp -d /tmp/openclaw-stage-XXXXXX 2>/dev/null || mktemp -d 2>/dev/null || echo "/tmp/openclaw-stage-$$-${RANDOM:-0}") mkdir -p "$stage_dir" OC_STAGE_DIR="$stage_dir" OC_CANDIDATE_PREFIX="" OC_BACKUP_PREFIX="" trap 'rc=$?; [ -n "${OC_STAGE_DIR:-}" ] && rm -rf "$OC_STAGE_DIR" 2>/dev/null || true; [ -n "${OC_CANDIDATE_PREFIX:-}" ] && rm -rf "$OC_CANDIDATE_PREFIX" 2>/dev/null || true; if [ -n "${OC_BACKUP_PREFIX:-}" ] && [ -d "${OC_BACKUP_PREFIX:-}" ] && [ ! -d "$OC_GLOBAL" ]; then mv "$OC_BACKUP_PREFIX" "$OC_GLOBAL" 2>/dev/null || true; fi; [ -n "${OC_BACKUP_PREFIX:-}" ] && rm -rf "$OC_BACKUP_PREFIX" 2>/dev/null || true; if [ "${OC_SETUP_CLEANUP:-0}" = "1" ] && [ $rc -ne 0 ]; then cleanup_partial_install; fi; exit $rc' EXIT INT TERM local pack_log="${stage_dir}/npm-pack.log" local pack_rc=0 echo " 正在下载并打包预检 OpenClaw (${oc_pkg})..." ( cd "$stage_dir" && "$NPM_BIN" pack "$oc_pkg" ) > "$pack_log" 2>&1 || pack_rc=$? if [ "$pack_rc" -ne 0 ]; then cat "$pack_log" 2>/dev/null || true log_error "npm pack 下载失败 (退出码: ${pack_rc})" npm_selfcheck || npm_runtime_broken_hint rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit "$pack_rc" fi local tarball tarball=$(ls "$stage_dir"/*.tgz 2>/dev/null | head -1) if [ -z "$tarball" ] || [ ! -f "$tarball" ]; then log_error "npm pack 未生成有效 tarball 产物" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi if ! validate_openclaw_tarball "$tarball" "$target_ver"; then rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi local unpack_dir="${stage_dir}/unpacked" ensure_mkdir "$unpack_dir" if ! tar xzf "$tarball" -C "$unpack_dir" 2>/dev/null; then log_error "解压预检 tarball 失败: $tarball" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi local staged_pkg_dir="${unpack_dir}/package" if [ ! -f "${staged_pkg_dir}/package.json" ]; then staged_pkg_dir=$(find "$unpack_dir" -maxdepth 2 -name "package.json" 2>/dev/null | head -1) [ -n "$staged_pkg_dir" ] && staged_pkg_dir=$(dirname "$staged_pkg_dir") fi if [ -z "$staged_pkg_dir" ] || [ ! -f "${staged_pkg_dir}/package.json" ]; then log_error "预检解包产物中未找到 package.json" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi echo " 正在执行解包清单与运行时预检 (零执行静态预检)..." if ! validate_openclaw_manifest "$staged_pkg_dir" "$target_ver"; then log_error "OpenClaw 预检校验失败,终止安装 (未修改现有安装)" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi local global_parent global_parent="$(dirname "$OC_GLOBAL")" ensure_mkdir "$global_parent" local candidate_prefix candidate_prefix=$(mktemp -d "${global_parent}/.global-candidate-XXXXXX" 2>/dev/null || echo "${global_parent}/.global-candidate-$$-${RANDOM:-0}") ensure_mkdir "$candidate_prefix" chmod 755 "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="$candidate_prefix" local npm_log="${stage_dir}/npm-install.log" local npm_rc=0 echo " 正在执行 npm 临时前缀安装 (${tarball} --ignore-scripts --omit=optional)..." "$NPM_BIN" install -g "$tarball" --prefix="$candidate_prefix" --ignore-scripts --omit=optional > "$npm_log" 2>&1 || npm_rc=$? if [ "$npm_rc" -ne 0 ]; then cat "$npm_log" 2>/dev/null || true log_error "npm 安装失败 (退出码: ${npm_rc})" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit "$npm_rc" fi tail -10 "$npm_log" 2>/dev/null || true # 检查候选入口文件 local cand_entry cand_entry=$(find_oc_entry "$candidate_prefix") if [ -z "$cand_entry" ] || [ ! -f "$cand_entry" ]; then log_error "候选安装未找到 OpenClaw 入口文件,安装中止" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi # 校验包清单并在候选依赖树上下文中执行白名单 lifecycle 各一次 local cand_pkg_dir cand_pkg_dir="$(dirname "$cand_entry")" echo " 正在候选依赖树上下文中执行已验证 lifecycle..." if ! execute_openclaw_lifecycle "$cand_pkg_dir" "$target_ver"; then log_error "OpenClaw 契约校验或 lifecycle 脚本执行失败,安装中止 (未修改现有安装)" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi # 候选安装与 lifecycle 全部成功后,事务式替换 OC_GLOBAL echo " 候选验证通过,正在原子切换 OC_GLOBAL..." local backup_prefix="${global_parent}/.global-backup-$$-${RANDOM:-0}" local has_existing_global=0 if [ -e "$OC_GLOBAL" ]; then has_existing_global=1 if ! mv "$OC_GLOBAL" "$backup_prefix" 2>/dev/null; then log_error "无法备份现有 OC_GLOBAL 目录,安装中止" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi OC_BACKUP_PREFIX="$backup_prefix" fi local switch_ok=0 chmod 755 "$candidate_prefix" 2>/dev/null || true chmod -R a+rX "$candidate_prefix" 2>/dev/null || true if mv "$candidate_prefix" "$OC_GLOBAL" 2>/dev/null; then OC_CANDIDATE_PREFIX="" local final_entry final_entry=$(find_oc_entry) if [ -n "$final_entry" ] && [ -f "$final_entry" ] && install_openclaw_cli_wrapper; then switch_ok=1 fi fi if [ "$switch_ok" -eq 1 ]; then if [ "$has_existing_global" -eq 1 ]; then rm -rf "$backup_prefix" 2>/dev/null || true OC_BACKUP_PREFIX="" fi else log_error "替换 OC_GLOBAL 或 wrapper 验证失败,正在回滚原有版本..." rm -rf "$OC_GLOBAL" 2>/dev/null || true [ -e "$candidate_prefix" ] && rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" if [ "$has_existing_global" -eq 1 ]; then mv "$backup_prefix" "$OC_GLOBAL" 2>/dev/null || true OC_BACKUP_PREFIX="" install_openclaw_cli_wrapper 2>/dev/null || true fi rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" exit 1 fi rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" if [ "${OC_SETUP_CLEANUP:-0}" != "1" ]; then trap - EXIT INT TERM fi # 验证 CLI wrapper 与运行时 if ! install_openclaw_cli_wrapper; then log_error "OpenClaw CLI wrapper 安装失败" exit 1 fi local oc_found oc_found=$(find_oc_entry) local oc_ver="" if [ -n "$oc_found" ]; then oc_ver=$("$NODE_BIN" "$oc_found" --version 2>/dev/null | tr -d '[:space:]') fi if [ -n "$oc_ver" ]; then log_info "OpenClaw v${oc_ver} 安装成功" assert_node_runtime else log_error "OpenClaw 安装验证失败,无法读取版本号" exit 1 fi } init_openclaw() { echo "" echo "=== 初始化 OpenClaw ===" # 创建数据目录 ensure_mkdir "$OC_DATA/.openclaw" # 运行 onboard local oc_entry="" oc_entry=$(find_oc_entry) if [ -n "$oc_entry" ]; then # 历史版本传了不存在的 --tools-profile 且缺少 NODE_ICU_DATA,onboard 实际从未成功, # 配置一直由 init.d 首次启动补全。这里改用上游支持的参数完成工作区与 Gateway 初始化: # - 显式 --gateway-bind lan/--gateway-port,避免 onboard 默认的 loopback 被 init.d 当作用户设置保留 # - 跳过守护进程/健康检查/渠道/技能等交互步骤,模型与渠道由插件菜单配置 # onboard 失败不影响安装: init.d 启动时仍会补全关键字段,Gateway 在最小配置下也能启动 (已实测)。 local gw_port gw_port=$(uci -q get openclaw.main.port 2>/dev/null || echo "18789") case "$gw_port" in ''|*[!0-9]*) gw_port=18789 ;; esac if HOME="$OC_DATA" \ OPENCLAW_HOME="$OC_DATA" \ OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \ OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json" \ NODE_ICU_DATA="${NODE_BASE}/share/icu" \ OPENCLAW_SUPERVISOR_MODE=external \ OPENCLAW_SERVICE_REPAIR_POLICY=external \ "$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk --mode local \ --auth-choice skip --gateway-bind lan --gateway-port "$gw_port" \ --skip-daemon --skip-health --skip-channels --skip-skills --skip-search --skip-hooks \ >/dev/null 2>&1; then log_info "初始化完成" else log_warn "OpenClaw onboard 未完成,首次启动服务时将由插件补全基础配置" fi fi # 设置文件权限 chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true chown -R openclaw:openclaw "$OC_GLOBAL" 2>/dev/null || true chown -R openclaw:openclaw "$NODE_BASE" 2>/dev/null || true } do_setup() { local node_ver="$NODE_VERSION" # 检查是否已安装 if [ -x "$NODE_BIN" ] && [ -n "$(find_oc_entry)" ]; then # 已安装但 npm 损坏 (#109): 只重新下载 Node.js 运行时,OpenClaw 与数据保持不变。 # 不启用 OC_SETUP_CLEANUP,失败时不清理已有安装;download_node 仅在新运行时解压校验通过后才替换旧目录。 # 仅在 npm 存在但无法运行时修复: 手工预放置的安装 (#113) 可能没有 npm, # 此时自动下载 Node.js 恰好会触发这类设备要避免的大量快速写入。 if [ -e "$NPM_BIN" ] && ! npm_selfcheck; then echo "=== 修复 Node.js 运行时 ===" log_warn "检测到 npm 无法运行,将重新下载 Node.js 运行时 (OpenClaw 程序与数据保持不变):" printf '%s\n' "$NPM_SELFCHECK_OUTPUT" | sed 's/^/ /' download_node "$node_ver" || exit 1 if npm_selfcheck; then log_info "Node.js 运行时修复完成,可以重新执行升级" exit 0 fi npm_runtime_broken_hint exit 1 fi install_openclaw_cli_wrapper || true # 预放置安装 (#113): 程序文件由用户限速拷贝到位但从未初始化 (无 openclaw.json)。 # 补齐系统用户、运行时契约校验与初始化,不触发任何下载或解压。 if [ ! -f "$OC_DATA/.openclaw/openclaw.json" ]; then echo "=== 初始化预放置的 OpenClaw 运行环境 ===" ensure_openclaw_user assert_node_runtime init_openclaw echo "" log_info "预放置安装已完成初始化 (未下载或解压任何文件)" log_info "请在 LuCI → 服务 → OpenClaw 中启用并启动服务,首次启动时会生成 openclaw.json" exit 0 fi local oc_ver="" local oc_entry="$(find_oc_entry)" local oc_pkg_dir="$(dirname "$oc_entry")" if [ -f "$oc_pkg_dir/package.json" ]; then oc_ver=$("$NODE_BIN" -e "try{console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version)}catch(e){}" "$oc_pkg_dir/package.json" 2>/dev/null || true) [ -z "$oc_ver" ] && oc_ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$oc_pkg_dir/package.json" 2>/dev/null | head -1) fi echo "╔══════════════════════════════════════════════════════════════╗" echo "║ ⚠️ OpenClaw 运行环境已安装,无需重复安装 ║" echo "╚══════════════════════════════════════════════════════════════╝" echo "" echo " Node.js: $($NODE_BIN --version 2>/dev/null)" [ -n "$oc_ver" ] && echo " OpenClaw: v${oc_ver}" echo "" echo " 如需升级,请使用: openclaw-env upgrade" echo " 如需重装,请先卸载: 在 LuCI 界面点击「卸载环境」" echo "" exit 0 fi echo "╔══════════════════════════════════════════════════════════════╗" echo "║ 一万AI分享 OpenClaw 环境安装 ║" echo "╚══════════════════════════════════════════════════════════════╝" echo "" echo " 架构: $(uname -m)" echo " Node 版本: v${node_ver}" case "$OC_VERSION" in ""|stable|latest|extended-stable) echo " OpenClaw: 发布通道 ${OC_VERSION:-${OC_CHANNEL:-默认}} (安装本插件已验证版本)" ;; *) echo " OpenClaw: 锁定版本 v${OC_VERSION}" ;; esac echo " 安装路径: ${OC_INSTALL_PATH}" echo " 数据路径: ${OC_DATA}" echo "" ensure_openclaw_user probe_install_root if [ -e "$OC_INSTALL_PATH" ]; then OC_SETUP_FRESH_ROOT=0 else OC_SETUP_FRESH_ROOT=1 fi OC_SETUP_CLEANUP=1 trap 'rc=$?; if [ $rc -ne 0 ] && [ "${OC_SETUP_CLEANUP:-0}" = "1" ]; then cleanup_partial_install; fi; exit $rc' EXIT download_node "$node_ver" || exit 1 install_pnpm install_openclaw init_openclaw OC_SETUP_CLEANUP=0 trap - EXIT echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ ✅ 安装完成! ║" echo "║ ║" echo "║ 如通过 LuCI 安装,服务已自动启用并启动。 ║" echo "║ 如通过命令行安装,请在 LuCI → 服务 → OpenClaw 中启用。 ║" echo "╚══════════════════════════════════════════════════════════════╝" } do_check() { echo "=== OpenClaw 环境检查 ===" echo "" # Node.js if [ -x "$NODE_BIN" ]; then echo " Node.js: $($NODE_BIN --version 2>/dev/null)" else echo " Node.js: 未安装" fi # pnpm if [ -x "$PNPM_BIN" ]; then echo " pnpm: $($PNPM_BIN --version 2>/dev/null)" elif [ -x "${NODE_BASE}/bin/pnpm" ]; then echo " pnpm: $(${NODE_BASE}/bin/pnpm --version 2>/dev/null)" else echo " pnpm: 未安装" fi # OpenClaw local oc_entry="" oc_entry=$(find_oc_entry) if [ -n "$oc_entry" ] && [ -x "$NODE_BIN" ]; then echo " OpenClaw: v$($NODE_BIN $oc_entry --version 2>/dev/null | tr -d '[:space:]')" else echo " OpenClaw: 未安装" fi # 配置文件 if [ -f "$OC_DATA/.openclaw/openclaw.json" ]; then echo " 配置: $OC_DATA/.openclaw/openclaw.json (存在)" else echo " 配置: 未初始化" fi # 磁盘使用 (检测实际安装路径) local used used=$(du -sh "$OC_INSTALL_PATH" 2>/dev/null | awk '{print $1}') echo " 磁盘: ${used:-N/A}" echo " 安装路径: ${OC_INSTALL_PATH}" # libc 类型 echo " C库: $(detect_libc)" } upgrade_openclaw() { echo "╔══════════════════════════════════════════════════════════════╗" echo "║ 一万AI分享 OpenClaw 升级 ║" echo "╚══════════════════════════════════════════════════════════════╝" echo "" local rep_result="failed" local rep_backup="skipped" local rep_doctor="skipped" local rep_gateway="skipped" local rep_wechat="not-enabled" local rep_rollback="none" local rep_verified="" record_verified_cmd() { local name="$1" local code="$2" if [ -z "$rep_verified" ]; then rep_verified="[${name} rc=${code}]" else rep_verified="${rep_verified} [${name} rc=${code}]" fi } print_upgrade_report() { echo "" echo "============================================================" echo "OpenClaw Upgrade Transaction Report" echo "============================================================" echo "RESULT: ${rep_result}" echo "BACKUP: ${rep_backup}" echo "DOCTOR: ${rep_doctor}" echo "GATEWAY: ${rep_gateway}" echo "WECHAT: ${rep_wechat}" echo "ROLLBACK: ${rep_rollback}" echo "VERIFIED: ${rep_verified}" echo "============================================================" echo "" } if [ ! -x "$NODE_BIN" ]; then log_error "Node.js 未安装, 请先运行: openclaw-env setup" print_upgrade_report exit 1 fi if [ ! -x "$NPM_BIN" ]; then log_error "npm 可执行文件不可用 ($NPM_BIN),无法升级 OpenClaw" print_upgrade_report exit 1 fi # 升级前先确认 npm 可运行,避免在 npm pack 阶段才以内部错误失败 (#109) if ! npm_selfcheck; then npm_runtime_broken_hint record_verified_cmd "npm-selfcheck" 1 print_upgrade_report exit 1 fi if [ ! -f "$OC_PACKAGE_CONTRACT" ]; then log_error "契约校验 helper 缺失 ($OC_PACKAGE_CONTRACT),升级终止 (失败关闭)" print_upgrade_report exit 1 fi # 获取当前版本 local current_ver="" local oc_entry="" oc_entry=$(find_oc_entry) if [ -n "$oc_entry" ]; then local oc_pkg_dir="$(dirname "$oc_entry")" [ -f "$oc_pkg_dir/package.json" ] && \ current_ver=$("$NODE_BIN" -e "try{console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version)}catch(e){}" "$oc_pkg_dir/package.json" 2>/dev/null || true) [ -z "$current_ver" ] && [ -f "$oc_pkg_dir/package.json" ] && \ current_ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$oc_pkg_dir/package.json" 2>/dev/null | head -1) fi echo " Node.js: $($NODE_BIN --version 2>/dev/null)" [ -n "$current_ver" ] && echo " 当前版本: v${current_ver}" echo "" echo "=== 正在升级 OpenClaw ===" echo "" if ! resolve_target_version; then log_error "无法确定升级目标版本,升级终止 (原有安装保持不变)" record_verified_cmd "resolve-target" 1 print_upgrade_report exit 1 fi print_target_summary local target_ver="$OC_TARGET_VER" local target_pkg="openclaw@${OC_TARGET_VER}" echo "目标版本: ${target_ver}" # 先把 Node.js 升级到推荐版本 (2026.9.34+ 要求 >= 24.22.1)。 # 推荐的 24.x 同时满足旧版 OpenClaw 的 engines,后续步骤失败或显式回滚时旧版本仍可运行; # download_node 只在新运行时完整解压并通过校验后才替换旧目录。 local node_rc=0 download_node "$NODE_VERSION" || node_rc=$? record_verified_cmd "node-runtime" "$node_rc" if [ "$node_rc" -ne 0 ]; then log_error "Node.js 运行时升级失败,OpenClaw 升级终止 (原有安装保持不变)" print_upgrade_report exit 1 fi local stage_dir stage_dir=$(mktemp -d /tmp/openclaw-stage-XXXXXX 2>/dev/null || mktemp -d 2>/dev/null || echo "/tmp/openclaw-stage-$$-${RANDOM:-0}") mkdir -p "$stage_dir" OC_STAGE_DIR="$stage_dir" OC_CANDIDATE_PREFIX="" OC_BACKUP_PREFIX="" trap 'rc=$?; [ -n "${OC_STAGE_DIR:-}" ] && rm -rf "$OC_STAGE_DIR" 2>/dev/null || true; [ -n "${OC_CANDIDATE_PREFIX:-}" ] && rm -rf "$OC_CANDIDATE_PREFIX" 2>/dev/null || true; if [ -n "${OC_BACKUP_PREFIX:-}" ] && [ -d "${OC_BACKUP_PREFIX:-}" ] && [ ! -d "$OC_GLOBAL" ]; then mv "$OC_BACKUP_PREFIX" "$OC_GLOBAL" 2>/dev/null || true; fi; [ -n "${OC_BACKUP_PREFIX:-}" ] && rm -rf "$OC_BACKUP_PREFIX" 2>/dev/null || true; exit $rc' EXIT INT TERM local pack_log="${stage_dir}/npm-pack.log" local pack_rc=0 echo " 正在下载并打包预检 OpenClaw (${target_pkg})..." ( cd "$stage_dir" && "$NPM_BIN" pack "$target_pkg" ) > "$pack_log" 2>&1 || pack_rc=$? record_verified_cmd "npm-pack" "$pack_rc" if [ "$pack_rc" -ne 0 ]; then cat "$pack_log" 2>/dev/null || true log_error "npm pack 升级下载失败 (退出码: ${pack_rc})" npm_selfcheck || npm_runtime_broken_hint rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit "$pack_rc" fi local tarball tarball=$(ls "$stage_dir"/*.tgz 2>/dev/null | head -1) if [ -z "$tarball" ] || [ ! -f "$tarball" ]; then log_error "npm pack 未生成有效 tarball 产物" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi local integrity_rc=0 validate_openclaw_tarball "$tarball" "$target_ver" || integrity_rc=1 record_verified_cmd "tarball-integrity" "$integrity_rc" if [ "$integrity_rc" -ne 0 ]; then rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi local unpack_dir="${stage_dir}/unpacked" ensure_mkdir "$unpack_dir" if ! tar xzf "$tarball" -C "$unpack_dir" 2>/dev/null; then log_error "解压预检 tarball 失败: $tarball" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi local staged_pkg_dir="${unpack_dir}/package" if [ ! -f "${staged_pkg_dir}/package.json" ]; then staged_pkg_dir=$(find "$unpack_dir" -maxdepth 2 -name "package.json" 2>/dev/null | head -1) [ -n "$staged_pkg_dir" ] && staged_pkg_dir=$(dirname "$staged_pkg_dir") fi if [ -z "$staged_pkg_dir" ] || [ ! -f "${staged_pkg_dir}/package.json" ]; then log_error "预检解包产物中未找到 package.json" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi echo " 正在执行解包清单与运行时预检 (零执行静态预检)..." local manifest_rc=0 if ! validate_openclaw_manifest "$staged_pkg_dir" "$target_ver"; then manifest_rc=1 fi record_verified_cmd "validate-manifest" "$manifest_rc" if [ "$manifest_rc" -ne 0 ]; then log_error "OpenClaw 升级预检校验失败,终止升级 (原有安装保持不变)" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi local global_parent global_parent="$(dirname "$OC_GLOBAL")" ensure_mkdir "$global_parent" local candidate_prefix candidate_prefix=$(mktemp -d "${global_parent}/.global-candidate-XXXXXX" 2>/dev/null || echo "${global_parent}/.global-candidate-$$-${RANDOM:-0}") ensure_mkdir "$candidate_prefix" chmod 755 "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="$candidate_prefix" # 升级一律先以 --ignore-scripts --omit=optional 阻断全部未知脚本,捕获真实退出码 local npm_log="${stage_dir}/npm-upgrade.log" local npm_rc=0 echo " 正在执行 npm 升级安装 (${tarball} --ignore-scripts --omit=optional)..." "$NPM_BIN" install -g "$tarball" --prefix="$candidate_prefix" --ignore-scripts --omit=optional > "$npm_log" 2>&1 || npm_rc=$? record_verified_cmd "candidate-install" "$npm_rc" if [ "$npm_rc" -ne 0 ]; then cat "$npm_log" 2>/dev/null || true log_error "npm 升级失败 (退出码: ${npm_rc})" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit "$npm_rc" fi tail -10 "$npm_log" 2>/dev/null || true # 验证升级候选入口文件 local new_entry new_entry=$(find_oc_entry "$candidate_prefix") if [ -z "$new_entry" ] || [ ! -f "$new_entry" ]; then log_error "升级验证失败,候选 OpenClaw 入口文件未找到" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi local new_pkg_dir new_pkg_dir="$(dirname "$new_entry")" echo " 正在候选依赖树上下文中执行已验证 lifecycle..." local lc_rc=0 if ! execute_openclaw_lifecycle "$new_pkg_dir" "$target_ver"; then lc_rc=1 fi record_verified_cmd "lifecycle" "$lc_rc" if [ "$lc_rc" -ne 0 ]; then log_error "OpenClaw 升级契约校验或 lifecycle 脚本执行失败,升级中止 (原有安装保持不变)" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" print_upgrade_report exit 1 fi # ── 状态管理事务 (备份创建与校验) ── local _script_dir _script_dir="$(CDPATH= cd -- "$(dirname "$0")" 2>/dev/null && pwd || echo "")" local state_dir="${OPENCLAW_STATE_DIR:-${OC_DATA:+$OC_DATA/.openclaw}}" local state_helper="/usr/libexec/openclaw-upgrade-state.sh" [ -x "$state_helper" ] || state_helper="${_script_dir}/../libexec/openclaw-upgrade-state.sh" [ -x "$state_helper" ] || state_helper="./root/usr/libexec/openclaw-upgrade-state.sh" local state_managed=0 local bk_archive="" if [ -n "$state_dir" ] && [ -d "$state_dir" ] && [ -x "$state_helper" ]; then state_managed=1 # 0. 初始化升级事务 "$state_helper" init-transaction "$target_ver" "$state_dir" 2>/dev/null || true # 1. 停止 Gateway,保证 SQLite 静态一致性 local stop_gw_rc=0 if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw stop >/dev/null 2>&1 || stop_gw_rc=$? elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd stop >/dev/null 2>&1 || stop_gw_rc=$? fi record_verified_cmd "gateway-stop" "$stop_gw_rc" # 2. 状态备份创建 (位于 State 目录之外,以 openclaw 用户执行) "$state_helper" set-phase backing_up "$state_dir" 0 "" 2>/dev/null || true local backup_dir="${OPENCLAW_BACKUP_DIR:-${OC_DATA:+$OC_DATA/backups}}" [ -z "$backup_dir" ] && backup_dir="$(dirname "$state_dir")/openclaw-backups" local bk_create_rc=0 local bk_out="" bk_out=$("$state_helper" backup-create "$backup_dir" "$state_dir" 2>&1) || bk_create_rc=$? record_verified_cmd "backup-create" "$bk_create_rc" if [ "$bk_create_rc" -ne 0 ]; then log_error "升级安全事务中止: 创建完整状态备份失败" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" rep_backup="failed (exit=${bk_create_rc})" rep_rollback="none" "$state_helper" set-phase failed "$state_dir" "$bk_create_rc" "Backup create failed" "" 2>/dev/null || true if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw start >/dev/null 2>&1 || true elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd start >/dev/null 2>&1 || true fi print_upgrade_report exit "$bk_create_rc" fi bk_archive=$(printf '%s\n' "$bk_out" | tail -1) rep_backup="created (${bk_archive})" # 3. 官方 backup verify 与完整覆盖清单校验 local bk_verify_rc=0 "$state_helper" backup-verify "$bk_archive" "$state_dir" >/dev/null 2>&1 || bk_verify_rc=$? record_verified_cmd "backup-verify" "$bk_verify_rc" if [ "$bk_verify_rc" -ne 0 ]; then log_error "升级安全事务中止: 备份归档官方验证或覆盖清单校验失败" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" rep_backup="failed (verify exit=${bk_verify_rc})" rep_rollback="none" "$state_helper" set-phase failed "$state_dir" "$bk_verify_rc" "Backup verify failed" "$bk_archive" 2>/dev/null || true if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw start >/dev/null 2>&1 || true elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd start >/dev/null 2>&1 || true fi print_upgrade_report exit "$bk_verify_rc" fi rep_backup="verified (${bk_archive})" "$state_helper" set-phase backup_verified "$state_dir" 0 "" "$bk_archive" 2>/dev/null || true # 4. 数据库前向迁移预检 (在 candidate 完成后、code switch 之前) local db_pre_rc=0 "$state_helper" database-preflight "$bk_archive" "$state_dir" >/dev/null 2>&1 || db_pre_rc=$? record_verified_cmd "database-preflight" "$db_pre_rc" if [ "$db_pre_rc" -ne 0 ]; then log_error "升级安全事务中止: 数据库前向迁移预检未通过" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" rep_rollback="none" "$state_helper" set-phase failed "$state_dir" "$db_pre_rc" "Database preflight failed" "$bk_archive" 2>/dev/null || true if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw start >/dev/null 2>&1 || true elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd start >/dev/null 2>&1 || true fi print_upgrade_report exit "$db_pre_rc" fi fi # ── 原子切换 OC_GLOBAL 代码 (旧代码保留至迁移与健康检查完成) ── echo " 候选验证通过,正在原子切换 OC_GLOBAL..." local backup_prefix="${global_parent}/.global-backup-$$-${RANDOM:-0}" local has_existing_global=0 if [ -e "$OC_GLOBAL" ]; then has_existing_global=1 if ! mv "$OC_GLOBAL" "$backup_prefix" 2>/dev/null; then log_error "无法备份现有 OC_GLOBAL 目录,升级中止" rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" record_verified_cmd "code-switch" 1 rep_result="failed" rep_rollback="none" print_upgrade_report exit 1 fi OC_BACKUP_PREFIX="$backup_prefix" fi local switch_ok=0 chmod 755 "$candidate_prefix" 2>/dev/null || true chmod -R a+rX "$candidate_prefix" 2>/dev/null || true if mv "$candidate_prefix" "$OC_GLOBAL" 2>/dev/null; then OC_CANDIDATE_PREFIX="" local final_entry final_entry=$(find_oc_entry) if [ -n "$final_entry" ] && [ -f "$final_entry" ] && install_openclaw_cli_wrapper; then switch_ok=1 fi fi if [ "$switch_ok" -eq 1 ]; then record_verified_cmd "code-switch" 0 else record_verified_cmd "code-switch" 1 log_error "替换 OC_GLOBAL 或 wrapper 验证失败,执行代码回滚 (迁移尚未开始)..." rm -rf "$OC_GLOBAL" 2>/dev/null || true [ -e "$candidate_prefix" ] && rm -rf "$candidate_prefix" 2>/dev/null || true OC_CANDIDATE_PREFIX="" local code_pre_rb_rc=0 if [ "$has_existing_global" -eq 1 ]; then mv "$backup_prefix" "$OC_GLOBAL" 2>/dev/null || code_pre_rb_rc=$? OC_BACKUP_PREFIX="" install_openclaw_cli_wrapper 2>/dev/null || true fi record_verified_cmd "code-rollback" "$code_pre_rb_rc" [ "$state_managed" -eq 1 ] && "$state_helper" set-phase failed "$state_dir" 1 "Code switch failed" "$bk_archive" 2>/dev/null || true if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw start >/dev/null 2>&1 || true elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd start >/dev/null 2>&1 || true fi rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" rep_rollback="code: ok, state: none" print_upgrade_report exit 1 fi # ── 迁移开始阶段: 任何失败必须保留新代码、备份与失败状态,进入 recovery 状态 ── # 严禁在迁移开始后将旧代码放回已迁移状态,完整回滚必须显式执行 if [ "$state_managed" -eq 1 ]; then "$state_helper" set-phase migrating "$state_dir" 0 "" "$bk_archive" 2>/dev/null || true # 0. Doctor 之前修正插件历史写入的不兼容字段 (Discord botToken → token 等); # 新版 Doctor 会删除未知键,不先迁移就会丢失用户的 Token。解析失败时不写入。 local fix_rc=0 run_config_fixups "$CONFIG_FILE" || fix_rc=$? record_verified_cmd "config-fixups" "$fix_rc" # 1. 幂等 Doctor migration、配置验证及 Token 对账 local doc_rc=0 "$state_helper" doctor-migration "$state_dir" "$target_ver" >/dev/null 2>&1 || doc_rc=$? record_verified_cmd "doctor" "$doc_rc" if [ "$doc_rc" -ne 0 ]; then log_error "Doctor migration 失败,系统已进入 recovery 恢复模式 (保留新版本代码、备份与状态,需显式回滚)..." rep_doctor="failed (exit=${doc_rc})" "$state_helper" set-phase recovery "$state_dir" "$doc_rc" "Doctor migration failed" "$bk_archive" 2>/dev/null || true rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" rep_rollback="recovery_required (manual explicit rollback needed; new code, state & backup preserved)" print_upgrade_report exit "$doc_rc" fi rep_doctor="passed" "$state_helper" set-phase migrated "$state_dir" 0 "" "$bk_archive" 2>/dev/null || true # 2. 插件收敛事务 (registry refresh / doctor --post-upgrade) local plug_rc=0 "$state_helper" plugin-convergence "$state_dir" >/dev/null 2>&1 || plug_rc=$? record_verified_cmd "plugin-convergence" "$plug_rc" # 微信插件版本收敛: 核心移除 plugin-sdk 子路径后旧插件的渠道会崩溃循环, # 而 plugins list 仍显示 loaded,所以按矩阵判断。网络失败不回滚核心,在报告里写明。 local wx_cur wx_plan wx_action wx_pin wx_min wx_new wx_cur=$(wechat_installed_version) if [ -n "$wx_cur" ]; then wx_plan=$("$NODE_BIN" "$OC_PACKAGE_CONTRACT" wechat-plan "$target_ver" "$wx_cur" 2>/dev/null || echo "unknown - -") wx_action=${wx_plan%% *} wx_min=${wx_plan##* } wx_pin=${wx_plan#* } wx_pin=${wx_pin%% *} case "$wx_action" in ok) rep_wechat="ok (${wx_cur})" ;; upgrade) log_info "微信插件 ${wx_cur} 低于 OpenClaw ${target_ver} 要求的 ${wx_min},升级到 ${wx_pin}..." local wx_rc=0 "$state_helper" wechat-install "$state_dir" "$wx_pin" >/tmp/openclaw-wechat-upgrade.log 2>&1 || wx_rc=$? record_verified_cmd "wechat-plugin" "$wx_rc" wx_new=$(wechat_installed_version) if [ "$wx_rc" -eq 0 ] && [ "$wx_new" = "$wx_pin" ]; then rep_wechat="updated (${wx_cur} -> ${wx_new})" else rep_wechat="failed (${wx_new:-unknown}, need ${wx_min}; see /tmp/openclaw-wechat-upgrade.log)" log_error "微信插件升级失败,微信渠道可能无法运行。可在 LuCI 微信页面重新安装,日志: /tmp/openclaw-wechat-upgrade.log" fi ;; *) rep_wechat="unknown (${wx_cur}, core ${target_ver} not in compat matrix)" log_warn "OpenClaw ${target_ver} 未经本插件验证,无法确认微信插件 ${wx_cur} 是否兼容" ;; esac fi # 3. 启动 Gateway local gw_start_rc=0 if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw start >/dev/null 2>&1 || gw_start_rc=$? elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd start >/dev/null 2>&1 || gw_start_rc=$? fi record_verified_cmd "gateway-start" "$gw_start_rc" # 3. Gateway 健康验证 "$state_helper" set-phase gateway_verifying "$state_dir" 0 "" "$bk_archive" 2>/dev/null || true local gw_health_rc=0 "$state_helper" gateway-verify "" "$state_dir" >/dev/null 2>&1 || gw_health_rc=$? record_verified_cmd "gateway-health" "$gw_health_rc" if [ "$gw_health_rc" -ne 0 ]; then log_error "Gateway 健康检查未通过,系统已进入 recovery 恢复模式 (需显式回滚)..." rep_gateway="failed (exit=${gw_health_rc})" if [ -x /etc/init.d/openclaw ]; then /etc/init.d/openclaw stop >/dev/null 2>&1 || true elif command -v openclaw-initd >/dev/null 2>&1; then openclaw-initd stop >/dev/null 2>&1 || true fi "$state_helper" set-phase recovery "$state_dir" "$gw_health_rc" "Gateway health verify failed" "$bk_archive" 2>/dev/null || true rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" rep_result="failed" rep_rollback="recovery_required (manual explicit rollback needed; new code, state & backup preserved)" print_upgrade_report exit "$gw_health_rc" fi rep_gateway="healthy" "$state_helper" set-phase completed "$state_dir" 0 "" "$bk_archive" 2>/dev/null || true fi # 迁移与 Gateway 验证全部成功,清理代码备份 (备份归档持续保留) if [ "$has_existing_global" -eq 1 ] && [ -n "$backup_prefix" ] && [ -d "$backup_prefix" ]; then rm -rf "$backup_prefix" 2>/dev/null || true OC_BACKUP_PREFIX="" fi rm -rf "$stage_dir" 2>/dev/null || true OC_STAGE_DIR="" trap - EXIT INT TERM local final_installed_entry final_installed_entry=$(find_oc_entry) local final_pkg_dir final_pkg_dir="$(dirname "$final_installed_entry")" local new_ver="" if [ -f "$final_pkg_dir/package.json" ]; then new_ver=$("$NODE_BIN" -e "try{console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version)}catch(e){}" "$final_pkg_dir/package.json" 2>/dev/null || true) [ -z "$new_ver" ] && new_ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$final_pkg_dir/package.json" 2>/dev/null | head -1) fi if [ -z "$new_ver" ] && [ -n "$final_installed_entry" ]; then new_ver=$("$NODE_BIN" "$final_installed_entry" --version 2>/dev/null | tr -d '[:space:]' || true) fi rep_result="success" rep_rollback="none" print_upgrade_report echo "" if [ -n "$new_ver" ]; then assert_node_runtime if [ "$current_ver" = "$new_ver" ]; then log_info "当前已是最新版本 v${new_ver}" else log_info "升级成功: v${current_ver} → v${new_ver}" fi echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ ✅ 升级完成! ║" echo "╚══════════════════════════════════════════════════════════════╝" else log_error "升级验证失败,无法读取新版本号" exit 1 fi } do_upgrade() { upgrade_openclaw "$@" } # ── 恢复出厂设置 (非交互式) ── do_factory_reset() { local config_dir="${OC_DATA}/.openclaw" local config_file="${config_dir}/openclaw.json" local providers_js="/usr/share/openclaw/oc-providers.js" log_info "恢复出厂设置..." # 1. 停止 Gateway log_info "停止 Gateway..." /etc/init.d/openclaw stop >/dev/null 2>&1 || true sleep 2 # 2. 备份当前配置 if [ -f "$config_file" ]; then local backup_dir="${config_dir}/backups" local backup_ts=$(date +%Y%m%d_%H%M%S) ensure_mkdir "$backup_dir" cp "$config_file" "${backup_dir}/openclaw_${backup_ts}.json" log_info "备份已保存: backups/openclaw_${backup_ts}.json" fi # 3. 清除认证信息: 凭据保存在上游认证库 (state/openclaw.sqlite),通过官方 logout 逐条注销。 # 旧实现清空的 auth-profiles.json 已在 2026.9.x 退役,实际凭据从未被清除。 # 必须在重置配置之前执行,否则自定义 provider 等上下文已丢失。 if [ -x "$NODE_BIN" ] && [ -f "$providers_js" ]; then local _pid for _pid in $(NODE_BIN="$NODE_BIN" NODE_BASE="$NODE_BASE" OC_GLOBAL="$OC_GLOBAL" OC_DATA="$OC_DATA" \ OPENCLAW_STATE_DIR="$config_dir" OPENCLAW_CONFIG_PATH="$config_file" \ "$NODE_BIN" "$providers_js" status 2>/dev/null | awk -F '\t' '$1=="PROFILE" { print $2 }'); do if NODE_BIN="$NODE_BIN" NODE_BASE="$NODE_BASE" OC_GLOBAL="$OC_GLOBAL" OC_DATA="$OC_DATA" \ OPENCLAW_STATE_DIR="$config_dir" OPENCLAW_CONFIG_PATH="$config_file" \ "$NODE_BIN" "$providers_js" remove-auth "$_pid" 2>/dev/null | grep -q '^OK'; then log_info "已注销凭据: $_pid" else log_warn "凭据注销失败: $_pid" fi done fi # 4. 重置配置 rm -f "$config_file" "${config_file}.bak" 2>/dev/null || true echo '{}' > "$config_file" chown openclaw:openclaw "$config_file" 2>/dev/null || true # 5. 重新初始化 if [ -x "$NODE_BIN" ]; then local oc_entry oc_entry=$(find "$OC_GLOBAL" -name "openclaw.mjs" -path "*/openclaw/openclaw.mjs" 2>/dev/null | head -1) if [ -n "$oc_entry" ]; then log_info "重新初始化..." # 参数与 init_openclaw 一致 (历史上的 --tools-profile 不是合法参数,onboard 一直静默失败) HOME="$OC_DATA" OPENCLAW_HOME="$OC_DATA" OPENCLAW_STATE_DIR="$config_dir" OPENCLAW_CONFIG_PATH="$config_file" \ NODE_ICU_DATA="${NODE_BASE}/share/icu" OPENCLAW_SUPERVISOR_MODE=external OPENCLAW_SERVICE_REPAIR_POLICY=external \ "$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk --mode local \ --auth-choice skip --gateway-bind lan --gateway-port 18789 \ --skip-daemon --skip-health --skip-channels --skip-skills --skip-search --skip-hooks >/dev/null 2>&1 \ || log_warn "onboard 未完成,首次启动服务时将由插件补全基础配置" fi fi # 6. 应用 OpenWrt 适配配置 if [ -x "$NODE_BIN" ] && [ -f "$config_file" ]; then local new_token new_token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48) _JS_KEY="gateway.port" _JS_VAL="18789" "$NODE_BIN" -e "const fs=require('fs');let d={};try{d=JSON.parse(fs.readFileSync('${config_file}','utf8'));}catch(e){}const ks=process.env._JS_KEY.split('.');let o=d;for(let i=0;i/dev/null for kv in "gateway.bind=lan" "gateway.mode=local" "gateway.auth.mode=token" "gateway.auth.token=${new_token}" "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true" "gateway.tailscale.mode=off" "acp.dispatch.enabled=false" "tools.profile=coding"; do local k="${kv%%=*}" v="${kv#*=}" _JS_KEY="$k" _JS_VAL="$v" "$NODE_BIN" -e "const fs=require('fs');let d={};try{d=JSON.parse(fs.readFileSync('${config_file}','utf8'));}catch(e){}const ks=process.env._JS_KEY.split('.');let o=d;for(let i=0;i/dev/null done chown openclaw:openclaw "$config_file" 2>/dev/null || true # 同步 token 到 UCI . /lib/functions.sh 2>/dev/null || true uci set openclaw.main.token="$new_token" 2>/dev/null || true uci commit openclaw 2>/dev/null || true log_info "新认证令牌: $new_token" fi # 7. 确保数据目录权限正确 (防止 root 操作留下的文件) chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true # 8. 重启服务 /etc/init.d/openclaw start >/dev/null 2>&1 & log_info "出厂设置已恢复,Gateway 重启中..." } # ── 离线安装 (从本地文件安装 Node.js + OpenClaw) ── do_setup_offline() { local offline_dir="${2:-/tmp/openclaw-offline}" if [ ! -d "$offline_dir" ]; then log_error "离线安装目录不存在: $offline_dir" log_error "此命令通常由离线 .run 安装器自动调用" exit 1 fi # 检查是否已安装 if [ -x "$NODE_BIN" ] && [ -n "$(find_oc_entry)" ]; then log_warn "OpenClaw 运行环境已安装" log_warn "如需重新离线安装,请先卸载现有环境" exit 0 fi echo "╔══════════════════════════════════════════════════════════════╗" echo "║ 一万AI分享 OpenClaw 离线安装 ║" echo "╚══════════════════════════════════════════════════════════════╝" echo "" echo " 架构: $(uname -m)" echo " 安装路径: ${OC_INSTALL_PATH}" echo " 数据路径: ${OC_DATA}" echo " 离线包: ${offline_dir}" echo "" ensure_openclaw_user probe_install_root # [1] 安装 Node.js local node_tarball="$offline_dir/node.tar.xz" if [ -f "$node_tarball" ]; then echo "=== 安装 Node.js (离线) ===" rm -rf "$NODE_BASE" 2>/dev/null [ -d /overlay/upper ] && rm -rf "/overlay/upper${NODE_BASE}" 2>/dev/null ensure_mkdir "$NODE_BASE" if tar --strip-components=1 -xf "$node_tarball" -C "$NODE_BASE" 2>/dev/null; then : # GNU tar else local tmp_extract="/tmp/node-extract-$$" ensure_mkdir "$tmp_extract" tar xf "$node_tarball" -C "$tmp_extract" local top_dir=$(ls "$tmp_extract" 2>/dev/null | head -1) if [ -n "$top_dir" ] && [ -d "$tmp_extract/$top_dir" ]; then cp -a "$tmp_extract/$top_dir/." "$NODE_BASE/" fi rm -rf "$tmp_extract" fi if [ -x "$NODE_BIN" ]; then log_info "Node.js $($NODE_BIN --version 2>/dev/null) 安装成功" assert_node_runtime else log_error "Node.js 安装失败" exit 1 fi else log_error "未找到 Node.js 离线包: $node_tarball" exit 1 fi # [2] 安装 OpenClaw local oc_tarball="$offline_dir/openclaw-deps.tar.gz" if [ -f "$oc_tarball" ]; then echo "" echo "=== 安装 OpenClaw (离线) ===" rm -rf "$OC_GLOBAL" 2>/dev/null [ -d /overlay/upper ] && rm -rf "/overlay/upper${OC_GLOBAL}" 2>/dev/null ensure_mkdir "$OC_GLOBAL" tar xzf "$oc_tarball" -C "$OC_GLOBAL" local oc_entry=$(find_oc_entry) if [ -n "$oc_entry" ]; then local oc_pkg_dir="$(dirname "$oc_entry")" if ! execute_openclaw_lifecycle "$oc_pkg_dir" "${OC_TESTED_VERSION}"; then log_error "OpenClaw 离线包契约校验或 lifecycle 脚本执行失败" exit 1 fi local oc_ver=$("$NODE_BIN" "$oc_entry" --version 2>/dev/null | tr -d '[:space:]') if ! install_openclaw_cli_wrapper; then log_error "OpenClaw CLI wrapper 安装失败" exit 1 fi log_info "OpenClaw v${oc_ver} 安装成功" assert_node_runtime else log_error "OpenClaw 安装验证失败" exit 1 fi else log_error "未找到 OpenClaw 离线包: $oc_tarball" exit 1 fi # [3] 初始化 init_openclaw echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ ✅ 离线安装完成! ║" echo "║ ║" echo "║ 请在 LuCI → 服务 → OpenClaw 中启用服务。 ║" echo "╚══════════════════════════════════════════════════════════════╝" } # ── 主入口 ── case "${1:-}" in setup) do_setup ;; setup-offline) do_setup_offline "$@" ;; check) do_check ;; upgrade) do_upgrade ;; node) download_node "$NODE_VERSION" || exit 1 ;; factory-reset) do_factory_reset ;; versions) shift do_versions "$@" ;; *) echo "用法: openclaw-env {setup|setup-offline|check|upgrade|node|versions|factory-reset}" echo "" echo " setup — 完整安装 (下载 Node.js + pnpm + OpenClaw)" echo " setup-offline — 离线安装 (从本地文件安装,由 .run 安装器调用)" echo " check — 检查环境状态" echo " upgrade — 升级 OpenClaw 到所选通道的已验证版本" echo " node — 仅下载/更新 Node.js" echo " versions — 输出发布通道、上游版本与已验证版本 (JSON,--offline 不联网)" echo " factory-reset — 恢复出厂设置 (清除所有配置)" exit 1 ;; esac