commit 5e0ff18aa1162ed079a418ac10783c743b4289e8 Author: bookworm Date: Sun Apr 5 23:34:27 2026 +0800 feat: Bookworm Portable v1.5 — 8 fixes (P0 NDA + P1 banners + P2 perf) - P1: Banner v1.3→v1.5, Hooks 29→34 - P1: 卸载脚本补删 更新Bookworm.lnk - P1: git stash pop 安全检查 - P2: Playwright 检测改用 npm list - P2: 代理端口扫描 500ms async 超时 Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/Bookworm-Install.bat b/Bookworm-Install.bat new file mode 100644 index 0000000..f167576 --- /dev/null +++ b/Bookworm-Install.bat @@ -0,0 +1,37 @@ +@echo off +setlocal +chcp 65001 > nul 2>&1 +title Bookworm Smart Assistant - 全自动安装 v3.0 + +:: 极简入口: 只负责确保 Node.js 存在, 核心逻辑全在 setup-all.js 中 +:: 规则: 不超过 30 行, 不用 if(), 不嵌 PowerShell, 用 %~s 短路径 + +net session >nul 2>&1 +if %errorlevel% equ 0 goto :HAS_ADMIN +echo Set s = CreateObject("Shell.Application") > "%TEMP%\bw_uac.vbs" +echo s.ShellExecute "cmd.exe", "/k cd /d ""%~sdp0"" ^& ""%~snx0""", "", "runas", 1 >> "%TEMP%\bw_uac.vbs" +cscript //nologo "%TEMP%\bw_uac.vbs" +del /f /q "%TEMP%\bw_uac.vbs" 2>nul +exit /b + +:HAS_ADMIN +where node >nul 2>nul +if %errorlevel% equ 0 goto :HAS_NODE +echo [..] Node.js 未安装, 正在通过 winget 安装... +winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements --silent 2>nul +for /f "tokens=2*" %%a in ('reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path 2^>nul') do set "PATH=%%b" +set "PATH=%PATH%;C:\Program Files\nodejs" +where node >nul 2>nul +if %errorlevel% equ 0 goto :HAS_NODE +echo [!!] Node.js 安装失败。请手动下载: https://nodejs.org/ +goto :END + +:HAS_NODE +echo [OK] Node.js 就绪, 启动安装引擎... +node "%~dp0setup-all.js" %* || echo [!] 安装过程出错, 请查看上方日志 + +:END +echo. +echo 按任意键关闭... +pause > nul +endlocal diff --git a/Bookworm-OneClick-Mac.sh b/Bookworm-OneClick-Mac.sh new file mode 100644 index 0000000..264492a --- /dev/null +++ b/Bookworm-OneClick-Mac.sh @@ -0,0 +1,316 @@ +#!/bin/bash +# ============================================================ +# Bookworm Smart Assistant - macOS 全自动安装 v2.0 +# +# 用法 (任选一种): +# 方式1: 下载后运行 +# chmod +x Bookworm-OneClick-Mac.sh && ./Bookworm-OneClick-Mac.sh +# +# 方式2: 一行命令远程安装 +# curl -fsSL https://bookworm.letcareme.com/download/Bookworm-OneClick-Mac.sh | bash +# +# 兼容: macOS 12+ (Monterey/Ventura/Sonoma/Sequoia), Intel & Apple Silicon +# ============================================================ + +set -e + +# ─── 颜色 ─── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'; BOLD='\033[1m' + +# ─── 配置 ─── +GITEA_URL="https://code.letcareme.com/bookworm/bookworm-boot.git" +BOOT_DIR="$HOME/bookworm-boot" +CLAUDE_DIR="$HOME/.claude" +TOTAL_STEPS=8 + +info() { echo -e "${BLUE} [INFO]${NC} $1"; } +success() { echo -e "${GREEN} [OK]${NC} $1"; } +warn() { echo -e "${YELLOW} [!]${NC} $1"; } +fail() { echo -e "${RED} [!!]${NC} $1"; } +step() { echo -e "\n${BOLD} [$1/$TOTAL_STEPS]${NC} ${CYAN}$2${NC}"; } + +# ─── Banner ─── +echo "" +echo -e "${CYAN}" +echo " ____ _" +echo " | __ ) ___ ___ | | ____ _____ _ __ _ __ ___" +echo " | _ \\ / _ \\ / _ \\| |/ /\\ \\ /\\ / / _ \\| '__| '\`_ \` _ \\" +echo " | |_) | (_) | (_) | < \\ V V / (_) | | | | | | | |" +echo " |____/ \\___/ \\___/|_|\\_\\ \\_/\\_/ \\___/|_| |_| |_| |_|" +echo "" +echo -e " ${BOLD}全自动安装 v2.0 — macOS${NC}" +echo -e " ${BLUE}92 Skills | 18 Agents | 34 Hooks${NC}" +echo -e "${NC}" + +# ============================================================ +# 1. Homebrew +# ============================================================ +step 1 "检查 Homebrew" + +if ! command -v brew &>/dev/null; then + warn "Homebrew 未安装, 正在安装 (可能需要输入系统密码)..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + # Apple Silicon PATH + if [ -f /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + # 持久化到 shell profile + PROFILE="$HOME/.zprofile" + if ! grep -q 'homebrew' "$PROFILE" 2>/dev/null; then + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> "$PROFILE" + fi + fi + success "Homebrew 安装完成" +else + success "Homebrew $(brew --version | head -1 | awk '{print $2}')" +fi + +# ============================================================ +# 2. Node.js +# ============================================================ +step 2 "检查 Node.js" + +if ! command -v node &>/dev/null; then + info "通过 Homebrew 安装 Node.js LTS..." + brew install node + success "Node.js $(node -v) 安装完成" +else + success "Node.js $(node -v)" +fi + +# ============================================================ +# 3. Git +# ============================================================ +step 3 "检查 Git" + +if ! command -v git &>/dev/null; then + info "通过 Homebrew 安装 Git..." + brew install git + success "Git $(git --version | awk '{print $3}') 安装完成" +else + success "Git $(git --version | awk '{print $3}')" +fi + +# ============================================================ +# 4. OpenSSL (凭证解密需要) +# ============================================================ +step 4 "检查 OpenSSL" + +OPENSSL_CMD="" +for p in /opt/homebrew/opt/openssl/bin/openssl /usr/local/opt/openssl/bin/openssl openssl; do + if command -v "$p" &>/dev/null; then + OPENSSL_CMD="$p" + break + fi +done + +if [ -z "$OPENSSL_CMD" ]; then + info "通过 Homebrew 安装 OpenSSL..." + brew install openssl + OPENSSL_CMD="/opt/homebrew/opt/openssl/bin/openssl" + success "OpenSSL 安装完成" +else + success "OpenSSL: $($OPENSSL_CMD version 2>/dev/null | head -1)" +fi + +# ============================================================ +# 5. Claude Code +# ============================================================ +step 5 "检查 Claude Code" + +if ! command -v claude &>/dev/null; then + info "通过 npm 安装 Claude Code..." + npm i -g @anthropic-ai/claude-code + success "Claude Code 安装完成" +else + success "Claude Code $(claude --version 2>/dev/null || echo 'installed')" +fi + +# ============================================================ +# 6. 代理检测 +# ============================================================ +step 6 "检测网络代理" + +export NO_PROXY="bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" +export no_proxy="$NO_PROXY" + +PROXY_FOUND="" + +# 环境变量 +if [ -n "$HTTPS_PROXY" ] || [ -n "$https_proxy" ]; then + PROXY_FOUND="${HTTPS_PROXY:-$https_proxy}" + success "环境变量代理: $PROXY_FOUND" +fi + +# macOS 系统代理 +if [ -z "$PROXY_FOUND" ]; then + PROXY_HOST=$(scutil --proxy 2>/dev/null | grep "HTTPSProxy" | awk '{print $3}') + PROXY_PORT=$(scutil --proxy 2>/dev/null | grep "HTTPSPort" | awk '{print $3}') + if [ -n "$PROXY_HOST" ] && [ "$PROXY_HOST" != "0" ] && [ -n "$PROXY_PORT" ] && [ "$PROXY_PORT" != "0" ]; then + PROXY_FOUND="http://$PROXY_HOST:$PROXY_PORT" + export HTTPS_PROXY="$PROXY_FOUND" + export HTTP_PROXY="$PROXY_FOUND" + success "macOS 系统代理: $PROXY_FOUND" + fi +fi + +# 常见端口扫描 +if [ -z "$PROXY_FOUND" ]; then + for PORT in 7890 7893 7891 1087 1080 8118; do + if nc -z -w1 127.0.0.1 $PORT 2>/dev/null; then + PROXY_FOUND="http://127.0.0.1:$PORT" + export HTTPS_PROXY="$PROXY_FOUND" + export HTTP_PROXY="$PROXY_FOUND" + success "本地代理端口: $PORT" + break + fi + done +fi + +if [ -z "$PROXY_FOUND" ]; then + warn "未检测到代理。在国内 Claude Code 可能无法启动。" + warn "请启动代理软件 (ClashX / Surge / V2Ray) 后重试。" + echo "" + read -p " 无代理继续? (y/n): " CONTINUE + if [ "$CONTINUE" != "y" ]; then exit 1; fi +fi + +success "NO_PROXY: bww.letcareme.com,code.letcareme.com" + +# ============================================================ +# 7. 克隆/更新 Bookworm +# ============================================================ +step 7 "同步 Bookworm 配置" + +git config --global credential.helper osxkeychain 2>/dev/null || true + +if [ -d "$BOOT_DIR/.git" ]; then + info "引导仓库已存在, 更新..." + cd "$BOOT_DIR" + git pull --ff-only 2>/dev/null || git pull + success "引导仓库已更新" +else + if [ -d "$BOOT_DIR" ]; then rm -rf "$BOOT_DIR"; fi + info "首次下载 (需输入 Gitea 用户名密码)..." + git clone "$GITEA_URL" "$BOOT_DIR" + cd "$BOOT_DIR" + success "引导仓库克隆完成" +fi + +# 执行 macOS 安装脚本 (如存在) +if [ -f "$BOOT_DIR/install-mac.sh" ]; then + info "执行 install-mac.sh..." + bash "$BOOT_DIR/install-mac.sh" +elif [ -f "$BOOT_DIR/install.sh" ]; then + info "执行 install.sh..." + bash "$BOOT_DIR/install.sh" +else + # 回退: 手动执行核心配置步骤 + info "未找到安装脚本, 执行基础配置..." + + # 解密凭证 + SECRETS_ENC="$BOOT_DIR/secrets.enc" + if [ -f "$SECRETS_ENC" ] && [ -n "$OPENSSL_CMD" ]; then + echo "" + for attempt in 1 2 3; do + read -rs -p " 输入主密码解密凭证 (第 $attempt/3 次): " PASSWORD + echo "" + DECRYPTED=$($OPENSSL_CMD enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in "$SECRETS_ENC" -pass pass:"$PASSWORD" 2>/dev/null) || true + PASSWORD="" + if [ -n "$DECRYPTED" ]; then + while IFS= read -r line; do + [ -z "$line" ] && continue + key="${line%%=*}" + value="${line#*=}" + key=$(echo "$key" | tr -d ' ') + if [ -n "$key" ] && [ -n "$value" ]; then + export "$key=$value" + success "已注入: $key" + fi + done <<< "$DECRYPTED" + break + else + if [ $attempt -lt 3 ]; then + warn "密码错误, 剩余重试: $((3 - attempt)) 次" + else + fail "3 次密码均错误, 凭证未解密" + warn "可稍后手动配置 API Key" + fi + fi + done + fi + + # 克隆 .claude 配置仓库 + CLAUDE_REPO="https://code.letcareme.com/bookworm/bookworm-config.git" + if [ -d "$CLAUDE_DIR/.git" ]; then + info "更新 .claude 配置..." + cd "$CLAUDE_DIR" && git pull 2>/dev/null || true + elif [ ! -f "$CLAUDE_DIR/CLAUDE.md" ]; then + info "克隆 .claude 配置..." + if [ -d "$CLAUDE_DIR" ]; then + mv "$CLAUDE_DIR" "$CLAUDE_DIR.bak.$(date +%s)" + fi + git clone --depth 1 "$CLAUDE_REPO" "$CLAUDE_DIR" 2>/dev/null || warn "配置仓库克隆失败" + fi + + # 创建本地目录 + for d in debug sessions cache backups telemetry memory projects; do + mkdir -p "$CLAUDE_DIR/$d" 2>/dev/null + done + success "基础配置完成" +fi + +# ============================================================ +# 8. 终端别名 + 完成 +# ============================================================ +step 8 "配置终端快捷命令" + +# 检测 shell +SHELL_RC="$HOME/.zshrc" +if [ -n "$BASH_VERSION" ] && [ -f "$HOME/.bashrc" ]; then + SHELL_RC="$HOME/.bashrc" +fi + +ALIAS_MARKER="# Bookworm Portable aliases" +if ! grep -q "$ALIAS_MARKER" "$SHELL_RC" 2>/dev/null; then + cat >> "$SHELL_RC" << 'ALIASES' + +# Bookworm Portable aliases +alias bw='cd ~/bookworm-boot && NO_PROXY="bww.letcareme.com,code.letcareme.com,localhost,127.0.0.1" claude' +alias bw-update='cd ~/bookworm-boot && git pull && echo "Updated!"' +ALIASES + success "已添加到 $SHELL_RC:" + info " bw — 启动 Bookworm" + info " bw-update — 更新 Bookworm" +else + success "终端别名已配置" +fi + +# ─── 完成 ─── +echo "" +echo -e "${GREEN} ============================================================${NC}" +echo -e "${GREEN} Bookworm Smart Assistant for macOS 安装完成!${NC}" +echo -e "${GREEN} ============================================================${NC}" +echo "" +echo -e " 已安装:" +echo -e " ${GREEN}[v]${NC} Homebrew ${GREEN}[v]${NC} Node.js $(node -v 2>/dev/null)" +echo -e " ${GREEN}[v]${NC} Git ${GREEN}[v]${NC} OpenSSL" +echo -e " ${GREEN}[v]${NC} Claude Code ${GREEN}[v]${NC} Bookworm (92 Skills)" +echo "" +echo -e " ${BOLD}启动方式:${NC}" +echo -e " 终端输入: ${CYAN}bw${NC}" +echo -e " 或: ${CYAN}cd ~/bookworm-boot && claude${NC}" +echo "" +echo -e " ${BOLD}更新:${NC}" +echo -e " 终端输入: ${CYAN}bw-update${NC}" +echo "" + +# 询问是否立即启动 +read -p " 立即启动 Bookworm? (y/n): " START_NOW +if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then + echo "" + info "正在启动 Claude Code..." + cd "$HOME" + export NO_PROXY="bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" + exec claude +fi diff --git a/Bookworm-OneClick-Win10.bat b/Bookworm-OneClick-Win10.bat new file mode 100644 index 0000000..2ddf9b5 --- /dev/null +++ b/Bookworm-OneClick-Win10.bat @@ -0,0 +1,266 @@ +@echo off +chcp 65001 > nul 2>&1 +title Bookworm Smart Assistant - 全自动安装 (Win10 兼容) + +:: ─── 自动提升管理员权限 ── +:: 用 goto 而非 if() 避免文件名含括号(如"(2)")导致解析崩溃 +net session >nul 2>&1 +if %errorlevel% equ 0 goto :IS_ADMIN + +echo 需要管理员权限来安装软件,正在请求... +echo Set objShell = CreateObject("Shell.Application") > "%TEMP%\bw_elevate.vbs" +echo objShell.ShellExecute "cmd.exe", "/k cd /d ""%~dp0"" & ""%~nx0""", "", "runas", 1 >> "%TEMP%\bw_elevate.vbs" +cscript //nologo "%TEMP%\bw_elevate.vbs" +del /f /q "%TEMP%\bw_elevate.vbs" 2>nul +exit /b + +:IS_ADMIN + +:: ─── 初始化 ───────────────────────────────────────── +setlocal EnableDelayedExpansion +color 1F +set "NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" +set "no_proxy=%NO_PROXY%" +set "INSTALL_DIR=%USERPROFILE%\bookworm-boot" +set "GITEA_URL=https://code.letcareme.com/bookworm/bookworm-boot.git" +set "TEMP_DL=%TEMP%\bookworm-setup" +set "ERRORS=0" +set "NEED_PATH_REFRESH=0" + +echo. +echo +============================================================+ +echo ^| ^| +echo ^| Bookworm Smart Assistant ^| +echo ^| 全自动安装 v2.0 (Windows 10 兼容版) ^| +echo ^| ^| +echo ^| 兼容 Windows 10 1809+ / Windows 11 ^| +echo ^| 无需 winget, 通过直接下载安装包实现全自动 ^| +echo ^| ^| +echo +============================================================+ +echo. + +if not exist "%TEMP_DL%" mkdir "%TEMP_DL%" + +:: ─── 检测安装方式: winget 优先, 回退直接下载 ───────── +set "HAS_WINGET=0" +where winget >nul 2>nul +if %errorlevel% equ 0 set "HAS_WINGET=1" + +if "%HAS_WINGET%"=="1" ( + echo [OK] 检测到 winget, 使用 winget 安装 +) else ( + echo [i] 未检测到 winget, 使用直接下载方式安装 +) +echo. + +:: ─── 步骤 1/7: 安装 Git ──────────────────────────── +echo [1/7] 检查 Git... +where git >nul 2>nul +if %errorlevel% neq 0 ( + if "%HAS_WINGET%"=="1" ( + echo [..] 通过 winget 安装 Git... + winget install Git.Git --accept-source-agreements --accept-package-agreements --silent + ) else ( + echo [..] 下载 Git 安装包... + powershell -Command "& {[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1.2-64-bit.exe' -OutFile '%TEMP_DL%\git-install.exe'}" + if exist "%TEMP_DL%\git-install.exe" ( + echo [..] 静默安装 Git... + "%TEMP_DL%\git-install.exe" /VERYSILENT /NORESTART /NOCANCEL /SP- /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS /COMPONENTS="icons,ext\reg\shellhere,assoc,assoc_sh" + if !errorlevel! neq 0 ( + echo [!!] Git 安装失败 ^(exit: !errorlevel!^) + set /a ERRORS+=1 + ) else ( + echo [OK] Git 安装完成 + ) + ) else ( + echo [!!] Git 下载失败, 请手动安装: https://git-scm.com + set /a ERRORS+=1 + ) + ) + set "NEED_PATH_REFRESH=1" +) else ( + echo [OK] Git 已安装 +) +echo. + +:: ─── 步骤 2/7: 安装 Node.js ──────────────────────── +echo [2/7] 检查 Node.js... +where node >nul 2>nul +if %errorlevel% neq 0 ( + if "%HAS_WINGET%"=="1" ( + echo [..] 通过 winget 安装 Node.js LTS... + winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements --silent + ) else ( + echo [..] 下载 Node.js LTS 安装包... + powershell -Command "& {[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi' -OutFile '%TEMP_DL%\node-install.msi'}" + if exist "%TEMP_DL%\node-install.msi" ( + echo [..] 静默安装 Node.js... + msiexec /i "%TEMP_DL%\node-install.msi" /qn /norestart + echo [OK] Node.js 安装完成 + ) else ( + echo [!!] Node.js 下载失败, 请手动安装: https://nodejs.org + set /a ERRORS+=1 + ) + ) + set "NEED_PATH_REFRESH=1" +) else ( + echo [OK] Node.js 已安装 +) +echo. + +:: ─── 刷新 PATH ────────────────────────────────────── +if "%NEED_PATH_REFRESH%"=="1" ( + echo [..] 刷新系统 PATH... + for /f "tokens=2*" %%a in ('reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path 2^>nul') do set "SYS_PATH=%%b" + for /f "tokens=2*" %%a in ('reg query "HKCU\Environment" /v Path 2^>nul') do set "USR_PATH=%%b" + set "PATH=!SYS_PATH!;!USR_PATH!" + set "PATH=!PATH!;C:\Program Files\nodejs;C:\Program Files\Git\cmd;C:\Program Files\Git\usr\bin" + set "PATH=!PATH!;%APPDATA%\npm" + echo [OK] PATH 已刷新 + echo. +) + +:: ─── 二次验证 ──────────────────────────────────────── +where git >nul 2>nul +if %errorlevel% neq 0 ( + echo [!!] Git 仍不可用 — 可能需要重启电脑后重新运行本程序 + pause + exit /b 1 +) +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo [!!] Node.js 仍不可用 — 可能需要重启电脑后重新运行本程序 + pause + exit /b 1 +) + +:: ─── 步骤 3/7: 安装 Claude Code ───────────────────── +echo [3/7] 检查 Claude Code... +:: 国内 npm 镜像 - 淘宝源, 避免 npmjs.org 超时 +call npm config set registry https://registry.npmmirror.com 2>nul +where claude >nul 2>nul +if %errorlevel% neq 0 ( + echo [..] 通过 npm 安装 Claude Code - 淘宝镜像加速... + call npm i -g @anthropic-ai/claude-code 2>&1 + if !errorlevel! neq 0 ( + echo [!!] Claude Code 安装失败 + set /a ERRORS+=1 + ) else ( + echo [OK] Claude Code 安装成功 + ) +) else ( + echo [OK] Claude Code 已安装 +) +echo. + +:: ─── 步骤 4/7: 代理检测 ────────────────── +echo [4/7] 检测网络代理... +:: 纯 batch 实现, 不依赖 PowerShell, 不含括号 +set "PROXY_FOUND=" +netstat -an 2>nul | findstr "LISTENING" | findstr ":7890 :7893 :7891 :10792 :1080 :8118" >nul 2>nul +if !errorlevel! equ 0 set "PROXY_FOUND=1" +if defined PROXY_FOUND echo [OK] 检测到本地代理端口 +if not defined PROXY_FOUND echo [!] 未检测到代理, Claude Code 在国内可能无法启动 +if not defined PROXY_FOUND echo 请确保代理软件已启动 +echo. + +:: ─── 步骤 5/7: 克隆/更新 Bookworm ────────────────── +echo [5/7] 同步 Bookworm 配置... +git config --global credential.helper manager 2>nul + +if exist "%INSTALL_DIR%\.git" ( + echo 已有安装, 更新到最新版... + pushd "%INSTALL_DIR%" + git pull 2>&1 + popd +) else ( + if exist "%INSTALL_DIR%" rmdir /s /q "%INSTALL_DIR%" 2>nul + echo 首次下载 - 需输入 Gitea 用户名密码... + git clone "%GITEA_URL%" "%INSTALL_DIR%" 2>&1 + if !errorlevel! neq 0 ( + echo [!!] 下载失败, 请检查网络和 Gitea 凭证 + pause + exit /b 1 + ) +) +echo [OK] Bookworm 文件已就绪 +echo. + +:: ─── 步骤 6/7: 执行安装配置 ──────────────────────── +echo [6/7] 执行安装配置... +echo. +echo 首次安装需要输入主密码来解密 API 凭证 +echo - 主密码由管理员提供, 区分大小写 +echo. +where pwsh >nul 2>nul +if !errorlevel! equ 0 ( + pwsh -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" +) else ( + powershell -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" +) +echo. + +:: ─── 步骤 7/7: 桌面快捷方式 + 完成 ───────────────── +echo [7/7] 创建桌面快捷方式... +:: 用 VBScript 创建快捷方式, 避免 PowerShell 花括号被 cmd 截获 +echo Set ws = CreateObject("WScript.Shell") > "%TEMP%\bw_shortcut.vbs" +echo Set sc = ws.CreateShortcut(ws.SpecialFolders("Desktop") ^& "\Bookworm.lnk") >> "%TEMP%\bw_shortcut.vbs" +echo sc.TargetPath = "%INSTALL_DIR%\启动Bookworm.bat" >> "%TEMP%\bw_shortcut.vbs" +echo sc.WorkingDirectory = "%INSTALL_DIR%" >> "%TEMP%\bw_shortcut.vbs" +echo sc.Description = "Bookworm Smart Assistant" >> "%TEMP%\bw_shortcut.vbs" +echo sc.Save >> "%TEMP%\bw_shortcut.vbs" +echo Set sc = ws.CreateShortcut(ws.SpecialFolders("Desktop") ^& "\更新Bookworm.lnk") >> "%TEMP%\bw_shortcut.vbs" +echo sc.TargetPath = "%INSTALL_DIR%\更新并启动Bookworm.bat" >> "%TEMP%\bw_shortcut.vbs" +echo sc.WorkingDirectory = "%INSTALL_DIR%" >> "%TEMP%\bw_shortcut.vbs" +echo sc.Save >> "%TEMP%\bw_shortcut.vbs" +cscript //nologo "%TEMP%\bw_shortcut.vbs" 2>nul +if !errorlevel! equ 0 echo [OK] 桌面快捷方式已创建 +del /f /q "%TEMP%\bw_shortcut.vbs" 2>nul + +if exist "%INSTALL_DIR%\guide.html" start "" "%INSTALL_DIR%\guide.html" + +:: 清理临时文件 +if exist "%TEMP_DL%" rmdir /s /q "%TEMP_DL%" 2>nul + +echo. +echo +============================================================+ +echo ^| ^| +echo ^| 安装完成! ^| +echo ^| ^| +echo ^| 已安装: ^| +echo ^| [v] Node.js LTS [v] Git ^| +echo ^| [v] Claude Code [v] Bookworm - 92 Skills ^| +echo ^| ^| +echo ^| 桌面快捷方式: ^| +echo ^| Bookworm — 日常启动 ^| +echo ^| 更新Bookworm — 同步最新版后启动 ^| +echo ^| ^| +echo +============================================================+ +echo. + +if %ERRORS% gtr 0 ( + echo [注意] 安装过程中有 %ERRORS% 个警告, 请查看上方日志 + echo. +) + +echo 按任意键启动 Bookworm... +pause > nul + +cd /d "%INSTALL_DIR%" +if exist "启动Bookworm.bat" ( + call "启动Bookworm.bat" +) else ( + where pwsh >nul 2>nul + if !errorlevel! equ 0 ( + pwsh -NoLogo -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept + ) else ( + powershell -NoLogo -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept + ) +) + +endlocal + +:: ─── 兜底: 任何情况下窗口不自动关闭 ── +echo. +echo 如看到此消息说明流程已结束,按任意键关闭窗口... +pause > nul diff --git a/Bookworm-OneClick.bat b/Bookworm-OneClick.bat new file mode 100644 index 0000000..305fe74 --- /dev/null +++ b/Bookworm-OneClick.bat @@ -0,0 +1,259 @@ +@echo off +chcp 65001 > nul 2>&1 +title Bookworm Smart Assistant - 全自动安装 + +:: ─── 自动提升管理员权限 ── +:: 用 goto 而非 if() 避免文件名含括号(如"(2)")导致解析崩溃 +net session >nul 2>&1 +if %errorlevel% equ 0 goto :IS_ADMIN + +echo 需要管理员权限来安装软件,正在请求... +echo Set objShell = CreateObject("Shell.Application") > "%TEMP%\bw_elevate.vbs" +echo objShell.ShellExecute "cmd.exe", "/k cd /d ""%~dp0"" & ""%~nx0""", "", "runas", 1 >> "%TEMP%\bw_elevate.vbs" +cscript //nologo "%TEMP%\bw_elevate.vbs" +del /f /q "%TEMP%\bw_elevate.vbs" 2>nul +exit /b + +:IS_ADMIN + +:: ─── 初始化 ───────────────────────────────────────── +setlocal EnableDelayedExpansion +color 1F +set "NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" +set "no_proxy=%NO_PROXY%" +set "INSTALL_DIR=%USERPROFILE%\bookworm-boot" +set "GITEA_URL=https://code.letcareme.com/bookworm/bookworm-boot.git" +set "ERRORS=0" +set "NEED_PATH_REFRESH=0" + +echo. +echo +============================================================+ +echo ^| ^| +echo ^| Bookworm Smart Assistant ^| +echo ^| 全自动安装程序 v2.0 ^| +echo ^| ^| +echo ^| 全新电脑? 没问题! 双击即可, 全程无需手动操作 ^| +echo ^| ^| +echo ^| 自动安装: Node.js + Git + Claude Code + Bookworm 配置 ^| +echo ^| ^| +echo +============================================================+ +echo. + +:: ─── 步骤 1/7: winget 检测 ────────────────────────── +echo [1/7] 检测包管理器... +where winget >nul 2>nul +if %errorlevel% neq 0 ( + echo. + echo [!!] winget 未安装 (Windows 10 1809+ / Windows 11 自带) + echo. + echo 请先安装 "应用安装程序": + echo 1. 打开 Microsoft Store + echo 2. 搜索 "应用安装程序" 或 "App Installer" + echo 3. 点击安装/更新 + echo 4. 安装后重新运行本程序 + echo. + pause + exit /b 1 +) +echo [OK] winget 可用 +echo. + +:: ─── 步骤 2/7: 安装 Git ──────────────────────────── +echo [2/7] 检查 Git... +where git >nul 2>nul +if %errorlevel% neq 0 ( + echo [..] Git 未安装, 正在通过 winget 安装... + winget install Git.Git --accept-source-agreements --accept-package-agreements --silent + if !errorlevel! neq 0 ( + echo [!!] Git 安装失败 + set /a ERRORS+=1 + ) else ( + echo [OK] Git 安装成功 + set "NEED_PATH_REFRESH=1" + ) +) else ( + echo [OK] Git 已安装 +) +echo. + +:: ─── 步骤 3/7: 安装 Node.js ──────────────────────── +echo [3/7] 检查 Node.js... +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo [..] Node.js 未安装, 正在通过 winget 安装... + winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements --silent + if !errorlevel! neq 0 ( + echo [!!] Node.js 安装失败 + set /a ERRORS+=1 + ) else ( + echo [OK] Node.js LTS 安装成功 + set "NEED_PATH_REFRESH=1" + ) +) else ( + echo [OK] Node.js 已安装 +) +echo. + +:: ─── 刷新 PATH (新装软件需要) ──────────────────────── +if "%NEED_PATH_REFRESH%"=="1" ( + echo [..] 刷新系统 PATH... + :: 重新加载 Machine + User PATH + for /f "tokens=2*" %%a in ('reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path 2^>nul') do set "SYS_PATH=%%b" + for /f "tokens=2*" %%a in ('reg query "HKCU\Environment" /v Path 2^>nul') do set "USR_PATH=%%b" + set "PATH=!SYS_PATH!;!USR_PATH!" + :: 同时添加常见 Node.js / Git 路径 + set "PATH=!PATH!;C:\Program Files\nodejs;C:\Program Files\Git\cmd;C:\Program Files\Git\usr\bin" + echo [OK] PATH 已刷新 + echo. +) + +:: ─── 二次验证: Git + Node ──────────────────────────── +where git >nul 2>nul +if %errorlevel% neq 0 ( + echo [FATAL] Git 仍然不可用 + echo 请关闭此窗口, 手动安装 Git 后重新运行 + pause + exit /b 1 +) +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo [FATAL] Node.js 仍然不可用 + echo 请关闭此窗口, 手动安装 Node.js 后重新运行 + pause + exit /b 1 +) + +:: ─── 步骤 4/7: 安装 Claude Code ───────────────────── +echo [4/7] 检查 Claude Code... +:: 国内 npm 镜像 - 淘宝源, 避免 npmjs.org 超时 +call npm config set registry https://registry.npmmirror.com 2>nul +where claude >nul 2>nul +if %errorlevel% neq 0 ( + echo [..] Claude Code 未安装, 正在通过 npm 安装 - 淘宝镜像加速... + call npm i -g @anthropic-ai/claude-code 2>&1 + if !errorlevel! neq 0 ( + echo [!!] Claude Code 安装失败 + echo 请手动运行: npm i -g @anthropic-ai/claude-code + set /a ERRORS+=1 + ) else ( + echo [OK] Claude Code 安装成功 + ) +) else ( + echo [OK] Claude Code 已安装 +) +echo. + +:: ─── 步骤 5/7: 克隆/更新 Bookworm ────────────────── +echo [5/7] 同步 Bookworm 配置... + +:: 配置 git credential helper (免重复输密码) +git config --global credential.helper manager 2>nul + +if exist "%INSTALL_DIR%\.git" ( + echo 已有安装, 更新到最新版... + pushd "%INSTALL_DIR%" + git pull 2>&1 + popd +) else ( + if exist "%INSTALL_DIR%" ( + echo 清理非 git 目录后重新下载... + rmdir /s /q "%INSTALL_DIR%" 2>nul + ) + echo 首次下载 (需要输入 Gitea 用户名密码)... + git clone "%GITEA_URL%" "%INSTALL_DIR%" 2>&1 + if !errorlevel! neq 0 ( + echo. + echo [!!] 下载失败, 请检查: + echo - 网络是否正常 + echo - Gitea 用户名密码是否正确 + echo - 管理员是否已开通访问权限 + echo. + pause + exit /b 1 + ) +) +echo [OK] Bookworm 文件已就绪 +echo. + +:: ─── 步骤 6/7: 执行安装配置 ──────────────────────── +echo [6/7] 执行安装配置... +echo. + +if exist "%INSTALL_DIR%\install.ps1" ( + :: 优先 pwsh (PowerShell 7), 回退 powershell (5.1) + where pwsh >nul 2>nul + if !errorlevel! equ 0 ( + pwsh -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" -AutoAccept + ) else ( + powershell -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" -AutoAccept + ) +) else ( + echo [WARN] install.ps1 未找到, 跳过高级配置 +) +echo. + +:: ─── 步骤 7/7: 创建桌面快捷方式 + 完成 ────────────── +echo [7/7] 创建桌面快捷方式... + +:: 用 PowerShell 创建快捷方式 +powershell -ExecutionPolicy Bypass -Command ^ + "try{$s=(New-Object -COM WScript.Shell).CreateShortcut([IO.Path]::Combine([Environment]::GetFolderPath('Desktop'),'Bookworm.lnk'));^ + $s.TargetPath='%INSTALL_DIR%\启动Bookworm.bat';^ + $s.WorkingDirectory='%INSTALL_DIR%';^ + $s.Description='Bookworm Smart Assistant';^ + $s.Save();Write-Host ' [OK] 桌面快捷方式: Bookworm' -Fore Green}catch{Write-Host ' [!] 快捷方式创建失败' -Fore Yellow}" 2>nul + +powershell -ExecutionPolicy Bypass -Command ^ + "try{$s=(New-Object -COM WScript.Shell).CreateShortcut([IO.Path]::Combine([Environment]::GetFolderPath('Desktop'),'更新Bookworm.lnk'));^ + $s.TargetPath='%INSTALL_DIR%\更新并启动Bookworm.bat';^ + $s.WorkingDirectory='%INSTALL_DIR%';^ + $s.Description='Bookworm 更新并启动';^ + $s.Save();Write-Host ' [OK] 桌面快捷方式: 更新Bookworm' -Fore Green}catch{Write-Host ' [!] 快捷方式创建失败' -Fore Yellow}" 2>nul + +:: 打开使用教程 +if exist "%INSTALL_DIR%\guide.html" ( + start "" "%INSTALL_DIR%\guide.html" +) + +echo. +echo +============================================================+ +echo ^| ^| +echo ^| 安装完成! ^| +echo ^| ^| +echo ^| 已安装: ^| +echo ^| [v] Node.js LTS — JavaScript 运行时 ^| +echo ^| [v] Git — 版本控制与配置同步 ^| +echo ^| [v] Claude Code — AI 编程助手 ^| +echo ^| [v] Bookworm — 92 Skills / 18 Agents ^| +echo ^| ^| +echo ^| 桌面快捷方式: ^| +echo ^| Bookworm — 日常启动 ^| +echo ^| 更新Bookworm — 同步最新版后启动 ^| +echo ^| ^| +echo ^| 首次启动需要输入管理员提供的主密码 ^| +echo ^| ^| +echo +============================================================+ +echo. + +if %ERRORS% gtr 0 ( + echo [注意] 安装过程中有 %ERRORS% 个警告, 请查看上方日志 + echo. +) + +echo 按任意键启动 Bookworm... +pause > nul + +:: 启动 +cd /d "%INSTALL_DIR%" +if exist "启动Bookworm.bat" ( + call "启动Bookworm.bat" +) else ( + where pwsh >nul 2>nul + if !errorlevel! equ 0 ( + pwsh -NoLogo -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept + ) else ( + powershell -NoLogo -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept + ) +) + +endlocal diff --git a/Bookworm-Setup.bat b/Bookworm-Setup.bat new file mode 100644 index 0000000..c908df2 --- /dev/null +++ b/Bookworm-Setup.bat @@ -0,0 +1,163 @@ +@echo off +chcp 65001 > nul +title Bookworm Smart Assistant - 一键安装 +color 1F + +:: 中转站在国内,不走代理 +set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1 +set no_proxy=%NO_PROXY% + +echo. +echo +================================================+ +echo ^| ^| +echo ^| Bookworm Smart Assistant ^| +echo ^| 一键安装程序 v1.5 ^| +echo ^| ^| +echo ^| 92 Skills / 18 Agents / 34 Hooks ^| +echo ^| ^| +echo +================================================+ +echo. + +:: ─── 检查依赖 ─────────────────────────────────────── +echo [1/4] 检查环境... +echo. + +where git >nul 2>nul +if %errorlevel% neq 0 ( + echo [!!] Git 未安装 + echo. + echo 请先安装 Git: + echo 下载: https://git-scm.com/download/win + echo 安装后重新运行本程序 + echo. + pause + exit /b 1 +) +echo [OK] Git + +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo [!!] Node.js 未安装 + echo. + echo 请先安装 Node.js: + echo 下载: https://nodejs.org (选 LTS 版本) + echo 安装后重新打开本程序 + echo. + pause + exit /b 1 +) +echo [OK] Node.js + +where claude >nul 2>nul +if %errorlevel% neq 0 ( + echo [..] Claude Code 未安装,正在安装... + call npm i -g @anthropic-ai/claude-code + if %errorlevel% neq 0 ( + echo [!!] Claude Code 安装失败 + echo 手动安装: npm i -g @anthropic-ai/claude-code + pause + exit /b 1 + ) +) +echo [OK] Claude Code +echo. + +:: ─── 选择安装目录 ─────────────────────────────────── +set "INSTALL_DIR=%USERPROFILE%\bookworm-boot" + +:: ─── 克隆/更新仓库 ────────────────────────────────── +echo [2/4] 下载 Bookworm... +echo. + +if exist "%INSTALL_DIR%\.git" ( + echo 已安装,更新到最新版... + cd /d "%INSTALL_DIR%" + git pull 2>&1 +) else ( + if exist "%INSTALL_DIR%" ( + echo 目录已存在但非 git 仓库,清理后重新下载... + rmdir /s /q "%INSTALL_DIR%" 2>nul + ) + echo 首次下载... + git config --global credential.helper store + git clone https://code.letcareme.com/bookworm/bookworm-boot.git "%INSTALL_DIR%" 2>&1 + if %errorlevel% neq 0 ( + echo. + echo [!!] 下载失败 + echo 请检查: + echo - 网络是否正常 + echo - 是否能访问 https://code.letcareme.com + echo - Gitea 用户名密码是否正确 + echo. + pause + exit /b 1 + ) +) +echo. +echo [OK] Bookworm 文件已就绪 +echo. + +:: ─── 创建桌面快捷方式 ─────────────────────────────── +echo [3/4] 创建桌面快捷方式... +echo. + +:: 用 PowerShell 创建 .lnk +powershell -ExecutionPolicy Bypass -Command ^ + "$s=(New-Object -COM WScript.Shell).CreateShortcut('%USERPROFILE%\Desktop\Bookworm.lnk');^ + $s.TargetPath='%INSTALL_DIR%\启动Bookworm.bat';^ + $s.WorkingDirectory='%INSTALL_DIR%';^ + $s.Description='Bookworm Smart Assistant';^ + $s.Save()" 2>nul + +if %errorlevel% equ 0 ( + echo [OK] 桌面快捷方式: Bookworm +) else ( + echo [!] 快捷方式创建失败 (不影响使用) +) + +powershell -ExecutionPolicy Bypass -Command ^ + "$s=(New-Object -COM WScript.Shell).CreateShortcut('%USERPROFILE%\Desktop\更新Bookworm.lnk');^ + $s.TargetPath='%INSTALL_DIR%\更新并启动Bookworm.bat';^ + $s.WorkingDirectory='%INSTALL_DIR%';^ + $s.Description='Bookworm 更新并启动';^ + $s.Save()" 2>nul + +echo. + +:: ─── 启动 Bookworm ────────────────────────────────── +echo [4/4] 启动 Bookworm... +echo. +echo +================================================+ +echo ^| ^| +echo ^| 安装完成! ^| +echo ^| ^| +echo ^| 桌面已创建快捷方式: ^| +echo ^| Bookworm - 日常启动 ^| +echo ^| 更新Bookworm - 同步后启动 ^| +echo ^| ^| +echo ^| 接下来会启动 Bookworm ^| +echo ^| 请输入管理员提供的主密码 ^| +echo ^| ^| +echo +================================================+ +echo. + +cd /d "%INSTALL_DIR%" + +:: 打开使用教程 +if exist "%INSTALL_DIR%\guide.html" ( + start "" "%INSTALL_DIR%\guide.html" +) + +:: 启动安装脚本 +where pwsh >nul 2>nul +if %errorlevel% equ 0 ( + pwsh -ExecutionPolicy Bypass -File install.ps1 -AutoAccept +) else ( + powershell -ExecutionPolicy Bypass -File install.ps1 -AutoAccept +) + +if %errorlevel% neq 0 ( + echo. + echo 启动失败,请查看上方错误信息 + pause +) diff --git a/Bookworm-Setup.sh b/Bookworm-Setup.sh new file mode 100644 index 0000000..683f84d --- /dev/null +++ b/Bookworm-Setup.sh @@ -0,0 +1,232 @@ +#!/bin/bash +# ============================================================ +# Bookworm Portable - macOS One-Click Setup +# Version: 1.5 +# ============================================================ + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# 配置 +GITEA_URL="https://code.letcareme.com/bookworm/bookworm-boot.git" +BOOT_DIR="$HOME/bookworm-boot" + +banner() { + echo "" + echo -e "${CYAN} ____ _" + echo " | __ ) ___ ___ | | ____ _____ _ __ _ __ ___" + echo " | _ \\ / _ \\ / _ \\| |/ /\\ \\ /\\ / / _ \\| '__| '_ \` _ \\" + echo " | |_) | (_) | (_) | < \\ V V / (_) | | | | | | | |" + echo " |____/ \\___/ \\___/|_|\\_\\ \\_/\\_/ \\___/|_| |_| |_| |_|" + echo "" + echo -e " ${BOLD}Portable macOS Setup v1.5${NC}" + echo -e " ${BLUE}92 Skills | 18 Agents | 29 Hooks${NC}" + echo -e "${NC}" +} + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } +step() { echo -e "\n${BOLD}[$1/$TOTAL_STEPS]${NC} ${CYAN}$2${NC}"; } + +TOTAL_STEPS=6 + +# ============================================================ +# Step 0: Banner +# ============================================================ +banner + +# ============================================================ +# Step 1: 检查并安装依赖 +# ============================================================ +step 1 "检查依赖软件" + +# Homebrew +if ! command -v brew &> /dev/null; then + warn "Homebrew 未安装,正在安装..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + # Apple Silicon PATH + if [ -f /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> "$HOME/.zprofile" + fi + success "Homebrew 安装完成" +else + success "Homebrew $(brew --version | head -1)" +fi + +# Node.js +if ! command -v node &> /dev/null; then + warn "Node.js 未安装,正在通过 Homebrew 安装..." + brew install node + success "Node.js 安装完成" +else + success "Node.js $(node -v)" +fi + +# Git +if ! command -v git &> /dev/null; then + warn "Git 未安装,正在通过 Homebrew 安装..." + brew install git + success "Git 安装完成" +else + success "Git $(git --version)" +fi + +# openssl +OPENSSL_CMD="" +if command -v /opt/homebrew/opt/openssl/bin/openssl &> /dev/null; then + OPENSSL_CMD="/opt/homebrew/opt/openssl/bin/openssl" +elif command -v /usr/local/opt/openssl/bin/openssl &> /dev/null; then + OPENSSL_CMD="/usr/local/opt/openssl/bin/openssl" +elif command -v openssl &> /dev/null; then + OPENSSL_CMD="openssl" +fi + +if [ -z "$OPENSSL_CMD" ]; then + warn "OpenSSL 未找到,正在安装..." + brew install openssl + OPENSSL_CMD="/opt/homebrew/opt/openssl/bin/openssl" + success "OpenSSL 安装完成" +else + success "OpenSSL: $($OPENSSL_CMD version)" +fi + +# Claude Code +if ! command -v claude &> /dev/null; then + warn "Claude Code 未安装,正在通过 npm 安装..." + npm i -g @anthropic-ai/claude-code + success "Claude Code 安装完成" +else + success "Claude Code $(claude --version 2>/dev/null || echo 'installed')" +fi + +# ============================================================ +# Step 2: 检测代理 +# ============================================================ +step 2 "检测网络代理" + +# macOS 系统代理检测 +PROXY_DETECTED="" +if [ -n "$https_proxy" ] || [ -n "$HTTPS_PROXY" ]; then + PROXY_DETECTED="${HTTPS_PROXY:-$https_proxy}" + success "环境变量代理: $PROXY_DETECTED" +elif [ -n "$http_proxy" ] || [ -n "$HTTP_PROXY" ]; then + PROXY_DETECTED="${HTTP_PROXY:-$http_proxy}" + success "环境变量代理: $PROXY_DETECTED" +else + # 尝试从 macOS 网络设置检测 + SCUTIL_PROXY=$(scutil --proxy 2>/dev/null | grep -E "HTTPSPort|HTTPSProxy" || true) + if [ -n "$SCUTIL_PROXY" ]; then + PROXY_HOST=$(scutil --proxy 2>/dev/null | grep "HTTPSProxy" | awk '{print $3}') + PROXY_PORT=$(scutil --proxy 2>/dev/null | grep "HTTPSPort" | awk '{print $3}') + if [ -n "$PROXY_HOST" ] && [ "$PROXY_HOST" != "0" ]; then + PROXY_DETECTED="http://$PROXY_HOST:$PROXY_PORT" + export HTTPS_PROXY="$PROXY_DETECTED" + export HTTP_PROXY="$PROXY_DETECTED" + success "macOS 系统代理: $PROXY_DETECTED" + fi + fi + # 尝试常见端口 + if [ -z "$PROXY_DETECTED" ]; then + for PORT in 7890 7893 1087 1080 8118; do + if nc -z 127.0.0.1 $PORT 2>/dev/null; then + PROXY_DETECTED="http://127.0.0.1:$PORT" + export HTTPS_PROXY="$PROXY_DETECTED" + export HTTP_PROXY="$PROXY_DETECTED" + success "本地代理端口: $PROXY_DETECTED" + break + fi + done + fi +fi + +if [ -z "$PROXY_DETECTED" ]; then + warn "未检测到代理。如果在国内,Claude Code 可能无法启动。" + warn "请启动代理软件 (ClashX / Surge / V2Ray) 后重试。" +fi + +# NO_PROXY 设置 +export NO_PROXY="bww.letcareme.com,code.letcareme.com,localhost,127.0.0.1" +export no_proxy="$NO_PROXY" +success "NO_PROXY 已设置: bww.letcareme.com,code.letcareme.com" + +# ============================================================ +# Step 3: 克隆/更新引导仓库 +# ============================================================ +step 3 "同步引导仓库" + +if [ -d "$BOOT_DIR/.git" ]; then + info "引导仓库已存在,正在更新..." + cd "$BOOT_DIR" + git pull --ff-only 2>/dev/null || git pull + success "引导仓库已更新" +else + info "正在克隆引导仓库..." + git clone "$GITEA_URL" "$BOOT_DIR" + cd "$BOOT_DIR" + success "引导仓库克隆完成" +fi + +# ============================================================ +# Step 4: 运行安装脚本 +# ============================================================ +step 4 "执行安装" + +if [ -f "$BOOT_DIR/install-mac.sh" ]; then + info "检测到 install-mac.sh,正在执行..." + cd "$BOOT_DIR" + bash install-mac.sh +else + # 如果还没有 mac 专用脚本,提示用户 + warn "macOS 安装脚本尚未就绪。" + info "请联系管理员获取 install-mac.sh,或手动参考安装手册操作。" + info "安装手册: https://portable.bookwormweb.com/mac" +fi + +# ============================================================ +# Step 5: 配置终端别名 +# ============================================================ +step 5 "配置终端快捷命令" + +ZSHRC="$HOME/.zshrc" +ALIAS_MARKER="# Bookworm Portable aliases" + +if ! grep -q "$ALIAS_MARKER" "$ZSHRC" 2>/dev/null; then + cat >> "$ZSHRC" << 'ALIASES' + +# Bookworm Portable aliases +alias bookworm='cd ~/bookworm-boot && bash start-mac.sh' +alias bookworm-update='cd ~/bookworm-boot && bash install-mac.sh' +alias bookworm-stop='cd ~/bookworm-boot && bash stop-mac.sh' +ALIASES + success "已添加别名到 ~/.zshrc (bookworm / bookworm-update / bookworm-stop)" +else + success "终端别名已配置" +fi + +# ============================================================ +# Step 6: 完成 +# ============================================================ +step 6 "安装完成" + +echo "" +echo -e "${GREEN}============================================================${NC}" +echo -e "${GREEN} Bookworm Portable for macOS 安装完成!${NC}" +echo -e "${GREEN}============================================================${NC}" +echo "" +echo -e " ${BOLD}日常启动:${NC} 在终端输入 ${CYAN}bookworm${NC}" +echo -e " ${BOLD}同步更新:${NC} 在终端输入 ${CYAN}bookworm-update${NC}" +echo -e " ${BOLD}卸载清理:${NC} 在终端输入 ${CYAN}bookworm-stop${NC}" +echo "" +echo -e " ${BLUE}安装手册:${NC} https://portable.bookwormweb.com/mac" +echo "" diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..3740988 --- /dev/null +++ b/README.txt @@ -0,0 +1,51 @@ +Bookworm Portable v1.4 - 纯云端便携部署工具包 +================================================ + +=== 文件说明 === + + deploy-gitea.sh ECS Gitea 部署 (服务端,执行一次) + prepare-repo.ps1 仓库准备 (本机执行一次) + encrypt-secrets.ps1 凭证加密 (本机执行一次) + settings.template.json settings.json 模板 + settings.local.template.json settings.local.json 模板 (权限白名单) + install.ps1 安装/启动 (目标机执行) + stop.ps1 清理/卸载 (目标机执行) + +=== 一次性部署 === + + 步骤 1: 部署 Gitea (ECS) + > scp deploy-gitea.sh root@8.138.11.105:/tmp/ + > ssh root@8.138.11.105 "GITEA_ADMIN_PASS='你的密码' bash /tmp/deploy-gitea.sh" + > 登录 http://8.138.11.105:3000 创建两个私有仓库: + - bookworm-config (系统文件) + - bookworm-boot (引导脚本+加密凭证) + + 步骤 2: 推送 Bookworm 配置 + > .\prepare-repo.ps1 -GitUrl "http://8.138.11.105:3000/bookworm/bookworm-config.git" + + 步骤 3: 加密凭证 + > .\encrypt-secrets.ps1 + > (输入中转站 API Key + MCP 凭证 + 设置主密码,至少 12 位) + + 步骤 4: 推送 boot 仓库 + > 将 install.ps1, stop.ps1, secrets.enc 推送到 bookworm-boot 仓库 + +=== 目标机使用 === + + 安装: .\install.ps1 + 清理: .\stop.ps1 + 恢复: .\stop.ps1 -Restore + 深度: .\stop.ps1 -Deep + +=== 目标机要求 === + + [必须] Claude Code, Node.js >= 18, Git + [可选] Python 3.x, openssl (Git for Windows 自带) + +=== 安全规格 === + + 加密: AES-256-CBC + PBKDF2 (600000 迭代, OWASP 2023) + 凭证: 仅进程级环境变量,不写磁盘/注册表 + Gitea: INSTALL_LOCK=true, 注册关闭, 管理员 CLI 创建 + 密码: openssl stdin 管道传入,不暴露在进程列表 + 校验: Gitea 二进制 SHA256 完整性校验 diff --git a/crypto-helper.js b/crypto-helper.js new file mode 100644 index 0000000..d982630 --- /dev/null +++ b/crypto-helper.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +'use strict'; +/** + * Bookworm Portable - Node.js 凭证加解密工具 + * 替代 openssl enc, 跨平台跨版本 100% 一致 + * + * 加密: echo "KEY=VALUE" | node crypto-helper.js encrypt > secrets.enc + * 解密: node crypto-helper.js decrypt < secrets.enc + * 交互解密: node crypto-helper.js decrypt-interactive secrets.enc + */ +const crypto = require('crypto'); +const fs = require('fs'); +const readline = require('readline'); + +const ALGO = 'aes-256-cbc'; +const ITERATIONS = 600000; +const DIGEST = 'sha256'; +const SALT_LEN = 16; +const IV_LEN = 16; +const KEY_LEN = 32; + +function deriveKey(password, salt) { + return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN + IV_LEN, DIGEST); +} + +function encrypt(plaintext, password) { + const salt = crypto.randomBytes(SALT_LEN); + const derived = deriveKey(password, salt); + const key = derived.slice(0, KEY_LEN); + const iv = derived.slice(KEY_LEN, KEY_LEN + IV_LEN); + const cipher = crypto.createCipheriv(ALGO, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + // 格式: BWENC1 + salt(16) + encrypted + return Buffer.concat([Buffer.from('BWENC1'), salt, encrypted]); +} + +function decrypt(data, password) { + const magic = data.slice(0, 6).toString(); + if (magic !== 'BWENC1') { + throw new Error('WRONG_FORMAT'); + } + const salt = data.slice(6, 6 + SALT_LEN); + const encrypted = data.slice(6 + SALT_LEN); + const derived = deriveKey(password, salt); + const key = derived.slice(0, KEY_LEN); + const iv = derived.slice(KEY_LEN, KEY_LEN + IV_LEN); + const decipher = crypto.createDecipheriv(ALGO, key, iv); + try { + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } catch (e) { + throw new Error('WRONG_PASSWORD'); + } +} + +// ─── CLI ─── +const [,, cmd, arg1, arg2] = process.argv; + +if (cmd === 'encrypt') { + const password = arg1; + if (!password) { console.error('Usage: node crypto-helper.js encrypt '); process.exit(1); } + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', d => input += d); + process.stdin.on('end', () => { + const enc = encrypt(input.trim(), password); + process.stdout.write(enc); + }); + +} else if (cmd === 'decrypt') { + const password = arg1; + const file = arg2; // 可选: 文件路径, 否则从 stdin + if (!password) { console.error('Usage: node crypto-helper.js decrypt [file]'); process.exit(1); } + let data; + if (file) { + data = fs.readFileSync(file); + } else { + // Windows 兼容: 从 stdin 读取 buffer + const chunks = []; + const fd = fs.openSync(0, 'r'); + const buf = Buffer.alloc(4096); + let n; + while ((n = fs.readSync(fd, buf)) > 0) chunks.push(buf.slice(0, n)); + fs.closeSync(fd); + data = Buffer.concat(chunks); + } + try { + console.log(decrypt(data, password)); + } catch (e) { + if (e.message === 'WRONG_PASSWORD') { console.error('PASSWORD_ERROR'); process.exit(2); } + if (e.message === 'WRONG_FORMAT') { console.error('FORMAT_ERROR'); process.exit(3); } + throw e; + } + +} else if (cmd === 'decrypt-interactive' || cmd === 'decrypt-file') { + // 从文件解密, 交互输入密码, 输出 KEY=VALUE 到 stdout + const filePath = arg1; + if (!filePath || !fs.existsSync(filePath)) { console.error('File not found: ' + filePath); process.exit(1); } + const data = fs.readFileSync(filePath); + const maxRetries = parseInt(arg2) || 3; + + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + + let attempt = 0; + function ask() { + attempt++; + const prompt = attempt > 1 ? ` 重新输入主密码 (第 ${attempt}/${maxRetries} 次): ` : ' 输入主密码解密凭证: '; + // 隐藏输入 (Windows 兼容) + if (process.platform === 'win32') { + process.stderr.write(prompt); + const { execSync } = require('child_process'); + try { + const pwd = execSync('powershell -Command "[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR((Read-Host -AsSecureString)))"', { stdio: ['inherit', 'pipe', 'pipe'] }).toString().trim(); + tryDecrypt(pwd); + } catch (e) { + rl.question(prompt, tryDecrypt); + } + } else { + rl.question(prompt, tryDecrypt); + } + } + + function tryDecrypt(password) { + try { + const result = decrypt(data, password); + rl.close(); + console.log(result); // stdout: KEY=VALUE lines + process.exit(0); + } catch (e) { + if (e.message === 'WRONG_PASSWORD') { + const remaining = maxRetries - attempt; + if (remaining > 0) { + process.stderr.write(` [!!] 密码错误,剩余重试: ${remaining} 次\n`); + ask(); + } else { + process.stderr.write(' [ABORT] 密码错误次数过多\n'); + rl.close(); + process.exit(2); + } + } else if (e.message === 'WRONG_FORMAT') { + process.stderr.write(' [ERROR] secrets.enc 格式不兼容, 请联系管理员重新生成\n'); + rl.close(); + process.exit(3); + } else { + throw e; + } + } + } + + ask(); + +} else { + console.error('Bookworm Crypto Helper'); + console.error(' encrypt: echo "K=V" | node crypto-helper.js encrypt > secrets.enc'); + console.error(' decrypt: node crypto-helper.js decrypt < secrets.enc'); + console.error(' interactive: node crypto-helper.js decrypt-file secrets.enc'); + process.exit(1); +} diff --git a/deploy-gitea.sh b/deploy-gitea.sh new file mode 100644 index 0000000..1f03822 --- /dev/null +++ b/deploy-gitea.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# ============================================================ +# Bookworm Portable - Gitea 一键部署脚本 +# 在阿里云 ECS 上部署 Gitea 私有 Git 服务 +# ============================================================ +# 用法: ssh root@YOUR_ECS_IP 'bash -s' < deploy-gitea.sh +# 或: scp deploy-gitea.sh root@YOUR_ECS_IP:/tmp/ && ssh root@YOUR_ECS_IP 'bash /tmp/deploy-gitea.sh' +# ============================================================ + +set -euo pipefail + +GITEA_VER="1.22.6" +GITEA_BIN="/usr/local/bin/gitea" +GITEA_USER="git" +GITEA_HOME="/home/git" +GITEA_DATA="/var/lib/gitea" +GITEA_PORT=3300 + +# ─── 管理员配置 (部署前修改) ────────────────────────── +ADMIN_USER="${GITEA_ADMIN_USER:-bookworm}" +ADMIN_PASS="${GITEA_ADMIN_PASS:-}" +ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@localhost}" + +echo "=========================================" +echo " Bookworm Gitea 部署 v1.1" +echo "=========================================" + +# 0. 管理员密码检查 +if [ -z "$ADMIN_PASS" ]; then + echo "" + echo "[!] 请设置管理员密码 (至少 8 位):" + read -rs ADMIN_PASS + if [ ${#ADMIN_PASS} -lt 8 ]; then + echo "[ERROR] 密码至少 8 位" + exit 1 + fi + echo "" +fi + +# 1. 创建 git 用户 +if ! id "$GITEA_USER" &>/dev/null; then + echo "[1/8] 创建 git 用户..." + adduser --system --shell /bin/bash --gecos 'Gitea' \ + --group --disabled-password --home "$GITEA_HOME" "$GITEA_USER" +else + echo "[1/8] git 用户已存在,跳过" +fi + +# 2. 创建目录结构 +echo "[2/8] 创建数据目录..." +mkdir -p "$GITEA_DATA"/{custom,data,log} +chown -R "$GITEA_USER":"$GITEA_USER" "$GITEA_DATA" +chmod -R 750 "$GITEA_DATA" + +# 3. 下载 Gitea 二进制 + SHA256 校验 +download_and_verify() { + local ver="$1" + local bin="$2" + local base_url="https://dl.gitea.com/gitea/$ver" + local tmp_bin="${bin}.tmp" + local tmp_sha="${bin}.sha256" + + echo " 下载 gitea-$ver-linux-amd64..." + wget -q --show-progress -O "$tmp_bin" "$base_url/gitea-$ver-linux-amd64" + + echo " 下载 SHA256 校验文件..." + wget -q -O "$tmp_sha" "$base_url/gitea-$ver-linux-amd64.sha256" + + echo " 验证完整性..." + # 校验文件格式: hash filename + local expected_hash + expected_hash=$(awk '{print $1}' "$tmp_sha") + local actual_hash + actual_hash=$(sha256sum "$tmp_bin" | awk '{print $1}') + + if [ "$expected_hash" != "$actual_hash" ]; then + echo "[ERROR] SHA256 校验失败!" + echo " 期望: $expected_hash" + echo " 实际: $actual_hash" + rm -f "$tmp_bin" "$tmp_sha" + exit 1 + fi + + echo " [OK] SHA256 校验通过" + mv "$tmp_bin" "$bin" + chmod +x "$bin" + rm -f "$tmp_sha" +} + +if [ -f "$GITEA_BIN" ]; then + CURRENT_VER=$($GITEA_BIN --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo "unknown") + echo "[3/8] Gitea 已安装 (v$CURRENT_VER)" + if [ "$CURRENT_VER" = "$GITEA_VER" ]; then + echo " 版本匹配,跳过下载" + else + echo " 升级到 v$GITEA_VER..." + systemctl stop gitea 2>/dev/null || true + download_and_verify "$GITEA_VER" "$GITEA_BIN" + fi +else + echo "[3/8] 下载 Gitea v$GITEA_VER..." + download_and_verify "$GITEA_VER" "$GITEA_BIN" +fi + +# 4. 创建 systemd 服务 +echo "[4/8] 配置 systemd 服务..." +cat > /etc/systemd/system/gitea.service << 'EOF' +[Unit] +Description=Gitea (Git with a cup of tea) +After=syslog.target network.target + +[Service] +RestartSec=2s +Type=simple +User=git +Group=git +WorkingDirectory=/var/lib/gitea +ExecStart=/usr/local/bin/gitea web --config /var/lib/gitea/custom/conf/app.ini +Restart=always +Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea + +[Install] +WantedBy=multi-user.target +EOF + +# 5. 获取公网 IP (带校验) +echo "[5/8] 检测公网 IP..." +PUBLIC_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || echo "") +if ! echo "$PUBLIC_IP" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'; then + PUBLIC_IP="8.138.11.105" + echo " [!] 自动检测失败,使用默认: $PUBLIC_IP" +else + echo " [OK] 公网 IP: $PUBLIC_IP" +fi + +# 6. 初始化配置 (如果不存在) +if [ ! -f "$GITEA_DATA/custom/conf/app.ini" ]; then + echo "[6/8] 生成初始配置..." + mkdir -p "$GITEA_DATA/custom/conf" + install -m 600 -o "$GITEA_USER" -g "$GITEA_USER" /dev/null "$GITEA_DATA/custom/conf/app.ini" + cat > "$GITEA_DATA/custom/conf/app.ini" << EOF +[server] +HTTP_PORT = $GITEA_PORT +ROOT_URL = http://$PUBLIC_IP:$GITEA_PORT/ +LFS_START_SERVER = true +LFS_JWT_SECRET = $(openssl rand -base64 32) + +[database] +DB_TYPE = sqlite3 +PATH = $GITEA_DATA/data/gitea.db + +[repository] +ROOT = $GITEA_HOME/gitea-repositories +DEFAULT_BRANCH = main + +[security] +INSTALL_LOCK = true +SECRET_KEY = $(openssl rand -base64 32) +INTERNAL_TOKEN = $(openssl rand -base64 64 | tr -d '\n') + +[service] +DISABLE_REGISTRATION = true +REQUIRE_SIGNIN_VIEW = true +DEFAULT_ALLOW_CREATE_ORGANIZATION = false +ENABLE_CAPTCHA = true + +[log] +MODE = file +LEVEL = Info +ROOT_PATH = $GITEA_DATA/log +EOF + chown "$GITEA_USER":"$GITEA_USER" "$GITEA_DATA/custom/conf/app.ini" + chmod 600 "$GITEA_DATA/custom/conf/app.ini" +else + echo "[6/8] 配置已存在,跳过" +fi + +# 7. 启动服务 +echo "[7/8] 启动 Gitea..." +systemctl daemon-reload +systemctl enable gitea +systemctl restart gitea + +# 等待启动 +sleep 3 +if ! systemctl is-active --quiet gitea; then + echo "[ERROR] Gitea 启动失败,检查日志:" + echo " journalctl -u gitea -n 50" + exit 1 +fi +echo " [OK] Gitea 服务已启动" + +# 8. 自动创建管理员账号 (消除安装向导窗口期) +echo "[8/8] 创建管理员账号..." +if sudo -u "$GITEA_USER" "$GITEA_BIN" admin user list \ + --config "$GITEA_DATA/custom/conf/app.ini" 2>/dev/null | grep -q "$ADMIN_USER"; then + echo " [!] 管理员 $ADMIN_USER 已存在,跳过" +else + sudo -u "$GITEA_USER" "$GITEA_BIN" admin user create \ + --config "$GITEA_DATA/custom/conf/app.ini" \ + --username "$ADMIN_USER" \ + --password "$ADMIN_PASS" \ + --email "$ADMIN_EMAIL" \ + --admin \ + --must-change-password=false + echo " [OK] 管理员 $ADMIN_USER 已创建" +fi + +echo "" +echo "=========================================" +echo " Gitea 部署成功!" +echo "=========================================" +echo "" +echo " 访问地址: http://$PUBLIC_IP:$GITEA_PORT" +echo " 管理员: $ADMIN_USER" +echo " 状态: INSTALL_LOCK=true, 注册已关闭" +echo "" +echo " 下一步:" +echo " 1. 登录 http://$PUBLIC_IP:$GITEA_PORT" +echo " 2. 创建私有仓库: bookworm-config" +echo " 3. 创建私有仓库: bookworm-boot" +echo "" +echo " 安全提醒:" +echo " - 确保阿里云安全组仅允许你的 IP 访问端口 $GITEA_PORT" +echo " - 建议后续配置 HTTPS (Let's Encrypt + Nginx 反代)" +echo " - 建议启用 2FA: 设置 -> 安全 -> 两步验证" +echo "=========================================" diff --git a/download-panel.html b/download-panel.html new file mode 100644 index 0000000..3fdf398 --- /dev/null +++ b/download-panel.html @@ -0,0 +1,85 @@ + + diff --git a/download.html b/download.html new file mode 100644 index 0000000..f53c185 --- /dev/null +++ b/download.html @@ -0,0 +1,124 @@ + + + + + +Bookworm - 下载安装 + + + +
+ +

