commit 2344884188f94a06dea886d36934245ff1649b3f Author: admin Date: Mon Aug 10 05:03:43 2026 +0000 OpenClaw 一键安装配置脚本 v1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d0f9d2 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# OpenClaw 一键安装脚本 + +在全新 VPS 上一条命令装好 OpenClaw:自动装 Node.js 24 与依赖、全局安装 `openclaw`、中文向导配置 Telegram 与模型、启动 daemon 并验证。 + +## 安装 + +```bash +bash <(curl -fsSL https://mjjtop.com/admin/openclaw-installer/raw/branch/main/install.sh/install.sh) +``` + +或者: + +```bash +curl -fsSL https://mjjtop.com/admin/openclaw-installer/raw/branch/main/install.sh/install.sh -o install.sh && bash install.sh +``` + +需要 root(或 `sudo bash install.sh`)。 + +## 支持的系统 + +- Debian 12 / 13 +- Ubuntu 22.04 / 24.04 +- 尽力兼容 RHEL / Rocky / AlmaLinux / Fedora(dnf 系,未完整测试) +- 架构:x86_64 (amd64) / aarch64 (arm64) +- 内存建议 ≥ 1 GB;低于 1 GB 脚本会提示先加 swap + +## 说明 + +- 重复运行安全:已安装会询问是否更新,不会破坏现有安装。 +- 写配置前自动备份为 `openclaw.json.bak.<时间戳>`,只修改你填写的字段。 +- 安装日志:`/var/log/openclaw-installer.log` +- 非交互安装(只装不问):`OPENCLAW_NONINTERACTIVE=1 bash install.sh` +- **不含任何埋点、统计或数据上报。** + +## 免责声明 + +本脚本按“现状”提供,不附带任何形式的担保。脚本会修改系统软件包与 OpenClaw 配置文件,请在了解其行为后自行承担使用风险;生产环境请先备份。配置文件含 Bot Token 与 API Key,请勿外发或提交到代码仓库。 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b72b3f6 --- /dev/null +++ b/install.sh @@ -0,0 +1,928 @@ +#!/usr/bin/env bash +# ============================================================================= +# OpenClaw 一键安装配置脚本 (install.sh) +# ============================================================================= +# 用途: +# 在一台全新的 VPS 上,全自动完成 OpenClaw 的安装与基础配置: +# 1. 系统 / 架构 / 内存 前置检查 +# 2. 安装系统依赖 (curl git build-essential python3) 与 Node.js 24 (NodeSource) +# 3. 通过 npm 全局安装 openclaw +# 4. 中文交互式向导:Telegram Bot Token / 用户 ID / 模型 provider +# 5. 使用 python3 安全读写 ~/.openclaw/openclaw.json (先备份,只改填写字段) +# 6. 启动 daemon 并验证运行状态 +# 7. 输出安装位置 / 配置路径 / 启停与日志命令 +# +# 支持系统: +# - Debian 12 / 13 +# - Ubuntu 22.04 / 24.04 +# - (尽力兼容 dnf 系: RHEL / Rocky / Alma / Fedora,不做保证) +# - 架构: x86_64 (amd64) / aarch64 (arm64) +# +# 用法: +# 以 root 身份运行: +# bash install.sh +# 非交互(仅安装、跳过向导): +# OPENCLAW_NONINTERACTIVE=1 bash install.sh +# +# 隐私声明: +# 本脚本不包含任何埋点、统计、遥测或向第三方上报的逻辑。 +# 除了 apt/dnf 官方源、NodeSource 与 npm registry 之外,不访问任何外部服务。 +# +# 免责声明: +# 本脚本按“现状”提供,不附带任何担保。请在了解其行为后自行承担使用风险。 +# 脚本会修改系统软件包与 OpenClaw 配置文件;生产环境请先备份。 +# +# 日志: /var/log/openclaw-installer.log +# ============================================================================= + +set -uo pipefail + +# --------------------------------------------------------------------------- +# 全局常量 +# --------------------------------------------------------------------------- +SCRIPT_VERSION="1.0.0" +LOG_FILE="/var/log/openclaw-installer.log" +NODE_MAJOR_REQUIRED=20 +NODE_MAJOR_INSTALL=24 +OPENCLAW_HOME="" # 稍后按真实用户 HOME 推导 +OPENCLAW_CONFIG="" # 稍后探测 +PKG_MGR="" # apt | dnf +OS_ID="" +OS_VERSION="" +ARCH="" +NONINTERACTIVE="${OPENCLAW_NONINTERACTIVE:-0}" + +# 向导收集到的值 +CFG_TG_TOKEN="" +CFG_TG_USERID="" +CFG_PROVIDER="" # openai-compat | anthropic | skip +CFG_BASEURL="" +CFG_APIKEY="" +CFG_MODEL="" + +# --------------------------------------------------------------------------- +# 颜色 (无 TTY 时禁用) +# --------------------------------------------------------------------------- +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RED=$'\033[0;31m' + C_GREEN=$'\033[0;32m' + C_YELLOW=$'\033[0;33m' + C_BLUE=$'\033[0;36m' + C_BOLD=$'\033[1m' + C_RESET=$'\033[0m' +else + C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_BOLD=""; C_RESET="" +fi + +# --------------------------------------------------------------------------- +# 日志与输出 +# --------------------------------------------------------------------------- +_log() { + # 只写文件,不上屏 + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + printf '[%s] %s\n' "$ts" "$*" >>"$LOG_FILE" 2>/dev/null || true +} + +info() { printf '%s\n' "${C_BLUE}==>${C_RESET} $*"; _log "INFO: $*"; } +ok() { printf '%s\n' "${C_GREEN}[✓]${C_RESET} $*"; _log "OK: $*"; } +warn() { printf '%s\n' "${C_YELLOW}[!]${C_RESET} $*"; _log "WARN: $*"; } +err() { printf '%s\n' "${C_RED}[✗]${C_RESET} $*" >&2; _log "ERROR: $*"; } +hint() { printf '%s\n' " ${C_YELLOW}提示:${C_RESET}$*"; _log "HINT: $*"; } +plain() { printf '%s\n' "$*"; } + +title() { + plain "" + printf '%s\n' "${C_BOLD}──── $* ────${C_RESET}" +} + +# 运行一条命令:输出进日志,屏幕保持安静;失败返回非零 +run_quiet() { + _log "RUN: $*" + if "$@" >>"$LOG_FILE" 2>&1; then + return 0 + fi + local rc=$? + _log "RUN FAILED (rc=$rc): $*" + return "$rc" +} + +# 致命错误:给中文提示后退出 +die() { + err "$1" + if [ -n "${2:-}" ]; then hint "$2"; fi + plain "" + plain "完整日志:${LOG_FILE}" + plain "可执行 ${C_BOLD}tail -n 50 ${LOG_FILE}${C_RESET} 查看最后的错误细节。" + exit 1 +} + +# --------------------------------------------------------------------------- +# 交互读取 +# --------------------------------------------------------------------------- +# ask <提示> <默认值> -> 结果放入 REPLY_VALUE +REPLY_VALUE="" +ask() { + local prompt="$1" default="${2:-}" ans="" + REPLY_VALUE="" + if [ "$NONINTERACTIVE" = "1" ] || [ ! -t 0 ]; then + REPLY_VALUE="$default" + return 0 + fi + if [ -n "$default" ]; then + printf '%s' "${prompt} [默认: ${default}]: " + else + printf '%s' "${prompt}(直接回车跳过): " + fi + IFS= read -r ans || ans="" + if [ -z "$ans" ]; then ans="$default"; fi + REPLY_VALUE="$ans" +} + +# ask_secret <提示> -> 不回显 +ask_secret() { + local prompt="$1" ans="" + REPLY_VALUE="" + if [ "$NONINTERACTIVE" = "1" ] || [ ! -t 0 ]; then + return 0 + fi + printf '%s' "${prompt}(输入不显示,直接回车跳过): " + IFS= read -rs ans || ans="" + printf '\n' + REPLY_VALUE="$ans" +} + +# ask_yes_no <提示> <默认 y|n> -> 返回 0 表示 yes +ask_yes_no() { + local prompt="$1" default="${2:-n}" ans="" + if [ "$NONINTERACTIVE" = "1" ] || [ ! -t 0 ]; then + [ "$default" = "y" ] && return 0 || return 1 + fi + local hintstr="[y/N]" + [ "$default" = "y" ] && hintstr="[Y/n]" + while true; do + printf '%s' "${prompt} ${hintstr}: " + IFS= read -r ans || ans="" + [ -z "$ans" ] && ans="$default" + case "$ans" in + y|Y|yes|YES|是) return 0 ;; + n|N|no|NO|否) return 1 ;; + *) plain "请输入 y 或 n。" ;; + esac + done +} + +# --------------------------------------------------------------------------- +# 0. 初始化日志 +# --------------------------------------------------------------------------- +init_log() { + if ! touch "$LOG_FILE" 2>/dev/null; then + LOG_FILE="/tmp/openclaw-installer.log" + touch "$LOG_FILE" 2>/dev/null || LOG_FILE="/dev/null" + fi + chmod 600 "$LOG_FILE" 2>/dev/null || true + _log "=========== OpenClaw installer v${SCRIPT_VERSION} 启动 ===========" + _log "命令行: $0 $*" +} + +# --------------------------------------------------------------------------- +# 1. 前置检查 +# --------------------------------------------------------------------------- +check_root() { + if [ "$(id -u)" -ne 0 ]; then + err "本脚本需要 root 权限运行。" + hint "请改用:${C_BOLD}sudo bash $0${C_RESET}" + exit 1 + fi + ok "权限检查通过(当前为 root)" +} + +detect_os() { + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + OS_ID="${ID:-unknown}" + OS_VERSION="${VERSION_ID:-unknown}" + else + OS_ID="unknown"; OS_VERSION="unknown" + fi + + if command -v apt-get >/dev/null 2>&1; then + PKG_MGR="apt" + elif command -v dnf >/dev/null 2>&1; then + PKG_MGR="dnf" + else + die "未找到受支持的包管理器(需要 apt-get 或 dnf)。" \ + "本脚本主要支持 Debian 12/13 与 Ubuntu 22.04/24.04。" + fi + + case "$OS_ID" in + debian|ubuntu) ok "系统:${PRETTY_NAME:-$OS_ID $OS_VERSION}(受支持)" ;; + rhel|centos|rocky|almalinux|fedora) + warn "系统:${PRETTY_NAME:-$OS_ID $OS_VERSION}(尽力兼容,未完整测试)" ;; + *) + warn "系统:${PRETTY_NAME:-$OS_ID $OS_VERSION}(未知发行版,可能失败)" ;; + esac + _log "OS_ID=$OS_ID OS_VERSION=$OS_VERSION PKG_MGR=$PKG_MGR" +} + +detect_arch() { + ARCH="$(uname -m 2>/dev/null || echo unknown)" + case "$ARCH" in + x86_64|amd64) ok "架构:${ARCH}(受支持)" ;; + aarch64|arm64) ok "架构:${ARCH}(受支持)" ;; + *) + warn "架构:${ARCH} 不在测试范围内,Node.js 官方源可能没有对应包。" + if ! ask_yes_no "仍要继续吗?" "n"; then + die "已按你的选择中止。" "如需在此架构上安装,请手动准备 Node.js ${NODE_MAJOR_REQUIRED}+ 后再运行本脚本。" + fi + ;; + esac +} + +check_memory() { + local mem_kb mem_mb swap_kb + mem_kb="$(awk '/^MemTotal:/{print $2}' /proc/meminfo 2>/dev/null)" + swap_kb="$(awk '/^SwapTotal:/{print $2}' /proc/meminfo 2>/dev/null)" + [ -z "$mem_kb" ] && { warn "无法读取内存信息,跳过内存检查。"; return 0; } + mem_mb=$(( mem_kb / 1024 )) + local swap_mb=$(( ${swap_kb:-0} / 1024 )) + + if [ "$mem_mb" -lt 1024 ]; then + warn "物理内存仅 ${mem_mb} MB(低于 1 GB),npm 安装阶段可能因内存不足被 OOM 杀掉。" + if [ "$swap_mb" -lt 512 ]; then + hint "建议先创建 1 GB swap 再继续,命令如下(复制整段执行):" + plain " fallocate -l 1G /swapfile && chmod 600 /swapfile && \\" + plain " mkswap /swapfile && swapon /swapfile && \\" + plain " echo '/swapfile none swap sw 0 0' >> /etc/fstab" + else + hint "已检测到 ${swap_mb} MB swap,风险较低,可继续。" + fi + if ! ask_yes_no "现在继续安装吗?" "y"; then + die "已按你的选择中止。" "加好 swap 后重新运行本脚本即可。" + fi + else + ok "内存:${mem_mb} MB(swap ${swap_mb} MB)" + fi +} + +# --------------------------------------------------------------------------- +# 2. 系统依赖 + Node.js +# --------------------------------------------------------------------------- +install_base_deps() { + title "安装系统依赖" + local missing=() + local c + for c in curl git python3; do + command -v "$c" >/dev/null 2>&1 || missing+=("$c") + done + # 编译工具链(node-gyp 可能需要) + if ! command -v cc >/dev/null 2>&1 && ! command -v gcc >/dev/null 2>&1; then + missing+=("__buildtools__") + fi + + if [ "${#missing[@]}" -eq 0 ]; then + ok "依赖已齐全(curl / git / python3 / 编译工具)" + return 0 + fi + + info "需要安装:${missing[*]//__buildtools__/编译工具链}" + if [ "$PKG_MGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if ! run_quiet apt-get update; then + warn "apt-get update 失败,可能是软件源不可达。" + hint "请检查网络/DNS,或更换为国内镜像源后重试。脚本将继续尝试安装。" + fi + local pkgs=(ca-certificates curl git python3) + printf '%s\n' "${missing[@]}" | grep -q '__buildtools__' && pkgs+=(build-essential) + if ! run_quiet apt-get install -y "${pkgs[@]}"; then + die "系统依赖安装失败。" \ + "请手动执行:apt-get update && apt-get install -y ca-certificates curl git python3 build-essential" + fi + else + if ! run_quiet dnf install -y ca-certificates curl git python3; then + die "系统依赖安装失败。" "请手动执行:dnf install -y ca-certificates curl git python3" + fi + printf '%s\n' "${missing[@]}" | grep -q '__buildtools__' && \ + run_quiet dnf groupinstall -y "Development Tools" || true + fi + ok "系统依赖安装完成" +} + +node_major() { + local v + v="$(node -v 2>/dev/null)" || return 1 + v="${v#v}" + printf '%s' "${v%%.*}" +} + +install_node() { + title "检查 Node.js" + local major="" + if command -v node >/dev/null 2>&1; then + major="$(node_major)" + fi + + if [ -n "$major" ] && [ "$major" -ge "$NODE_MAJOR_REQUIRED" ] 2>/dev/null; then + ok "已安装 Node.js $(node -v),满足要求(>= ${NODE_MAJOR_REQUIRED}),跳过。" + return 0 + fi + + if [ -n "$major" ]; then + warn "当前 Node.js $(node -v) 版本过低(需要 >= ${NODE_MAJOR_REQUIRED}),将安装 Node.js ${NODE_MAJOR_INSTALL}。" + else + info "未检测到 Node.js,开始安装 Node.js ${NODE_MAJOR_INSTALL}。" + fi + + local setup_url="https://deb.nodesource.com/setup_${NODE_MAJOR_INSTALL}.x" + [ "$PKG_MGR" = "dnf" ] && setup_url="https://rpm.nodesource.com/setup_${NODE_MAJOR_INSTALL}.x" + + local tmp_script + tmp_script="$(mktemp /tmp/nodesource_setup.XXXXXX.sh)" || \ + die "无法创建临时文件。" "请检查 /tmp 是否可写、磁盘是否已满(df -h /tmp)。" + + info "下载 NodeSource 安装源脚本…" + if ! run_quiet curl -fsSL --retry 3 --connect-timeout 20 -o "$tmp_script" "$setup_url"; then + rm -f "$tmp_script" + die "下载 NodeSource 脚本失败:${setup_url}" \ + "常见原因:服务器无法访问外网或 DNS 异常。可先执行 ping -c2 deb.nodesource.com 排查,或手动安装 Node.js ${NODE_MAJOR_REQUIRED}+ 后重跑本脚本。" + fi + + info "配置 NodeSource 软件源…" + if ! run_quiet bash "$tmp_script"; then + rm -f "$tmp_script" + die "NodeSource 软件源配置失败。" \ + "该发行版可能暂不被 NodeSource 支持。可改用发行版自带的 nodejs 包(需 >= ${NODE_MAJOR_REQUIRED}),或用 nvm 安装后重跑本脚本。" + fi + rm -f "$tmp_script" + + info "安装 Node.js ${NODE_MAJOR_INSTALL}…" + if [ "$PKG_MGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + run_quiet apt-get install -y nodejs || \ + die "Node.js 安装失败。" "请查看日志末尾的 apt 报错:tail -n 40 ${LOG_FILE}" + else + run_quiet dnf install -y nodejs || \ + die "Node.js 安装失败。" "请查看日志末尾的 dnf 报错:tail -n 40 ${LOG_FILE}" + fi + + if ! command -v node >/dev/null 2>&1; then + die "Node.js 安装后仍找不到 node 命令。" "请重新登录 shell 或检查 PATH 后重试。" + fi + major="$(node_major)" + if [ -z "$major" ] || [ "$major" -lt "$NODE_MAJOR_REQUIRED" ] 2>/dev/null; then + die "Node.js 版本仍不满足要求(当前 $(node -v))。" "请手动安装 Node.js ${NODE_MAJOR_REQUIRED}+ 后重跑本脚本。" + fi + ok "Node.js 安装完成:$(node -v),npm $(npm -v 2>/dev/null || echo '未知')" +} + +# --------------------------------------------------------------------------- +# 3. 安装 / 更新 OpenClaw +# --------------------------------------------------------------------------- +openclaw_local_version() { + npm list -g openclaw --depth=0 --no-update-notifier 2>/dev/null \ + | grep openclaw | awk '{print $NF}' | sed 's/^.*@//' +} + +openclaw_remote_version() { + npm view openclaw version --no-update-notifier 2>/dev/null | tr -d '[:space:]' +} + +npm_install_openclaw() { + info "正在执行 npm install -g openclaw@latest(首次安装可能需要几分钟)…" + if run_quiet npm install -g openclaw@latest --no-update-notifier; then + return 0 + fi + warn "npm 安装失败,尝试清理缓存后重试一次…" + run_quiet npm cache clean --force || true + if run_quiet npm install -g openclaw@latest --no-update-notifier; then + return 0 + fi + return 1 +} + +install_openclaw() { + title "安装 OpenClaw" + local local_ver remote_ver + local_ver="$(openclaw_local_version)" + + if [ -n "$local_ver" ]; then + remote_ver="$(openclaw_remote_version)" + if [ -n "$remote_ver" ] && [ "$local_ver" = "$remote_ver" ]; then + ok "已安装 OpenClaw v${local_ver},已是最新版本,跳过安装。" + return 0 + fi + if [ -n "$remote_ver" ]; then + warn "检测到已安装 OpenClaw v${local_ver},最新版本为 v${remote_ver}。" + else + warn "检测到已安装 OpenClaw v${local_ver}(无法查询最新版本,可能网络受限)。" + fi + if ask_yes_no "是否更新到最新版本?(选 n 将保留现有版本继续配置)" "y"; then + if ! npm_install_openclaw; then + warn "更新失败,将继续使用已安装的 v${local_ver}。" + hint "稍后可手动重试:npm install -g openclaw@latest" + else + ok "OpenClaw 已更新到 v$(openclaw_local_version)" + fi + else + ok "保留现有版本 v${local_ver},跳过安装。" + fi + else + if ! npm_install_openclaw; then + die "OpenClaw 安装失败。" \ + "排查建议:1) 检查网络能否访问 registry.npmjs.org;2) 内存不足请加 swap;3) 查看详细报错 tail -n 60 ${LOG_FILE}" + fi + ok "OpenClaw 安装完成" + fi + + # 验证 + if ! command -v openclaw >/dev/null 2>&1; then + die "找不到 openclaw 命令。" \ + "npm 全局 bin 目录可能不在 PATH 中。请执行:export PATH=\"\$(npm prefix -g)/bin:\$PATH\",然后重跑本脚本。" + fi + local ver_out + ver_out="$(openclaw --version 2>&1 | head -n 1)" + if [ -z "$ver_out" ]; then + warn "openclaw --version 无输出,安装可能不完整,但将继续。" + else + ok "版本验证:openclaw ${ver_out}" + fi +} + +# --------------------------------------------------------------------------- +# 4. 配置文件探测 +# --------------------------------------------------------------------------- +resolve_home() { + # root 运行时 daemon 通常也以 root 身份,配置放在 root 的 HOME + OPENCLAW_HOME="${HOME:-/root}" + [ -d "$OPENCLAW_HOME" ] || OPENCLAW_HOME="/root" + _log "OPENCLAW_HOME=$OPENCLAW_HOME" +} + +detect_config_path() { + local dir="${OPENCLAW_HOME}/.openclaw" + mkdir -p "$dir" 2>/dev/null || true + chmod 700 "$dir" 2>/dev/null || true + + # 优先使用已存在的文件;openclaw.json 优先于 config.json + local candidate + for candidate in "$dir/openclaw.json" "$dir/config.json"; do + if [ -f "$candidate" ]; then + OPENCLAW_CONFIG="$candidate" + _log "探测到已有配置: $OPENCLAW_CONFIG" + return 0 + fi + done + # 都不存在 -> 使用默认名 + OPENCLAW_CONFIG="$dir/openclaw.json" + _log "未发现已有配置,将使用: $OPENCLAW_CONFIG" +} + +# --------------------------------------------------------------------------- +# 5. 交互式配置向导 +# --------------------------------------------------------------------------- +wizard() { + title "配置向导" + if [ "$NONINTERACTIVE" = "1" ] || [ ! -t 0 ]; then + warn "当前为非交互模式,跳过配置向导。" + hint "稍后可手动编辑配置文件:${OPENCLAW_CONFIG}" + CFG_PROVIDER="skip" + return 0 + fi + + plain "下面几步都可以直接回车跳过,之后随时能改配置文件。" + plain "" + + # --- Telegram --- + plain "${C_BOLD}[1/3] Telegram 机器人${C_RESET}" + plain " 获取方式:在 Telegram 里搜索 ${C_BOLD}@BotFather${C_RESET} → 发送 /newbot → 按提示创建" + plain " 创建成功后它会给你一串形如 123456:AAxxxxxxxx 的 Token" + ask_secret " 请粘贴 Bot Token" + CFG_TG_TOKEN="$REPLY_VALUE" + if [ -n "$CFG_TG_TOKEN" ]; then + if printf '%s' "$CFG_TG_TOKEN" | grep -Eq '^[0-9]{5,}:[A-Za-z0-9_-]{20,}$'; then + ok " Token 已记录(格式校验通过,内容不显示)" + else + warn " Token 格式看起来不太对(通常是 数字:字母数字)。仍会写入,请稍后自行确认。" + fi + else + plain " 已跳过 Telegram Token。" + fi + + plain "" + plain "${C_BOLD}[2/3] 你的 Telegram 用户 ID${C_RESET}(用于白名单,只允许你本人使用机器人)" + plain " 获取方式:在 Telegram 里搜索 ${C_BOLD}@userinfobot${C_RESET} → 发送任意消息 → 它会回复你的数字 ID" + while true; do + ask " 请输入你的数字用户 ID" "" + CFG_TG_USERID="$REPLY_VALUE" + [ -z "$CFG_TG_USERID" ] && { plain " 已跳过用户 ID(注意:不设白名单意味着任何人都能用你的机器人)。"; break; } + if printf '%s' "$CFG_TG_USERID" | grep -Eq '^[0-9]{5,}$'; then + ok " 用户 ID 已记录:${CFG_TG_USERID}" + break + fi + warn " 用户 ID 应该是纯数字(一般 8-11 位),请重新输入,或直接回车跳过。" + done + + # --- Provider --- + plain "" + plain "${C_BOLD}[3/3] AI 模型服务${C_RESET}" + plain " 1) OpenAI 兼容接口(自定义 baseURL + Key,适用于中转站/自建/DeepSeek 等)" + plain " 2) Anthropic 官方(Claude)" + plain " 3) 稍后手动配置" + local choice + while true; do + ask " 请输入编号" "3" + choice="$REPLY_VALUE" + case "$choice" in + 1) CFG_PROVIDER="openai-compat"; break ;; + 2) CFG_PROVIDER="anthropic"; break ;; + 3) CFG_PROVIDER="skip"; break ;; + *) warn " 请输入 1、2 或 3。" ;; + esac + done + + case "$CFG_PROVIDER" in + openai-compat) + ask " 接口地址 baseURL" "https://api.openai.com/v1" + CFG_BASEURL="$REPLY_VALUE" + case "$CFG_BASEURL" in + http://*|https://*) : ;; + *) warn " baseURL 通常以 http:// 或 https:// 开头,请稍后确认。" ;; + esac + ask_secret " API Key" + CFG_APIKEY="$REPLY_VALUE" + [ -z "$CFG_APIKEY" ] && warn " 未填写 API Key,模型将无法调用,需稍后补上。" + ask " 模型名称" "gpt-4o-mini" + CFG_MODEL="$REPLY_VALUE" + ok " 已记录:baseURL=${CFG_BASEURL},模型=${CFG_MODEL}(Key 不显示)" + ;; + anthropic) + ask_secret " Anthropic API Key(形如 sk-ant-...)" + CFG_APIKEY="$REPLY_VALUE" + [ -z "$CFG_APIKEY" ] && warn " 未填写 API Key,模型将无法调用,需稍后补上。" + ask " 模型名称" "claude-sonnet-4-5" + CFG_MODEL="$REPLY_VALUE" + ok " 已记录:Anthropic,模型=${CFG_MODEL}(Key 不显示)" + ;; + skip) + plain " 已跳过模型配置。" + ;; + esac +} + +# --------------------------------------------------------------------------- +# 6. 写配置(python3 + 先备份 + 只改填写字段) +# --------------------------------------------------------------------------- +backup_config() { + [ -f "$OPENCLAW_CONFIG" ] || return 0 + local bak + bak="${OPENCLAW_CONFIG}.bak.$(date '+%Y%m%d-%H%M%S')" + if cp -p "$OPENCLAW_CONFIG" "$bak" 2>/dev/null; then + ok "原配置已备份:${bak}" + _log "backup -> $bak" + else + warn "备份原配置失败,为安全起见不修改配置文件。" + hint "请检查磁盘空间与目录权限:ls -ld $(dirname "$OPENCLAW_CONFIG")" + return 1 + fi + return 0 +} + +write_config() { + title "写入配置" + + if [ -z "$CFG_TG_TOKEN" ] && [ -z "$CFG_TG_USERID" ] && [ "$CFG_PROVIDER" = "skip" ]; then + info "没有需要写入的配置项,跳过。" + return 0 + fi + + if ! backup_config; then + return 1 + fi + + # 值通过环境变量传给 python,避免出现在命令行/ps 中,也避免引号注入 + OC_CFG_PATH="$OPENCLAW_CONFIG" \ + OC_TG_TOKEN="$CFG_TG_TOKEN" \ + OC_TG_USERID="$CFG_TG_USERID" \ + OC_PROVIDER="$CFG_PROVIDER" \ + OC_BASEURL="$CFG_BASEURL" \ + OC_APIKEY="$CFG_APIKEY" \ + OC_MODEL="$CFG_MODEL" \ + python3 - <<'PYEOF' >>"$LOG_FILE" 2>&1 +import json, os, sys, tempfile + +path = os.environ["OC_CFG_PATH"] +token = os.environ.get("OC_TG_TOKEN", "") +userid = os.environ.get("OC_TG_USERID", "") +provider = os.environ.get("OC_PROVIDER", "skip") +baseurl = os.environ.get("OC_BASEURL", "") +apikey = os.environ.get("OC_APIKEY", "") +model = os.environ.get("OC_MODEL", "") + +# 1) 读取已有配置;坏 JSON 不覆盖,直接报错退出 +cfg = {} +if os.path.exists(path) and os.path.getsize(path) > 0: + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + if not isinstance(cfg, dict): + print("ERROR: existing config root is not a JSON object") + sys.exit(3) + except Exception as e: + print("ERROR: cannot parse existing config: %s" % e) + sys.exit(2) + +def node(parent, key): + """取出 dict 子节点;已存在但类型不对则不动它,返回 None。""" + cur = parent.get(key) + if cur is None: + cur = {} + parent[key] = cur + if not isinstance(cur, dict): + return None + return cur + +changed = [] + +# 2) Telegram:只填用户给了值的字段 +if token or userid: + channels = node(cfg, "channels") + tg = node(channels, "telegram") if channels is not None else None + if tg is None: + print("WARN: channels.telegram exists with unexpected type; skipped") + else: + if token: + tg["botToken"] = token + tg["enabled"] = True + changed.append("channels.telegram.botToken") + changed.append("channels.telegram.enabled") + if userid: + existing = tg.get("allowFrom") + if not isinstance(existing, list): + existing = [] + vals = [str(x) for x in existing] + if userid not in vals: + vals.append(userid) + tg["allowFrom"] = vals + changed.append("channels.telegram.allowFrom") + +# 3) 模型 provider +if provider == "openai-compat" and (baseurl or apikey or model): + models_root = node(cfg, "models") + providers = node(models_root, "providers") if models_root is not None else None + p = node(providers, "openai-compat") if providers is not None else None + if p is None: + print("WARN: models.providers.openai-compat unexpected type; skipped") + else: + if baseurl: + p["baseURL"] = baseurl + changed.append("models.providers.openai-compat.baseURL") + if apikey: + p["apiKey"] = apikey + changed.append("models.providers.openai-compat.apiKey") + if model: + models = p.get("models") + if not isinstance(models, list): + models = [] + if model not in models: + models.append(model) + p["models"] = models + changed.append("models.providers.openai-compat.models") + agents_root = node(cfg, "agents") + adef = node(agents_root, "defaults") if agents_root is not None else None + if adef is not None: + adef["model"] = "openai-compat/%s" % model + changed.append("agents.defaults.model") +elif provider == "anthropic" and (apikey or model): + models_root = node(cfg, "models") + providers = node(models_root, "providers") if models_root is not None else None + p = node(providers, "anthropic") if providers is not None else None + if p is None: + print("WARN: models.providers.anthropic unexpected type; skipped") + else: + if apikey: + p["apiKey"] = apikey + changed.append("models.providers.anthropic.apiKey") + if model: + agents_root = node(cfg, "agents") + adef = node(agents_root, "defaults") if agents_root is not None else None + if adef is not None: + adef["model"] = "anthropic/%s" % model + changed.append("agents.defaults.model") + +# 4) 原子写入 + 权限 600 +d = os.path.dirname(path) or "." +try: + fd, tmp = tempfile.mkstemp(dir=d, prefix=".openclaw.json.") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + f.write("\n") + os.chmod(tmp, 0o600) + os.replace(tmp, path) +except Exception as e: + print("ERROR: write failed: %s" % e) + sys.exit(4) + +print("CHANGED_FIELDS: %s" % (", ".join(changed) if changed else "(none)")) +sys.exit(0) +PYEOF + + local rc=$? + case "$rc" in + 0) + chmod 600 "$OPENCLAW_CONFIG" 2>/dev/null || true + ok "配置已写入:${OPENCLAW_CONFIG}(权限 600)" + local fields + fields="$(grep 'CHANGED_FIELDS:' "$LOG_FILE" 2>/dev/null | tail -n 1 | sed 's/.*CHANGED_FIELDS: //')" + [ -n "$fields" ] && plain " 本次改动字段:${fields}" + ;; + 2) + err "已有配置文件不是合法 JSON,为避免破坏,脚本没有修改它。" + hint "请检查 ${OPENCLAW_CONFIG} 的内容(或从刚才的 .bak 备份恢复),修好后重跑本脚本。" + return 1 ;; + 3) + err "已有配置文件的顶层结构不是对象(应为 { ... }),未做修改。" + hint "请人工检查 ${OPENCLAW_CONFIG}。" + return 1 ;; + 4) + err "写入配置文件失败(可能是磁盘满或权限不足)。" + hint "请检查:df -h $(dirname "$OPENCLAW_CONFIG") 与 ls -ld $(dirname "$OPENCLAW_CONFIG")" + return 1 ;; + *) + err "写入配置时发生未知错误(rc=${rc})。" + hint "查看日志末尾:tail -n 30 ${LOG_FILE};原配置已备份,可随时回滚。" + return 1 ;; + esac + return 0 +} + +# --------------------------------------------------------------------------- +# 7. 初始化 / 启动 / 验证 +# --------------------------------------------------------------------------- +is_running() { + pgrep -f "openclaw.*gateway" >/dev/null 2>&1 +} + +onboard_and_start() { + title "初始化并启动服务" + + if is_running; then + ok "检测到 OpenClaw gateway 已在运行,跳过初始化。" + else + if [ "$NONINTERACTIVE" = "1" ] || [ ! -t 0 ]; then + info "非交互模式:执行 openclaw onboard --install-daemon(自动模式)…" + run_quiet openclaw onboard --install-daemon || \ + warn "onboard 未成功完成,稍后可手动运行:openclaw onboard --install-daemon" + else + plain "接下来运行 ${C_BOLD}openclaw onboard --install-daemon${C_RESET} 完成初始化并安装 systemd 服务。" + plain "该命令可能会有自己的交互提问,按屏幕提示回答即可。" + if ask_yes_no "现在执行吗?" "y"; then + _log "RUN(interactive): openclaw onboard --install-daemon" + if openclaw onboard --install-daemon 2>&1 | tee -a "$LOG_FILE"; then + ok "初始化流程结束" + else + warn "onboard 返回了非零状态,可能部分步骤未完成。" + hint "可稍后单独重试:openclaw onboard --install-daemon" + fi + else + warn "已跳过 onboard,daemon 可能未安装。" + hint "稍后请手动执行:openclaw onboard --install-daemon" + fi + fi + + info "启动 gateway…" + if ! run_quiet openclaw gateway start; then + warn "openclaw gateway start 返回失败,稍后统一检查运行状态。" + fi + fi + + info "等待服务就绪(最多约 20 秒)…" + local i + for i in 1 2 3 4 5 6 7 8 9 10; do + if is_running; then break; fi + sleep 2 + done + + plain "" + info "运行状态(openclaw status):" + local status_out + status_out="$(openclaw status 2>&1)" + _log "STATUS OUTPUT: $status_out" + printf '%s\n' "$status_out" | sed 's/^/ /' | head -n 30 + + plain "" + if is_running; then + ok "OpenClaw gateway 正在运行。" + return 0 + fi + + err "没有检测到运行中的 OpenClaw gateway 进程。" + hint "按顺序排查:" + plain " 1) 手动启动看报错:openclaw gateway start" + plain " 2) 看服务日志: journalctl -u openclaw -n 50 --no-pager" + plain " 3) 看安装日志: tail -n 50 ${LOG_FILE}" + plain " 4) 确认配置正确: ${OPENCLAW_CONFIG}" + return 1 +} + +# --------------------------------------------------------------------------- +# 8. 结尾摘要 +# --------------------------------------------------------------------------- +summary() { + local bin_path npm_prefix webui + bin_path="$(command -v openclaw 2>/dev/null || echo '未找到')" + npm_prefix="$(npm prefix -g 2>/dev/null || echo '未知')" + + # 尝试从配置里读出 WebUI 端口(读不到就给默认说明) + webui="" + if [ -f "$OPENCLAW_CONFIG" ]; then + webui="$(OC_CFG_PATH="$OPENCLAW_CONFIG" python3 -c ' +import json,os +try: + with open(os.environ["OC_CFG_PATH"],encoding="utf-8") as f: + c=json.load(f) + g=c.get("gateway") if isinstance(c.get("gateway"),dict) else {} + p=g.get("port") or c.get("port") + print(p if p else "") +except Exception: + print("") +' 2>/dev/null)" + fi + + title "安装完成" + plain "${C_BOLD}安装位置${C_RESET}" + plain " openclaw 命令: ${bin_path}" + plain " npm 全局目录: ${npm_prefix}" + plain " 版本: $(openclaw_local_version 2>/dev/null || echo '未知')" + plain "" + plain "${C_BOLD}配置文件${C_RESET}" + plain " ${OPENCLAW_CONFIG}" + plain " 备份文件: ${OPENCLAW_CONFIG}.bak.<时间戳>(如有改动)" + plain "" + plain "${C_BOLD}常用命令${C_RESET}" + plain " 启动: openclaw gateway start" + plain " 停止: openclaw gateway stop" + plain " 重启: openclaw gateway stop && openclaw gateway start" + plain " 状态: openclaw status" + plain "" + plain "${C_BOLD}查看日志${C_RESET}" + plain " 服务日志: journalctl -u openclaw -f" + plain " 安装日志: ${LOG_FILE}" + plain "" + plain "${C_BOLD}WebUI${C_RESET}" + if [ -n "$webui" ]; then + plain " http://<你的服务器IP>:${webui}" + plain " ${C_YELLOW}注意${C_RESET}:如需公网访问,请确认防火墙规则并务必启用鉴权。" + else + plain " 未在配置中读到端口。若已启用 WebUI,地址形如 http://<你的服务器IP>:<端口>" + plain " 可用 openclaw status 或配置文件确认端口。" + fi + plain "" + plain "${C_RED}${C_BOLD}安全提醒${C_RESET}" + plain " ${OPENCLAW_CONFIG} 中含有 Bot Token 与 API Key(已设为 600 权限)。" + plain " ${C_BOLD}请勿把该文件、其备份或截图发给任何人${C_RESET},也不要提交到 Git 仓库。" + plain "" + plain "本脚本不含任何埋点、统计或数据上报。" + plain "" +} + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- +main() { + init_log "$@" + + plain "" + plain "${C_BOLD}OpenClaw 一键安装配置脚本 v${SCRIPT_VERSION}${C_RESET}" + plain "日志:${LOG_FILE}" + + title "环境检查" + check_root + detect_os + detect_arch + check_memory + + install_base_deps + install_node + install_openclaw + + resolve_home + detect_config_path + + wizard + write_config || warn "配置未完整写入,请按上面的提示处理后再重跑本脚本。" + + onboard_and_start + local start_rc=$? + + summary + + if [ "$start_rc" -eq 0 ]; then + ok "全部完成,祝使用愉快。" + exit 0 + fi + warn "安装已完成,但服务未确认启动,请按上面的排查步骤处理。" + exit 1 +} + +main "$@"