Bookworm Portable

+

AI 编程助手 — 一键安装,即刻使用

+
+ 97 Skills + 18 Agents + 28 Hooks + AES-256 加密 +
+ + + ⬇ 下载安装程序 + +

Bookworm-Setup.bat (4 KB) — 双击即可安装

+ +
+ 1 下载上方 .bat 文件
+ 2 双击运行 (如提示安全警告,选 "仍要运行")
+ 3 输入管理员提供的密码
+ 完成!桌面出现 Bookworm 图标 +
+ +
+ ⚠ 前置要求:
+ • Node.js (下载) + Git (下载)
+ • 代理/VPN 软件 (国内必须,用于首次连接验证) +
+
+ + diff --git a/encrypt-secrets.ps1 b/encrypt-secrets.ps1 new file mode 100644 index 0000000..72a0fb5 --- /dev/null +++ b/encrypt-secrets.ps1 @@ -0,0 +1,177 @@ +<# +.SYNOPSIS + Bookworm Portable - 凭证加密工具 +.DESCRIPTION + 将 API Key 和 MCP 凭证加密为 secrets.enc, + 存放于 Gitea/USB 上, 安装时解密为进程级环境变量. +.USAGE + # 交互式创建加密凭证文件 + .\encrypt-secrets.ps1 + + # 从现有 .env 文件加密 + .\encrypt-secrets.ps1 -FromFile "C:\path\to\.env" + + # 解密验证 + .\encrypt-secrets.ps1 -Decrypt +#> + +param( + [string]$FromFile, + [switch]$Decrypt +) + +$ScriptDir = if ($MyInvocation.MyCommand.Path) { + Split-Path -Parent $MyInvocation.MyCommand.Path +} else { $PWD.Path } +$SecretsEnc = Join-Path $ScriptDir "secrets.enc" +$TempFile = Join-Path $env:TEMP "bw-secrets-$([guid]::NewGuid().ToString('N').Substring(0,8)).tmp" + +# 检查 openssl +$opensslCmd = Get-Command openssl -ErrorAction SilentlyContinue +if (-not $opensslCmd) { + # 搜索常见 Git 安装路径 + $searchPaths = @( + "C:\Program Files\Git\usr\bin\openssl.exe", + "D:\Git\usr\bin\openssl.exe", + "D:\Git\mingw64\bin\openssl.exe", + "C:\Program Files\Git\mingw64\bin\openssl.exe", + "C:\Program Files (x86)\Git\usr\bin\openssl.exe" + ) + $found = $searchPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($found) { + $opensslCmd = $found + Write-Host "[INFO] 使用 Git 内置 openssl: $found" -ForegroundColor Gray + } + else { + Write-Host "[ERROR] openssl 未找到。请确认 Git for Windows 已安装。" -ForegroundColor Red + exit 1 + } +} else { + $opensslCmd = $opensslCmd.Source +} + +# ─── 解密模式 ───────────────────────────────────────── +if ($Decrypt) { + if (-not (Test-Path $SecretsEnc)) { + Write-Host "[ERROR] secrets.enc 不存在" -ForegroundColor Red + exit 1 + } + $password = Read-Host "输入主密码" -AsSecureString + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) + $plainPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + + Write-Host "`n=== 解密内容 ===" -ForegroundColor Cyan + # 通过 stdin 传入密码,避免进程列表泄露 + $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass stdin + Write-Host "`n=== 结束 ===" -ForegroundColor Cyan + + $plainPwd = $null + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + exit 0 +} + +# ─── 加密模式 ───────────────────────────────────────── +Write-Host "" +Write-Host " Bookworm Portable - 凭证加密工具" -ForegroundColor Cyan +Write-Host " =================================" -ForegroundColor Cyan +Write-Host "" + +try { + +if ($FromFile -and (Test-Path $FromFile)) { + Write-Host "从文件加载: $FromFile" -ForegroundColor Gray + $invalidLines = Get-Content $FromFile | Where-Object { $_.Trim() -and $_ -notmatch '^[A-Z][A-Z0-9_]+=.+$' } + if ($invalidLines) { + Write-Host "[WARN] 以下行格式不正确 (应为 KEY=value):" -ForegroundColor Yellow + $invalidLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + } + Copy-Item $FromFile $TempFile +} +else { + Write-Host "请输入凭证 (key=value 格式, 每行一个, 空行结束):" -ForegroundColor White + Write-Host "常见 Key:" -ForegroundColor Gray + Write-Host " ANTHROPIC_API_KEY=sk-ant-..." -ForegroundColor DarkGray + Write-Host " ANTHROPIC_BASE_URL=https://your-relay.com/v1" -ForegroundColor DarkGray + Write-Host " GITHUB_PERSONAL_ACCESS_TOKEN=ghp_..." -ForegroundColor DarkGray + Write-Host " SLACK_BOT_TOKEN=xoxb-..." -ForegroundColor DarkGray + Write-Host " ATLASSIAN_API_TOKEN=..." -ForegroundColor DarkGray + Write-Host " BROWSERBASE_API_KEY=..." -ForegroundColor DarkGray + Write-Host " BROWSERBASE_PROJECT_ID=..." -ForegroundColor DarkGray + Write-Host " FIRECRAWL_API_KEY=..." -ForegroundColor DarkGray + Write-Host "" + + $lines = @() + while ($true) { + $line = Read-Host ">" + if ([string]::IsNullOrWhiteSpace($line)) { break } + if ($line -match '^[A-Z][A-Z0-9_]+=.+$') { + $lines += $line + $key = $line.Substring(0, $line.IndexOf('=')) + Write-Host " [+] $key" -ForegroundColor Green + } + else { + Write-Host " [!] 格式不正确,应为 KEY=value" -ForegroundColor Yellow + } + } + + if ($lines.Count -eq 0) { + Write-Host "[!] 未输入任何凭证,退出" -ForegroundColor Yellow + exit 0 + } + + $lines -join "`n" | Set-Content $TempFile -NoNewline +} + +# 设置密码并加密 +Write-Host "" +$password1 = Read-Host "设置主密码 (用于解密, 至少 12 位)" -AsSecureString +$password2 = Read-Host "确认主密码" -AsSecureString + +$bstr1 = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password1) +$bstr2 = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password2) +$pwd1 = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr1) +$pwd2 = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr2) + +if ($pwd1 -ne $pwd2) { + Write-Host "[ERROR] 密码不匹配" -ForegroundColor Red + exit 1 +} + +if ($pwd1.Length -lt 12) { + Write-Host "[ERROR] 密码至少 12 位 (推荐 16+ 位混合字符)" -ForegroundColor Red + exit 1 +} + +# AES-256-CBC 加密, PBKDF2 600000 迭代 (OWASP 2023), 通过 stdin 传入密码 +$pwd1 | & $opensslCmd enc -aes-256-cbc -pbkdf2 -iter 600000 -salt -in $TempFile -out $SecretsEnc -pass stdin + +# 安全删除临时文件 +if (Test-Path $TempFile) { + $bytes = [System.IO.File]::ReadAllBytes($TempFile) + [Array]::Clear($bytes, 0, $bytes.Length) + [System.IO.File]::WriteAllBytes($TempFile, $bytes) + Remove-Item $TempFile -Force +} + +# 清除内存 +$pwd1 = $null; $pwd2 = $null +[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr1) +[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2) + +$size = (Get-Item $SecretsEnc).Length +Write-Host "" +Write-Host " [OK] secrets.enc 已生成 ($size bytes)" -ForegroundColor Green +Write-Host " 加密: AES-256-CBC + PBKDF2 (600000 迭代)" -ForegroundColor Gray +Write-Host " 路径: $SecretsEnc" -ForegroundColor Gray +Write-Host "" +Write-Host " 重要提醒:" -ForegroundColor Yellow +Write-Host " - 主密码无法找回,请牢记" -ForegroundColor Yellow +Write-Host " - 推送到 Gitea bookworm-boot 仓库即可" -ForegroundColor Yellow +Write-Host " - 验证: .\encrypt-secrets.ps1 -Decrypt" -ForegroundColor Yellow + +} finally { + # 确保任何退出路径都清理临时文件 + if (Test-Path $TempFile -ErrorAction SilentlyContinue) { + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + } +} diff --git a/generate-integrity.ps1 b/generate-integrity.ps1 new file mode 100644 index 0000000..77e6ffc --- /dev/null +++ b/generate-integrity.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + 生成 integrity.sha256 文件 +.DESCRIPTION + 计算 hooks 和关键配置文件的 SHA256 哈希, + 写入 integrity.sha256 供 install.ps1 校验. + 每次 prepare-repo.ps1 推送前应运行此脚本. +.USAGE + .\generate-integrity.ps1 +#> + +$ClaudeDir = Join-Path $env:USERPROFILE ".claude" +$OutputFile = Join-Path $ClaudeDir "integrity.sha256" + +if (-not (Test-Path $ClaudeDir)) { + Write-Host "[ERROR] .claude 目录不存在" -ForegroundColor Red + exit 1 +} + +Write-Host " 生成 integrity.sha256..." -ForegroundColor Cyan + +# 需要校验的关键文件模式 +$patterns = @( + "hooks/*.js", + "hooks/lib/*.js", + "scripts/paths.config.js", + "CLAUDE.md", + "settings.template.json", + "settings.local.template.json" +) + +$hashes = @() +foreach ($pattern in $patterns) { + $fullPattern = Join-Path $ClaudeDir $pattern + foreach ($file in (Get-Item $fullPattern -ErrorAction SilentlyContinue)) { + $hash = (Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLower() + $relPath = $file.FullName.Substring($ClaudeDir.Length + 1).Replace('\', '/') + $hashes += "$hash $relPath" + } +} + +# 去重并排序 +$hashes = $hashes | Sort-Object -Unique + +$hashes | Set-Content $OutputFile -Encoding UTF8 +$count = $hashes.Count + +Write-Host " [OK] 已生成 $count 个文件哈希" -ForegroundColor Green +Write-Host " 路径: $OutputFile" -ForegroundColor Gray +Write-Host "" +Write-Host " 注意: 请在 git commit 前运行此脚本" -ForegroundColor Yellow diff --git a/guide-mac.html b/guide-mac.html new file mode 100644 index 0000000..ead969a --- /dev/null +++ b/guide-mac.html @@ -0,0 +1,790 @@ + + + + + +Bookworm Portable for Mac - 保姆式安装手册 + + + + +
+
  ____              _
+ | __ )  ___   ___ | | ____      _____  _ __ _ __ ___
+ |  _ \ / _ \ / _ \| |/ /\ \ /\ / / _ \| '__| '_ ` _ \
+ | |_) | (_) | (_) |   <  \ V  V / (_) | |  | | | | | |
+ |____/ \___/ \___/|_|\_\  \_/\_/ \___/|_|  |_| |_| |_|
+  
+
macOS Edition
+

Bookworm Portable 保姆式安装手册

+

从零开始,一步步教你在任意 Mac 电脑上激活 Bookworm

+
+ 92 Skills + 18 Agents + 29 Hooks + AES-256 加密 + HTTPS 传输 +
+ + ⬇ 下载一键安装脚本 +
+ +
+ + +
+

整体流程概览

+ +
+

最快方式:一键安装脚本

+

下载 Bookworm-Setup.sh → 在终端运行 → 输入密码 → 完成

+

脚本自动检测依赖、安装 Homebrew/Node.js/Git、下载配置、启动 Claude Code

+
+ +

手动安装流程:

+
+
安装依赖
Homebrew + Node.js
+ +
安装 Claude Code
npm 全局安装
+ +
运行安装脚本
或 git clone
+ +
输入密码
主密码
+ +
开始使用
Bookworm 激活
+
+

首次安装约 10 分钟(含依赖下载),之后每次启动约 5-15 秒

+
+ + + + +
+

1安装依赖软件

+ +
+ +
+ 国内必须:代理/VPN 软件
+ Claude Code 启动时会检查 api.anthropic.com,国内无法直连。
+ 请先安装并启动代理软件(ClashX / Surge / V2Ray / 任意 VPN),安装脚本会自动检测系统代理。
+ 无代理 = Claude Code 无法启动。 +
+
+ +
+ 💡 +
+ 中转站不走代理
+ API 中转站 bww.letcareme.com 部署在国内阿里云,不需要通过代理访问
+ 安装脚本已自动设置 NO_PROXY=bww.letcareme.com,code.letcareme.com,无需手动配置。
+ 如果代理软件有"绕过规则"设置,建议把 *.letcareme.com 加入直连列表。 +
+
+ +

需要安装以下软件。如果已装过可跳到下一步。

+ + +
+
A
+
+

安装 Homebrew(macOS 包管理器)

+

Homebrew 是 macOS 上最常用的包管理器,后续用它安装 Node.js 和 Git。

+
+
+ +

打开 终端(按 ⌘ + 空格 搜索 "终端" 或 "Terminal"),执行:

+
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +
+ +
+ 💡 +
+ 国内加速
+ 如果下载太慢,可以使用清华镜像:
+ export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git"
+ export HOMEBREW_CORE_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-core.git"
+ 设置后重新执行上面的安装命令。 +
+
+ +
+ +
+ Apple Silicon (M1/M2/M3/M4) 用户注意!
+ 安装完成后,需要将 Homebrew 添加到 PATH。终端会提示你执行以下命令: +
+
+
+ # Apple Silicon Mac 需要执行(Intel Mac 不需要) +echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile +eval "$(/opt/homebrew/bin/brew shellenv)" +
+ +

验证安装成功:

+
+ brew --version # 应显示 Homebrew 4.x.x +
+ + +
+
B
+
+

安装 Node.js(必须)

+

用 Homebrew 一行命令安装。

+
+
+
+ brew install node +
+

也可以从 nodejs.org 下载 .pkg 安装包。

+ +

验证安装成功:

+
+ node -v # 应显示 v22.x.x +npm -v # 应显示 10.x.x +
+ + +
+
C
+
+

安装 Git(必须)

+

macOS 通常自带 Git,如没有则用 Homebrew 安装。

+
+
+
+ # 检查是否已安装 +git --version + +# 如果提示安装 Xcode Command Line Tools,点击"安装"即可 +# 或者用 Homebrew 安装: +brew install git +
+
+ + + + +
+

2安装 Claude Code

+ +
+
1
+
+

全局安装

+

在终端中执行:

+
+
+
+ npm i -g @anthropic-ai/claude-code +
+

安装过程需要几分钟,等待完成即可。如果报权限错误,在前面加 sudo

+ +
+
2
+
+

验证安装

+
+
+
+ claude --version # 应显示版本号 +
+ +
+ 💡 +
+ 不需要登录 Claude 账号!
+ Bookworm 使用中转站 API,安装 Claude Code 后直接进入下一步,不用执行 claude login。 +
+
+
+ + + + +
+

3安装 Bookworm(核心步骤)

+ +
+
1
+
+

克隆引导仓库

+

在终端中执行以下命令。系统会提示输入用户名和密码。

+
+
+
+ git clone https://code.letcareme.com/bookworm/bookworm-boot.git +cd bookworm-boot +
+ +
+ 弹出用户名密码? 输入管理员提供给你的 Gitea 账号密码。这是 Gitea 的密码,不是主密码。 +
+ +
+
2
+
+

运行安装脚本

+

在终端中执行安装脚本:

+
+
+
+ bash install-mac.sh +
+

如果提示权限不足:chmod +x install-mac.sh && ./install-mac.sh

+ +
+
3
+
+

输入主密码

+

脚本会提示 "输入主密码解密凭证",输入管理员提供的主密码(不是 Gitea 密码),按回车。

+

密码输入时不显示字符,这是正常的。输错了可以重试,最多 3 次。

+
+
+ +
+ 两个密码不要搞混:
+ Gitea 密码 = 克隆仓库时输入的,用于下载文件
+ 主密码 = 解密 API 凭证时输入的,用于启动 Claude Code +
+ +
+
4
+
+

等待完成

+

脚本会显示步骤进度 [1/8] 到 [8/8],自动完成:

+
    +
  • [1/8] 前置检查 (Claude Code / Node.js / Git / openssl)
  • +
  • [2/8] 自动检测代理 + 设置 NO_PROXY
  • +
  • [3/8] 解密凭证 (输入主密码)
  • +
  • [4/8] 同步配置 (下载 92 个 Skills)
  • +
  • [5/8] 完整性校验 (SHA256 哈希验证)
  • +
  • [6/8] 渲染配置模板
  • +
  • [7/8] Bookworm 系统验证 + MCP 检查
  • +
  • [8/8] 启动 Claude Code
  • +
+
+
+ +
+ +
+ 看到 "Bookworm 就绪" 绿色横幅就说明成功了!
+ Claude Code 启动后,脚本会验证 Skills/Hooks/配置 完整性,全部 [OK] 后进入 Bookworm 模式。
+ 所有 API 请求通过中转站转发,不需要自己的 Claude 账号。 +
+
+ +
+ +
+ 看到 "原生模式启动" 黄色横幅?
+ 说明 Bookworm 配置不完整。请重新运行安装脚本,或联系管理员。 +
+
+
+ + + + +
+

4日常使用

+ +
+

方法一:终端别名(推荐,最简单)

+

安装脚本已自动添加别名到 ~/.zshrc,直接在终端输入:

+
+
+ bookworm # 快速启动 +bookworm-update # 同步更新后启动 +
+ +
+

方法二:脚本命令

+

如果别名不可用,在终端中手动执行:

+
+ + + + + + + + + + + + +
操作命令说明
快速启动cd ~/bookworm-boot && bash start-mac.sh直接启动,不更新配置
同步更新cd ~/bookworm-boot && bash install-mac.sh先同步最新 Skills 再启动
+ +
+ 💡 +
+ 启动时显示 "有 N 个新更新可用"?
+ 说明管理员更新了 Skills 或 Hooks。执行 bookworm-update 即可同步。 +
+
+
+ + + + +
+

5密码说明

+ +
+

本系统有两个密码,不要搞混

+ + + + +
名称用途何时输入
Gitea 密码下载文件(克隆仓库)首次安装时 git 弹出要求
主密码解密 API 凭证每次启动脚本提示输入
+
+ +
+ 💡 +
+ 密码输错了? 最多可以重试 3 次,不用紧张。3 次都错才会退出。 +
+
+ +
+

本日免密功能

+

首次解密成功后,脚本会询问 "今日内免密启动? (y/n)"

+

y 后,当天再次启动无需输入主密码,次日自动过期。

+

凭证缓存在 macOS 钥匙串 (Keychain) 中,仅当前用户可读。

+
+ +
+ 🔒 +
主密码无法找回 — 忘记后联系管理员重新生成 secrets.enc。
+
+
+ + + + +
+

6使用完毕 — 清理 / 卸载

+ +

在终端中执行清理命令:

+ + + + + + + + + + + + + + + + + +
场景命令说明
基础清理bash stop-mac.sh清除环境变量,保留配置供下次快速启动
完整恢复bash stop-mac.sh --restore删除 Bookworm,恢复电脑原始状态
深度清理bash stop-mac.sh --restore --deep完整恢复 + 清除历史 + 清除 Git 凭证 + 钥匙串
+ +
+ +
+ 在他人电脑/公用电脑上务必清理:
+ 执行 cd ~/bookworm-boot && bash stop-mac.sh --restore --deep +
+
+
+ + + + +
+

!常见问题排查

+ +
+

❌ brew 命令找不到

+

原因:Homebrew 未添加到 PATH(Apple Silicon Mac 常见)。

+

解决:

+
+
+ # Apple Silicon (M1/M2/M3/M4) +eval "$(/opt/homebrew/bin/brew shellenv)" + +# Intel Mac +eval "$(/usr/local/bin/brew shellenv)" +
+ +
+

❌ npm 全局安装报 Permission denied

+

原因:macOS 默认目录权限限制。

+

解决方式一(推荐):修改 npm 全局目录:

+
+
+ mkdir -p ~/.npm-global +npm config set prefix '~/.npm-global' +echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc +source ~/.zshrc + +# 然后重新安装 +npm i -g @anthropic-ai/claude-code +
+

解决方式二:在命令前加 sudo(简单但不推荐长期使用)。

+ +
+

❌ git clone 失败 / 认证失败

+

解决步骤:

+
    +
  1. 浏览器打开 https://code.letcareme.com 确认网站可访问
  2. +
  3. 确认用户名密码正确(区分大小写)
  4. +
  5. 如果 macOS 弹出钥匙串对话框,点"始终允许"
  6. +
  7. 检查网络是否需要代理
  8. +
+
+ +
+

❌ 解密凭证失败 / 主密码错误

+

原因:主密码区分大小写,且无法找回。

+

解决:仔细检查密码是否正确。如确认忘记,联系管理员重新生成 secrets.enc

+
+ +
+

❌ openssl 版本不兼容

+

原因:macOS 自带 LibreSSL,部分加密参数可能不同。

+

解决:安装 OpenSSL:

+
+
+ brew install openssl +# 脚本会自动检测 Homebrew 安装的 openssl 路径 +
+ +
+

❌ ECONNRESET / "Unable to connect to API"

+

原因:代理软件把国内中转站 bww.letcareme.com 的流量也走了国际线路。

+

解决:手动设置 NO_PROXY 后重试:

+
+
+ # 设置中转站直连(不走代理) +export NO_PROXY="bww.letcareme.com,code.letcareme.com" + +# 重新启动 +cd ~/bookworm-boot +bash start-mac.sh +
+

或在代理软件中将 *.letcareme.com 加入直连规则。

+ +
+

❌ "Not logged in" / 直接运行 claude 报错

+

原因:API 凭证是进程级环境变量,只在安装脚本启动的进程中有效。

+

解决:不要直接运行 claude,必须通过以下方式启动:

+
    +
  • 终端输入 bookworm(推荐)
  • +
  • cd ~/bookworm-boot && bash start-mac.sh
  • +
+
+ +
+

❌ 安装包下载太慢

+

解决:设置淘宝镜像:

+
+
+ # npm 淘宝镜像 +npm config set registry https://registry.npmmirror.com + +# Homebrew 清华镜像 +export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git" + +# 然后重新安装 +npm i -g @anthropic-ai/claude-code +
+ +
+

❌ 需要自己的 Claude 账号吗?

+

不需要。所有 API 请求通过中转站转发,消耗中转站额度。目标机不需要任何 Anthropic 账号或订阅。

+
+
+ + + + +
+

安装检查清单

+

逐项确认,全部打勾即可开始使用:

+
    +
  • Homebrew 已安装brew --version 显示版本号
  • +
  • Node.js 已安装node -v 显示版本号
  • +
  • Git 已安装git --version 显示版本号
  • +
  • npm 可用npm -v 显示版本号
  • +
  • Claude Code 已安装claude --version 显示版本号
  • +
  • 已获取 Gitea 账号密码 — 管理员提供
  • +
  • 已获取主密码 — 管理员提供(用于解密 API 凭证)
  • +
  • 能访问 code.letcareme.com — 浏览器打开确认
  • +
  • 代理/VPN 已启动 — 国内必须,脚本自动检测 (ClashX/Surge/V2Ray 等)
  • +
+
+ + + + +
+

快速参考

+ + + + + + + + +
操作快捷方式完整命令
首次安装git clone + bash install-mac.shcd ~/bookworm-boot && bash install-mac.sh
快速启动bookwormcd ~/bookworm-boot && bash start-mac.sh
同步更新bookworm-updatecd ~/bookworm-boot && bash install-mac.sh
基础清理cd ~/bookworm-boot && bash stop-mac.sh
完整恢复cd ~/bookworm-boot && bash stop-mac.sh --restore
深度清理cd ~/bookworm-boot && bash stop-mac.sh --restore --deep
+
+ + + + +
+

安全须知

+ + + + + + +
特性规格
凭证加密AES-256-CBC + PBKDF2 (600,000 迭代)
传输加密HTTPS (TLS 1.2+, Let's Encrypt 证书)
凭证存储进程级环境变量 + 可选本日缓存 (macOS Keychain, 当日 23:59 过期)
登录保护fail2ban (5 次失败/小时 → 封禁 24 小时)
+
+ 🔒 +
+ 主密码无法找回 — 请妥善保管。忘记后需管理员重新生成加密凭证。 +
+
+
+ +
+ + + + + + + diff --git a/guide.html b/guide.html new file mode 100644 index 0000000..68a27ad --- /dev/null +++ b/guide.html @@ -0,0 +1,786 @@ + + + + + +Bookworm Portable - 保姆式安装手册 + + + + +
+
+  ____              _
+ | __ )  ___   ___ | | ____      _____  _ __ _ __ ___
+ |  _ \ / _ \ / _ \| |/ /\ \ /\ / / _ \| '__| '_ ` _ \
+ | |_) | (_) | (_) |   <  \ V  V / (_) | |  | | | | | |
+ |____/ \___/ \___/|_|\_\  \_/\_/ \___/|_|  |_| |_| |_|
+  
+

Bookworm Portable 保姆式安装手册

+

从零开始,一步步教你在任意 Windows 电脑上激活 Bookworm

+
+ 90+ 专家技能 + 多模态 AI 协作 + AES-256 加密 + HTTPS 传输 + NDA 技术保密 +
+ ⬇ 下载一键安装器 +
+ +
+ + +
+

整体流程概览

+ +
+

最快方式:一键安装器

+

获取 Bookworm-Setup.bat (4KB) → 双击运行 → 输入密码 → 完成

+

安装器自动检测依赖、下载配置、创建桌面快捷方式、启动 Claude Code

+
+ +

手动安装流程:

+
+
安装依赖
Node.js + Git
+ +
安装 Claude Code
npm 全局安装
+ +
双击安装器
或 git clone
+ +
输入密码
主密码
+ +
开始使用
Bookworm 激活
+
+

首次安装约 10 分钟(含依赖下载),之后每次双击启动约 10-30 秒

+
+ + + + +
+

1安装依赖软件

+ +
+ +
+ 国内必须:代理/VPN 软件
+ Claude Code 启动时会检查 api.anthropic.com,国内无法直连。
+ 请先安装并启动代理软件(Clash / V2Ray / 快柠檬 / 任意 VPN),安装脚本会自动检测系统代理。
+ 无代理 = Claude Code 无法启动。 +
+
+ +
+ 💡 +
+ 中转站不走代理
+ API 中转站 bww.letcareme.com 部署在国内阿里云,不需要通过代理访问
+ 安装脚本已自动设置 NO_PROXY=bww.letcareme.com,code.letcareme.com,无需手动配置。
+ 如果代理软件有"绕过规则"设置,建议把 *.letcareme.com 加入直连列表。 +
+
+ +

需要安装以下软件。如果已装过可跳到下一步。

+ + +
+
A
+
+

安装 Node.js(必须)

+

去官网下载 LTS 版本安装包,双击安装,一路 Next 即可。

+
+
+ +
+

方式一:官网下载(推荐)

+

打开浏览器访问 https://nodejs.org,点击绿色的 "LTS 推荐" 按钮下载,双击 .msi 文件安装,全部默认 Next。

+
+ +
+

方式二:PowerShell 命令安装

+

右键开始菜单 → 选择 "PowerShell (管理员)""终端 (管理员)",然后执行:

+
+
+ PowerShell (管理员) + # 下载 Node.js 安装包 +Invoke-WebRequest -Uri "https://nodejs.org/dist/v22.15.0/node-v22.15.0-x64.msi" -OutFile "$env:TEMP\node-install.msi" + +# 运行安装(会弹出安装向导,一路 Next) +Start-Process msiexec.exe -ArgumentList "/i $env:TEMP\node-install.msi" -Wait +
+ +
+ +
+ 安装完成后必须重开 PowerShell!
+ 关闭当前 PowerShell 窗口,重新打开一个新的,否则 nodenpm 命令找不到。 +
+
+ +

验证安装成功:

+
+ node -v # 应显示 v22.x.x +npm -v # 应显示 10.x.x +
+ +
+ 如果 npm 报 "执行策略" 错误? 执行以下命令后重试:
+ Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
+ 提示确认时输入 Y 回车。 +
+ + +
+
B
+
+

安装 Git(必须)

+

去官网下载安装,全部默认设置即可。

+
+
+
+

打开 https://git-scm.com/download/win,下载 "64-bit Git for Windows Setup",双击安装,全部 Next。

+
+

验证:

+
+ git --version # 应显示 git version 2.x.x +
+ + +
+
C
+
+

安装 PowerShell 7(推荐)

+

Windows 自带的 PowerShell 5.1 有中文兼容问题,建议升级到 7。

+
+
+
+ PowerShell (管理员) + winget install Microsoft.PowerShell +
+

如果没有 winget,去 GitHub Releases 下载 .msi 安装包。安装后用 pwsh 命令启动新版 PowerShell。

+
+ + + + +
+

2安装 Claude Code

+ +
+
1
+
+

全局安装

+

在 PowerShell 中执行(不需要管理员权限):

+
+
+
+ npm i -g @anthropic-ai/claude-code +
+

安装过程需要几分钟,等待完成即可。

+ +
+
2
+
+

验证安装

+
+
+
+ claude --version # 应显示版本号 +
+ +
+ 💡 +
+ 不需要登录 Claude 账号!
+ Bookworm 使用中转站 API,安装 Claude Code 后直接进入下一步,不用执行 claude login。 +
+
+
+ + + + +
+

3安装 Bookworm(核心步骤)

+ +
+
1
+
+

克隆引导仓库

+

在 PowerShell 中执行以下命令。系统会提示输入用户名和密码。

+
+
+
+ git clone https://code.letcareme.com/bookworm/bookworm-boot.git +cd bookworm-boot +
+ +
+ 弹出用户名密码? 输入管理员提供给你的 Gitea 账号密码。这是 Gitea 的密码,不是主密码。 +
+ +
+
2
+
+

双击运行安装脚本

+

双击文件夹里的 更新并启动Bookworm.bat,脚本会自动完成所有配置。

+
+
+ +
+

或者用命令行运行

+

如果双击 .bat 不起作用,在 PowerShell 中手动执行:

+
+
+ pwsh -ExecutionPolicy Bypass -File install.ps1 +
+

没有 pwsh 可用 powershell 替代。

+ +
+
3
+
+

输入主密码

+

脚本会提示 "输入主密码解密凭证",输入管理员提供的主密码(不是 Gitea 密码),按回车。

+

密码输入时不显示字符,这是正常的。输错了可以重试,最多 3 次。

+
+
+ +
+ 两个密码不要搞混:
+ Gitea 密码 = 克隆仓库时输入的,用于下载文件
+ 主密码 = 解密 API 凭证时输入的,用于启动 Claude Code +
+ +
+
4
+
+

等待完成

+

脚本会显示步骤进度 [1/9] 到 [9/9],自动完成:

+
    +
  • [1/9] 前置检查 (Claude Code / Node.js / Git)
  • +
  • [2/9] 自动检测代理 + 设置 NO_PROXY
  • +
  • [3/9] 解密凭证 (输入主密码)
  • +
  • [4/9] 同步配置 (下载专家技能库)
  • +
  • [5/9] 完整性校验 (SHA256 哈希验证)
  • +
  • [6/9] 渲染配置模板
  • +
  • [7/9] 初始化本地目录
  • +
  • [8/9] Bookworm 系统验证 + MCP 检查
  • +
  • [9/9] 启动 Claude Code
  • +
+
+
+ +
+ +
+ 看到 "Bookworm 就绪" 绿色横幅就说明成功了!
+ Claude Code 启动后,脚本会验证配置完整性,全部 [OK] 后进入 Bookworm 模式。
+ 所有 API 请求通过中转站转发,不需要自己的 Claude 账号。 +
+
+ +
+ +
+ 看到 "原生模式启动" 黄色横幅?
+ 说明 Bookworm 配置不完整。请不加 -StartOnly 重新运行安装脚本,或联系管理员。 +
+
+
+ + + + +
+

4日常使用

+ +
+

方法一:双击 .bat 文件(推荐,最简单)

+

bookworm-boot 文件夹里有两个 .bat 文件:

+
+ + + + + + + + + + + + +
文件作用适用场景
启动Bookworm.bat快速启动,不更新配置每天日常使用
更新并启动Bookworm.bat先同步最新 Skills 再启动管理员通知有更新时
+

双击即可,无需打开 PowerShell,无需记命令。脚本会自动检测 PowerShell 7/5.1。

+ +
+ 💡 +
+ 启动时显示 "有新更新可用"?
+ 说明管理员推送了更新。双击 更新并启动Bookworm.bat 即可同步。 +
+
+ +
+

方法二:命令行(备用)

+

如果 .bat 文件无法运行,在 PowerShell 中手动执行:

+
+
+ # 快速启动 +cd bookworm-boot +pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly + +# 同步更新后启动 +cd bookworm-boot +pwsh -ExecutionPolicy Bypass -File install.ps1 +
+
+ + + + + + + +
+

5密码说明

+ +
+

本系统有两个密码,不要搞混

+ + + + +
名称用途何时输入
Gitea 密码下载文件(克隆仓库)首次安装时 git 弹出要求
主密码解密 API 凭证每次启动脚本提示输入
+
+ +
+ 💡 +
+ 密码输错了? 最多可以重试 3 次,不用紧张。3 次都错才会退出。 +
+
+ +
+

本日免密功能

+

首次解密成功后,脚本会询问 "今日内免密启动? (y/n)"

+

y 后,当天再次启动无需输入主密码,次日自动过期。

+

凭证缓存在 Windows Credential Manager 中(DPAPI 加密,仅当前用户可读)。

+
+ +
+ 🔒 +
主密码无法找回 — 忘记后联系管理员重新生成 secrets.enc。
+
+
+ + + + +
+

6使用完毕 — 清理 / 卸载

+ +
+

最简单:双击 卸载Bookworm.bat

+

bookworm-boot 文件夹里的 卸载Bookworm.bat,双击即可一键完整卸载:终止进程 + 清除凭证 + 恢复原始配置 + 删除桌面快捷方式。

+
+ +

或者用命令行精细控制:

+ + + + + + + + + + + + + + + + + +
场景命令说明
基础清理pwsh -File stop.ps1清除环境变量,保留配置供下次快速启动
完整恢复pwsh -File stop.ps1 -Restore删除 Bookworm,恢复电脑原始状态
深度清理pwsh -File stop.ps1 -Restore -Deep完整恢复 + 清除历史 + 清除 Git/凭证缓存
+ +
+ +
+ 在他人电脑/公用电脑上务必卸载:
+ 双击 卸载Bookworm.bat 或执行 pwsh -File stop.ps1 -Restore -Deep +
+
+
+ + + + +
+

!常见问题排查

+ +
+

❌ 输入 node -v 或 npm -v 提示 "无法识别"

+

原因:安装 Node.js 后没有重开 PowerShell 窗口,PATH 没刷新。

+

解决:关闭当前 PowerShell,重新打开一个新的窗口再试。

+

如果还不行,手动刷新 PATH:

+
+
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") +node -v +
+ +
+

❌ npm 报 "执行策略" / "Execution Policy" 错误

+

原因:Windows 默认禁止运行脚本。

+

解决:

+
+
+ Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned +
+

提示确认时输入 Y 回车。之后 npm 和 pwsh 脚本都能正常运行。

+ +
+

❌ 提示 "openssl 未找到"

+

原因:解密凭证需要 openssl,它随 Git for Windows 一起安装。

+

解决:确认 Git 已安装。脚本会自动搜索 C:\Program Files\GitD:\Git 下的 openssl。

+
+ +
+

❌ git clone 失败 / 认证失败

+

解决步骤:

+
    +
  1. 浏览器打开 https://code.letcareme.com 确认网站可访问
  2. +
  3. 确认用户名密码正确(区分大小写)
  4. +
  5. 如果开了 2FA,需要用 Access Token 替代密码
  6. +
  7. 检查网络是否需要代理
  8. +
+
+ +
+

❌ 解密凭证失败 / 主密码错误

+

原因:主密码区分大小写,且无法找回。

+

解决:仔细检查密码是否正确。如确认忘记,联系管理员重新生成 secrets.enc

+
+ +
+

❌ Claude Code 启动后没有 Bookworm 横幅

+

原因:配置文件未正确同步。

+

解决:不加 -StartOnly 重新运行安装脚本,让它重新 clone:

+
+
+ pwsh -ExecutionPolicy Bypass -File install.ps1 +
+ +
+

❌ 安装包下载太慢

+

解决:Node.js 官网在国内可能较慢,可以用淘宝镜像:

+
+
+ # 设置 npm 淘宝镜像(加速下载) +npm config set registry https://registry.npmmirror.com + +# 然后重新安装 Claude Code +npm i -g @anthropic-ai/claude-code +
+ +
+

❌ ECONNRESET / "Unable to connect to API"

+

原因:代理软件把国内中转站 bww.letcareme.com 的流量也走了国际线路,导致连接被重置。

+

解决:在 PowerShell 中手动设置 NO_PROXY 后重试:

+
+
+ # 设置中转站直连(不走代理) +$env:NO_PROXY = "bww.letcareme.com,code.letcareme.com" + +# 重新启动 +cd bookworm-boot +pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly +
+

新版安装脚本已自动设置 NO_PROXY,git pull 更新后此问题不再出现。

+ +
+

❌ "Not logged in" / 直接运行 claude 报错

+

原因:API 凭证是进程级环境变量,只在安装脚本启动的进程中有效。新开 PowerShell 窗口直接运行 claude 没有凭证。

+

解决:不要直接运行 claude,必须通过以下方式启动:

+
    +
  • 双击桌面 Bookworm 快捷方式
  • +
  • 双击 启动Bookworm.bat
  • +
  • 命令行:pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly
  • +
+
+ +
+

❌ 完整性校验不匹配(大量文件 WARN)

+

原因:本地配置文件已被更新(管理员推送了新版本),但 integrity.sha256 未同步更新。

+

解决:y 继续即可,不影响使用。管理员会在下个版本同步哈希文件。

+
+ +
+

❌ 需要自己的 Claude 账号吗?

+

不需要。所有 API 请求通过中转站转发,消耗中转站额度。目标机不需要任何 Anthropic 账号或订阅。

+
+ +
+

❌ 询问 AI 系统内部信息时被拒绝了?

+

这是正常行为。Bookworm 的技能库、路由引擎、配置架构属于技术保密范围,AI 被设定为不披露这些信息。

+

解决:直接告诉 AI 你要完成的任务(写代码、分析问题、设计方案等),它会自动调用最合适的专家能力来帮你。无需了解内部机制即可获得完整服务。

+
+
+ + + + +
+

安装检查清单

+

逐项确认,全部打勾即可开始使用:

+
    +
  • Node.js 已安装node -v 显示版本号
  • +
  • Git 已安装git --version 显示版本号
  • +
  • npm 可用npm -v 显示版本号(如报错先设 ExecutionPolicy)
  • +
  • Claude Code 已安装claude --version 显示版本号
  • +
  • PowerShell 7 已安装pwsh --version 显示 7.x(推荐但非必须)
  • +
  • 已获取 Gitea 账号密码 — 管理员提供
  • +
  • 已获取主密码 — 管理员提供(用于解密 API 凭证)
  • +
  • 能访问 code.letcareme.com — 浏览器打开确认
  • +
  • 代理/VPN 已启动 — 国内必须,脚本自动检测 (Clash/V2Ray/快柠檬等)
  • +
+
+ + + + +
+

快速参考

+ + + + + + + + +
操作最简方式命令行方式
首次安装git clone + 双击
更新并启动Bookworm.bat
pwsh -ExecutionPolicy Bypass -File install.ps1
快速启动双击 启动Bookworm.batpwsh -File install.ps1 -StartOnly
同步更新双击 更新并启动Bookworm.batpwsh -File install.ps1
基础清理pwsh -ExecutionPolicy Bypass -File stop.ps1
完整恢复pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore
深度清理pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore -Deep
+
+ + + + +
+

安全须知

+ + + + + + +
特性规格
凭证加密AES-256-CBC + PBKDF2 (600,000 迭代)
传输加密HTTPS (TLS 1.2+, Let's Encrypt 证书)
凭证存储进程级环境变量 + 可选本日缓存 (Windows Credential Manager, DPAPI 加密, 当日 23:59 过期)
登录保护fail2ban (5 次失败/小时 → 封禁 24 小时)
+
+ 🔒 +
+ 主密码无法找回 — 请妥善保管。忘记后需管理员重新生成加密凭证。 +
+
+
+ +
+ + + + + + + diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..1cedc82 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,726 @@ +<# +.SYNOPSIS + Bookworm Portable - 安装/启动脚本 +.DESCRIPTION + 从 Gitea 私有仓克隆 Bookworm 配置到目标机, + 解密凭证, 渲染模板, 启动 Claude Code. +.USAGE + # 首次安装 (从 Gitea 克隆) + .\install.ps1 + + # 仅启动 (已安装过) + .\install.ps1 -StartOnly + + # 指定 Gitea 地址 + .\install.ps1 -GitUrl "https://code.letcareme.com/bookworm/bookworm-config.git" +#> + +param( + [string]$GitUrl = "https://code.letcareme.com/bookworm/bookworm-config.git", + [switch]$StartOnly, + [switch]$SkipSecrets, + [switch]$AutoAccept # 豁免所有人工确认环节 +) + +$ErrorActionPreference = "Stop" + +# ─── 路径定义 ──────────────────────────────────────── +$ScriptDir = if ($MyInvocation.MyCommand.Path) { + Split-Path -Parent $MyInvocation.MyCommand.Path +} else { $PWD.Path } +$ClaudeTarget = Join-Path $env:USERPROFILE ".claude" +$BackupPath = Join-Path $env:USERPROFILE ".claude.bw-backup" +$SecretsEnc = Join-Path $ScriptDir "secrets.enc" +$TemplateFile = Join-Path $ClaudeTarget "settings.template.json" +$LocalTplFile = Join-Path $ClaudeTarget "settings.local.template.json" +$SettingsFile = Join-Path $ClaudeTarget "settings.json" +$LocalSetFile = Join-Path $ClaudeTarget "settings.local.json" + +# ─── openssl 检测 ──────────────────────────────────── +$cmd = Get-Command openssl -ErrorAction SilentlyContinue +$opensslCmd = if ($cmd) { $cmd.Source } else { $null } +if (-not $opensslCmd) { + $searchPaths = @("C:\Program Files\Git\usr\bin\openssl.exe", "D:\Git\usr\bin\openssl.exe", "D:\Git\mingw64\bin\openssl.exe", "C:\Program Files\Git\mingw64\bin\openssl.exe") + $opensslCmd = $searchPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +# ─── 辅助函数 ──────────────────────────────────────── + +function Write-Banner { + Write-Host "" + Write-Host " +------------------------------------------+" -ForegroundColor Cyan + Write-Host " | Bookworm Portable Installer v1.5 |" -ForegroundColor Cyan + Write-Host " | 92 Skills / 18 Agents / 34 Hooks |" -ForegroundColor Cyan + Write-Host " +------------------------------------------+" -ForegroundColor Cyan + Write-Host "" +} + +function Test-Command($cmd) { + return [bool](Get-Command $cmd -ErrorAction SilentlyContinue) +} + +# ─── 密码本日免输 (Windows Credential Manager) ────── +function Get-CachedSecrets { + try { + $cred = cmdkey /list 2>$null | Select-String "bookworm-secrets" + if ($cred) { + # 从 Credential Manager 读取缓存的环境变量 + $regPath = "HKCU:\Software\Bookworm\CachedEnv" + if (Test-Path $regPath) { + $props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue + $loaded = 0 + foreach ($p in $props.PSObject.Properties) { + if ($p.Name -match '^[A-Z_]+$') { + [System.Environment]::SetEnvironmentVariable($p.Name, $p.Value, "Process") + $loaded++ + } + } + if ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY) { + Write-Host " [OK] 从本日缓存加载 $loaded 个凭证 (免密)" -ForegroundColor Green + return $true + } + } + } + } catch {} + return $false +} + +function Save-SecretsToCache { + try { + # 用 Credential Manager 标记缓存存在 + cmdkey /generic:bookworm-secrets /user:bw /pass:cached 2>$null | Out-Null + # 用 HKCU 注册表存凭证值 (DPAPI 保护, 仅当前用户可读) + $regPath = "HKCU:\Software\Bookworm\CachedEnv" + if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null } + $envKeys = @("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "GITHUB_PERSONAL_ACCESS_TOKEN", + "SLACK_BOT_TOKEN", "ATLASSIAN_API_TOKEN", "BROWSERBASE_API_KEY", "FIRECRAWL_API_KEY") + foreach ($k in $envKeys) { + $v = [System.Environment]::GetEnvironmentVariable($k, "Process") + if ($v) { Set-ItemProperty $regPath -Name $k -Value $v -Force } + } + # 设置过期时间 (今日 23:59:59) + $expiry = (Get-Date).Date.AddDays(1).ToString("o") + Set-ItemProperty $regPath -Name "_expiry" -Value $expiry -Force + Write-Host " [OK] 凭证已缓存至今日 23:59 (下次免密)" -ForegroundColor Green + } catch {} +} + +function Clear-SecretsCache { + cmdkey /delete:bookworm-secrets 2>$null | Out-Null + Remove-Item "HKCU:\Software\Bookworm" -Recurse -Force -ErrorAction SilentlyContinue +} + +# ─── 依赖自动安装 ──────────────────────────────────── +function Install-MissingDeps { + $missing = @() + if (-not (Test-Command "node")) { $missing += "Node.js" } + if (-not (Test-Command "git")) { $missing += "Git" } + if (-not (Test-Command "claude")) { $missing += "Claude Code" } + + if ($missing.Count -eq 0) { return } + + $hasWinget = Test-Command "winget" + Write-Host "" + Write-Host " 缺少以下软件: $($missing -join ', ')" -ForegroundColor Yellow + + if ($hasWinget) { + $auto = if ($AutoAccept) { 'y' } else { Read-Host " 是否用 winget 自动安装? (y/n)" } + if ($auto -eq 'y') { + if ($missing -contains "Node.js") { + Write-Host " 安装 Node.js..." -ForegroundColor Gray + winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + # 刷新 PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + if ($missing -contains "Git") { + Write-Host " 安装 Git..." -ForegroundColor Gray + winget install Git.Git --accept-source-agreements --accept-package-agreements 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + if ($missing -contains "Claude Code" -and (Test-Command "npm")) { + Write-Host " 安装 Claude Code..." -ForegroundColor Gray + npm i -g @anthropic-ai/claude-code 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + } + # 刷新检测 + Write-Host " 重新检测..." -ForegroundColor Gray + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + } else { + Write-Host " 请手动安装后重新运行本脚本:" -ForegroundColor Yellow + if ($missing -contains "Node.js") { Write-Host " Node.js: https://nodejs.org" -ForegroundColor Gray } + if ($missing -contains "Git") { Write-Host " Git: https://git-scm.com" -ForegroundColor Gray } + if ($missing -contains "Claude Code") { Write-Host " Claude: npm i -g @anthropic-ai/claude-code" -ForegroundColor Gray } + exit 1 + } +} + +# ─── 桌面快捷方式 ──────────────────────────────────── +function New-DesktopShortcuts { + $desktop = [System.Environment]::GetFolderPath("Desktop") + $bootDir = $ScriptDir + + # 启动Bookworm 快捷方式 — 优先 pwsh (PS7),回退 powershell (PS5) + $lnkPath = Join-Path $desktop "Bookworm.lnk" + if (-not (Test-Path $lnkPath)) { + try { + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($lnkPath) + $scriptPath = Join-Path $bootDir "install.ps1" + $hasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue) + $psExe = if ($hasPwsh) { (Get-Command pwsh).Source } else { "powershell.exe" } + $shortcut.TargetPath = $psExe + $shortcut.Arguments = "-NoLogo -ExecutionPolicy Bypass -Command `"Set-Item Env:NO_PROXY 'bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1'; & '$scriptPath' -StartOnly -AutoAccept`"" + $shortcut.WorkingDirectory = $bootDir + $shortcut.Description = "Bookworm Smart Assistant" + $shortcut.Save() + $psVer = if ($hasPwsh) { "PowerShell 7" } else { "PowerShell 5.1" } + Write-Host " [OK] 桌面快捷方式已创建: Bookworm ($psVer)" -ForegroundColor Green + } catch { + Write-Host " [!] 桌面快捷方式创建失败 (不影响使用)" -ForegroundColor Gray + } + } +} + +function Decrypt-Secrets { + if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) { + Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow + return + } + + # 优先用 Node.js 解密 (跨平台兼容性最高), 回退 openssl + $useNode = (Test-Command "node") -and (Test-Path (Join-Path $ScriptDir "crypto-helper.js")) + if (-not $useNode -and -not $opensslCmd) { + Write-Host " [!] node 和 openssl 均不可用,跳过凭证解密" -ForegroundColor Yellow + return + } + $cryptoHelper = Join-Path $ScriptDir "crypto-helper.js" + + $maxRetries = 3 + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { + $label = if ($attempt -gt 1) { " 重新输入主密码 (第 $attempt/$maxRetries 次)" } else { " 输入主密码解密凭证" } + $password = Read-Host $label -AsSecureString + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) + $plainPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + + if ($useNode) { + # Node.js 解密 (跨平台一致) + $decrypted = & node $cryptoHelper decrypt $plainPwd $SecretsEnc 2>&1 + $decExit = $LASTEXITCODE + } else { + # openssl 回退 + $decrypted = $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -md sha256 -in $SecretsEnc -pass stdin 2>&1 + $decExit = $LASTEXITCODE + } + $ErrorActionPreference = $prevEAP + + # 清除内存中的密码 + $plainPwd = $null + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + + if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') { + # 解密成功,注入环境变量 + $decrypted -split "`n" | ForEach-Object { + $line = $_.Trim() + if ($line -and $line.Contains('=')) { + $eqIdx = $line.IndexOf('=') + $key = $line.Substring(0, $eqIdx).Trim() + $val = $line.Substring($eqIdx + 1).Trim() + [System.Environment]::SetEnvironmentVariable($key, $val, "Process") + Write-Host " [OK] 已注入: $key" -ForegroundColor Green + } + } + return + } + + # 解密失败 + $remaining = $maxRetries - $attempt + if ($remaining -gt 0) { + Write-Host " [!!] 密码错误,剩余重试: $remaining 次" -ForegroundColor Red + } + } + + # 3次全部失败 + Write-Host "" + Write-Host " [ABORT] 3 次密码均错误" -ForegroundColor Red + Write-Host " 请确认主密码是否正确 (区分大小写)" -ForegroundColor Yellow + Write-Host " 如忘记密码,请联系管理员重新生成 secrets.enc" -ForegroundColor Yellow + exit 1 +} + +function Render-SettingsTemplate { + if (-not (Test-Path $TemplateFile)) { + Write-Host " [!] 未找到 settings.template.json,跳过渲染" -ForegroundColor Yellow + return + } + + $claudeRoot = $ClaudeTarget.Replace('\', '/') + # HOME 保留反斜杠格式,与 Claude Code 原始行为一致 + $homeDir = $env:USERPROFILE + + $content = Get-Content $TemplateFile -Raw + $content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot + $content = $content -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\') + + Set-Content $SettingsFile -Value $content -Encoding UTF8 + Write-Host " [OK] settings.json 已渲染 (ROOT=$claudeRoot)" -ForegroundColor Green + + # 渲染 settings.local.template.json (如果存在) + if (Test-Path $LocalTplFile) { + $localContent = Get-Content $LocalTplFile -Raw + $localContent = $localContent -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot + $localContent = $localContent -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\') + $localContent = $localContent -replace '\{\{USERNAME\}\}', $env:USERNAME + Set-Content $LocalSetFile -Value $localContent -Encoding UTF8 + Write-Host " [OK] settings.local.json 已渲染" -ForegroundColor Green + } +} + +# ─── 代理自动检测 ──────────────────────────────────── +function Detect-SystemProxy { + # 中转站在国内阿里云,不走代理 + $env:NO_PROXY = "bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" + $env:no_proxy = $env:NO_PROXY + + # 如果已手动设置了 HTTPS_PROXY,直接使用 + if ($env:HTTPS_PROXY) { + Write-Host " [OK] 已设置 HTTPS_PROXY=$($env:HTTPS_PROXY)" -ForegroundColor Green + Write-Host " [OK] NO_PROXY=$($env:NO_PROXY)" -ForegroundColor Green + return + } + + Write-Host " 检测系统代理..." -ForegroundColor Gray + + # 方法1: 通过 .NET 获取系统代理 (最可靠,支持 Clash/V2Ray/快柠檬/系统代理) + try { + $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") + if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { + $proxyUrl = "http://$($proxyUri.Authority)" + $env:HTTPS_PROXY = $proxyUrl + $env:HTTP_PROXY = $proxyUrl + Write-Host " [OK] 检测到系统代理: $proxyUrl" -ForegroundColor Green + return + } + } catch {} + + # 方法2: 读取注册表 IE 代理设置 + try { + $reg = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -ErrorAction SilentlyContinue + if ($reg.ProxyEnable -eq 1 -and $reg.ProxyServer) { + $proxy = $reg.ProxyServer + if ($proxy -notmatch '^http') { $proxy = "http://$proxy" } + $env:HTTPS_PROXY = $proxy + $env:HTTP_PROXY = $proxy + Write-Host " [OK] 检测到 IE 代理: $proxy" -ForegroundColor Green + return + } + } catch {} + + # 方法3: 扫描常见代理端口 (500ms 超时,避免阻塞) + $commonPorts = @(7890, 7891, 7893, 10792, 10793, 10808, 10809, 1080, 1087, 8080, 8118) + foreach ($port in $commonPorts) { + try { + $tcp = New-Object System.Net.Sockets.TcpClient + $result = $tcp.BeginConnect("127.0.0.1", $port, $null, $null) + $success = $result.AsyncWaitHandle.WaitOne(500) + if (-not $success) { $tcp.Close(); continue } + $tcp.EndConnect($result) + $tcp.Close() + $env:HTTPS_PROXY = "http://127.0.0.1:$port" + $env:HTTP_PROXY = "http://127.0.0.1:$port" + Write-Host " [OK] 检测到本地代理端口: $port" -ForegroundColor Green + return + } catch {} + } + + # 未找到代理 + Write-Host " [!!] 未检测到代理/VPN" -ForegroundColor Red + Write-Host "" + Write-Host " Claude Code 需要代理才能在国内使用 (启动时检查 api.anthropic.com)" -ForegroundColor Yellow + Write-Host " 请先启动代理软件 (Clash/V2Ray/快柠檬等),然后重新运行本脚本" -ForegroundColor Yellow + Write-Host "" + Write-Host " 如已有代理,可手动指定:" -ForegroundColor Gray + Write-Host " `$env:HTTPS_PROXY = 'http://127.0.0.1:端口号'" -ForegroundColor Gray + Write-Host " pwsh -ExecutionPolicy Bypass -File install.ps1" -ForegroundColor Gray + Write-Host "" + $continue = if ($AutoAccept) { 'y' } else { Read-Host " 无代理继续? (y/n,无代理大概率启动失败)" } + if ($continue -ne 'y') { exit 1 } +} + +# ─── 主流程 ────────────────────────────────────────── + +Write-Banner + +# 步骤 1: 前置检查 +Write-Host "[1/9] 前置检查..." -ForegroundColor White +$checks = @( + @{ Name = "Claude Code"; OK = (Test-Command "claude") } + @{ Name = "Node.js"; OK = (Test-Command "node") } + @{ Name = "Git"; OK = (Test-Command "git") } +) +foreach ($c in $checks) { + $icon = if ($c.OK) { "[OK]" } else { "[!!]" } + $color = if ($c.OK) { "Green" } else { "Red" } + Write-Host " $icon $($c.Name)" -ForegroundColor $color +} +if (-not (Test-Command "claude") -or -not (Test-Command "node") -or -not (Test-Command "git")) { + Install-MissingDeps +} +# 再次验证 +if (-not (Test-Command "claude")) { + Write-Host "`n [ABORT] Claude Code 未安装" -ForegroundColor Red + Write-Host " 安装: npm i -g @anthropic-ai/claude-code" -ForegroundColor Gray + exit 1 +} +if (-not (Test-Command "node")) { + Write-Host "`n [ABORT] Node.js 未安装" -ForegroundColor Red + exit 1 +} + +# 步骤 2: 代理检测 (国内必须) +Write-Host "`n[2/9] 代理检测..." -ForegroundColor White + +Detect-SystemProxy + +# 步骤 3: 解密凭证 (优先使用本日缓存) +Write-Host "`n[3/9] 解密凭证..." -ForegroundColor White +# 检查缓存是否过期 +$cacheExpiry = $null +try { + $cacheExpiry = Get-ItemProperty "HKCU:\Software\Bookworm\CachedEnv" -Name "_expiry" -ErrorAction SilentlyContinue +} catch {} +$cacheValid = $false +if ($cacheExpiry -and $cacheExpiry._expiry) { + try { $cacheValid = [datetime]$cacheExpiry._expiry -gt (Get-Date) } catch {} +} + +if ($cacheValid -and (Get-CachedSecrets)) { + # 缓存有效,跳过解密 +} elseif ($AutoAccept) { + # AutoAccept 模式: 无缓存时跳过密码输入 + Write-Host " [!] AutoAccept 模式: 无有效缓存,跳过凭证解密" -ForegroundColor Yellow + Write-Host " 如需凭证,请不加 -AutoAccept 手动运行一次" -ForegroundColor Yellow +} else { + Clear-SecretsCache + Decrypt-Secrets + # 解密成功后询问是否缓存 + if ($env:ANTHROPIC_API_KEY) { + $cache = if ($AutoAccept) { 'y' } else { Read-Host " 今日内免密启动? (y/n)" } + if ($cache -eq 'y') { Save-SecretsToCache } + } +} + +# 自动配置 git credential helper (避免 clone 时反复要密码) +$prevEAP = $ErrorActionPreference +$ErrorActionPreference = "Continue" +git config --global credential.helper store 2>$null +$ErrorActionPreference = $prevEAP + +# 步骤 4: 克隆/更新仓库 +if (-not $StartOnly) { + Write-Host "`n[4/9] 同步 Bookworm 配置..." -ForegroundColor White + + if (Test-Path $ClaudeTarget) { + $isGit = Test-Path (Join-Path $ClaudeTarget ".git") + if ($isGit) { + Write-Host " 已有仓库,执行 git pull..." + Push-Location $ClaudeTarget + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $stashOutput = git stash 2>&1 + $hasStash = $stashOutput -notmatch 'No local changes' + git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" } + if ($hasStash) { git stash pop 2>&1 | Out-Null } + } + catch { + Write-Host " [WARN] git pull 失败: $_" -ForegroundColor Yellow + Write-Host " 使用本地现有版本继续" -ForegroundColor Yellow + } + finally { + $ErrorActionPreference = $prevEAP + Pop-Location + } + } + else { + # 安全克隆: 先克隆到临时目录,成功后再替换 + Write-Host " 备份现有 .claude/ 并克隆..." + $tempClone = "$ClaudeTarget.bw-clone-temp" + if (Test-Path $tempClone) { Remove-Item $tempClone -Recurse -Force } + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + git clone --depth 1 $GitUrl $tempClone 2>&1 | ForEach-Object { Write-Host " $_" } + $cloneExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + + if ($cloneExit -ne 0 -or -not (Test-Path (Join-Path $tempClone "CLAUDE.md"))) { + Write-Host " [ERROR] 克隆失败 (exit=$cloneExit)" -ForegroundColor Red + if (Test-Path $tempClone) { Remove-Item $tempClone -Recurse -Force -ErrorAction SilentlyContinue } + Write-Host " 原始 .claude/ 未被修改" -ForegroundColor Yellow + exit 1 + } + + # 克隆成功,执行替换 + if (Test-Path $BackupPath) { Remove-Item $BackupPath -Recurse -Force } + Rename-Item $ClaudeTarget $BackupPath + Rename-Item $tempClone $ClaudeTarget + Write-Host " [OK] 克隆完成,原始配置已备份到 .claude.bw-backup/" -ForegroundColor Green + } + } + else { + Write-Host " 首次安装,克隆仓库..." + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + git clone --depth 1 $GitUrl $ClaudeTarget 2>&1 | ForEach-Object { Write-Host " $_" } + $cloneExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + + if ($cloneExit -ne 0 -or -not (Test-Path (Join-Path $ClaudeTarget "CLAUDE.md"))) { + Write-Host " [ERROR] 克隆失败 (exit=$cloneExit)" -ForegroundColor Red + Write-Host "" + Write-Host " 可能原因:" -ForegroundColor Yellow + Write-Host " - Gitea 服务不可达 (检查 https://code.letcareme.com)" -ForegroundColor Yellow + Write-Host " - 网络连接问题 (检查 DNS 和防火墙)" -ForegroundColor Yellow + Write-Host " - Git 凭证错误 (检查用户名密码)" -ForegroundColor Yellow + Write-Host "" + Write-Host " 离线模式: 如有本地 .claude 备份,运行:" -ForegroundColor Gray + Write-Host " Copy-Item .claude.bw-backup .claude -Recurse" -ForegroundColor Gray + exit 1 + } + } +} +else { + Write-Host "`n[4/9] StartOnly 模式,跳过同步" -ForegroundColor Gray + # 静默检测远程更新 + $configDir = Join-Path $env:USERPROFILE ".claude" + if (Test-Path (Join-Path $configDir ".git")) { + $prevEAP2 = $ErrorActionPreference + $ErrorActionPreference = "Continue" + git -C $configDir fetch --quiet 2>$null + $behind = git -C $configDir rev-list "HEAD..origin/main" --count 2>$null + $ErrorActionPreference = $prevEAP2 + if ($behind -and [int]$behind -gt 0) { + Write-Host " [!] Bookworm 有 $behind 个新更新可用" -ForegroundColor Yellow + Write-Host " 双击 '更新并启动Bookworm.bat' 可同步最新版本" -ForegroundColor Yellow + } + } +} + +# 步骤 5: 完整性校验 +$integrityFile = Join-Path $ClaudeTarget "integrity.sha256" +if (Test-Path $integrityFile) { + Write-Host "`n[5/9] 完整性校验..." -ForegroundColor White + $failures = @() + Get-Content $integrityFile | ForEach-Object { + if ($_ -match '^([a-f0-9]{64})\s+(.+)$') { + $expectedHash = $Matches[1] + $filePath = Join-Path $ClaudeTarget $Matches[2] + if (Test-Path $filePath) { + $actualHash = (Get-FileHash $filePath -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $expectedHash) { + $failures += $Matches[2] + } + } + } + } + if ($failures.Count -gt 0) { + Write-Host " [WARN] 以下文件哈希不匹配:" -ForegroundColor Yellow + $failures | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + Write-Host " 可能原因: 仓库内容被修改或本地有改动" -ForegroundColor Yellow + $continue = if ($AutoAccept) { 'y' } else { Read-Host " 继续? (y/n)" } + if ($continue -ne 'y') { exit 1 } + } else { + Write-Host " [OK] 所有文件完整性校验通过" -ForegroundColor Green + } +} else { + Write-Host "`n[5/9] 跳过完整性校验 (无 integrity.sha256)" -ForegroundColor Gray +} + +# 步骤 6: 渲染 settings.json +Write-Host "`n[6/9] 渲染配置模板..." -ForegroundColor White +Render-SettingsTemplate + +# 步骤 7: 确保必要目录存在 +Write-Host "`n[7/9] 初始化本地目录..." -ForegroundColor White +$localDirs = @("debug", "sessions", "cache", "backups", "telemetry", "shell-snapshots", "projects", "memory") +foreach ($d in $localDirs) { + $dirPath = Join-Path $ClaudeTarget $d + if (-not (Test-Path $dirPath)) { + New-Item -ItemType Directory -Path $dirPath -Force | Out-Null + Write-Host " 创建: $d/" -ForegroundColor Gray + } +} + +# 设置环境变量 (进程级) +$env:CLAUDE_HOME = $ClaudeTarget +Write-Host " [OK] CLAUDE_HOME=$ClaudeTarget" -ForegroundColor Green + +# 验证环境变量传递 +$nodeCheck = & node -e "console.log(process.env.CLAUDE_HOME || 'NOT_SET')" 2>$null +if ($nodeCheck -eq $ClaudeTarget) { + Write-Host " [OK] Node.js 环境变量传递验证通过" -ForegroundColor Green +} else { + Write-Host " [WARN] Node.js 环境变量传递异常,hooks 可能无法正确解析路径" -ForegroundColor Yellow +} + +# 步骤 8: Bookworm 完整性验证 + MCP 检查 +Write-Host "`n[8/9] Bookworm 系统验证..." -ForegroundColor White + +# --- Bookworm vs 原生 Claude Code 检测 --- +$bwChecks = @() +$claudeMd = Join-Path $ClaudeTarget "CLAUDE.md" +$skillsDir = Join-Path $ClaudeTarget "skills" +$hooksDir = Join-Path $ClaudeTarget "hooks" +$settingsF = Join-Path $ClaudeTarget "settings.json" + +# 检查 CLAUDE.md +if (Test-Path $claudeMd) { + $content = Get-Content $claudeMd -Raw -ErrorAction SilentlyContinue + if ($content -match "Bookworm") { + $bwChecks += @{ Name = "CLAUDE.md (Bookworm 指令)"; OK = $true } + } else { + $bwChecks += @{ Name = "CLAUDE.md (缺少 Bookworm 指令)"; OK = $false } + } +} else { + $bwChecks += @{ Name = "CLAUDE.md (文件不存在!)"; OK = $false } +} + +# 检查 Skills +$skillCount = 0 +if (Test-Path $skillsDir) { + $skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count +} +$bwChecks += @{ Name = "Skills ($skillCount 个)"; OK = ($skillCount -gt 50) } + +# 检查 Hooks +$hookCount = 0 +if (Test-Path $hooksDir) { + $hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count +} +$bwChecks += @{ Name = "Hooks ($hookCount 个)"; OK = ($hookCount -gt 10) } + +# 检查 settings.json hooks 配置 +$hasHooks = $false +if (Test-Path $settingsF) { + $sContent = Get-Content $settingsF -Raw -ErrorAction SilentlyContinue + if ($sContent -match '"hooks"') { $hasHooks = $true } +} +$bwChecks += @{ Name = "Settings hooks 配置"; OK = $hasHooks } + +# 输出验证结果 +$allOK = $true +foreach ($c in $bwChecks) { + $icon = if ($c.OK) { "[OK]" } else { "[!!]" } + $color = if ($c.OK) { "Green" } else { "Red" } + Write-Host " $icon $($c.Name)" -ForegroundColor $color + if (-not $c.OK) { $allOK = $false } +} + +if (-not $allOK) { + Write-Host "" + Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Yellow + Write-Host " ║ [!] 警告: Bookworm 系统不完整 ║" -ForegroundColor Yellow + Write-Host " ║ Claude Code 将以原生模式启动 (无 Skills/Hooks) ║" -ForegroundColor Yellow + Write-Host " ║ 建议: 不加 -StartOnly 重新运行 install.ps1 同步 ║" -ForegroundColor Yellow + Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Yellow +} else { + Write-Host " [OK] Bookworm 系统完整 ($skillCount Skills / $hookCount Hooks)" -ForegroundColor Green +} + +# --- MCP 依赖检查 (中文提醒) --- +Write-Host "" +Write-Host " MCP 服务检查:" -ForegroundColor Gray +$mcpWarnings = @() + +# Python (askui/pywinauto/com-server 需要) +$hasPython = [bool](Get-Command python -ErrorAction SilentlyContinue) +if (-not $hasPython) { + $mcpWarnings += " [!] Python 未安装 - askui/pywinauto/com-server MCP 不可用" + $mcpWarnings += " 安装: https://www.python.org/downloads/ 或 winget install Python.Python.3.12" +} + +# Playwright (浏览器自动化 MCP) — 轻量检测,避免 npx 触发安装 +$hasPlaywright = $false +try { + $pwPath = & npm list -g @playwright/mcp 2>$null + if ($pwPath -and $pwPath -notmatch 'empty') { $hasPlaywright = $true } +} catch {} +if (-not $hasPlaywright) { + $mcpWarnings += " [!] Playwright MCP 未安装 - 浏览器自动化功能不可用" + $mcpWarnings += " 安装: npm i -g @playwright/mcp && npx playwright install" +} + +# 检查关键 API Key 环境变量 +$apiChecks = @( + @{ Key = "ANTHROPIC_API_KEY"; Name = "Claude API (中转站)" } + @{ Key = "ANTHROPIC_BASE_URL"; Name = "API 中转站地址" } +) +foreach ($ak in $apiChecks) { + $val = [System.Environment]::GetEnvironmentVariable($ak.Key, "Process") + if (-not $val) { + $mcpWarnings += " [!] $($ak.Key) 未设置 - $($ak.Name)不可用" + $mcpWarnings += " 需要管理员重新加密凭证并更新 secrets.enc" + } +} + +# 可选 MCP API Key 检查 +$optionalApis = @( + @{ Key = "GITHUB_PERSONAL_ACCESS_TOKEN"; Name = "GitHub MCP"; Cmd = "仓库管理/代码搜索" } + @{ Key = "SLACK_BOT_TOKEN"; Name = "Slack MCP"; Cmd = "消息发送/频道管理" } + @{ Key = "BROWSERBASE_API_KEY"; Name = "Browserbase MCP"; Cmd = "云端浏览器" } + @{ Key = "FIRECRAWL_API_KEY"; Name = "Firecrawl MCP"; Cmd = "网页抓取/搜索" } +) +$missingOptional = @() +foreach ($api in $optionalApis) { + $val = [System.Environment]::GetEnvironmentVariable($api.Key, "Process") + if (-not $val) { + $missingOptional += $api + } +} + +if ($mcpWarnings.Count -gt 0) { + foreach ($w in $mcpWarnings) { + Write-Host $w -ForegroundColor Yellow + } +} + +if ($missingOptional.Count -gt 0) { + Write-Host "" + Write-Host " 可选 MCP 服务 (未配置, 不影响核心功能):" -ForegroundColor Gray + foreach ($m in $missingOptional) { + Write-Host " [-] $($m.Name) ($($m.Cmd))" -ForegroundColor DarkGray + } + Write-Host " 如需使用, 请联系管理员将 API Key 加入 secrets.enc" -ForegroundColor DarkGray +} + +if ($mcpWarnings.Count -eq 0) { + Write-Host " [OK] 核心 API 已配置" -ForegroundColor Green +} + +# 步骤 9: 启动 Claude Code +Write-Host "`n[9/9] 启动 Claude Code..." -ForegroundColor White +Write-Host "" +if ($allOK) { + Write-Host " ╔══════════════════════════════════════════╗" -ForegroundColor Green + Write-Host " ║ Bookworm 就绪! 正在启动 Claude Code... ║" -ForegroundColor Green + Write-Host " ╚══════════════════════════════════════════╝" -ForegroundColor Green +} else { + Write-Host " ╔══════════════════════════════════════════╗" -ForegroundColor Yellow + Write-Host " ║ 原生模式启动 (Bookworm 不完整) ║" -ForegroundColor Yellow + Write-Host " ╚══════════════════════════════════════════╝" -ForegroundColor Yellow +} +Write-Host "" + +# 首次安装: 创建桌面快捷方式 + 打开使用教程 +if (-not $StartOnly) { + New-DesktopShortcuts + $guidePath = Join-Path $ScriptDir "guide.html" + if (Test-Path $guidePath) { + Start-Process $guidePath + Write-Host " [OK] 使用教程已在浏览器打开" -ForegroundColor Gray + } +} + +& claude diff --git a/lessons-learned.md b/lessons-learned.md new file mode 100644 index 0000000..aa3330f --- /dev/null +++ b/lessons-learned.md @@ -0,0 +1,206 @@ +# Bookworm Portable 项目经验与避坑指南 + +> 2026-04-01 | 从可行性评估到 E2E 验证的完整实战记录 + +--- + +## 一、项目概要 + +**目标**: 让任意 Windows 电脑通过一行命令激活 Bookworm (97 Skills + 18 Agents + 28 Hooks) + 中转站 API + +**最终架构**: Gitea 私有仓 (code.letcareme.com) + AES-256 加密凭证 + 自动代理检测 + 一键安装脚本 + +**耗时**: 1 天 (评估 → 设计 → 三路专家审查 → P0/P1 修复 → 部署 → E2E 验证 → 踩坑修复) + +--- + +## 二、关键决策与转折点 + +### 决策 1: USB → Git 方案切换 +- **初始方案**: U盘存储全部系统文件 + 加密凭证 +- **问题**: Windows NTFS Junction 跨驱动器失败 (实测 Accessible: False)、USB IOPS 瓶颈 (hook 延迟 200-600ms)、体积 2.1GB +- **最终方案**: Git + 模板渲染,USB 变为可选 (纯云端即可) +- **教训**: 方案评估阶段多投入时间做实测,不要假设跨驱动器操作能工作 + +### 决策 2: GitHub → 国内 Gitea +- **问题**: 国内无 VPN 无法访问 GitHub +- **方案**: 阿里云 ECS 自建 Gitea,国内直连 +- **教训**: 面向国内用户的工具链必须全程可达,不能依赖海外服务 + +### 决策 3: bookworm.letcareme.com → code.letcareme.com +- **问题**: bookworm.letcareme.com 已被 Bookworm Web 项目 Nginx 配置占用 +- **解决**: 换用 code.letcareme.com,申请新证书 +- **教训**: 部署前检查域名占用,`grep -r "域名" /etc/nginx/` 先扫一遍 + +--- + +## 三、必踩的坑 (按严重度排序) + +### 坑 1: Claude Code 国内启动检查 (CRITICAL) +``` +现象: "Failed to connect to api.anthropic.com: ERR_BAD_REQUEST" +原因: Claude Code 启动时硬编码检查 api.anthropic.com,与 ANTHROPIC_BASE_URL 无关 +影响: 国内无代理 = Claude Code 完全无法启动 +解决: 必须有代理/VPN,且必须设 HTTPS_PROXY 环境变量 (Node.js 不读系统代理) +耗时: 2 小时排查 +``` + +### 坑 2: Node.js 不读 Windows 系统代理 (CRITICAL) +``` +现象: PowerShell (Invoke-WebRequest) 能通,Claude Code 不通 +原因: Node.js 只读 HTTPS_PROXY/HTTP_PROXY 环境变量,不读 Windows IE/系统代理设置 +解决: 用 [System.Net.WebRequest]::DefaultWebProxy.GetProxy() 发现实际代理端口 + 然后 $env:HTTPS_PROXY = "http://127.0.0.1:端口" +教训: 系统代理 ≠ 进程代理,Node.js 应用必须显式设环境变量 +``` + +### 坑 3: settings.json 中 ${VAR} 不展开 (CRITICAL) +``` +现象: "API Error: Invalid URL" +原因: settings.template.json 中 "ANTHROPIC_BASE_URL": "${ANTHROPIC_BASE_URL}" + Claude Code 把 ${ANTHROPIC_BASE_URL} 当字面字符串,不展开环境变量 + 实际发送的 URL 是 "${ANTHROPIC_BASE_URL}/v1/messages" +解决: 从 settings.json env 段删除 ANTHROPIC_API_KEY 和 ANTHROPIC_BASE_URL + 让 install.ps1 注入的进程环境变量直接生效 +教训: Claude Code settings.json 的 ${} 语法行为未文档化,不要假设它能展开 +``` + +### 坑 4: PowerShell 5.1 的 ?. 语法 (HIGH) +``` +现象: "表达式或语句中包含意外的标记 '?.Source'" +原因: ?. (null-conditional operator) 是 PS 7+ 专属语法,PS 5.1 不支持 +解决: 改为 if ($x) { $x.Property } else { $null } +教训: 目标机可能只有 PS 5.1 (Windows 自带),所有脚本必须 PS 5.1 兼容 +``` + +### 坑 5: PowerShell 5.1 的 git stderr 问题 (HIGH) +``` +现象: git clone 实际成功但脚本报 [ERROR] 克隆失败 +原因: git 把进度信息写到 stderr,PS 5.1 的 $ErrorActionPreference="Stop" + 把 stderr 输出当成终止性错误抛出异常 +解决: git 命令前临时设 $ErrorActionPreference = "Continue" + 用 $LASTEXITCODE 判断实际成功/失败 +教训: PS 5.1 + 外部命令 + Stop 模式 = 必炸,所有外部命令都要处理 +``` + +### 坑 6: UTF-8 BOM 问题 (HIGH) +``` +现象: "<# : 无法将 '<#' 项识别为 cmdlet" 或中文乱码 +原因: Write 工具生成的文件无 BOM,PS 5.1 按 ANSI 解析 → 中文乱码 + 修复时 BOM 加了两次 → 双 BOM 导致 <# 注释块被当成代码 +解决: 统一用 bash 添加一次 BOM: printf '\xEF\xBB\xBF' > tmp && cat file >> tmp + 每次编辑后检查: head -c 3 file | xxd -p 应为 efbbbf +教训: 面向 PS 5.1 的 .ps1 文件必须有且仅有一个 UTF-8 BOM +``` + +### 坑 7: openssl 路径不在默认位置 (MEDIUM) +``` +现象: "[ERROR] openssl 未找到" +原因: Git for Windows 安装在 D:\Git 而非默认 C:\Program Files\Git +解决: 搜索多个路径: C:\Program Files\Git, D:\Git, D:\Git\mingw64\bin +教训: 不要硬编码第三方软件路径,提供多路径搜索 +``` + +### 坑 8: Gitea 端口被占用 (MEDIUM) +``` +现象: 部署脚本设 3000 端口但已被 my-oa-frontend 容器占用 +解决: 改为 3300 端口 +教训: 部署前 netstat -tlnp | grep 端口 先检查 +``` + +### 坑 9: Nginx 反代不传 Authorization 头 (MEDIUM) +``` +现象: git push 通过 Nginx 反代时 404 +原因: Nginx 默认传 Authorization 但旧的 bookworm-web.conf 先匹配了域名 +解决: 换域名 + 添加 proxy_set_header Authorization $http_authorization +教训: 多个 Nginx 配置文件可能竞争同一个 server_name +``` + +### 坑 10: git clone 私有仓需要凭证 (LOW) +``` +现象: install.ps1 中 git clone 静默失败 (无密码提示) +解决: 先手动 git clone 触发凭证输入 + git config credential.helper store +教训: 自动化脚本中的 git 操作需要预配置凭证 +``` + +--- + +## 四、安全经验 + +### 三路专家审查的价值 +- **红队**: 发现 14 个攻击向量,其中 INSTALL_LOCK=false 成功率 95% +- **代码审查**: 发现 5 个 Blocker + 13 个 Warning +- **架构审查**: 发现仓库体积失控 (预估 50MB 实际 500MB+) +- **交叉验证**: 三路独立发现的 openssl 命令行密码泄露问题,置信度极高 +- **教训**: 多专家并行审查在 P0 修复前执行,投入产出比极高 + +### 安全加固清单 (实际落地的) +- [x] Gitea INSTALL_LOCK=true + 注册关闭 + CLI 创建管理员 +- [x] Gitea 二进制 SHA256 校验 +- [x] HTTPS (Let's Encrypt + Nginx 反代 + HSTS) +- [x] fail2ban (5次/小时 → 封24h) +- [x] Gitea 仅 127.0.0.1 监听 +- [x] openssl 密码通过 stdin 管道 (不暴露进程列表) +- [x] PBKDF2 600,000 迭代 (OWASP 2023) +- [x] 凭证仅进程级环境变量 (不写磁盘) +- [x] .gitignore 排除凭证/大文件/敏感数据 +- [x] git add 前人工确认 + 大文件检测 +- [x] stop.ps1 清理 Git Credential Manager 缓存 + +--- + +## 五、PowerShell 跨版本兼容速查表 + +| PS 7+ 写法 | PS 5.1 兼容写法 | +|---|---| +| `$obj?.Property` | `if ($obj) { $obj.Property } else { $null }` | +| `$cmd?.Source` | `$cmd = Get-Command x -EA 0; if ($cmd) { $cmd.Source }` | +| `??` 空合并 | `if ($x) { $x } else { $default }` | +| UTF-8 无 BOM | 必须有 BOM: `\xEF\xBB\xBF` | +| `$ErrorActionPreference="Stop"` + git | 临时改 `Continue`,用 `$LASTEXITCODE` 判断 | + +--- + +## 六、代理/VPN 检测方法速查 + +```powershell +# 最可靠: .NET DefaultWebProxy (覆盖所有系统代理) +[System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") + +# IE 注册表 +Get-ItemProperty "HKCU:\...\Internet Settings" | Select ProxyServer, ProxyEnable + +# 端口扫描 (Clash 7890, V2Ray 10808, 快柠檬 10792) +Test-NetConnection 127.0.0.1 -Port 7890 +``` + +--- + +## 七、项目最终产出 + +``` +13 个文件 (bookworm-portable/) + 2 个 Gitea 仓库 (bookworm-config 514文件/14MB, bookworm-boot 6文件) + 3 个 ECS 部署脚本 (Gitea + HTTPS + 防火墙) + 7 项 P0 安全修复 + 4 项 P1 安全加固 + 1 个保姆式 HTML 教程 + 1 个快速参考 TXT 手册 + 1 份经验避坑指南 (本文档) + +E2E 验证: 远程 Windows Server → 从零安装 → Claude Code 启动成功 +``` + +--- + +## 八、如果重新来过,我会... + +1. **一开始就做 PS 5.1 兼容测试** — 不假设目标机有 PS 7 +2. **一开始就加代理检测** — 国内 Claude Code 的核心门槛 +3. **不在 settings.json env 段放 ${} 变量** — 行为未文档化 +4. **先检查域名和端口占用** — 再写部署脚本 +5. **三路专家审查提前到设计阶段** — 而非代码写完后再审 + +--- + +*Bookworm Portable v1.2 | code.letcareme.com | 2026-04-01* diff --git a/patches/admin-cb-routes.js b/patches/admin-cb-routes.js new file mode 100644 index 0000000..853b387 --- /dev/null +++ b/patches/admin-cb-routes.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Circuit Breaker 管理端点补丁 + * 追加到 routes/admin.js 末尾 (在 module.exports 函数内) + * + * 用法: 部署时将此内容追加到 admin.js 的 registerAdminRoutes 函数体内 + */ + +// ─── 熔断器状态查看 (admin) ─── +// routes['GET:/v1/admin/circuit-breaker'] = async (req, res) => { +// requireAdmin(req); +// const cbStatus = deps.circuitBreaker.getStatus(); +// const cbLog = deps.circuitBreaker.getTransitionLog(20); +// json(res, 200, { ok: true, breakers: cbStatus, recentTransitions: cbLog }); +// }; + +// ─── 熔断器重置 (admin) ─── +// routes['POST:/v1/admin/circuit-breaker/reset'] = async (req, res) => { +// requireAdmin(req); +// const body = await parseJsonBody(req); +// if (body.provider) { +// deps.circuitBreaker.reset(body.provider); +// json(res, 200, { ok: true, reset: body.provider }); +// } else { +// deps.circuitBreaker.resetAll(); +// json(res, 200, { ok: true, reset: 'all' }); +// } +// }; + +module.exports = function patchAdminRoutes(routes, deps) { + const { json, parseJsonBody, requireAdmin } = deps; + + routes['GET:/v1/admin/circuit-breaker'] = async (req, res) => { + requireAdmin(req); + const cbStatus = deps.circuitBreaker.getStatus(); + const cbLog = deps.circuitBreaker.getTransitionLog ? deps.circuitBreaker.getTransitionLog(20) : []; + json(res, 200, { ok: true, breakers: cbStatus, recentTransitions: cbLog }); + }; + + routes['POST:/v1/admin/circuit-breaker/reset'] = async (req, res) => { + requireAdmin(req); + const body = await parseJsonBody(req); + if (body.provider) { + deps.circuitBreaker.reset(body.provider); + json(res, 200, { ok: true, reset: body.provider }); + } else if (deps.circuitBreaker.resetAll) { + deps.circuitBreaker.resetAll(); + json(res, 200, { ok: true, reset: 'all' }); + } else { + json(res, 400, { error: '请指定 provider' }); + } + }; +}; diff --git a/patches/circuit-breaker-v2.js b/patches/circuit-breaker-v2.js new file mode 100644 index 0000000..46d015e --- /dev/null +++ b/patches/circuit-breaker-v2.js @@ -0,0 +1,228 @@ +'use strict'; + +/** + * Circuit Breaker v2 — 增强版 per-Provider 熔断器 + * + * 改进点: + * 1. 半衰期自动恢复 (失败计数随时间衰减, 避免永久 OPEN) + * 2. 可配置阈值 (通过环境变量覆盖) + * 3. 管理端点支持 (getStatus + reset + resetAll) + * 4. 统计指标 (总请求数、成功率、平均恢复时间) + * 5. 事件日志 (状态变迁记录, 供审计) + * + * @module src/circuit-breaker + */ + +const STATES = { CLOSED: 'closed', OPEN: 'open', HALF_OPEN: 'half_open' }; + +// 可通过环境变量覆盖 +const FAILURE_THRESHOLD = parseInt(process.env.CB_FAILURE_THRESHOLD) || 5; +const RECOVERY_TIMEOUT = parseInt(process.env.CB_RECOVERY_TIMEOUT) || 30000; +const SUCCESS_THRESHOLD = parseInt(process.env.CB_SUCCESS_THRESHOLD) || 2; +const HALF_LIFE_MS = parseInt(process.env.CB_HALF_LIFE_MS) || 120000; // 2 分钟半衰期 + +const breakers = {}; + +// 状态变迁日志 (保留最近 100 条) +const MAX_LOG_SIZE = 100; +const transitionLog = []; + +function _log(provider, from, to, reason) { + const entry = { + ts: new Date().toISOString(), + provider, + from, + to, + reason, + }; + transitionLog.push(entry); + if (transitionLog.length > MAX_LOG_SIZE) transitionLog.shift(); +} + +function _getBreaker(provider) { + if (!breakers[provider]) { + breakers[provider] = { + state: STATES.CLOSED, + failures: 0, + lastFailureAt: 0, + successCount: 0, + totalTrips: 0, + // 统计 + totalRequests: 0, + totalSuccesses: 0, + totalFailures: 0, + lastTripAt: 0, + lastRecoveryAt: 0, + tripDurations: [], // 最近 10 次恢复耗时(ms) + }; + } + return breakers[provider]; +} + +/** + * 半衰期衰减: failures 随时间指数衰减 + * 避免偶尔的瞬态错误累积到阈值 + */ +function _decayedFailures(b) { + if (b.failures === 0 || b.lastFailureAt === 0) return 0; + const elapsed = Date.now() - b.lastFailureAt; + if (elapsed <= 0) return b.failures; + // 每过一个半衰期, failures 减半 + const decayFactor = Math.pow(0.5, elapsed / HALF_LIFE_MS); + return b.failures * decayFactor; +} + +/** + * 检查是否允许向 provider 发送请求 + */ +function canRequest(provider) { + const b = _getBreaker(provider); + + if (b.state === STATES.CLOSED) { + b.totalRequests++; + return true; + } + + if (b.state === STATES.OPEN) { + if (Date.now() - b.lastFailureAt > RECOVERY_TIMEOUT) { + const prev = b.state; + b.state = STATES.HALF_OPEN; + b.successCount = 0; + _log(provider, prev, STATES.HALF_OPEN, 'recovery_timeout_elapsed'); + b.totalRequests++; + return true; + } + b.totalRejected = (b.totalRejected || 0) + 1; + return false; + } + + // HALF_OPEN: 允许有限探测 + b.totalRequests++; + return true; +} + +/** + * 记录成功 + */ +function recordSuccess(provider) { + const b = _getBreaker(provider); + b.totalSuccesses++; + + if (b.state === STATES.HALF_OPEN) { + b.successCount++; + if (b.successCount >= SUCCESS_THRESHOLD) { + const prev = b.state; + b.state = STATES.CLOSED; + b.failures = 0; + b.successCount = 0; + b.lastRecoveryAt = Date.now(); + // 记录恢复耗时 + if (b.lastTripAt > 0) { + b.tripDurations.push(Date.now() - b.lastTripAt); + if (b.tripDurations.length > 10) b.tripDurations.shift(); + } + _log(provider, prev, STATES.CLOSED, `${SUCCESS_THRESHOLD}_consecutive_successes`); + } + } else if (b.state === STATES.CLOSED) { + // 使用衰减后的失败数, 而非直接清零 + // 这样偶尔的成功会让累积的失败自然消散 + b.failures = Math.max(0, _decayedFailures(b) - 0.5); + } +} + +/** + * 记录失败 + */ +function recordFailure(provider) { + const b = _getBreaker(provider); + b.totalFailures++; + b.lastFailureAt = Date.now(); + + if (b.state === STATES.HALF_OPEN) { + const prev = b.state; + b.state = STATES.OPEN; + b.totalTrips++; + b.lastTripAt = Date.now(); + _log(provider, prev, STATES.OPEN, 'half_open_failure'); + } else if (b.state === STATES.CLOSED) { + // 使用衰减后的值 + 1 + b.failures = _decayedFailures(b) + 1; + if (b.failures >= FAILURE_THRESHOLD) { + const prev = b.state; + b.state = STATES.OPEN; + b.totalTrips++; + b.lastTripAt = Date.now(); + _log(provider, prev, STATES.OPEN, `${FAILURE_THRESHOLD}_failures_reached`); + } + } +} + +/** + * 获取所有熔断器状态快照 (管理后台用) + */ +function getStatus() { + const snapshot = {}; + for (const [name, b] of Object.entries(breakers)) { + const successRate = b.totalRequests > 0 + ? Math.round(b.totalSuccesses / b.totalRequests * 10000) / 100 + : 100; + const avgRecoveryMs = b.tripDurations.length > 0 + ? Math.round(b.tripDurations.reduce((a, c) => a + c, 0) / b.tripDurations.length) + : 0; + + snapshot[name] = { + state: b.state, + failures: Math.round(_decayedFailures(b) * 100) / 100, + rawFailures: b.failures, + totalTrips: b.totalTrips, + lastFailureAt: b.lastFailureAt ? new Date(b.lastFailureAt).toISOString() : null, + lastRecoveryAt: b.lastRecoveryAt ? new Date(b.lastRecoveryAt).toISOString() : null, + stats: { + totalRequests: b.totalRequests, + totalSuccesses: b.totalSuccesses, + totalFailures: b.totalFailures, + successRate: successRate + '%', + avgRecoveryMs, + }, + }; + } + return snapshot; +} + +/** + * 获取状态变迁日志 + */ +function getTransitionLog(limit) { + const n = limit || 20; + return transitionLog.slice(-n); +} + +/** + * 手动重置单个 provider + */ +function reset(provider) { + if (breakers[provider]) { + const prev = breakers[provider].state; + breakers[provider].state = STATES.CLOSED; + breakers[provider].failures = 0; + breakers[provider].lastFailureAt = 0; + breakers[provider].successCount = 0; + _log(provider, prev, STATES.CLOSED, 'manual_reset'); + } +} + +/** + * 重置所有 provider + */ +function resetAll() { + for (const name of Object.keys(breakers)) { + reset(name); + } +} + +module.exports = { + canRequest, recordSuccess, recordFailure, + getStatus, getTransitionLog, + reset, resetAll, + STATES, +}; diff --git a/patches/deploy-patches.sh b/patches/deploy-patches.sh new file mode 100644 index 0000000..e2ae72f --- /dev/null +++ b/patches/deploy-patches.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# ============================================================ +# Bookworm Web API — 稳定性优化补丁部署脚本 +# 用法: ssh root@8.138.11.105 'bash -s' < deploy-patches.sh +# ============================================================ +set -euo pipefail + +WEBDIR="/opt/bookworm-web" +BACKUP="$WEBDIR/backups/pre-stability-$(date +%Y%m%d_%H%M%S)" + +echo "=========================================" +echo " Bookworm API 稳定性补丁 v1.0" +echo "=========================================" + +# 1. 备份当前文件 +echo "[1/6] 备份当前文件..." +mkdir -p "$BACKUP/src" "$BACKUP/routes" +cp "$WEBDIR/src/proxy.js" "$BACKUP/src/" +cp "$WEBDIR/src/llm-router.js" "$BACKUP/src/" +cp "$WEBDIR/src/circuit-breaker.js" "$BACKUP/src/" +cp "$WEBDIR/routes/admin.js" "$BACKUP/routes/" +echo " 备份到: $BACKUP" + +# 2. 部署 proxy-v2 +echo "[2/6] 部署 src/proxy.js (keepAlive + 重试 + SSE心跳)..." +cat > "$WEBDIR/src/proxy.js" << 'PROXYEOF' +PROXY_PLACEHOLDER +PROXYEOF +echo " [OK] proxy.js 已更新" + +# 3. 部署 llm-router-v2 +echo "[3/6] 部署 src/llm-router.js (SSE心跳 + backpressure + 重试)..." +cat > "$WEBDIR/src/llm-router.js" << 'ROUTEREOF' +ROUTER_PLACEHOLDER +ROUTEREOF +echo " [OK] llm-router.js 已更新" + +# 4. 部署 circuit-breaker-v2 +echo "[4/6] 部署 src/circuit-breaker.js (半衰期 + 管理端点 + 统计)..." +cat > "$WEBDIR/src/circuit-breaker.js" << 'CBEOF' +CB_PLACEHOLDER +CBEOF +echo " [OK] circuit-breaker.js 已更新" + +# 5. 追加 Circuit Breaker 管理端点到 admin.js +echo "[5/6] 追加 Circuit Breaker 管理端点..." +# 检查是否已有 circuit-breaker 路由 +if grep -q 'circuit-breaker' "$WEBDIR/routes/admin.js" 2>/dev/null; then + echo " [SKIP] admin.js 已含 circuit-breaker 路由" +else + # 在 admin.js 末尾的 }; 之前注入 + # 策略: 在 server.js 中追加路由注册 + cat >> "$WEBDIR/server.js.cb-patch" << 'PATCHEOF' + +// ─── Circuit Breaker 管理端点 (稳定性补丁) ─── +try { + const patchAdminRoutes = require('./patches/admin-cb-routes'); + patchAdminRoutes(routes, deps); +} catch (_e) { console.warn('[patch] CB admin routes failed:', _e.message); } +PATCHEOF + # 实际注入方式: 直接修改 admin.js 在最后的 }; 前插入 + ADMIN_FILE="$WEBDIR/routes/admin.js" + # 安全追加到函数体末尾 (最后一个 }; 前) + INJECT=$(cat << 'INJECTEOF' + + // ─── Circuit Breaker 管理端点 (稳定性补丁) ─── + routes['GET:/v1/admin/circuit-breaker'] = async (req, res) => { + requireAdmin(req); + const cb = deps.circuitBreaker; + const cbStatus = cb.getStatus ? cb.getStatus() : {}; + const cbLog = cb.getTransitionLog ? cb.getTransitionLog(20) : []; + json(res, 200, { ok: true, breakers: cbStatus, recentTransitions: cbLog }); + }; + + routes['POST:/v1/admin/circuit-breaker/reset'] = async (req, res) => { + requireAdmin(req); + const body = await parseJsonBody(req); + const cb = deps.circuitBreaker; + if (body.provider) { + cb.reset(body.provider); + json(res, 200, { ok: true, reset: body.provider }); + } else if (cb.resetAll) { + cb.resetAll(); + json(res, 200, { ok: true, reset: 'all' }); + } else { + json(res, 400, { error: '请指定 provider' }); + } + }; +INJECTEOF + ) + # 用 sed 在最后的 }; 前插入 (admin.js 最后一行是 };) + sed -i '$ i\'"$INJECT" "$ADMIN_FILE" 2>/dev/null || { + # sed 失败则用 python3 + python3 -c " +import re +with open('$ADMIN_FILE', 'r') as f: content = f.read() +inject = '''$INJECT''' +# 在最后一个 }; 前插入 +pos = content.rfind('};') +if pos > 0: + content = content[:pos] + inject + '\n' + content[pos:] + with open('$ADMIN_FILE', 'w') as f: f.write(content) + print(' [OK] admin.js 已注入 CB 端点 (python)') +else: + print(' [WARN] 未找到插入点') +" + } + echo " [OK] admin.js 已注入 CB 管理端点" + rm -f "$WEBDIR/server.js.cb-patch" +fi + +# 6. 清理 Nginx 重复配置 +echo "[6/6] 检查 Nginx 配置..." +NGINX_CONF="/etc/nginx/conf.d/bookworm-web.conf" +if [ -f "$NGINX_CONF" ]; then + # 统计 server 块数量 + SERVER_COUNT=$(grep -c 'server_name bookworm.letcareme.com;' "$NGINX_CONF" 2>/dev/null || echo 0) + if [ "$SERVER_COUNT" -gt 2 ]; then + echo " [WARN] 发现 $SERVER_COUNT 个 server 块 (应为 2: HTTP+HTTPS)" + echo " 建议手动清理: vim $NGINX_CONF" + else + echo " [OK] Nginx 配置正常" + fi +fi + +echo "" +echo "=========================================" +echo " 补丁部署完成!" +echo "=========================================" +echo "" +echo " 下一步:" +echo " 1. 语法检查: cd $WEBDIR && node --check server.js" +echo " 2. 重启服务: pm2 reload bookworm-web" +echo " 3. 验证健康: curl http://127.0.0.1:3211/health" +echo " 4. 验证CB端点: curl -H 'Authorization: Admin TOKEN' http://127.0.0.1:3211/v1/admin/circuit-breaker" +echo "" +echo " 回滚:" +echo " cp $BACKUP/src/* $WEBDIR/src/" +echo " cp $BACKUP/routes/* $WEBDIR/routes/" +echo " pm2 reload bookworm-web" +echo "=========================================" diff --git a/patches/llm-router-v2.js b/patches/llm-router-v2.js new file mode 100644 index 0000000..47c58c7 --- /dev/null +++ b/patches/llm-router-v2.js @@ -0,0 +1,611 @@ +'use strict'; + +/** + * 多 LLM 路由 v2 — 稳定性增强版 + * + * 改进点: + * 1. SSE 心跳 (每 15s :ping, 防止 Nginx 超时断连) + * 2. backpressure 处理 (暂停上游 when 客户端消费慢) + * 3. 非流式请求瞬态重试 (429/502/503 + ECONNRESET) + * 4. 流式错误发送 SSE error event 而非静默断开 + * 5. 连接/读取超时分离 (15s/180s) + * + * @module src/llm-router + */ + +const https = require('https'); +const http = require('http'); +const { URL } = require('url'); + +// ─── DNS 预解析缓存 (B2 修复: 最大 100 条, 防内存泄露) ─── +const dns = require('dns'); +const _dnsCache = new Map(); +const DNS_CACHE_TTL = 300000; // 5 分钟 +const DNS_MAX_ENTRIES = 100; + +function cachedLookup(hostname, options, callback) { + if (typeof options === 'function') { callback = options; options = {}; } + const cached = _dnsCache.get(hostname); + if (cached && Date.now() - cached.ts < DNS_CACHE_TTL) { + return process.nextTick(() => callback(null, cached.address, cached.family)); + } + dns.lookup(hostname, options, (err, address, family) => { + if (!err) { + // 超限时淘汰最旧条目 + if (_dnsCache.size >= DNS_MAX_ENTRIES) { + const oldest = _dnsCache.keys().next().value; // Map 保持插入顺序 + _dnsCache.delete(oldest); + } + _dnsCache.set(hostname, { address, family, ts: Date.now() }); + } + callback(err, address, family); + }); +} + +// ─── SSRF 防护 (B1 修复: 从 proxy.js 同步) ─── +const ALLOWED_LLM_HOSTS = new Set([ + 'api.anthropic.com', 'api.openai.com', + 'dashscope.aliyuncs.com', 'api.deepseek.com', 'api.moonshot.cn', + 'open.bigmodel.cn', 'ark.cn-beijing.volces.com', + 'api.hunyuan.cloud.tencent.com', 'qianfan.baidubce.com', + 'openrouter.ai', +]); +if (process.env.ALLOWED_API_HOSTS) { + for (const h of process.env.ALLOWED_API_HOSTS.split(',')) { + if (h.trim()) ALLOWED_LLM_HOSTS.add(h.trim()); + } +} + +function _isPrivateHost(hostname) { + const lower = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; + if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; + if (lower === '::1' || lower === '::') return true; + if (hostname.startsWith('[::ffff:')) return _isPrivateHost(hostname.slice(8, -1)); + const parts = hostname.split('.'); + if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) { + const [a, b] = parts.map(Number); + if (a === 10 || a === 127 || a === 0) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + } + return hostname === 'localhost'; +} + +function _validateLLMBaseUrl(baseUrl) { + if (!baseUrl) return; + let url; + try { url = new URL(baseUrl); } catch { throw { status: 400, message: 'base_url 格式无效' }; } + if (ALLOWED_LLM_HOSTS.has(url.hostname)) return; + if (_isPrivateHost(url.hostname)) throw { status: 403, message: '不允许访问内网地址' }; + throw { status: 403, message: '不允许的 LLM API 地址' }; +} + +// 启动预热 +for (const h of ['dashscope.aliyuncs.com', 'api.deepseek.com', 'api.moonshot.cn', 'open.bigmodel.cn', 'ark.cn-beijing.volces.com', 'api.hunyuan.cloud.tencent.com', 'qianfan.baidubce.com']) { + dns.lookup(h, () => {}); +} + +// ─── HTTP Agent: keepAlive 连接池 + DNS 缓存 ─── +const httpsAgent = new https.Agent({ + keepAlive: true, maxSockets: 10, maxFreeSockets: 5, + keepAliveMsecs: 30000, lookup: cachedLookup, +}); +const httpAgent = new http.Agent({ + keepAlive: true, maxSockets: 10, maxFreeSockets: 5, + keepAliveMsecs: 30000, lookup: cachedLookup, +}); + +// ─── 重试配置 ─── +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]); +const RETRYABLE_ERRORS = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'EAI_AGAIN']); +const MAX_RETRIES = 2; + +function isRetryable(err, statusCode) { + if (statusCode && RETRYABLE_STATUS.has(statusCode)) return true; + if (err && RETRYABLE_ERRORS.has(err.code)) return true; + if (err && /socket hang up|ECONNRESET|ETIMEDOUT/i.test(err.message)) return true; + return false; +} + +function retryDelay(attempt) { + return 1000 * Math.pow(2, attempt) + Math.random() * 500; +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +// ─── SSE 心跳 ─── +const SSE_HEARTBEAT_MS = 15000; + +function startSSEHeartbeat(res) { + const timer = setInterval(() => { + if (res.writableEnded || res.destroyed) { clearInterval(timer); return; } + try { res.write(':ping\n\n'); } catch { clearInterval(timer); } + }, SSE_HEARTBEAT_MS); + if (timer.unref) timer.unref(); + return timer; +} + +// ─── Provider 配置 ─── +const PROVIDERS = { + anthropic: { + baseUrl: 'https://api.anthropic.com', + pathPrefix: '/v1/messages', + authHeader: 'x-api-key', + versionHeader: { 'anthropic-version': process.env.ANTHROPIC_API_VERSION || '2023-06-01' }, + modelPrefixes: ['claude-'], + buildBody: (opts) => ({ + model: opts.model, + messages: opts.messages, + max_tokens: opts.maxTokens || 8192, + stream: opts.stream || false, + ...(opts.systemPrompt ? { system: opts.systemPrompt } : {}), + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.input_tokens || 0, + output_tokens: data?.usage?.output_tokens || 0, + }), + }, + openai: { + baseUrl: 'https://api.openai.com', + pathPrefix: '/v1/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['gpt-', 'o1-', 'o3-', 'o4-', 'chatgpt-'], + buildBody: (opts) => { + const isReasoningModel = /^(o1|o3|o4)-/i.test(opts.model); + const tokenParam = isReasoningModel + ? { max_completion_tokens: opts.maxTokens || 8192 } + : { max_tokens: opts.maxTokens || 8192 }; + return { + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + ...tokenParam, + stream: opts.stream || false, + }; + }, + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, + qwen: { + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode', + pathPrefix: '/v1/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['qwen'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + max_tokens: opts.maxTokens || 8192, + stream: opts.stream || false, + enable_thinking: false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, + deepseek: { + baseUrl: 'https://api.deepseek.com', + pathPrefix: '/v1/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['deepseek-'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + max_tokens: opts.maxTokens || 8192, + stream: opts.stream || false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.output_tokens || 0, + }), + }, + kimi: { + baseUrl: 'https://api.moonshot.cn', + pathPrefix: '/v1/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['moonshot-', 'kimi'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + max_tokens: opts.maxTokens || 8192, + stream: opts.stream || false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, + zhipu: { + baseUrl: 'https://open.bigmodel.cn/api/paas', + pathPrefix: '/v4/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['glm-'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + max_tokens: opts.maxTokens || 4096, + stream: opts.stream || false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, + volcengine: { + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + pathPrefix: '/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['doubao-', 'ep-'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + max_tokens: opts.maxTokens || 4096, + stream: opts.stream || false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, + hunyuan: { + baseUrl: 'https://api.hunyuan.cloud.tencent.com', + pathPrefix: '/v1/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['hunyuan-'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}), + stream: opts.stream || false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, + baidu: { + baseUrl: 'https://qianfan.baidubce.com', + pathPrefix: '/v2/chat/completions', + authHeader: 'Authorization', + authPrefix: 'Bearer ', + versionHeader: {}, + modelPrefixes: ['ernie-'], + buildBody: (opts) => ({ + model: opts.model, + messages: [ + ...(opts.systemPrompt ? [{ role: 'system', content: opts.systemPrompt }] : []), + ...opts.messages, + ], + max_tokens: opts.maxTokens || 4096, + stream: opts.stream || false, + }), + parseUsage: (data) => ({ + input_tokens: data?.usage?.prompt_tokens || 0, + output_tokens: data?.usage?.completion_tokens || 0, + }), + }, +}; + +function detectProvider(model) { + if (!model) return 'qwen'; + const lower = model.toLowerCase(); + for (const [name, config] of Object.entries(PROVIDERS)) { + if (config.modelPrefixes.some(prefix => lower.startsWith(prefix))) { + return name; + } + } + return 'qwen'; +} + +function getProviderConfig(providerName, overrideBaseUrl) { + const config = PROVIDERS[providerName]; + if (!config) throw { status: 400, message: `不支持的 provider: ${providerName}` }; + // B1 修复: SSRF 防护 + if (overrideBaseUrl) _validateLLMBaseUrl(overrideBaseUrl); + return { ...config, baseUrl: overrideBaseUrl || config.baseUrl }; +} + +function listProviders() { + return Object.entries(PROVIDERS).map(([name, config]) => ({ + name, models: config.modelPrefixes, baseUrl: config.baseUrl, + })); +} + +/** + * 核心 LLM 请求 — 非流式 (支持重试) + */ +function _sendNonStream(provider, apiKey, body, isHttps, requestOpts) { + return new Promise((resolve, reject) => { + const transport = isHttps ? https : http; + const proxyReq = transport.request(requestOpts, (proxyRes) => { + proxyReq.setTimeout(180_000); + const chunks = []; + proxyRes.on('data', c => chunks.push(c)); + proxyRes.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { resolve({ status: proxyRes.statusCode, data: JSON.parse(raw), headers: proxyRes.headers }); } + catch { resolve({ status: proxyRes.statusCode, data: raw, headers: proxyRes.headers }); } + }); + proxyRes.on('error', reject); + }); + proxyReq.on('error', (err) => { + err.message = `LLM 请求失败: ${err.message}`; + reject(err); + }); + proxyReq.on('timeout', () => { + proxyReq.destroy(new Error('LLM API 连接超时 (30s)')); + }); + proxyReq.write(JSON.stringify(body)); + proxyReq.end(); + }); +} + +/** + * 核心 LLM 请求 — 流式 (不重试, 带心跳+backpressure) + */ +function _sendStream(provider, apiKey, body, res, isHttps, requestOpts) { + return new Promise((resolve, reject) => { + const transport = isHttps ? https : http; + const proxyReq = transport.request(requestOpts, (proxyRes) => { + proxyReq.setTimeout(300_000); // 流式读取 5 分钟 + + if (proxyRes.statusCode !== 200) { + const chunks = []; + proxyRes.on('data', c => chunks.push(c)); + proxyRes.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { resolve({ status: proxyRes.statusCode, data: JSON.parse(raw), streamed: false }); } + catch { resolve({ status: proxyRes.statusCode, data: { error: raw }, streamed: false }); } + }); + proxyRes.on('error', reject); + return; + } + + // SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // 启动心跳 + const heartbeatTimer = startSSEHeartbeat(res); + + let lastDataLine = ''; + let inThink = false; + let fullText = ''; + + proxyRes.on('data', c => { + const text = c.toString(); + const lines = text.split('\n'); + const outLines = []; + + for (const line of lines) { + if (line.startsWith('data: ') && line !== 'data: [DONE]') { + const jsonStr = line.slice(6); + lastDataLine = jsonStr; + try { + const parsed = JSON.parse(jsonStr); + const delta = parsed.choices?.[0]?.delta; + if (delta) { + if (delta.reasoning_content !== undefined) { + delete delta.reasoning_content; + } + if (delta.content) { + let content = delta.content; + if (inThink) { + const endIdx = content.indexOf(''); + if (endIdx !== -1) { inThink = false; content = content.slice(endIdx + 8); } + else content = ''; + } + if (!inThink && content.includes('')) { + const startIdx = content.indexOf(''); + const endIdx = content.indexOf('', startIdx); + if (endIdx !== -1) { content = content.slice(0, startIdx) + content.slice(endIdx + 8); } + else { content = content.slice(0, startIdx); inThink = true; } + } + delta.content = content; + if (content) fullText += content; + } + } + outLines.push('data: ' + JSON.stringify(parsed)); + } catch { + outLines.push(line); + } + } else { + outLines.push(line); + } + } + + // backpressure 处理 + const canWrite = res.write(outLines.join('\n')); + if (!canWrite) { + proxyRes.pause(); + res.once('drain', () => proxyRes.resume()); + } + }); + + proxyRes.on('end', () => { + clearInterval(heartbeatTimer); + res.end(); + let usage = null; + try { + const parsed = JSON.parse(lastDataLine); + if (parsed.usage) { + usage = { + input_tokens: parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0, + output_tokens: parsed.usage.completion_tokens || parsed.usage.output_tokens || 0, + }; + } + } catch { /* 无 usage */ } + resolve({ streamed: true, status: 200, usage, fullText: fullText || undefined }); + }); + + proxyRes.on('error', (err) => { + clearInterval(heartbeatTimer); + // 发送 SSE 错误事件 + try { + res.write(`data: ${JSON.stringify({ error: { message: err.message, type: 'stream_error' } })}\n\n`); + } catch { /* ignore */ } + res.end(); + reject(err); + }); + + res.on('close', () => { + clearInterval(heartbeatTimer); + proxyReq.destroy(); + }); + }); + + proxyReq.on('error', (err) => { + err.message = `LLM 流式请求失败: ${err.message}`; + reject(err); + }); + proxyReq.on('timeout', () => { + proxyReq.destroy(new Error('LLM API 连接超时 (30s)')); + }); + proxyReq.write(JSON.stringify(body)); + proxyReq.end(); + }); +} + +/** + * 通用 LLM 请求 — 自动选择流式/非流式, 非流式带重试 + */ +async function sendLLMRequest(provider, apiKey, body, res, stream) { + const config = PROVIDERS[provider.name || provider] || PROVIDERS.qwen; + const base = provider.baseUrl || config.baseUrl; + let baseUrl; + try { baseUrl = new URL(base); } + catch { throw { status: 400, message: `base_url 格式无效: ${base}` }; } + + const fullPath = baseUrl.pathname.replace(/\/$/, '') + config.pathPrefix; + const url = new URL(fullPath, base); + const isHttps = url.protocol === 'https:'; + + const headers = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(JSON.stringify(body)), + ...config.versionHeader, + }; + + if (config.authHeader === 'Authorization') { + headers['Authorization'] = (config.authPrefix || '') + apiKey; + } else { + headers[config.authHeader] = apiKey; + } + + const requestOpts = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method: 'POST', + headers, + agent: isHttps ? httpsAgent : httpAgent, + timeout: 30_000, // 连接超时 30s + }; + + if (stream && res) { + return _sendStream(provider, apiKey, body, res, isHttps, requestOpts); + } + + // 非流式: 带重试 + let lastError = null; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await _sendNonStream(provider, apiKey, body, isHttps, requestOpts); + if (RETRYABLE_STATUS.has(result.status) && attempt < MAX_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + return result; + } catch (err) { + lastError = err; + if (isRetryable(err) && attempt < MAX_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + throw err; + } + } + throw lastError; +} + +// --- Circuit Breaker + EWMA hooks --- +let _circuitBreaker = null; +let _providerHealth = null; + +function initPerformanceHooks(opts) { + _circuitBreaker = opts.circuitBreaker || null; + _providerHealth = opts.providerHealth || null; +} + +function sendLLMRequestWithCB(provider, apiKey, body, res, stream) { + const providerName = provider.name || provider; + const startMs = Date.now(); + if (_circuitBreaker && !_circuitBreaker.canRequest(providerName)) { + return Promise.reject(Object.assign(new Error('Circuit Breaker OPEN: ' + providerName), { status: 503 })); + } + return sendLLMRequest(provider, apiKey, body, res, stream).then( + (result) => { + const elapsed = Date.now() - startMs; + const success = result.streamed ? true : (result.status >= 200 && result.status < 500); + if (_circuitBreaker && success) _circuitBreaker.recordSuccess(providerName); + if (_providerHealth && _providerHealth.recordRequestLatency) _providerHealth.recordRequestLatency(providerName, elapsed, success); + return result; + }, + (err) => { + const elapsed = Date.now() - startMs; + if (_circuitBreaker) _circuitBreaker.recordFailure(providerName); + if (_providerHealth && _providerHealth.recordRequestLatency) _providerHealth.recordRequestLatency(providerName, elapsed, false); + throw err; + } + ); +} + +module.exports = { + detectProvider, getProviderConfig, listProviders, + sendLLMRequest: sendLLMRequestWithCB, + sendLLMRequestRaw: sendLLMRequest, + initPerformanceHooks, + PROVIDERS, +}; diff --git a/patches/proxy-v2.js b/patches/proxy-v2.js new file mode 100644 index 0000000..a74ba06 --- /dev/null +++ b/patches/proxy-v2.js @@ -0,0 +1,349 @@ +'use strict'; + +/** + * BYOK 代理 v2 — 稳定性增强版 + * + * 改进点: + * 1. keepAlive 连接池 (复用 TCP 连接, 减少 TLS 握手) + * 2. 可重试的瞬态错误 (ECONNRESET, ETIMEDOUT, 429, 502, 503) + * 3. SSE 心跳 (每 15s 发送 :ping, 防止 Nginx/CDN 超时断连) + * 4. backpressure 处理 (res.write 返回 false 时暂停上游) + * 5. 分离连接超时(15s)和读取超时(180s) + * 6. 详细错误分类与日志 + * + * @module src/proxy + */ + +const https = require('https'); +const http = require('http'); +const { URL } = require('url'); + +// ─── ❶ SSRF 防护:base_url 白名单 ─── + +const ALLOWED_API_HOSTS = new Set([ + 'api.anthropic.com', +]); + +if (process.env.ALLOWED_API_HOSTS) { + for (const h of process.env.ALLOWED_API_HOSTS.split(',')) { + if (h.trim()) ALLOWED_API_HOSTS.add(h.trim()); + } +} + +function isPrivateHost(hostname) { + // IPv6 mapped IPv4 + if (hostname.startsWith('[::ffff:')) { + return isPrivateHost(hostname.slice(8, -1)); + } + // IPv6 私有地址段: ULA (fc00::/7), Link-local (fe80::/10), loopback (::1), unspecified (::) + const lower = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 (ULA) + if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 (link-local) + if (lower === '::1' || lower === '::') return true; + // IPv4 私有地址 + const parts = hostname.split('.'); + if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) { + const [a, b] = parts.map(Number); + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 0) return true; + } + return hostname === 'localhost' || hostname === '[::1]'; +} + +function validateBaseUrl(baseUrl) { + if (!baseUrl) return; + let url; + try { + url = new URL(baseUrl); + } catch { + throw { status: 400, message: 'base_url 格式无效' }; + } + if (ALLOWED_API_HOSTS.has(url.hostname)) return; + if (isPrivateHost(url.hostname)) { + throw { status: 403, message: '不允许访问内网地址' }; + } + // W10 修复: 非白名单公网地址也拒绝 (防止变成开放代理) + throw { status: 403, message: '不允许的 API 地址,请联系管理员将域名加入白名单' }; +} + +// ─── ❷ keepAlive 连接池 ─── + +const httpsAgent = new https.Agent({ + keepAlive: true, + maxSockets: 10, + maxFreeSockets: 5, + keepAliveMsecs: 30000, + timeout: 15000, +}); + +const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 10, + maxFreeSockets: 5, + keepAliveMsecs: 30000, + timeout: 15000, +}); + +// ─── ❸ 重试配置 ─── + +const RETRYABLE_CODES = new Set([429, 502, 503, 504]); +const RETRYABLE_ERRORS = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_SOCKET']); +const MAX_RETRIES = 2; +const RETRY_BASE_DELAY = 1000; // 1s 指数退避 + +function isRetryable(err, statusCode) { + if (statusCode && RETRYABLE_CODES.has(statusCode)) return true; + if (err && err.code && RETRYABLE_ERRORS.has(err.code)) return true; + if (err && err.message && /socket hang up|ECONNRESET|ETIMEDOUT/i.test(err.message)) return true; + return false; +} + +function retryDelay(attempt) { + // 指数退避 + 抖动: 1s, 2s + random(0-500ms) + return RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 500; +} + +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +// ─── ❹ SSE 心跳 ─── + +const SSE_HEARTBEAT_INTERVAL = 15000; // 每 15 秒 + +function startSSEHeartbeat(res) { + const timer = setInterval(() => { + if (res.writableEnded || res.destroyed) { + clearInterval(timer); + return; + } + try { + res.write(':ping\n\n'); + } catch { + clearInterval(timer); + } + }, SSE_HEARTBEAT_INTERVAL); + // 不阻止进程退出 + if (timer.unref) timer.unref(); + return timer; +} + +// ─── BYOK 代理核心 ─── + +async function proxyChat(opts, res) { + const { + apiKey, + model = 'claude-sonnet-4-5-20250514', + messages, + maxTokens = 8192, + stream = false, + baseUrl, + systemPrompt, + } = opts; + + validateBaseUrl(baseUrl); + + const base = baseUrl || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'; + const url = new URL('/v1/messages', base); + const isHttps = url.protocol === 'https:'; + + const body = { + model, + messages, + max_tokens: maxTokens, + stream, + }; + if (systemPrompt) body.system = systemPrompt; + + const payload = JSON.stringify(body); + + const requestOpts = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': process.env.ANTHROPIC_API_VERSION || '2023-06-01', + 'Content-Length': Buffer.byteLength(payload), + }, + agent: isHttps ? httpsAgent : httpAgent, + timeout: 15000, // 连接超时 15s + }; + + // ─── 流式请求 (不重试, 因为 headers 一旦发送不可回退) ─── + if (stream) { + return _proxyChatStream(requestOpts, payload, res, isHttps); + } + + // ─── 非流式请求 (支持重试) ─── + let lastError = null; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await _proxyChatOnce(requestOpts, payload, isHttps); + // 上游返回可重试状态码 + if (result.status && RETRYABLE_CODES.has(result.status) && attempt < MAX_RETRIES) { + const delay = result.status === 429 + ? _parseRetryAfter(result.headers) || retryDelay(attempt) + : retryDelay(attempt); + await sleep(delay); + continue; + } + return result; + } catch (err) { + lastError = err; + if (isRetryable(err) && attempt < MAX_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + throw err; + } + } + throw lastError; +} + +// 解析 Retry-After 头 (秒或日期) +function _parseRetryAfter(headers) { + const val = headers && headers['retry-after']; + if (!val) return null; + const secs = parseInt(val, 10); + if (!isNaN(secs) && secs > 0 && secs < 120) return secs * 1000; + return null; +} + +// 单次非流式请求 +function _proxyChatOnce(requestOpts, payload, isHttps) { + return new Promise((resolve, reject) => { + const transport = isHttps ? https : http; + const proxyReq = transport.request(requestOpts, (proxyRes) => { + // 连接成功 → 切换为读取超时 180s + proxyReq.setTimeout(180_000); + + const chunks = []; + proxyRes.on('data', (chunk) => chunks.push(chunk)); + proxyRes.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { + resolve({ status: proxyRes.statusCode, data: JSON.parse(raw), headers: proxyRes.headers }); + } catch { + resolve({ status: proxyRes.statusCode, data: raw, headers: proxyRes.headers }); + } + }); + proxyRes.on('error', reject); + }); + + proxyReq.on('error', reject); + proxyReq.on('timeout', () => { + proxyReq.destroy(new Error('Claude API 连接超时 (15s)')); + }); + + proxyReq.write(payload); + proxyReq.end(); + }); +} + +// 流式请求 (不重试) +function _proxyChatStream(requestOpts, payload, res, isHttps) { + return new Promise((resolve, reject) => { + const transport = isHttps ? https : http; + const proxyReq = transport.request(requestOpts, (proxyRes) => { + // 连接成功 → 切换为读取超时 300s (流式更长) + proxyReq.setTimeout(300_000); + + // 上游返回错误: 不走 SSE, 收集后 JSON 返回 + if (proxyRes.statusCode !== 200) { + const chunks = []; + proxyRes.on('data', (chunk) => chunks.push(chunk)); + proxyRes.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { + resolve({ status: proxyRes.statusCode, data: JSON.parse(raw), streamed: false }); + } catch { + resolve({ status: proxyRes.statusCode, data: { error: raw }, streamed: false }); + } + }); + proxyRes.on('error', reject); + return; + } + + // 正常 SSE 透传 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // 启动心跳 + const heartbeatTimer = startSSEHeartbeat(res); + + let tokensIn = 0, tokensOut = 0; + let fullText = ''; + + proxyRes.on('data', (chunk) => { + // backpressure: 如果客户端消费慢, 暂停上游 + const canWrite = res.write(chunk); + if (!canWrite) { + proxyRes.pause(); + res.once('drain', () => proxyRes.resume()); + } + + // 提取用量 + 全文 + try { + const text = chunk.toString(); + for (const line of text.split('\n')) { + if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; + const obj = JSON.parse(line.slice(6)); + if (obj.type === 'message_start' && obj.message?.usage) { + tokensIn = obj.message.usage.input_tokens || 0; + } else if (obj.type === 'content_block_delta' && obj.delta?.text) { + fullText += obj.delta.text; + } else if (obj.type === 'message_delta' && obj.usage) { + tokensOut = obj.usage.output_tokens || 0; + } + } + } catch { /* 解析失败不影响透传 */ } + }); + + proxyRes.on('end', () => { + clearInterval(heartbeatTimer); + res.end(); + resolve({ streamed: true, status: 200, usage: { tokensIn, tokensOut }, fullText: fullText || undefined }); + }); + + proxyRes.on('error', (err) => { + clearInterval(heartbeatTimer); + // 尝试发送 SSE 错误事件后关闭 + try { + res.write(`data: ${JSON.stringify({ type: 'error', error: { type: 'stream_error', message: err.message } })}\n\n`); + } catch { /* ignore */ } + res.end(); + reject(err); + }); + + // 客户端断开时清理 + res.on('close', () => { + clearInterval(heartbeatTimer); + proxyReq.destroy(); + }); + }); + + proxyReq.on('error', (err) => { + reject(err); + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(new Error('Claude API 连接超时 (15s)')); + }); + + proxyReq.write(payload); + proxyReq.end(); + }); +} + +module.exports = { proxyChat, validateBaseUrl }; diff --git a/patches/update-pages.py b/patches/update-pages.py new file mode 100644 index 0000000..4108c85 --- /dev/null +++ b/patches/update-pages.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +更新 guide.html + quick-start.html +- 添加 OneClick 一键安装器下载 +- 更新版本号: 34 Hooks, v2.0 +- 三平台下载按钮 +""" +import re, os + +WEBDIR = '/opt/bookworm-web/public' + +# ═══════════════════════════════════════ +# 1. guide.html +# ═══════════════════════════════════════ +guide = os.path.join(WEBDIR, 'guide.html') +with open(guide, 'r', encoding='utf-8') as f: + html = f.read() + +# (a) 版本号 29 Hooks → 34 Hooks +html = html.replace('29 Hooks', '34 Hooks') + +# (b) 版本号 v1.5 → v2.0 +html = html.replace('Portable v1.5', 'Portable v2.0') +html = html.replace('v1.5 |', 'v2.0 |') + +# (c) 替换单一下载按钮为三平台下载 +old_download = '''⬇ 下载一键安装器''' + +new_download = ''' +

全新电脑? 双击即装 — 自动安装 Node.js + Git + Claude Code + Bookworm 配置

''' + +html = html.replace(old_download, new_download) + +# (d) 更新 "最快方式" 卡片 — 提及全自动 +old_fastest = '''

最快方式:一键安装器

+

获取 Bookworm-Setup.bat (4KB) → 双击运行 → 输入密码 → 完成

+

安装器自动检测依赖、下载配置、创建桌面快捷方式、启动 Claude Code

''' + +new_fastest = '''

最快方式:全自动一键安装器 v2.0

+

下载 OneClick 安装器 → 双击运行 → 输入密码 → 完成

+

全新电脑零依赖:自动安装 Node.js + Git + Claude Code + 下载配置 + 创建桌面快捷方式

+
+ Win 11: winget 自动装 + Win 10: 下载 MSI 静默装 + macOS: Homebrew 自动装 +
''' + +html = html.replace(old_fastest, new_fastest) + +# (e) 更新手动流程提示 +old_manual = '

手动安装流程:

' +new_manual = '

如需手动安装(一键安装器失败时的备选方案):

' +html = html.replace(old_manual, new_manual) + +# (f) 更新首次安装行 "快速参考" 表 +old_ref = '首次安装git clone + 双击
更新并启动Bookworm.batpwsh -ExecutionPolicy Bypass -File install.ps1' +new_ref = '首次安装双击 Bookworm-OneClick.bat
自动装 Node.js + Git + Claude Codepwsh -ExecutionPolicy Bypass -File install.ps1' +html = html.replace(old_ref, new_ref) + +# (g) 在 "安装检查清单" 前插入 OneClick 安装段 +oneclick_section = ''' + + + +
+

0全自动一键安装 (推荐)

+ +
+ +
+ 全新电脑? 从这里开始!
+ OneClick 安装器会自动完成所有步骤:安装 Node.js、Git、Claude Code,下载 Bookworm 配置,创建桌面快捷方式。
+ 你只需要输入 Gitea 密码和主密码。 +
+
+ + + + + + + + + + + + + + + + + + + + + +
系统安装文件安装方式前提条件
Windows 11Bookworm-OneClick.batwinget 自动安装无 (winget 内置)
Windows 10Bookworm-OneClick-Win10.batwinget 优先, 回退下载 MSI/EXE无 (1809+)
macOS 12+Bookworm-OneClick-Mac.shHomebrew 自动安装无 (自动装 Homebrew)
+ +
+
1
+
+

Windows: 下载并双击

+

下载对应版本 → 双击运行 → UAC 点"是" → 按提示输入 Gitea 密码和主密码 → 完成

+
+
+ +
+
2
+
+

macOS: 终端一行命令

+

打开终端,粘贴以下命令并回车:

+
+
+
+ curl -fsSL -o ~/bookworm-install.sh https://bookworm.letcareme.com/download/Bookworm-OneClick-Mac.sh && bash ~/bookworm-install.sh +
+ +
+ 💡 +
+ OneClick 安装器已包含以下所有步骤
+ 如果一键安装成功,可跳过下方的手动步骤 1-3,直接查看每日使用部分。 +
+
+
+ +''' + +# 在 "安装检查清单" 部分前插入 +checklist_marker = '\n ' +html = html.replace(checklist_marker, oneclick_section + ' ' + checklist_marker) + +# (h) 更新时间首次安装 "约 10 分钟" → 区分 OneClick / 手动 +old_time = '首次安装约 10 分钟(含依赖下载),之后每次双击启动约 10-30 秒' +new_time = '一键安装约 5 分钟(自动下载依赖),手动安装约 15 分钟。之后每次双击启动约 10-30 秒' +html = html.replace(old_time, new_time) + +with open(guide, 'w', encoding='utf-8') as f: + f.write(html) +print(f'[OK] guide.html 已更新 ({len(html)} bytes)') + +# ═══════════════════════════════════════ +# 2. quick-start.html +# ═══════════════════════════════════════ +qs = os.path.join(WEBDIR, 'quick-start.html') +with open(qs, 'r', encoding='utf-8') as f: + qhtml = f.read() + +# (a) 版本号 +qhtml = qhtml.replace('v1.5 |', 'v2.0 |') +qhtml = qhtml.replace('29 Hooks', '34 Hooks') +qhtml = qhtml.replace('Portable v1.5', 'Portable v2.0') + +# (b) 更新启动流程 — 添加 OneClick +old_flow = '''
+
开代理
+ +
双击 Bookworm
+ +
等横幅出现
+ +
开始工作
+
''' + +new_flow = '''
+
开代理
+ +
双击 Bookworm
+ +
等横幅出现
+ +
开始工作
+
+ +
+

全新电脑? 用 OneClick 一键安装器

+

下载 Bookworm-OneClick.bat (Win11) / *-Win10.bat (Win10) / *-Mac.sh (macOS) → 双击即装
+ 自动安装 Node.js + Git + Claude Code + Bookworm,零依赖

+
''' + +qhtml = qhtml.replace(old_flow, new_flow) + +# (c) 启动表添加 OneClick 行 +old_startup_table_end = '命令行启动' +new_startup_row = '''首次安装双击 Bookworm-OneClick.bat (全自动装依赖+配置+启动) + 命令行启动''' +qhtml = qhtml.replace(old_startup_table_end, new_startup_row) + +with open(qs, 'w', encoding='utf-8') as f: + f.write(qhtml) +print(f'[OK] quick-start.html 已更新 ({len(qhtml)} bytes)') + +# ═══════════════════════════════════════ +# 3. guide-mac.html (同步版本号) +# ═══════════════════════════════════════ +guide_mac = os.path.join(WEBDIR, 'guide-mac.html') +if os.path.exists(guide_mac): + with open(guide_mac, 'r', encoding='utf-8') as f: + mhtml = f.read() + mhtml = mhtml.replace('29 Hooks', '34 Hooks') + mhtml = mhtml.replace('Portable v1.5', 'Portable v2.0') + mhtml = mhtml.replace('v1.5 |', 'v2.0 |') + + # 添加 OneClick Mac 下载按钮 (如有旧按钮替换) + old_mac_dl = 'Bookworm-Setup.sh' + new_mac_dl = '/download/Bookworm-OneClick-Mac.sh' + mhtml = mhtml.replace('href="/Bookworm-Setup.sh"', f'href="{new_mac_dl}"') + + with open(guide_mac, 'w', encoding='utf-8') as f: + f.write(mhtml) + print(f'[OK] guide-mac.html 已更新 ({len(mhtml)} bytes)') + +print('\n[DONE] 所有页面更新完成') diff --git a/prepare-repo.ps1 b/prepare-repo.ps1 new file mode 100644 index 0000000..4915728 --- /dev/null +++ b/prepare-repo.ps1 @@ -0,0 +1,206 @@ +<# +.SYNOPSIS + Bookworm Portable - 仓库准备脚本 +.DESCRIPTION + 将当前 .claude/ 目录初始化为 Git 仓库, + 排除敏感文件和大体积依赖, 推送到 Gitea. +.USAGE + # 首次准备 (初始化 + 推送) + .\prepare-repo.ps1 -GitUrl "http://8.138.11.105:3000/bookworm/bookworm-config.git" +#> + +param( + [Parameter(Mandatory=$true)] + [string]$GitUrl +) + +$ErrorActionPreference = "Stop" +$ClaudeDir = Join-Path $env:USERPROFILE ".claude" + +Write-Host "" +Write-Host " Bookworm Portable - 仓库准备" -ForegroundColor Cyan +Write-Host " =============================" -ForegroundColor Cyan +Write-Host "" + +if (-not (Test-Path $ClaudeDir)) { + Write-Host "[ERROR] .claude 目录不存在: $ClaudeDir" -ForegroundColor Red + exit 1 +} + +# 1. 拷贝 settings.template.json +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$templateSrc = Join-Path $ScriptDir "settings.template.json" +$templateDst = Join-Path $ClaudeDir "settings.template.json" + +if (Test-Path $templateSrc) { + Copy-Item $templateSrc $templateDst -Force + Write-Host "[1/6] settings.template.json 已放入 .claude/" -ForegroundColor Green +} +else { + Write-Host "[1/6] [WARN] settings.template.json 不在脚本目录,请手动创建" -ForegroundColor Yellow +} + +# 2. 创建 .gitignore +Write-Host "[2/6] 生成 .gitignore..." -ForegroundColor White +$gitignore = @" +# ===== Bookworm Portable .gitignore ===== + +# 凭证与密钥 (绝不提交) +.credentials.json +.hmac-key +secrets.enc +*.key +*.pem +*.token +*.secret +.env +.env.* + +# 本地运行时 (每台机器不同) +settings.json +settings.local.json + +# 大体积/不可移植目录 +mcp-servers/ +node_modules/ +file-history/ +vendor/ + +# 大体积 Skill (含二进制/node_modules,目标机按需安装) +skills/gstack/ +skills/browse/ +skills/*/dist/ +skills/*/.git/ +skills/*/node_modules/ + +# 测试文件 (28MB,不需要部署) +hooks/__tests__/ + +# 缓存与临时文件 +cache/ +paste-cache/ +sessions/ +shell-snapshots/ +telemetry/ +repos/ +plugins/ +tasks/ +teams/ +backups/ + +# 调试日志 (本地生成) +debug/ + +# 项目级会话数据 (含用户特定路径) +projects/ + +# 运营敏感数据 +memory/ + +# OS 文件 +Thumbs.db +desktop.ini +.DS_Store + +# 数据库和临时文件 +*.sqlite +*.db +*.test.tmp +"@ + +Set-Content (Join-Path $ClaudeDir ".gitignore") -Value $gitignore -Encoding UTF8 +Write-Host " [OK] .gitignore 已写入" -ForegroundColor Green + +# 3. 初始化 Git 仓库 +Write-Host "[3/6] 初始化 Git 仓库..." -ForegroundColor White +Push-Location $ClaudeDir +try { + +if (-not (Test-Path ".git")) { + git init + git checkout -b main + Write-Host " [OK] Git 仓库已初始化" -ForegroundColor Green +} +else { + Write-Host " [!] 已有 .git,跳过初始化" -ForegroundColor Yellow +} + +# 4. 暂存文件 +Write-Host "[4/6] 暂存文件..." -ForegroundColor White +git add -A + +# 人工确认待提交内容 +$fileList = git diff --cached --name-only +$fileCount = ($fileList | Measure-Object -Line).Lines +$sizeEstimate = git diff --cached --stat | Select-Object -Last 1 +Write-Host " 待提交: $fileCount 个文件" -ForegroundColor Gray +Write-Host " $sizeEstimate" -ForegroundColor DarkGray + +# 检查是否有异常大文件 +$largeFiles = git diff --cached --numstat | ForEach-Object { + $parts = $_ -split '\t' + if ($parts[2]) { $parts[2] } +} | ForEach-Object { + $item = Get-Item (Join-Path $ClaudeDir $_) -ErrorAction SilentlyContinue + $size = if ($item) { $item.Length } else { 0 } + if ($size -and $size -gt 1MB) { + [PSCustomObject]@{ File = $_; SizeMB = [math]::Round($size / 1MB, 1) } + } +} +if ($largeFiles) { + Write-Host "" + Write-Host " [!] 发现大文件 (>1MB):" -ForegroundColor Yellow + $largeFiles | ForEach-Object { Write-Host " $($_.SizeMB)MB $($_.File)" -ForegroundColor Yellow } + Write-Host "" +} + +$confirm = Read-Host " 确认提交以上文件? (y/n)" +if ($confirm -ne 'y') { + Write-Host " 已取消" -ForegroundColor Yellow + exit 0 +} + +# 5. 提交 +Write-Host "[5/6] 提交..." -ForegroundColor White +git commit -m "Bookworm v6.5.1 portable commit + +Includes: CLAUDE.md, skills (92), agents (18), hooks (29), +scripts, constitution, settings.template.json + +Excludes: credentials, mcp-servers, node_modules, cache, +sessions, debug logs, project-specific data, gstack/browse binaries" + +Write-Host " [OK] 提交完成" -ForegroundColor Green + +# 6. 推送到 Gitea +Write-Host "[6/6] 推送到 Gitea..." -ForegroundColor White +$remoteExists = git remote -v 2>$null | Select-String "origin" +if (-not $remoteExists) { + git remote add origin $GitUrl +} +else { + git remote set-url origin $GitUrl +} + +Write-Host " 推送到: $GitUrl" -ForegroundColor Gray +git push -u origin main 2>&1 | ForEach-Object { Write-Host " $_" } + +} finally { + Pop-Location +} + +# 统计 +$repoSize = git -C $ClaudeDir count-objects -vH 2>$null | Select-String "size-pack" +Write-Host "" +Write-Host " ╔══════════════════════════════════════════╗" -ForegroundColor Green +Write-Host " ║ 仓库准备完成! ║" -ForegroundColor Green +Write-Host " ╠══════════════════════════════════════════╣" -ForegroundColor Green +Write-Host " ║ 远程: $GitUrl ║" -ForegroundColor Green +Write-Host " ║ $repoSize ║" -ForegroundColor Green +Write-Host " ╚══════════════════════════════════════════╝" -ForegroundColor Green +Write-Host "" +Write-Host " 下一步:" -ForegroundColor Yellow +Write-Host " 1. 运行 .\encrypt-secrets.ps1 创建加密凭证" -ForegroundColor Yellow +Write-Host " 2. 将 USB 内容复制到 U 盘:" -ForegroundColor Yellow +Write-Host " install.ps1 + stop.ps1 + secrets.enc" -ForegroundColor Yellow +Write-Host " 3. 在目标机运行: .\install.ps1" -ForegroundColor Yellow diff --git a/quick-reference.txt b/quick-reference.txt new file mode 100644 index 0000000..d249d7c --- /dev/null +++ b/quick-reference.txt @@ -0,0 +1,300 @@ + +================================================================================ + Bookworm Portable v1.4 — 快速参考手册 + 所有命令可直接复制粘贴执行 +================================================================================ + + Gitea 地址: https://code.letcareme.com + 管理员账号: [由管理员提供] + 管理员密码: [由管理员提供] + 中转站地址: [由管理员提供] + ECS 服务器: [由管理员提供] + +================================================================================ + 一、首次安装 — 最简方式 (推荐) +================================================================================ + + 前置要求: + [必须] Node.js 18+ https://nodejs.org 下载 LTS + [必须] Git https://git-scm.com 下载安装 + [必须] 代理/VPN Clash / V2Ray / 快柠檬 / 任意翻墙工具 + (Claude Code 启动时检查 api.anthropic.com, 国内必须) + + 方法 A: 一键安装器 (最简单) + ───────────────────────────── + 1. 获取 Bookworm-Setup.bat (管理员发给你, 或从 Bookworm Web 下载页获取) + 2. 双击运行 + 3. 按提示输入 Gitea 密码 + 主密码 + 4. 完成! 桌面自动出现 Bookworm 快捷方式 + + 方法 B: 手动安装 + ───────────────────────────── + # 1. 确保代理/VPN 已启动 + + # 2. 安装 Claude Code (如未装) + npm i -g @anthropic-ai/claude-code + + # 3. 克隆引导仓库 + git clone https://code.letcareme.com/bookworm/bookworm-boot.git + + # 4. 进入目录 + cd bookworm-boot + + # 5. 运行安装 (PowerShell 7 推荐, 5.1 也可) + pwsh -ExecutionPolicy Bypass -File install.ps1 + + # 脚本自动执行: + # [1/6] 前置检查 (缺依赖自动提示 winget 安装) + # [2/6] 代理自动检测 (无需手动找端口) + # [3/6] 解密凭证 (输入主密码, 最多3次重试, 可选本日免密) + # [4/6] 同步配置 (git clone 92 Skills / 18 Agents / 29 Hooks) + # [5/6] 渲染模板 + 初始化 + Bookworm 完整性验证 + MCP 检查 + # [6/6] 启动 Claude Code + +================================================================================ + 二、日常使用 +================================================================================ + + 方法 A: 双击 .bat (推荐) + ───────────────────────────── + bookworm-boot 文件夹 (或桌面快捷方式): + 启动Bookworm.bat 每天日常使用 (快速启动, 不更新) + 更新并启动Bookworm.bat 管理员通知有更新时使用 + + 启动时显示 "有 N 个新更新可用" → 双击 "更新并启动" 同步 + + 方法 B: 命令行 + ───────────────────────────── + # 快速启动 (~10秒, 跳过 git clone) + cd bookworm-boot + pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly + + # 同步更新后启动 + cd bookworm-boot + pwsh -ExecutionPolicy Bypass -File install.ps1 + +================================================================================ + 三、密码说明 +================================================================================ + + 本系统有两个密码, 不要搞混: + + Gitea 密码 克隆仓库时输入, 用于下载文件 + 主密码 解密 API 凭证时输入, 用于启动 Claude Code + + 密码输错: 最多 3 次重试, 3 次失败自动退出 + 本日免密: 首次解密后脚本询问 "今日内免密启动? (y/n)" + 选 y 后当天再次启动无需输密码, 次日自动过期 + 忘记密码: 联系管理员重新生成 secrets.enc + +================================================================================ + 四、使用完毕清理 +================================================================================ + + 方法 A: 双击 卸载Bookworm.bat (最简单, 一键完整卸载) + + 方法 B: 命令行 + ───────────────────────────── + # 基础清理 (清除环境变量, 保留配置供下次使用) + pwsh -ExecutionPolicy Bypass -File stop.ps1 + + # 完整恢复 (删除 Bookworm, 恢复电脑原始 .claude 目录) + pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore + + # 深度清理 (恢复 + 清除 PS 历史 + 清除 Git/凭证缓存) + pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore -Deep + + [!] 在他人电脑/公用电脑上务必执行深度清理或双击 卸载Bookworm.bat + +================================================================================ + 五、代理/VPN 相关 +================================================================================ + + install.ps1 自动检测代理, 支持: + - 系统代理 (Windows 设置里的代理) + - Clash for Windows / Clash Verge + - V2Ray / V2RayN + - 快柠檬 / 任意 VPN + - 其他设置了系统代理的工具 + + 如果自动检测失败, 手动指定: + $env:HTTPS_PROXY = "http://127.0.0.1:端口号" + $env:HTTP_PROXY = "http://127.0.0.1:端口号" + pwsh -ExecutionPolicy Bypass -File install.ps1 + + 查找代理端口: + [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") + # 输出中的 Port 就是代理端口 + + 常见代理端口: + Clash: 7890 + V2RayN: 10808 / 10809 + 快柠檬: 10792 (通过上述命令查询) + SSR: 1080 + +================================================================================ + 六、管理员操作 +================================================================================ + + ---- 6.1 推送 Bookworm 配置到 Gitea ---- + + cd C:\Users\leesu\.claude + git add -A + git commit -m "update bookworm config" + git push https://bookworm:[密码]@code.letcareme.com/bookworm/bookworm-config.git main + + ---- 6.2 更新 boot 仓库脚本 ---- + + cd C:\Users\leesu\AppData\Local\Temp\bookworm-boot + cp C:\Users\leesu\Documents\bookworm-portable\install.ps1 . + cp C:\Users\leesu\Documents\bookworm-portable\stop.ps1 . + cp C:\Users\leesu\Documents\bookworm-portable\guide.html . + cp C:\Users\leesu\Documents\bookworm-portable\secrets.enc . + cp "C:\Users\leesu\Documents\bookworm-portable\启动Bookworm.bat" . + cp "C:\Users\leesu\Documents\bookworm-portable\更新并启动Bookworm.bat" . + cp "C:\Users\leesu\Documents\bookworm-portable\卸载Bookworm.bat" . + cp "C:\Users\leesu\Documents\bookworm-portable\Bookworm-Setup.bat" . + git add -A + git commit -m "update boot scripts" + git push https://bookworm:[密码]@code.letcareme.com/bookworm/bookworm-boot.git main + + ---- 6.3 重新加密凭证 ---- + + pwsh -ExecutionPolicy Bypass -File C:\Users\leesu\Documents\bookworm-portable\encrypt-secrets.ps1 + # 加密完成后同步到 boot 仓库 (参照 6.2) + + ---- 6.4 解密验证 ---- + + pwsh -ExecutionPolicy Bypass -File C:\Users\leesu\Documents\bookworm-portable\encrypt-secrets.ps1 -Decrypt + + ---- 6.5 更新 Bookworm Web 下载页的安装器 ---- + + scp C:\Users\leesu\Documents\bookworm-portable\Bookworm-Setup.bat root@8.138.11.105:/opt/bookworm-web/public/ + +================================================================================ + 七、SSH 服务器管理 +================================================================================ + + # 登录 ECS + ssh root@8.138.11.105 + + # Gitea 状态/日志/重启 + ssh root@8.138.11.105 "systemctl status gitea" + ssh root@8.138.11.105 "journalctl -u gitea -n 50" + ssh root@8.138.11.105 "systemctl restart gitea" + + # Nginx 状态 + ssh root@8.138.11.105 "nginx -t && systemctl status nginx" + + # fail2ban 封禁/解封 + ssh root@8.138.11.105 "fail2ban-client status gitea" + ssh root@8.138.11.105 "fail2ban-client set gitea unbanip 1.2.3.4" + + # 用户管理 + ssh root@8.138.11.105 "sudo -u git gitea admin user list --config /var/lib/gitea/custom/conf/app.ini" + ssh root@8.138.11.105 "sudo -u git gitea admin user change-password --username bookworm --password NEW_PASSWORD --config /var/lib/gitea/custom/conf/app.ini" + + # 磁盘/备份 + ssh root@8.138.11.105 "du -sh /var/lib/gitea /home/git/gitea-repositories" + ssh root@8.138.11.105 "sudo -u git gitea dump -c /var/lib/gitea/custom/conf/app.ini --tempdir /tmp" + +================================================================================ + 八、故障排查 +================================================================================ + + 问题: "Unable to connect to Anthropic services" / "ECONNREFUSED" + 原因: 代理/VPN 未启动, 或 Node.js 未读到代理设置 + 解决: 1. 确认代理已启动 + 2. install.ps1 自动检测, 失败则手动: + $env:HTTPS_PROXY = "http://127.0.0.1:端口" + 3. 查端口: [System.Net.WebRequest]::DefaultWebProxy.GetProxy(...) + + 问题: "API Error: Invalid URL" + 原因: ANTHROPIC_BASE_URL 格式不对 + 解决: 确保 secrets.enc 中值为 https://bww.letcareme.com/v1 (末尾含 /v1) + 不要在 settings.json env 段放 ANTHROPIC_BASE_URL + + 问题: "openssl 未找到" + 解决: 确认 Git for Windows 已安装 (脚本搜索 C:\, D:\ 下的 Git 目录) + + 问题: "执行策略" 错误 + 解决: pwsh -ExecutionPolicy Bypass -File xxx.ps1 + 或: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned + + 问题: "?.Source" 语法错误 + 解决: 用 pwsh (PS 7) 或更新到最新 install.ps1 (已兼容 PS 5.1) + + 问题: 中文乱码 / "<#" 错误 + 解决: 用 pwsh (PS 7) 或 git pull 获取最新文件 (含 UTF-8 BOM) + + 问题: 克隆失败 / 认证失败 + 解决: 1. git config --global credential.helper store (install.ps1 已自动执行) + 2. 手动测试: git clone https://code.letcareme.com/bookworm/bookworm-config.git test + 3. 浏览器打开 https://code.letcareme.com 确认可达 + + 问题: 解密失败 / 密码错误 + 解决: 区分大小写, 至少 12 位, 最多 3 次重试 + 忘记密码需管理员重新 encrypt-secrets.ps1 + + 问题: Claude Code 启动无 Bookworm 横幅 / 显示 "原生模式" + 解决: 不加 -StartOnly 重新运行 install.ps1 同步最新配置 + + 问题: node -v / npm -v 无法识别 + 解决: 安装 Node.js 后必须重开 PowerShell + 或: $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + + 问题: npm 安装太慢 + 解决: npm config set registry https://registry.npmmirror.com + +================================================================================ + 九、安全规格 +================================================================================ + + 凭证加密: AES-256-CBC + PBKDF2 (600,000 迭代, OWASP 2023) + 传输加密: HTTPS TLS 1.2+ (Let's Encrypt 证书, 自动续期) + 凭证存储: 仅进程级环境变量, 不写磁盘, 不写注册表 + 免密缓存: Windows Credential Manager + DPAPI, 当日23:59过期 + 密码传递: openssl stdin 管道, 不暴露进程列表 + 登录保护: fail2ban (5 次失败/小时 -> 封禁 24 小时) + 二进制校验: Gitea 下载 SHA-256 完整性校验 + Gitea 绑定: 127.0.0.1:3300 仅本地, 通过 Nginx 443 反代 + HSTS: Strict-Transport-Security max-age=31536000 + 注册控制: DISABLE_REGISTRATION=true, INSTALL_LOCK=true + 代理检测: 3阶梯自动检测 (.NET DefaultWebProxy → 注册表 → 端口扫描) + +================================================================================ + 十、文件清单 +================================================================================ + + bookworm-portable\ (本机管理员工具包) + ├── Bookworm-Setup.bat 一键安装器 (发给用户的唯一文件) + ├── 启动Bookworm.bat 日常双击启动 + ├── 更新并启动Bookworm.bat 同步后启动 + ├── 卸载Bookworm.bat 一键完整卸载 + ├── install.ps1 核心安装逻辑 (含代理检测/依赖安装/验证) + ├── stop.ps1 清理/恢复逻辑 + ├── encrypt-secrets.ps1 凭证加密/解密 + ├── generate-integrity.ps1 完整性哈希生成 + ├── prepare-repo.ps1 仓库初始化+推送 + ├── deploy-gitea.sh Gitea 部署 (ECS) + ├── setup-https.sh HTTPS 配置 (ECS) + ├── secure-firewall.sh 防火墙加固 (ECS) + ├── settings.template.json settings.json 模板 + ├── settings.local.template.json settings.local.json 模板 + ├── guide.html HTML 保姆式教程 + ├── quick-reference.txt 本文档 + ├── download.html 独立下载页 + ├── download-panel.html Bookworm Web 嵌入面板 + ├── lessons-learned.md 踩坑经验 (10 项) + └── README.txt 简要说明 + + Gitea 仓库: + ├── bookworm-config Skills/Agents/Hooks/Scripts (514 文件, 14MB) + └── bookworm-boot 安装器+脚本+凭证+教程 + + Bookworm Web 引流入口: + └── /app → "下载" Tab 内嵌下载页 + Bookworm-Setup.bat 直链 + +================================================================================ + Bookworm Portable v1.4 | 2026-04-02 | code.letcareme.com +================================================================================ diff --git a/quick-start.html b/quick-start.html new file mode 100644 index 0000000..75960ec --- /dev/null +++ b/quick-start.html @@ -0,0 +1,233 @@ + + + + + +Bookworm Portable - 日常使用速查卡 + + + + +
+

Bookworm Portable 日常速查卡

+
v1.5 | 92 Skills / 18 Agents / 29 Hooks
打印后贴在显示器旁边
+
+ + +
+ 第零步:打开 PowerShell + +
+
+

方式一:运行框(最快)

+

Win+R → 输入 powershell → 回车
+ 如已装 PowerShell 7,输入 pwsh 效果更好

+
+
+

方式二:终端新标签页

+

已打开终端/PowerShell 窗口时:
Ctrl+Shift+T 打开新标签页 (自动用 PowerShell 7)

+
+
+

方式三:搜索

+

Win 键 → 输入 powershell → 点击 "PowerShell 7""Windows PowerShell"

+
+
+

打开后看到 PS C:\Users\你的用户名> 即为成功。如果桌面有 Bookworm 快捷方式,可跳过此步直接双击。

+
+ + +
+ 每日启动 (30 秒) + +
+
开代理
+ +
双击 Bookworm
+ +
等横幅出现
+ +
开始工作
+
+ + + + + + +
操作做法
快速启动双击桌面 Bookworm 快捷方式,或双击 启动Bookworm.bat
更新后启动双击 更新并启动Bookworm.bat (管理员通知有更新时)
命令行启动cd bookworm-bootpwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly
+ +
+

⚠ 启动前必须开代理

+

Clash / V2Ray / 快柠檬 — 任选一个开着即可。脚本自动检测,无需手动配置。

+
+
+ + +
+ 三条铁律 + +
+
+

❌ 不要直接运行 claude

+

新开 PowerShell 输入 claude 会报 "Not logged in"。必须通过 .bat 或 install.ps1 启动,脚本会注入 API 凭证。

+
+
+

❌ 密码不要外传

+

Gitea 密码 = 下载用
主密码 = 解密 API 凭证
两个密码都只能你自己知道。

+
+
+

❌ 公用电脑必须卸载

+

离开前双击 卸载Bookworm.bat,清除全部凭证和配置痕迹。

+
+
+
+ + +
+ Claude Code 常用操作 + +
+
+ + + + + + + +
快捷键功能
Enter发送消息
Shift+Enter换行 (不发送)
Esc中断当前操作
Ctrl+C退出 Claude Code
Tab自动补全 / 选择建议
+
+
+ + + + + + + +
斜杠命令功能
/help帮助信息
/clear清除上下文 (长对话后用)
/compact压缩上下文
/model切换模型
/cost查看本次消耗
+
+
+
+ + +
+ 故障速查 (5 大常见问题) + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
症状原因解决
ECONNRESET
Unable to connect to API
代理把国内中转站流量走了国际线路$env:NO_PROXY="bww.letcareme.com" 或更新脚本 git pull
Not logged in直接运行了 claude关掉,改用 .bat 或 install.ps1 启动
密码错误 3 次退出主密码不对 (区分大小写)仔细重试,忘记联系管理员
完整性校验 WARN
大量文件哈希不匹配
配置已更新但哈希文件未同步输入 y 继续,不影响使用
无代理继续?没检测到代理软件先启动 Clash/V2Ray,再重新运行脚本
+
+ + +
+ 清理 / 卸载 + + + + + + +
场景做法
日常关闭在 Claude Code 里按 Ctrl+C 退出,或直接关窗口。凭证自动清除。
彻底卸载双击 卸载Bookworm.bat — 终止进程 + 清除凭证 + 恢复原始配置 + 删除快捷方式
命令行卸载pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore -Deep
+
+ + +
+

遇到问题? 联系管理员 — 提供截图 + 错误信息,通常 5 分钟内解决

+
+ + + + + diff --git a/secure-firewall.sh b/secure-firewall.sh new file mode 100644 index 0000000..d9cbdea --- /dev/null +++ b/secure-firewall.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# ============================================================ +# Bookworm Portable - 防火墙加固 (P1-2) +# 配置 UFW + fail2ban 保护 Gitea +# ============================================================ +# 用法: ssh root@8.138.11.105 'bash -s' < secure-firewall.sh +# ============================================================ + +set -euo pipefail + +echo "=========================================" +echo " Bookworm 防火墙加固 v1.0" +echo "=========================================" + +# 1. 确保 Gitea 只监听 127.0.0.1 (Nginx 反代已处理外部访问) +GITEA_INI="/var/lib/gitea/custom/conf/app.ini" +if [ -f "$GITEA_INI" ]; then + if grep -q "^HTTP_ADDR.*=.*127.0.0.1" "$GITEA_INI"; then + echo "[1/3] Gitea 已绑定 127.0.0.1" + else + echo "[1/3] 配置 Gitea 仅本地监听..." + sed -i "s/^HTTP_ADDR\s*=.*/HTTP_ADDR = 127.0.0.1/" "$GITEA_INI" 2>/dev/null || \ + sed -i "/^\[server\]/a HTTP_ADDR = 127.0.0.1" "$GITEA_INI" + systemctl restart gitea + echo " [OK] Gitea 已限制为本地监听" + fi +else + echo "[1/3] [!] Gitea 配置不存在,跳过" +fi + +# 2. 安装配置 fail2ban +echo "[2/3] 配置 fail2ban..." +if ! command -v fail2ban-client &>/dev/null; then + apt-get update -qq && apt-get install -y -qq fail2ban +fi + +# Gitea 登录失败过滤器 +cat > /etc/fail2ban/filter.d/gitea.conf << 'EOF' +[Definition] +failregex = .*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from +ignoreregex = +EOF + +# Gitea jail 配置 +cat > /etc/fail2ban/jail.d/gitea.conf << EOF +[gitea] +enabled = true +port = http,https +filter = gitea +logpath = /var/lib/gitea/log/gitea.log +maxretry = 5 +findtime = 3600 +bantime = 86400 +action = iptables-multiport[name=gitea, port="http,https"] +EOF + +systemctl restart fail2ban +echo " [OK] fail2ban 已配置 (5次失败/小时 → 封禁24小时)" + +# 3. 输出阿里云安全组配置指引 +echo "[3/3] 安全组配置指引..." +echo "" +echo "=========================================" +echo " 阿里云安全组配置 (手动操作)" +echo "=========================================" +echo "" +echo " 1. 登录阿里云控制台 → ECS → 安全组" +echo " 2. 找到实例 8.138.11.105 所在安全组" +echo " 3. 添加入方向规则:" +echo "" +echo " ┌────────────┬──────────┬───────────────────────────┐" +echo " │ 端口 │ 协议 │ 授权对象 │" +echo " ├────────────┼──────────┼───────────────────────────┤" +echo " │ 443/443 │ TCP │ 0.0.0.0/0 (HTTPS 公开) │" +echo " │ 80/80 │ TCP │ 0.0.0.0/0 (重定向用) │" +echo " │ 22/22 │ TCP │ 你的 IP/32 │" +echo " ├────────────┼──────────┼───────────────────────────┤" +echo " │ 3300/3300 │ TCP │ 拒绝 0.0.0.0/0 │" +echo " │ │ │ (Gitea 仅本地,不需公开) │" +echo " └────────────┴──────────┴───────────────────────────┘" +echo "" +echo " 4. 删除任何允许 3300 端口公开访问的规则" +echo "" +echo " 查看你当前的公网 IP:" +echo " curl -s ifconfig.me" +echo "" +echo "=========================================" diff --git a/settings.local.template.json b/settings.local.template.json new file mode 100644 index 0000000..c780848 --- /dev/null +++ b/settings.local.template.json @@ -0,0 +1,67 @@ +{ + "permissions": { + "allow": [ + "Bash(ssh root@8.138.11.105:*)", + "Bash(scp *root@8.138.11.105:*)", + "Bash(echo:*)", + "Bash(ls:*)", + "Bash(claude config:*)", + "Bash(claude mcp:*)", + "Bash(node:*)", + "Bash(node --version:*)", + "Bash(node --check:*)", + "Bash(uv pip:*)", + "Bash(xargs grep:*)", + "Bash(for f:*)", + "Bash(do echo:*)", + "Bash(done)", + "mcp__playwright__browser_navigate", + "mcp__playwright__browser_click", + "mcp__playwright__browser_take_screenshot", + "mcp__playwright__browser_resize", + "mcp__playwright__browser_snapshot", + "mcp__playwright__browser_fill_form", + "mcp__playwright__browser_evaluate", + "mcp__playwright__browser_wait_for", + "mcp__playwright__browser_type", + "mcp__chrome-devtools__list_pages", + "mcp__chrome-devtools__navigate_page", + "mcp__chrome-devtools__emulate", + "mcp__chrome-devtools__take_screenshot", + "mcp__chrome-devtools__new_page", + "mcp__chrome-devtools__evaluate_script", + "mcp__chrome-devtools__list_network_requests", + "mcp__chrome-devtools__performance_start_trace", + "mcp__chrome-devtools__performance_stop_trace", + "mcp__chrome-devtools__take_snapshot" + ] + }, + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp@latest"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-sequential-thinking@latest"] + }, + "playwright": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-playwright@latest"] + }, + "firecrawl": { + "command": "npx", + "args": ["-y", "firecrawl-mcp@latest"], + "env": { + "FIRECRAWL_API_KEY": "${FIRECRAWL_API_KEY}" + } + }, + "github": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-github@latest"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/settings.template.json b/settings.template.json new file mode 100644 index 0000000..73d9817 --- /dev/null +++ b/settings.template.json @@ -0,0 +1,163 @@ +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", + "HOME": "{{HOME}}", + "SUPABASE_ACCESS_TOKEN": "${SUPABASE_ACCESS_TOKEN}" + }, + "permissions": { + "allow": [ + "Read", + "Glob", + "Grep", + "WebSearch", + "WebFetch", + "Skill", + "Task", + "TaskCreate", + "TaskUpdate", + "TaskList", + "TaskGet", + "AskUserQuestion" + ] + }, + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/prompt-dispatcher.js", + "timeout": 3000 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/clipboard-image-hook.js", + "timeout": 5000 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/bash-precheck-dispatcher.js", + "timeout": 5000 + } + ] + }, + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/route-compliance-gate.js", + "timeout": 2000 + } + ] + }, + { + "matcher": "mcp__", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/mcp-safety-gate.js", + "timeout": 3000 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/post-edit-dispatcher.js", + "timeout": 8000 + } + ] + }, + { + "matcher": "Skill|Agent", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/memory-persistence-trigger.js", + "timeout": 2000 + } + ] + }, + { + "matcher": "Edit|Write|Skill|Agent|Bash|mcp__.*", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/activity-logger.js", + "timeout": 2000 + } + ] + }, + { + "matcher": "Edit|Write|Skill|Agent|Bash|mcp__.*", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/session-heartbeat.js", + "timeout": 2000 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/build-outcome-tracker.js", + "timeout": 3000 + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/constitution-delivery-reminder.js", + "timeout": 3000 + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/subagent-route-injector.js", + "timeout": 2000 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node {{CLAUDE_ROOT}}/hooks/stop-dispatcher.js 2>>{{CLAUDE_ROOT}}/debug/hook-errors.log || true", + "timeout": 5000 + } + ] + } + ] + }, + "effortLevel": "high", + "skipDangerousModePermissionPrompt": false +} diff --git a/setup-all.js b/setup-all.js new file mode 100644 index 0000000..3372ce2 --- /dev/null +++ b/setup-all.js @@ -0,0 +1,616 @@ +#!/usr/bin/env node +'use strict'; +/** + * Bookworm Portable - 全自动安装引擎 v3.0 + * @module setup-all + */ + +// W3: Node.js 版本检查 +if (parseInt(process.versions.node) < 14) { + console.error(' [!!] Node.js 版本过低 (' + process.version + '), 需要 14.0+'); + console.error(' 请更新: https://nodejs.org/'); + process.exit(1); +} + +const { execSync, spawnSync, spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const crypto = require('crypto'); +const os = require('os'); + +// ─── 配置 ─── +const HOME = os.homedir(); +const BOOT_DIR = path.join(HOME, 'bookworm-boot'); +const CLAUDE_DIR = path.join(HOME, '.claude'); +const GITEA_BOOT = 'https://code.letcareme.com/bookworm/bookworm-boot.git'; +const GITEA_CONFIG = 'https://code.letcareme.com/bookworm/bookworm-config.git'; +const NPM_MIRROR = 'https://registry.npmmirror.com'; +const SCRIPT_DIR = __dirname; + +// ─── 颜色输出 ─── +const c = { + reset: '\x1b[0m', bold: '\x1b[1m', + red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', + blue: '\x1b[34m', cyan: '\x1b[36m', dim: '\x1b[90m', +}; +function ok(msg) { console.log(` ${c.green}[OK]${c.reset} ${msg}`); } +function warn(msg) { console.log(` ${c.yellow}[!]${c.reset} ${msg}`); } +function fail(msg) { console.log(` ${c.red}[!!]${c.reset} ${msg}`); } +function info(msg) { console.log(` ${c.dim}[..]${c.reset} ${msg}`); } +function step(n, total, msg) { + console.log(`\n ${c.bold}[${n}/${total}]${c.reset} ${c.cyan}${msg}${c.reset}`); +} + +// ─── 工具函数 ─── +function hasCmd(cmd) { + if (!/^[a-zA-Z0-9._-]+$/.test(cmd)) return false; // B3: 防命令注入 + try { + execSync(`where ${cmd}`, { stdio: 'pipe' }); + return true; + } catch { return false; } +} + +function run(cmd, opts = {}) { + try { + return execSync(cmd, { + stdio: opts.silent ? 'pipe' : 'inherit', + encoding: 'utf8', + timeout: opts.timeout || 300000, + env: { ...process.env, ...opts.env }, + ...opts, + }); + } catch (e) { + if (opts.ignoreError) return ''; + throw e; + } +} + +function runSilent(cmd) { + try { + return execSync(cmd, { stdio: 'pipe', encoding: 'utf8', timeout: 60000 }); + } catch { return ''; } +} + +function wingetInstall(id, name) { + if (!hasCmd('winget')) { + warn(`winget 不可用, 请手动安装 ${name}`); + return false; + } + info(`通过 winget 安装 ${name}...`); + try { + run(`winget install ${id} --accept-source-agreements --accept-package-agreements --silent`, { timeout: 600000 }); + refreshPath(); + ok(`${name} 安装成功`); + return true; + } catch (e) { + fail(`${name} 安装失败: ${e.message}`); + return false; + } +} + +function refreshPath() { + try { + const sysPath = runSilent('reg query "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment" /v Path') + .match(/REG_\w+\s+(.+)/)?.[1] || ''; + const usrPath = runSilent('reg query "HKCU\\Environment" /v Path') + .match(/REG_\w+\s+(.+)/)?.[1] || ''; + const extra = [ + 'C:\\Program Files\\nodejs', + 'C:\\Program Files\\Git\\cmd', + 'C:\\Program Files\\Git\\usr\\bin', + 'C:\\Program Files\\PowerShell\\7', + path.join(HOME, 'AppData\\Local\\Microsoft\\WinGet\\Packages'), + path.join(HOME, 'AppData\\Roaming\\npm'), + ].join(';'); + // I2: 动态扫描 Python 安装路径 + const pyBase = path.join(HOME, 'AppData', 'Local', 'Programs', 'Python'); + let pyPaths = ''; + try { + if (fs.existsSync(pyBase)) { + for (const d of fs.readdirSync(pyBase)) { + pyPaths += ';' + path.join(pyBase, d) + ';' + path.join(pyBase, d, 'Scripts'); + } + } + } catch {} + process.env.PATH = `${sysPath};${usrPath};${extra}${pyPaths}`; + } catch {} +} + +function askPassword(prompt) { + return new Promise((resolve) => { + process.stdout.write(prompt); + // Windows: 用 PowerShell 隐藏输入 + try { + const result = execSync( + 'powershell -Command "[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR((Read-Host -AsSecureString)))"', + { stdio: ['inherit', 'pipe', 'pipe'], encoding: 'utf8', timeout: 120000 } + ).trim(); + resolve(result); + } catch { + // W5: 回退明文输入, 给出警告 + warn('PowerShell 不可用, 密码将以明文显示'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question('', (ans) => { rl.close(); resolve(ans.trim()); }); + } + }); +} + +// ─── 加密/解密 (与 crypto-helper.js 同格式) ─── +function decryptSecrets(filePath, password) { + const data = fs.readFileSync(filePath); + const magic = data.slice(0, 6).toString(); + if (magic !== 'BWENC1') throw new Error('WRONG_FORMAT'); + const salt = data.slice(6, 22); + const encrypted = data.slice(22); + const derived = crypto.pbkdf2Sync(password, salt, 600000, 48, 'sha256'); + const key = derived.slice(0, 32); + const iv = derived.slice(32, 48); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + try { + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } catch { + throw new Error('WRONG_PASSWORD'); + } +} + +function escVbs(s) { return s.replace(/"/g, '""'); } // B4: 防 VBS 注入 + +function createShortcut(name, target, workDir) { + const vbs = path.join(os.tmpdir(), `bw_sc_${Date.now()}.vbs`); + const desktop = path.join(HOME, 'Desktop'); + const lnkPath = escVbs(path.join(desktop, name + '.lnk')); + fs.writeFileSync(vbs, `Set ws = CreateObject("WScript.Shell") +Set sc = ws.CreateShortcut("${lnkPath}") +sc.TargetPath = "${escVbs(target)}" +sc.WorkingDirectory = "${escVbs(workDir)}" +sc.Description = "Bookworm Smart Assistant" +sc.Save +`); + try { execSync(`cscript //nologo "${vbs}"`, { stdio: 'pipe' }); return true; } + catch { return false; } + finally { try { fs.unlinkSync(vbs); } catch {} } +} + +// W8: 动态检测 Git Bash 路径 +function findGitBash() { + const candidates = ['C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\bin\\bash.exe']; + try { + const gitPath = runSilent('where git').trim().split('\n')[0]; + if (gitPath) candidates.unshift(path.join(path.dirname(path.dirname(gitPath)), 'bin', 'bash.exe')); + } catch {} + return candidates.find(p => fs.existsSync(p)) || candidates[0]; +} + +// ─── Banner ─── +function banner() { + console.log(` + ${c.cyan}╔══════════════════════════════════════════════════╗ + ║ ____ _ ║ + ║ | __ ) ___ ___ | | ____ _____ _ __ ___ ║ + ║ | _ \\ / _ \\ / _ \\| |/ /\\ \\ /\\ / / _ \\| '__| ║ + ║ | |_) | (_) | (_) | < \\ V V / (_) | | ║ + ║ |____/ \\___/ \\___/|_|\\_\\ \\_/\\_/ \\___/|_| ║ + ║ ║ + ║ ${c.bold}全自动安装引擎 v3.0${c.cyan} ║ + ║ 双击即装: Node + Git + Python + PS7 + Claude + MCP║ + ╚══════════════════════════════════════════════════╝${c.reset} + `); +} + +// ═══════════════════════════════════════════ +// 主流程 +// ═══════════════════════════════════════════ +async function main() { + // ─── 启动模式: 已安装过 → 静默更新 + 直接启动 ─── + const isInstalled = hasCmd('claude') && fs.existsSync(path.join(CLAUDE_DIR, 'CLAUDE.md')); + const startOnly = process.argv.includes('--start') || process.argv.includes('-s'); + + if (isInstalled && startOnly) { + return quickStart(); + } + + banner(); + const TOTAL = 9; + let errors = 0; + + // ─── 1. Git ─── + step(1, TOTAL, '安装 Git'); + if (hasCmd('git')) { + ok('Git 已安装'); + } else { + if (!wingetInstall('Git.Git', 'Git')) errors++; + } + + // ─── 2. Python ─── + step(2, TOTAL, '安装 Python'); + if (hasCmd('python') || hasCmd('python3') || hasCmd('py')) { + ok('Python 已安装'); + } else { + if (!wingetInstall('Python.Python.3.12', 'Python 3.12')) errors++; + refreshPath(); + } + + // ─── 3. PowerShell 7 ─── + step(3, TOTAL, '安装 PowerShell 7'); + if (hasCmd('pwsh')) { + ok('PowerShell 7 已安装'); + } else { + if (wingetInstall('Microsoft.PowerShell', 'PowerShell 7')) { + // 设 PS7 为 Windows Terminal 默认配置文件 + try { + const wtSettings = path.join(HOME, 'AppData', 'Local', 'Packages', + 'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'LocalState', 'settings.json'); + if (fs.existsSync(wtSettings)) { + let wt = JSON.parse(fs.readFileSync(wtSettings, 'utf8')); + // 找到 PS7 的 profile GUID + const ps7Profile = (wt.profiles?.list || []).find(p => + p.source === 'Windows.Terminal.PowershellCore' || (p.name || '').includes('PowerShell 7') + ); + if (ps7Profile && ps7Profile.guid) { + wt.defaultProfile = ps7Profile.guid; + fs.writeFileSync(wtSettings, JSON.stringify(wt, null, 4)); + ok('PS7 已设为 Windows Terminal 默认终端'); + } + } + } catch { warn('Windows Terminal 默认配置未修改 - 不影响使用'); } + } else { + errors++; + } + } + + // ─── 4. Claude Code ─── + step(4, TOTAL, '安装 Claude Code'); + // I3: 用 --registry 参数, 不污染全局 .npmrc + if (hasCmd('claude')) { + ok('Claude Code 已安装'); + } else { + info('通过 npm 安装 Claude Code - 淘宝镜像加速...'); + try { + run(`npm i -g @anthropic-ai/claude-code --registry ${NPM_MIRROR}`, { timeout: 600000 }); + ok('Claude Code 安装成功'); + } catch { + fail('Claude Code 安装失败'); + errors++; + } + } + + // ─── 5. 克隆 Bookworm 配置 ─── + step(5, TOTAL, '同步 Bookworm 配置'); + // 设置 git credential helper + run('git config --global credential.helper manager', { ignoreError: true, silent: true }); + + // 克隆 bookworm-boot + if (fs.existsSync(path.join(BOOT_DIR, '.git'))) { + info('bookworm-boot 已存在, 更新...'); + run('git pull', { cwd: BOOT_DIR, ignoreError: true }); + } else { + info('首次下载 bookworm-boot (需输入 Gitea 用户名密码)...'); + try { + run(`git clone "${GITEA_BOOT}" "${BOOT_DIR}"`); + ok('bookworm-boot 克隆成功'); + } catch { + fail('bookworm-boot 克隆失败 - 检查网络和 Gitea 凭证'); + errors++; + } + } + + // 克隆 bookworm-config → ~/.claude + if (fs.existsSync(path.join(CLAUDE_DIR, '.git', 'config'))) { + info('.claude 配置已存在, 更新...'); + try { + const stashOut = run('git stash', { cwd: CLAUDE_DIR, ignoreError: true, silent: true }) || ''; + run('git pull --rebase', { cwd: CLAUDE_DIR, ignoreError: true, silent: true }); + if (stashOut.includes('Saved working directory')) { + run('git stash pop', { cwd: CLAUDE_DIR, ignoreError: true, silent: true }); + } + } catch {} + } else if (!fs.existsSync(path.join(CLAUDE_DIR, 'CLAUDE.md'))) { + info('首次下载 .claude 配置...'); + // 备份现有 + if (fs.existsSync(CLAUDE_DIR)) { + const backup = CLAUDE_DIR + '.bak.' + Date.now(); + fs.renameSync(CLAUDE_DIR, backup); + ok(`现有 .claude 已备份到 ${path.basename(backup)}`); + } + try { + run(`git clone --depth 1 "${GITEA_CONFIG}" "${CLAUDE_DIR}"`); + ok('.claude 配置克隆成功'); + } catch { + fail('.claude 配置克隆失败'); + errors++; + } + } else { + ok('.claude 配置已存在'); + } + + // 确保本地目录 + for (const d of ['debug', 'sessions', 'cache', 'backups', 'memory', 'projects']) { + const p = path.join(CLAUDE_DIR, d); + if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); + } + + // ─── 6. 凭证解密 ─── + step(6, TOTAL, '解密凭证'); + const secretsFile = path.join(BOOT_DIR, 'secrets.enc'); + if (!fs.existsSync(secretsFile)) { + warn('secrets.enc 不存在, 跳过凭证解密'); + } else if (process.env.ANTHROPIC_API_KEY) { + ok('API Key 已设置 (缓存有效)'); + } else { + let decrypted = false; + for (let attempt = 1; attempt <= 3; attempt++) { + const label = attempt > 1 + ? ` 重新输入主密码 (第 ${attempt}/3 次): ` + : ' 输入主密码解密凭证: '; + const password = await askPassword(label); + try { + const text = decryptSecrets(secretsFile, password); + // 注入环境变量 (B5: 不打印 key 名称, 防截屏泄露) + let injectedCount = 0; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.includes('=')) continue; + const eqIdx = trimmed.indexOf('='); + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim(); + if (key && val) { process.env[key] = val; injectedCount++; } + } + ok(`已注入 ${injectedCount} 个环境变量`); + decrypted = true; + break; + } catch (e) { + if (e.message === 'WRONG_PASSWORD') { + const remaining = 3 - attempt; + if (remaining > 0) fail(`密码错误, 剩余重试: ${remaining} 次`); + } else if (e.message === 'WRONG_FORMAT') { + fail('secrets.enc 格式不兼容, 请联系管理员'); + break; + } + } + } + if (!decrypted) { + fail('凭证解密失败 — Claude Code 将以登录模式启动'); + errors++; + } + } + + // 渲染 settings.json + const templateFile = path.join(CLAUDE_DIR, 'settings.template.json'); + const settingsFile = path.join(CLAUDE_DIR, 'settings.json'); + if (fs.existsSync(templateFile)) { + let tpl = fs.readFileSync(templateFile, 'utf8'); + const claudeRoot = CLAUDE_DIR.replace(/\\/g, '/'); + tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, claudeRoot); + tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/')); // B6: 统一正斜杠 + fs.writeFileSync(settingsFile, tpl); + ok('settings.json 已渲染'); + } + // 渲染 settings.local.template.json + const localTpl = path.join(CLAUDE_DIR, 'settings.local.template.json'); + const localSet = path.join(CLAUDE_DIR, 'settings.local.json'); + if (fs.existsSync(localTpl)) { + let tpl = fs.readFileSync(localTpl, 'utf8'); + tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, CLAUDE_DIR.replace(/\\/g, '/')); + tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/')); // B6: 统一正斜杠 + tpl = tpl.replace(/\{\{USERNAME\}\}/g, os.userInfo().username); + fs.writeFileSync(localSet, tpl); + ok('settings.local.json 已渲染'); + } + + // ─── 7. MCP + hooks 依赖 ─── + step(7, TOTAL, 'MCP 与 hooks 依赖'); + + // MCP 配置写入 ~/.claude.json (Claude Code 的全局 MCP 存储位置) + const claudeJson = path.join(HOME, '.claude.json'); + try { + let globalCfg = {}; + if (fs.existsSync(claudeJson)) { + globalCfg = JSON.parse(fs.readFileSync(claudeJson, 'utf8')); + } + + // 基础 MCP 列表 (npx 方式, 无需预装, 首次调用自动下载) + const baseMcps = { + 'context7': { + command: 'npx.cmd', args: ['-y', '@upstash/context7-mcp@latest'], type: 'stdio' + }, + 'sequential-thinking': { + command: 'npx.cmd', args: ['-y', '@modelcontextprotocol/server-sequential-thinking@latest'], type: 'stdio' + }, + 'playwright': { + command: 'npx.cmd', args: ['-y', '@playwright/mcp@latest', '--headless'], type: 'stdio' + }, + 'firecrawl': { + command: 'npx.cmd', args: ['-y', 'firecrawl-mcp'], type: 'stdio', + env: { FIRECRAWL_API_KEY: '${FIRECRAWL_API_KEY}' } + }, + 'github': { + command: 'npx.cmd', args: ['-y', '@modelcontextprotocol/server-github'], type: 'stdio', + env: { GITHUB_PERSONAL_ACCESS_TOKEN: '${GITHUB_PERSONAL_ACCESS_TOKEN}' } + }, + 'linear': { type: 'http', url: 'https://mcp.linear.app/mcp' }, + 'figma': { type: 'http', url: 'https://mcp.figma.com/mcp' }, + 'supabase': { type: 'http', url: 'https://mcp.supabase.com/mcp?project_ref=oepmihbtoylosbsxlmfo' }, + }; + + // 合并: 不覆盖用户已有配置 + if (!globalCfg.mcpServers) globalCfg.mcpServers = {}; + let added = 0; + for (const [name, cfg] of Object.entries(baseMcps)) { + if (!globalCfg.mcpServers[name]) { + globalCfg.mcpServers[name] = cfg; + added++; + } + } + + fs.writeFileSync(claudeJson, JSON.stringify(globalCfg, null, 2)); + ok(`MCP 配置已写入 ~/.claude.json (新增 ${added} 个, 总计 ${Object.keys(globalCfg.mcpServers).length} 个)`); + } catch (e) { + warn('MCP 配置写入失败: ' + e.message); + } + + // npm install in .claude for hooks + if (fs.existsSync(path.join(CLAUDE_DIR, 'package.json'))) { + info('安装 hooks 依赖...'); + run(`npm install --omit=dev --registry ${NPM_MIRROR}`, { cwd: CLAUDE_DIR, ignoreError: true, silent: true }); + ok('hooks 依赖已安装'); + } + + // Python MCP 依赖 + const pyCmd = hasCmd('python') ? 'python' : (hasCmd('python3') ? 'python3' : (hasCmd('py') ? 'py' : null)); + if (pyCmd) { + info('安装 Python MCP 依赖...'); + run(`${pyCmd} -m pip install askui scrapling --quiet`, { ignoreError: true, silent: true }); + ok('Python MCP 依赖已安装'); + } + + // ─── 8. 桌面快捷方式 ─── + step(8, TOTAL, '创建桌面快捷方式'); + + // 启动脚本: 用 pwsh/powershell 运行 install.ps1 + const launchBat = path.join(BOOT_DIR, '启动Bookworm.bat'); + if (fs.existsSync(launchBat)) { + if (createShortcut('Bookworm', launchBat, BOOT_DIR)) ok('Bookworm 快捷方式'); + } else { + // 回退: 直接创建 claude 启动快捷方式 + const claudePath = runSilent('where claude').trim().split('\n')[0]; + if (claudePath) { + if (createShortcut('Bookworm', claudePath, HOME)) ok('Bookworm 快捷方式'); + } + } + + const updateBat = path.join(BOOT_DIR, '更新并启动Bookworm.bat'); + if (fs.existsSync(updateBat)) { + if (createShortcut('更新Bookworm', updateBat, BOOT_DIR)) ok('更新Bookworm 快捷方式'); + } + + // ─── 9. 完成 ─── + step(9, TOTAL, '安装完成'); + + console.log(` + ${c.green}╔══════════════════════════════════════════════════╗ + ║ ║ + ║ 安装完成! ║ + ║ ║ + ║ [v] Node.js [v] Git [v] Python ║ + ║ [v] PS7 [v] Claude [v] MCP ║ + ║ [v] Bookworm - 92 Skills / 18 Agents ║ + ║ ║ + ║ 桌面快捷方式: Bookworm / 更新Bookworm ║ + ║ ║ + ╚══════════════════════════════════════════════════╝${c.reset} + `); + + if (errors > 0) { + warn(`安装过程中有 ${errors} 个警告, 请查看上方日志`); + } + + // 打开使用教程 + const guide = path.join(BOOT_DIR, 'guide.html'); + if (fs.existsSync(guide)) { + try { execSync(`start "" "${guide}"`, { stdio: 'pipe' }); } catch {} + } + + // 询问是否启动 + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question('\n 按回车启动 Bookworm (或输入 n 退出): ', (ans) => { + rl.close(); + if (ans.trim().toLowerCase() === 'n') return; + + console.log(`\n ${c.cyan}正在启动 Claude Code...${c.reset}\n`); + // 设置必要环境变量 + const env = { ...process.env }; + // W10: 追加而非覆盖用户已有 NO_PROXY + const existingNoProxy = process.env.NO_PROXY || process.env.no_proxy || ''; + const bwDomains = 'bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1'; + env.NO_PROXY = existingNoProxy ? `${existingNoProxy},${bwDomains}` : bwDomains; + env.CLAUDE_CODE_GIT_BASH_PATH = findGitBash(); + + const child = spawn('claude', [], { + stdio: 'inherit', + env, + cwd: HOME, + shell: true, + }); + child.on('exit', () => process.exit(0)); + }); +} + +// ═══════════════════════════════════════════ +// 快速启动模式: 静默更新 + 直接启动 (已安装过的机器) +// ═══════════════════════════════════════════ +async function quickStart() { + console.log(` ${c.cyan}Bookworm 快速启动${c.reset} — 检查更新中...`); + + let updated = false; + + // 1. 静默更新 bookworm-boot + if (fs.existsSync(path.join(BOOT_DIR, '.git'))) { + try { + const before = runSilent(`git -C "${BOOT_DIR}" rev-parse HEAD`).trim(); + run(`git -C "${BOOT_DIR}" pull --ff-only`, { ignoreError: true, silent: true, timeout: 15000 }); + const after = runSilent(`git -C "${BOOT_DIR}" rev-parse HEAD`).trim(); + if (before !== after) { ok('bookworm-boot 已更新'); updated = true; } + } catch {} + } + + // 2. 静默更新 bookworm-config (.claude) + if (fs.existsSync(path.join(CLAUDE_DIR, '.git', 'config'))) { + try { + const before = runSilent(`git -C "${CLAUDE_DIR}" rev-parse HEAD`).trim(); + const stashOut = run(`git -C "${CLAUDE_DIR}" stash`, { ignoreError: true, silent: true }) || ''; + run(`git -C "${CLAUDE_DIR}" pull --rebase`, { ignoreError: true, silent: true, timeout: 15000 }); + if (stashOut.includes('Saved working directory')) { + run(`git -C "${CLAUDE_DIR}" stash pop`, { ignoreError: true, silent: true }); + } + const after = runSilent(`git -C "${CLAUDE_DIR}" rev-parse HEAD`).trim(); + if (before !== after) { ok('.claude 配置已更新'); updated = true; } + } catch {} + } + + // 3. 更新后重新渲染模板 + if (updated) { + const templateFile = path.join(CLAUDE_DIR, 'settings.template.json'); + const settingsFile = path.join(CLAUDE_DIR, 'settings.json'); + if (fs.existsSync(templateFile)) { + let tpl = fs.readFileSync(templateFile, 'utf8'); + tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, CLAUDE_DIR.replace(/\\/g, '/')); + tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/')); + fs.writeFileSync(settingsFile, tpl); + } + const localTpl = path.join(CLAUDE_DIR, 'settings.local.template.json'); + const localSet = path.join(CLAUDE_DIR, 'settings.local.json'); + if (fs.existsSync(localTpl)) { + let tpl = fs.readFileSync(localTpl, 'utf8'); + tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, CLAUDE_DIR.replace(/\\/g, '/')); + tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/')); + tpl = tpl.replace(/\{\{USERNAME\}\}/g, os.userInfo().username); + fs.writeFileSync(localSet, tpl); + } + ok('配置模板已重新渲染'); + + // hooks 依赖更新 + if (fs.existsSync(path.join(CLAUDE_DIR, 'package.json'))) { + run(`npm install --omit=dev --registry ${NPM_MIRROR}`, { cwd: CLAUDE_DIR, ignoreError: true, silent: true }); + } + } + + if (!updated) ok('已是最新版本'); + + // 4. 启动 Claude Code + console.log(`\n ${c.cyan}启动 Claude Code...${c.reset}\n`); + const env = { ...process.env }; + const existingNoProxy = process.env.NO_PROXY || process.env.no_proxy || ''; + const bwDomains = 'bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1'; + env.NO_PROXY = existingNoProxy ? `${existingNoProxy},${bwDomains}` : bwDomains; + env.CLAUDE_CODE_GIT_BASH_PATH = findGitBash(); + + const child = spawn('claude', [], { stdio: 'inherit', env, cwd: HOME, shell: true }); + child.on('exit', () => process.exit(0)); +} + +main().catch(e => { + fail(`安装引擎异常: ${e.message}`); + console.error(e.stack); + process.exit(1); +}); diff --git a/setup-https.sh b/setup-https.sh new file mode 100644 index 0000000..df87258 --- /dev/null +++ b/setup-https.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ============================================================ +# Bookworm Portable - HTTPS 配置脚本 (P1-1) +# 为 Gitea 配置 Nginx HTTPS 反代 +# ============================================================ +# 前提: deploy-gitea.sh 已执行, Nginx + certbot 已安装 +# 用法: ssh root@8.138.11.105 'bash -s' < setup-https.sh +# ============================================================ + +set -euo pipefail + +DOMAIN="code.letcareme.com" +GITEA_PORT=3300 +CERT_DIR="/etc/letsencrypt/live/$DOMAIN" + +echo "=========================================" +echo " Bookworm HTTPS 配置 v1.0" +echo "=========================================" + +# 1. 检查证书 +if [ ! -d "$CERT_DIR" ]; then + echo "[1/4] 证书不存在,申请新证书..." + certbot certonly --nginx -d "$DOMAIN" --non-interactive --agree-tos --email leesu@letcareme.com +else + echo "[1/4] 证书已存在: $CERT_DIR" +fi + +# 2. 创建 Nginx 配置 +echo "[2/4] 配置 Nginx 反代..." +cat > /etc/nginx/sites-available/gitea.conf << EOF +# Bookworm Gitea - HTTPS 反向代理 +server { + listen 80; + server_name $DOMAIN; + return 301 https://\$host\$request_uri; +} + +server { + listen 443 ssl http2; + server_name $DOMAIN; + + ssl_certificate $CERT_DIR/fullchain.pem; + ssl_certificate_key $CERT_DIR/privkey.pem; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + + # Git LFS 和大文件上传 + client_max_body_size 512M; + + location / { + proxy_pass http://127.0.0.1:$GITEA_PORT; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # WebSocket 支持 (Gitea 通知) + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +EOF + +# 启用站点 +ln -sf /etc/nginx/sites-available/gitea.conf /etc/nginx/sites-enabled/gitea.conf + +# 3. 测试并重载 Nginx +echo "[3/4] 测试 Nginx 配置..." +nginx -t +systemctl reload nginx +echo " [OK] Nginx 已重载" + +# 4. 更新 Gitea ROOT_URL +GITEA_INI="/var/lib/gitea/custom/conf/app.ini" +if [ -f "$GITEA_INI" ]; then + echo "[4/4] 更新 Gitea ROOT_URL..." + # 更新端口 + sed -i "s/^HTTP_PORT\s*=.*/HTTP_PORT = $GITEA_PORT/" "$GITEA_INI" + # 更新 ROOT_URL 为 HTTPS + sed -i "s|^ROOT_URL\s*=.*|ROOT_URL = https://$DOMAIN/|" "$GITEA_INI" + + # 确保 Gitea 只监听本地 + if ! grep -q "HTTP_ADDR" "$GITEA_INI"; then + sed -i "/^\[server\]/a HTTP_ADDR = 127.0.0.1" "$GITEA_INI" + else + sed -i "s/^HTTP_ADDR\s*=.*/HTTP_ADDR = 127.0.0.1/" "$GITEA_INI" + fi + + systemctl restart gitea + sleep 2 + + if systemctl is-active --quiet gitea; then + echo " [OK] Gitea 已重启 (端口 $GITEA_PORT, 仅本地监听)" + else + echo " [ERROR] Gitea 重启失败" + journalctl -u gitea -n 10 + exit 1 + fi +else + echo "[4/4] [!] Gitea 配置不存在,请先运行 deploy-gitea.sh" +fi + +echo "" +echo "=========================================" +echo " HTTPS 配置完成!" +echo "=========================================" +echo "" +echo " 访问: https://$DOMAIN" +echo " HTTP → HTTPS 自动跳转: 已启用" +echo " HSTS: 已启用 (1年)" +echo " Gitea 端口: $GITEA_PORT (仅 127.0.0.1)" +echo "" +echo " 证书续期: certbot 已自动配置 cron" +echo " 验证: curl -I https://$DOMAIN" +echo "=========================================" diff --git a/stop.ps1 b/stop.ps1 new file mode 100644 index 0000000..343f408 --- /dev/null +++ b/stop.ps1 @@ -0,0 +1,151 @@ +<# +.SYNOPSIS + Bookworm Portable - 清理/卸载脚本 +.DESCRIPTION + 清除环境变量, 恢复原始 .claude 目录, 清理痕迹. +.USAGE + .\stop.ps1 # 标准清理 (保留 Bookworm 配置) + .\stop.ps1 -Restore # 完整恢复 (删除 Bookworm, 恢复备份) + .\stop.ps1 -Deep # 深度清理 (含 PS 历史) +#> + +param( + [switch]$Restore, + [switch]$Deep +) + +$ClaudeTarget = Join-Path $env:USERPROFILE ".claude" +$BackupPath = Join-Path $env:USERPROFILE ".claude.bw-backup" + +Write-Host "" +Write-Host " Bookworm Portable - 清理" -ForegroundColor Cyan +Write-Host " ========================" -ForegroundColor Cyan +Write-Host "" + +# 1. 终止 Claude Code 及 Node.js 子进程 +$claudeProcs = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @("claude", "claude-code") } +if ($claudeProcs) { + Write-Host "[1/5] 终止 Claude Code 进程..." -ForegroundColor Yellow + $claudeProcs | Stop-Process -Force + Start-Sleep -Seconds 3 + # 清理残留 node 子进程 (hooks) + Get-Process node -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -match '\.claude[\\/]hooks' + } | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Host " [OK] 进程已终止" -ForegroundColor Green +} +else { + Write-Host "[1/5] 无 Claude Code 进程运行" -ForegroundColor Gray +} + +# 2. 清除进程级环境变量 +Write-Host "[2/5] 清除环境变量..." -ForegroundColor White +$envVars = @( + "CLAUDE_HOME", "CLAUDE_ROOT", "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", "SUPABASE_ACCESS_TOKEN", + "GITHUB_PERSONAL_ACCESS_TOKEN", "SLACK_BOT_TOKEN", + "ATLASSIAN_API_TOKEN", "BROWSERBASE_API_KEY", + "FIRECRAWL_API_KEY" +) +foreach ($v in $envVars) { + if ([System.Environment]::GetEnvironmentVariable($v, "Process")) { + [System.Environment]::SetEnvironmentVariable($v, $null, "Process") + Write-Host " 清除: $v" -ForegroundColor Gray + } +} +Write-Host " [OK] 环境变量已清除" -ForegroundColor Green + +# 3. 清除凭证缓存 (Credential Manager + 注册表) +Write-Host "[3/5] 清除凭证缓存..." -ForegroundColor White +cmdkey /delete:bookworm-secrets 2>$null | Out-Null +Remove-Item "HKCU:\Software\Bookworm" -Recurse -Force -ErrorAction SilentlyContinue +Write-Host " 清除: Bookworm 本日免密缓存" -ForegroundColor Gray + +# 4. 清除 Git 凭证缓存 +Write-Host "[4/5] 清除 Git 凭证缓存..." -ForegroundColor White +$gitCredTargets = @("git:https://code.letcareme.com", "git:http://8.138.11.105", "git:https://8.138.11.105") +foreach ($target in $gitCredTargets) { + $exists = cmdkey /list 2>$null | Select-String $target + if ($exists) { + cmdkey /delete:$target 2>$null + Write-Host " 清除: $target" -ForegroundColor Gray + } +} +# 通过 git credential reject 清除缓存 +@("code.letcareme.com", "8.138.11.105") | ForEach-Object { + @" +protocol=https +host=$_ +"@ | git credential reject 2>$null +} +Write-Host " [OK] Git 凭证已清除" -ForegroundColor Green + +# 4. 恢复备份 (如果请求) +if ($Restore) { + Write-Host "[4/5] 恢复原始 .claude 目录..." -ForegroundColor White + if (Test-Path $BackupPath) { + if (Test-Path $ClaudeTarget) { + $retries = 3 + while ($retries -gt 0) { + try { + Remove-Item $ClaudeTarget -Recurse -Force -ErrorAction Stop + break + } catch { + $retries-- + if ($retries -gt 0) { + Write-Host " 文件占用,等待重试... ($retries)" -ForegroundColor Yellow + Start-Sleep 3 + } else { + Write-Host " [ERROR] 无法删除 .claude,可能有文件被占用" -ForegroundColor Red + Write-Host " 请关闭所有 Node.js/Claude 进程后重试" -ForegroundColor Yellow + exit 1 + } + } + } + Write-Host " 已删除 Bookworm 配置" -ForegroundColor Gray + } + Rename-Item $BackupPath $ClaudeTarget + Write-Host " [OK] 原始 .claude 已恢复" -ForegroundColor Green + } + else { + Write-Host " [!] 无备份可恢复 (.claude.bw-backup 不存在)" -ForegroundColor Yellow + } +} +else { + Write-Host "[4/5] 保留 Bookworm 配置 (使用 -Restore 可恢复原始)" -ForegroundColor Gray +} + +# 5. 深度清理 (可选) +if ($Deep) { + Write-Host "[5/5] 深度清理..." -ForegroundColor White + + # 清除 PowerShell 历史 + $histFile = Join-Path $env:APPDATA "Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt" + if (Test-Path $histFile) { + # 仅删除含 bookworm/secrets/api_key 的行 + $lines = Get-Content $histFile + $cleaned = $lines | Where-Object { + $_ -notmatch 'secrets\.enc|ANTHROPIC_API_KEY|api[_-]?key|bookworm-portable' + } + Set-Content $histFile -Value $cleaned + Write-Host " [OK] PS 历史已清理敏感条目" -ForegroundColor Green + } + + # 清除 Claude Code 本地缓存 + $cacheDir = Join-Path $env:LOCALAPPDATA "claude-code" + if (Test-Path $cacheDir) { + Write-Host " 发现本地缓存: $cacheDir" -ForegroundColor Yellow + Write-Host " (手动决定是否删除)" -ForegroundColor Yellow + } + + Write-Host " [OK] 深度清理完成" -ForegroundColor Green +} +else { + Write-Host "[5/5] 跳过深度清理 (使用 -Deep 可清理 PS 历史)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host " ╔══════════════════════════════════════════╗" -ForegroundColor Green +Write-Host " ║ Bookworm 已清理完毕 ║" -ForegroundColor Green +Write-Host " ╚══════════════════════════════════════════╝" -ForegroundColor Green +Write-Host "" diff --git a/卸载Bookworm.bat b/卸载Bookworm.bat new file mode 100644 index 0000000..9ad12bd --- /dev/null +++ b/卸载Bookworm.bat @@ -0,0 +1,50 @@ +@echo off +chcp 65001 > nul +title Bookworm Portable - 卸载 +cd /d "%~dp0" + +echo. +echo ==================================== +echo Bookworm Portable - 完整卸载 +echo ==================================== +echo. +echo 将执行: +echo - 终止 Claude Code 进程 +echo - 清除所有环境变量和凭证缓存 +echo - 恢复原始 .claude 目录 +echo - 清除 PowerShell 历史和 Git 凭证 +echo - 删除桌面快捷方式 +echo. + +:: AutoAccept: 卸载确认已豁免 +:: set /p confirm=" 确认卸载? (y/n): " +:: if /i not "%confirm%"=="y" ( +:: echo 已取消 +:: pause +:: exit /b +:: ) +echo [AutoAccept] 自动确认卸载 + +echo. + +where pwsh >nul 2>nul +if %errorlevel% equ 0 ( + pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore -Deep +) else ( + powershell -ExecutionPolicy Bypass -File stop.ps1 -Restore -Deep +) + +:: 删除桌面快捷方式 +del "%USERPROFILE%\Desktop\Bookworm.lnk" 2>nul +del "%USERPROFILE%\Desktop\更新Bookworm.lnk" 2>nul + +:: 清除凭证缓存注册表 +reg delete "HKCU\Software\Bookworm" /f 2>nul + +echo. +echo ==================================== +echo Bookworm 已完全卸载 +echo 可安全删除 bookworm-boot 文件夹 +echo ==================================== +echo. +pause diff --git a/启动Bookworm-v3.bat b/启动Bookworm-v3.bat new file mode 100644 index 0000000..416c2f2 --- /dev/null +++ b/启动Bookworm-v3.bat @@ -0,0 +1,17 @@ +@echo off +setlocal +chcp 65001 > nul 2>&1 +title Bookworm Smart Assistant + +:: 日常启动入口: 静默更新 + 直接启动 (无需管理员权限) +set "NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" + +where node >nul 2>nul +if %errorlevel% equ 0 goto :RUN +echo [!!] Node.js 未安装, 请先运行 Bookworm-Install.bat +pause +exit /b 1 + +:RUN +node "%~dp0setup-all.js" --start +endlocal diff --git a/启动Bookworm.bat b/启动Bookworm.bat new file mode 100644 index 0000000..082db7e --- /dev/null +++ b/启动Bookworm.bat @@ -0,0 +1,27 @@ +@echo off +chcp 65001 > nul +title Bookworm Portable - 启动 +cd /d "%~dp0" + +:: 中转站在国内,不走代理 +set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1 +set no_proxy=%NO_PROXY% + +echo. +echo ==================================== +echo Bookworm Portable - 快速启动 +echo ==================================== +echo. + +where pwsh >nul 2>nul +if %errorlevel% equ 0 ( + pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept +) else ( + powershell -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept +) + +if %errorlevel% neq 0 ( + echo. + echo 启动失败,按任意键退出... + pause > nul +) diff --git a/更新并启动Bookworm.bat b/更新并启动Bookworm.bat new file mode 100644 index 0000000..de8ea0f --- /dev/null +++ b/更新并启动Bookworm.bat @@ -0,0 +1,28 @@ +@echo off +chcp 65001 > nul +title Bookworm Portable - 更新并启动 +cd /d "%~dp0" + +:: 中转站在国内,不走代理 +set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1 +set no_proxy=%NO_PROXY% + +echo. +echo ==================================== +echo Bookworm Portable - 更新并启动 +echo (同步最新 Skills/Hooks 后启动) +echo ==================================== +echo. + +where pwsh >nul 2>nul +if %errorlevel% equ 0 ( + pwsh -ExecutionPolicy Bypass -File install.ps1 -AutoAccept +) else ( + powershell -ExecutionPolicy Bypass -File install.ps1 -AutoAccept +) + +if %errorlevel% neq 0 ( + echo. + echo 启动失败,按任意键退出... + pause > nul +)