Compare commits
No commits in common. "main" and "v2.0" have entirely different histories.
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,10 +0,0 @@
|
|||||||
secrets.txt
|
|
||||||
users.txt
|
|
||||||
authcode-history.log
|
|
||||||
auto-setup.ps1.bak-*
|
|
||||||
.tmp-authcodes.json
|
|
||||||
.tmp-release.json
|
|
||||||
.tmp-*.png
|
|
||||||
Bookworm-AuthGen.exe
|
|
||||||
管理员SOP.html
|
|
||||||
dist/
|
|
||||||
@ -1,366 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# Bookworm Smart Assistant - macOS 全自动安装 v2.3.1
|
|
||||||
#
|
|
||||||
# 用法 (任选一种):
|
|
||||||
# 方式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.3.1 — 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 "未找到安装脚本, 执行基础配置..."
|
|
||||||
|
|
||||||
# Keychain 缓存
|
|
||||||
KC_SVC="bookworm-secrets"
|
|
||||||
KC_ACCT="$(whoami)"
|
|
||||||
|
|
||||||
_kc_load() {
|
|
||||||
local cached
|
|
||||||
cached=$(security find-generic-password -s "$KC_SVC" -a "$KC_ACCT" -w 2>/dev/null) || return 1
|
|
||||||
local expiry_date
|
|
||||||
expiry_date=$(echo "$cached" | head -1 | sed 's/EXPIRY=//')
|
|
||||||
[ "$expiry_date" != "$(date +%Y-%m-%d)" ] && { security delete-generic-password -s "$KC_SVC" -a "$KC_ACCT" 2>/dev/null; return 1; }
|
|
||||||
local count=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
[ -z "$line" ] && continue; [[ "$line" == EXPIRY=* ]] && continue
|
|
||||||
local key="${line%%=*}" value="${line#*=}"
|
|
||||||
key=$(echo "$key" | tr -d ' ')
|
|
||||||
[ -n "$key" ] && [ -n "$value" ] && export "$key=$value" && count=$((count + 1))
|
|
||||||
done <<< "$cached"
|
|
||||||
[ $count -gt 0 ] && [ -n "$ANTHROPIC_API_KEY" ] && { success "从 Keychain 缓存加载 $count 个凭证 (免密)"; return 0; }
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_kc_save() {
|
|
||||||
local data="EXPIRY=$(date +%Y-%m-%d)"
|
|
||||||
for k in ANTHROPIC_API_KEY ANTHROPIC_BASE_URL GITHUB_PERSONAL_ACCESS_TOKEN SLACK_BOT_TOKEN ATLASSIAN_API_TOKEN BROWSERBASE_API_KEY FIRECRAWL_API_KEY; do
|
|
||||||
local v="${!k}"; [ -n "$v" ] && data="$data
|
|
||||||
$k=$v"
|
|
||||||
done
|
|
||||||
security add-generic-password -s "$KC_SVC" -a "$KC_ACCT" -w "$data" -U 2>/dev/null && \
|
|
||||||
success "凭证已缓存至今日 23:59 (下次免密)" || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解密工具: 优先 node crypto-helper.js (BWENC1 格式), 回退 openssl
|
|
||||||
CRYPTO_HELPER="$BOOT_DIR/crypto-helper.js"
|
|
||||||
_do_decrypt() {
|
|
||||||
local pass="$1" enc="$2"
|
|
||||||
if command -v node &>/dev/null && [ -f "$CRYPTO_HELPER" ]; then
|
|
||||||
node "$CRYPTO_HELPER" decrypt "$pass" "$enc" 2>/dev/null
|
|
||||||
elif [ -n "$OPENSSL_CMD" ]; then
|
|
||||||
$OPENSSL_CMD enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in "$enc" -pass pass:"$pass" 2>/dev/null
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解密凭证 (先查缓存)
|
|
||||||
SECRETS_ENC="$BOOT_DIR/secrets.enc"
|
|
||||||
if _kc_load 2>/dev/null; then
|
|
||||||
: # 缓存命中
|
|
||||||
elif [ -f "$SECRETS_ENC" ]; then
|
|
||||||
echo ""
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
read -rs -p " 输入主密码解密凭证 (第 $attempt/3 次): " PASSWORD
|
|
||||||
echo ""
|
|
||||||
DECRYPTED=$(_do_decrypt "$PASSWORD" "$SECRETS_ENC") || 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"
|
|
||||||
DECRYPTED=""
|
|
||||||
echo ""
|
|
||||||
read -p " 今日内免密启动? (y/n): " _cache_yn
|
|
||||||
[ "$_cache_yn" = "y" ] || [ "$_cache_yn" = "Y" ] && _kc_save
|
|
||||||
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 && cd ~/.claude && 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
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
@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/8: winget 检测 ──────────────────────────
|
|
||||||
echo [1/8] 检测包管理器...
|
|
||||||
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/8: 安装 Git ────────────────────────────
|
|
||||||
echo [2/8] 检查 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/8: 安装 Node.js ────────────────────────
|
|
||||||
echo [3/8] 检查 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.
|
|
||||||
|
|
||||||
:: ─── 步骤 4/8: 安装 PowerShell 7 ────────────────────
|
|
||||||
echo [4/8] 检查 PowerShell 7...
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [..] PowerShell 7 未安装, 正在通过 winget 安装...
|
|
||||||
winget install Microsoft.PowerShell --accept-source-agreements --accept-package-agreements --silent
|
|
||||||
if !errorlevel! neq 0 (
|
|
||||||
echo [!!] PowerShell 7 安装失败
|
|
||||||
set /a ERRORS+=1
|
|
||||||
) else (
|
|
||||||
echo [OK] PowerShell 7 安装成功
|
|
||||||
set "NEED_PATH_REFRESH=1"
|
|
||||||
)
|
|
||||||
) else (
|
|
||||||
echo [OK] PowerShell 7 已安装
|
|
||||||
)
|
|
||||||
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;C:\Program Files\PowerShell\7"
|
|
||||||
echo [OK] PATH 已刷新
|
|
||||||
echo.
|
|
||||||
)
|
|
||||||
|
|
||||||
:: ─── 二次验证: Git + Node + pwsh ─────────────────────
|
|
||||||
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
|
|
||||||
)
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [WARN] PowerShell 7 未就绪, Claude Code 将使用 PowerShell 5.1
|
|
||||||
)
|
|
||||||
|
|
||||||
:: ─── 步骤 5/8: 安装 Claude Code ─────────────────────
|
|
||||||
echo [5/8] 检查 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.
|
|
||||||
|
|
||||||
:: ─── 步骤 6/8: 克隆/更新 Bookworm ──────────────────
|
|
||||||
echo [6/8] 同步 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.
|
|
||||||
|
|
||||||
:: ─── 步骤 7/8: 执行安装配置 ────────────────────────
|
|
||||||
echo [7/8] 执行安装配置...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
if exist "%INSTALL_DIR%\install.ps1" (
|
|
||||||
:: 安装配置 (不启动 claude, 最终块负责在 pwsh7 窗口中启动)
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if !errorlevel! equ 0 (
|
|
||||||
pwsh -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" -AutoAccept -SkipLaunch
|
|
||||||
) else (
|
|
||||||
powershell -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" -AutoAccept -SkipLaunch
|
|
||||||
)
|
|
||||||
) else (
|
|
||||||
echo [WARN] install.ps1 未找到, 跳过高级配置
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: ─── 步骤 8/8: 创建桌面快捷方式 + 完成 ──────────────
|
|
||||||
echo [8/8] 创建桌面快捷方式...
|
|
||||||
|
|
||||||
:: 用 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] PowerShell 7 — 现代终端环境 ^|
|
|
||||||
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
|
|
||||||
|
|
||||||
:: 启动 — pwsh7 新窗口直接运行 claude
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if !errorlevel! equ 0 (
|
|
||||||
start "Bookworm Smart Assistant" pwsh -NoLogo -NoExit -Command "& claude --dangerously-skip-permissions"
|
|
||||||
) else (
|
|
||||||
cd /d "%INSTALL_DIR%"
|
|
||||||
powershell -NoLogo -ExecutionPolicy Bypass -File "%INSTALL_DIR%\install.ps1" -StartOnly -AutoAccept
|
|
||||||
)
|
|
||||||
|
|
||||||
endlocal
|
|
||||||
163
Bookworm-Setup.bat
Normal file
163
Bookworm-Setup.bat
Normal file
@ -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.3 ^|
|
||||||
|
echo ^| ^|
|
||||||
|
echo ^| 92 Skills / 18 Agents / 29 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
|
||||||
|
)
|
||||||
Binary file not shown.
@ -1,686 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# Bookworm Portable - macOS Setup (从 boot 仓库内运行)
|
|
||||||
# Version: 3.0.11
|
|
||||||
#
|
|
||||||
# 用法: cd ~/bookworm-boot && bash Bookworm-Setup.sh
|
|
||||||
#
|
|
||||||
# 前提: 已 git clone bookworm-boot 到本地
|
|
||||||
# 功能: 检查依赖 → 代理检测 → 克隆配置 → 解密凭证 → 配置别名
|
|
||||||
#
|
|
||||||
# v3.0.11 注: macOS 不受 Windows wt+Base64 启动链路问题影响
|
|
||||||
# (Mac shell 无 wt, claude 启动经标准 PATH 即可),
|
|
||||||
# 此版本仅同步主版本号. 核心逻辑保持 v3.0.3 流程稳定.
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
# 配置
|
|
||||||
BOOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
CLAUDE_DIR="$HOME/.claude"
|
|
||||||
CONFIG_REPO="https://code.letcareme.com/bookworm/bookworm-config.git"
|
|
||||||
SECRETS_ENC="$BOOT_DIR/secrets.enc"
|
|
||||||
TOTAL_STEPS=6
|
|
||||||
|
|
||||||
banner() {
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN} ____ _"
|
|
||||||
echo " | __ ) ___ ___ | | ____ _____ _ __ _ __ ___"
|
|
||||||
echo " | _ \\ / _ \\ / _ \\| |/ /\\ \\ /\\ / / _ \\| '__| '\` _ \\"
|
|
||||||
echo " | |_) | (_) | (_) | < \\ V V / (_) | | | | | | | |"
|
|
||||||
echo " |____/ \\___/ \\___/|_|\\_\\ \\_/\\_/ \\___/|_| |_| |_| |_|"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Portable macOS Setup v2.3.1${NC}"
|
|
||||||
echo -e " ${BLUE}92 Skills | 18 Agents | 34 Hooks${NC}"
|
|
||||||
echo -e "${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
# ============================================================
|
|
||||||
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)"
|
|
||||||
if [ -f /opt/homebrew/bin/brew ]; then
|
|
||||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
|
||||||
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
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
if ! command -v node &>/dev/null; then
|
|
||||||
info "通过 Homebrew 安装 Node.js..."
|
|
||||||
brew install node
|
|
||||||
success "Node.js $(node -v) 安装完成"
|
|
||||||
else
|
|
||||||
success "Node.js $(node -v)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Step 2: 检测代理
|
|
||||||
# ============================================================
|
|
||||||
step 2 "检测网络代理"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 常见端口扫描 (500ms 超时)
|
|
||||||
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"
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Step 3: 克隆/更新配置仓库到 ~/.claude
|
|
||||||
# ============================================================
|
|
||||||
step 3 "同步 Bookworm 配置"
|
|
||||||
|
|
||||||
git config --global credential.helper osxkeychain 2>/dev/null || true
|
|
||||||
|
|
||||||
if [ -d "$CLAUDE_DIR/.git" ]; then
|
|
||||||
info "配置仓库已存在, 更新..."
|
|
||||||
cd "$CLAUDE_DIR"
|
|
||||||
# 设置 git 身份 (auto-resolve 需要)
|
|
||||||
git config user.email "bookworm@auto.local" 2>/dev/null
|
|
||||||
git config user.name "Bookworm" 2>/dev/null
|
|
||||||
# 清除冲突状态 (运行时文件不重要, 后续会重新渲染)
|
|
||||||
git reset --hard HEAD 2>/dev/null || true
|
|
||||||
if git pull --rebase --autostash 2>/dev/null; then
|
|
||||||
success "配置仓库已更新"
|
|
||||||
else
|
|
||||||
warn "git pull 失败, 使用本地版本"
|
|
||||||
fi
|
|
||||||
cd "$BOOT_DIR"
|
|
||||||
elif [ -f "$CLAUDE_DIR/CLAUDE.md" ]; then
|
|
||||||
warn "~/.claude 已存在但非 git 仓库, 备份后克隆..."
|
|
||||||
mv "$CLAUDE_DIR" "$CLAUDE_DIR.bak.$(date +%s)"
|
|
||||||
git clone --depth 1 "$CONFIG_REPO" "$CLAUDE_DIR"
|
|
||||||
success "配置仓库克隆完成 (旧目录已备份)"
|
|
||||||
else
|
|
||||||
info "首次安装, 克隆配置仓库 (需输入 Gitea 密码)..."
|
|
||||||
mkdir -p "$(dirname "$CLAUDE_DIR")"
|
|
||||||
git clone --depth 1 "$CONFIG_REPO" "$CLAUDE_DIR"
|
|
||||||
success "配置仓库克隆完成"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 创建本地运行时目录
|
|
||||||
for d in debug sessions cache backups telemetry memory projects; do
|
|
||||||
mkdir -p "$CLAUDE_DIR/$d" 2>/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Step 4: 解密凭证 (含 Keychain 本日免密)
|
|
||||||
# ============================================================
|
|
||||||
step 4 "解密凭证"
|
|
||||||
|
|
||||||
# ─── v3.0.1: $BW_LICENSE_KEY 静默激活 (零输入路径) ───
|
|
||||||
# 若 install.sh 通过 env 传入 License Key (BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX), 优先走这条
|
|
||||||
# activate.js 已支持 HTTPS_PROXY 的 HTTP CONNECT 隧道 (Gitea ce354ca)
|
|
||||||
ACTIVATE_JS="$CLAUDE_DIR/lib/activate.js"
|
|
||||||
BW_TOKEN_FILE="$HOME/.claude/.bw-token"
|
|
||||||
if [ -n "$BW_LICENSE_KEY" ] && [[ "$BW_LICENSE_KEY" =~ ^BW-[A-F0-9]{4}(-[A-F0-9]{4}){5}$ ]] && [ -f "$ACTIVATE_JS" ] && command -v node &>/dev/null; then
|
|
||||||
info "检测到 \$BW_LICENSE_KEY, 静默激活..."
|
|
||||||
if printf '%s' "$BW_LICENSE_KEY" | node "$ACTIVATE_JS" 2>&1 | tail -3 | grep -q "OK\|激活成功"; then
|
|
||||||
if [ -f "$BW_TOKEN_FILE" ]; then
|
|
||||||
success "License 静默激活成功"
|
|
||||||
else
|
|
||||||
warn "activate.js 返回 OK 但 .bw-token 未生成, 回退到交互模式"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "静默激活失败, 回退到交互模式 (中转站 sk-Key 流程)"
|
|
||||||
fi
|
|
||||||
unset BW_LICENSE_KEY # 清掉, 不在子进程泄露
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Keychain 缓存相关
|
|
||||||
KEYCHAIN_SERVICE="bookworm-secrets"
|
|
||||||
KEYCHAIN_ACCOUNT="$(whoami)"
|
|
||||||
CACHE_LOADED=false
|
|
||||||
|
|
||||||
# 尝试从 Keychain 加载缓存
|
|
||||||
load_cached_secrets() {
|
|
||||||
local cached
|
|
||||||
cached=$(security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w 2>/dev/null) || return 1
|
|
||||||
# 检查是否过期 (缓存格式: EXPIRY=ISO日期\nKEY=VALUE\n...)
|
|
||||||
local expiry
|
|
||||||
expiry=$(echo "$cached" | head -1)
|
|
||||||
local expiry_date="${expiry#EXPIRY=}"
|
|
||||||
local today
|
|
||||||
today=$(date +%Y-%m-%d)
|
|
||||||
if [ "$expiry_date" != "$today" ]; then
|
|
||||||
# 已过期,删除缓存
|
|
||||||
security delete-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" 2>/dev/null || true
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# 加载环境变量 (跳过 EXPIRY 行)
|
|
||||||
local count=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
[ -z "$line" ] && continue
|
|
||||||
[[ "$line" == EXPIRY=* ]] && continue
|
|
||||||
local key="${line%%=*}"
|
|
||||||
local value="${line#*=}"
|
|
||||||
key=$(echo "$key" | tr -d ' ')
|
|
||||||
if [ -n "$key" ] && [ -n "$value" ]; then
|
|
||||||
export "$key=$value"
|
|
||||||
count=$((count + 1))
|
|
||||||
fi
|
|
||||||
done <<< "$cached"
|
|
||||||
if [ $count -gt 0 ] && [ -n "$ANTHROPIC_API_KEY" ]; then
|
|
||||||
success "从 Keychain 缓存加载 $count 个凭证 (免密)"
|
|
||||||
CACHE_LOADED=true
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 保存凭证到 Keychain
|
|
||||||
save_secrets_to_cache() {
|
|
||||||
local today
|
|
||||||
today=$(date +%Y-%m-%d)
|
|
||||||
local data="EXPIRY=$today"
|
|
||||||
local env_keys="ANTHROPIC_API_KEY ANTHROPIC_BASE_URL GITHUB_PERSONAL_ACCESS_TOKEN SLACK_BOT_TOKEN ATLASSIAN_API_TOKEN BROWSERBASE_API_KEY FIRECRAWL_API_KEY GEMINI_API_KEY"
|
|
||||||
for k in $env_keys; do
|
|
||||||
local v="${!k}"
|
|
||||||
if [ -n "$v" ]; then
|
|
||||||
data="$data
|
|
||||||
$k=$v"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
security add-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w "$data" -U 2>/dev/null && \
|
|
||||||
success "凭证已缓存至今日 23:59 (下次免密)" || \
|
|
||||||
warn "Keychain 缓存失败 (不影响使用)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解密工具: 优先 node crypto-helper.js (BWENC1 格式), 回退 openssl
|
|
||||||
CRYPTO_HELPER="$BOOT_DIR/crypto-helper.js"
|
|
||||||
_decrypt_secrets() {
|
|
||||||
local pass="$1" enc="$2"
|
|
||||||
if command -v node &>/dev/null && [ -f "$CRYPTO_HELPER" ]; then
|
|
||||||
node "$CRYPTO_HELPER" decrypt "$pass" "$enc" 2>/dev/null
|
|
||||||
elif [ -n "$OPENSSL_CMD" ]; then
|
|
||||||
$OPENSSL_CMD enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in "$enc" -pass pass:"$pass" 2>/dev/null
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 按 token 前8位定位 .enc 文件 (多用户模式),回退 secrets.enc
|
|
||||||
resolve_secrets_file() {
|
|
||||||
local token="$1"
|
|
||||||
local file_id="${token:0:8}"
|
|
||||||
local per_user="$BOOT_DIR/secrets-${file_id}.enc"
|
|
||||||
if [ -f "$per_user" ]; then
|
|
||||||
echo "$per_user"
|
|
||||||
elif [ -f "$SECRETS_ENC" ]; then
|
|
||||||
echo "$SECRETS_ENC"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解析授权码: BW-YYYYMMDD-TOKEN (24位Hex)
|
|
||||||
# 返回: 小写 token (成功) | "EXPIRED" (已过期) | "" (格式错误)
|
|
||||||
parse_authcode() {
|
|
||||||
local code
|
|
||||||
code=$(echo "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' ')
|
|
||||||
# 格式校验: BW-8位数字-24位Hex
|
|
||||||
if [[ ! "$code" =~ ^BW-([0-9]{8})-([A-F0-9]{24})$ ]]; then
|
|
||||||
echo ""
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local expiry_str="${BASH_REMATCH[1]}"
|
|
||||||
local token_upper="${BASH_REMATCH[2]}"
|
|
||||||
local today
|
|
||||||
today=$(date +%Y%m%d)
|
|
||||||
if [ "$expiry_str" -lt "$today" ]; then
|
|
||||||
echo "EXPIRED"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "$token_upper" | tr '[:upper:]' '[:lower:]' # 兼容 bash 3.2 (macOS 默认)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 先尝试缓存
|
|
||||||
if load_cached_secrets 2>/dev/null; then
|
|
||||||
: # 缓存加载成功
|
|
||||||
else
|
|
||||||
# 优先级 3: 调用 change-key.js 验证+持久化 (stdin 管道, 无 argv 泄露)
|
|
||||||
CHANGE_KEY_JS="$CLAUDE_DIR/change-key.js"
|
|
||||||
if [ -f "$CHANGE_KEY_JS" ] && command -v node &>/dev/null; then
|
|
||||||
echo ""
|
|
||||||
info "配置中转站凭证 (https://bww.letcareme.com)"
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo ""
|
|
||||||
read -rs -p " 粘贴凭证 (第 $attempt/3 次, 输入不显示, 留空跳过): " UCRED
|
|
||||||
echo ""
|
|
||||||
[ -z "$UCRED" ] && { warn "已跳过"; break; }
|
|
||||||
if printf '%s' "$UCRED" | node "$CHANGE_KEY_JS"; then
|
|
||||||
UCRED=""
|
|
||||||
success "凭证已验证并持久化"
|
|
||||||
echo ""
|
|
||||||
info "换凭证方式:"
|
|
||||||
info " 1. 重跑安装器"
|
|
||||||
info " 2. bash ~/.claude/change-key.sh"
|
|
||||||
info " 3. Claude Code 里: /change-key"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
UCRED=""
|
|
||||||
[ $attempt -lt 3 ] && warn "验证失败, 剩余 $((3-attempt)) 次" || fail "3 次失败"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 优先级 3.5: v3.0.1 新增 — 直接输入 sk- Key (中转站 Key) + 5 模型候选验证
|
|
||||||
# 适用: fresh install 没 change-key.js, 没 .enc 文件的新用户 (BYOK)
|
|
||||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
|
||||||
# 测 sk- Key 是否可调通 (5 模型候选, 中转站白名单)
|
|
||||||
validate_sk_key() {
|
|
||||||
local key="$1"
|
|
||||||
local baseurl="${ANTHROPIC_BASE_URL:-https://bww.letcareme.com}"
|
|
||||||
local models=("claude-opus-4-7" "claude-opus-4-6" "claude-opus-4-6-thinking" "claude-sonnet-4-6" "claude-sonnet-4-6-thinking")
|
|
||||||
for model in "${models[@]}"; do
|
|
||||||
local code
|
|
||||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 15 --noproxy '*' \
|
|
||||||
-X POST "$baseurl/v1/messages" \
|
|
||||||
-H "x-api-key: $key" \
|
|
||||||
-H "anthropic-version: 2023-06-01" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"model\":\"$model\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>/dev/null)
|
|
||||||
# 401/403 认证失败, 立即退, 不继续试
|
|
||||||
[[ "$code" == "401" || "$code" == "403" ]] && { echo "AUTH_FAIL"; return 1; }
|
|
||||||
# 200 或 400 都说明 Key 通过, 400 只是请求体问题
|
|
||||||
[[ "$code" == "200" || "$code" == "400" ]] && { echo "OK"; return 0; }
|
|
||||||
# 503/404 继续试下个模型
|
|
||||||
done
|
|
||||||
echo "NO_CHANNEL" # 全部 503 = 中转站无渠道
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
echo ""
|
|
||||||
info "配置中转站 API Key (没有的话去 bww.letcareme.com 注册+充值)"
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo ""
|
|
||||||
read -rs -p " 粘贴 sk- Key (第 $attempt/3 次, 输入不显示, 留空跳过): " SK_KEY
|
|
||||||
echo ""
|
|
||||||
[ -z "$SK_KEY" ] && { warn "已跳过"; break; }
|
|
||||||
# 基础格式校验
|
|
||||||
if [[ ! "$SK_KEY" =~ ^sk- ]] || [ ${#SK_KEY} -lt 20 ]; then
|
|
||||||
warn "格式错误 (应 sk- 开头, 至少 20 字符), 请重试"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
info "验证中 (试 5 个模型候选)..."
|
|
||||||
result=$(validate_sk_key "$SK_KEY")
|
|
||||||
case "$result" in
|
|
||||||
OK)
|
|
||||||
success "sk- Key 验证成功"
|
|
||||||
# v3.0.1: chmod 600 防同机其它 uid 读取 + 清 .bak 残留 (red-team-attacker P0)
|
|
||||||
for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do
|
|
||||||
[ -f "$rc" ] || touch "$rc"
|
|
||||||
# BSD sed (macOS 默认): -i '' 无 .bak; GNU sed (Linux): -i 无 .bak
|
|
||||||
if sed --version 2>/dev/null | grep -q GNU; then
|
|
||||||
sed -i '/^export ANTHROPIC_API_KEY=/d' "$rc" 2>/dev/null || true
|
|
||||||
sed -i '/^export ANTHROPIC_BASE_URL=/d' "$rc" 2>/dev/null || true
|
|
||||||
else
|
|
||||||
sed -i '' '/^export ANTHROPIC_API_KEY=/d' "$rc" 2>/dev/null || true
|
|
||||||
sed -i '' '/^export ANTHROPIC_BASE_URL=/d' "$rc" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "export ANTHROPIC_API_KEY=\"$SK_KEY\"" >> "$rc"
|
|
||||||
echo "export ANTHROPIC_BASE_URL=\"https://bww.letcareme.com\"" >> "$rc"
|
|
||||||
chmod 600 "$rc" # 只 owner 可读, 防同机 uid 泄露
|
|
||||||
done
|
|
||||||
# 扫残留 .bak 副本 (可能含旧 Key)
|
|
||||||
rm -f "$HOME/.zshrc.bak" "$HOME/.bashrc.bak" 2>/dev/null || true
|
|
||||||
export ANTHROPIC_API_KEY="$SK_KEY"
|
|
||||||
export ANTHROPIC_BASE_URL="https://bww.letcareme.com"
|
|
||||||
# 存 Keychain 本日免密
|
|
||||||
security add-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w "ANTHROPIC_API_KEY=$SK_KEY
|
|
||||||
ANTHROPIC_BASE_URL=https://bww.letcareme.com
|
|
||||||
EXPIRY=$(date -v+1d -u +%FT%TZ 2>/dev/null || date -u -d '+1 day' +%FT%TZ)" -U 2>/dev/null || true
|
|
||||||
SK_KEY=""
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
AUTH_FAIL)
|
|
||||||
warn "Key 无效或余额为 0 (中转站返回 401/403)"
|
|
||||||
SK_KEY=""
|
|
||||||
[ $attempt -lt 3 ] && continue || { fail "3 次失败, 跳过 sk- 配置"; break; }
|
|
||||||
;;
|
|
||||||
NO_CHANNEL)
|
|
||||||
fail "中转站没有可用 Claude 渠道 (5 模型全返 503). 联系中转站客服"
|
|
||||||
SK_KEY=""
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
warn "验证异常, 剩余 $((3-attempt)) 次"
|
|
||||||
SK_KEY=""
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 优先级 4: 授权码模式 (向后兼容旧用户)
|
|
||||||
if [ -z "$ANTHROPIC_API_KEY" ] && { [ -f "$SECRETS_ENC" ] || ls "$BOOT_DIR"/secrets-*.enc 2>/dev/null | head -1 | grep -q .; }; then
|
|
||||||
DECRYPTED=""
|
|
||||||
valid_attempts=0
|
|
||||||
total_attempts=0
|
|
||||||
while [ $valid_attempts -lt 3 ] && [ $total_attempts -lt 10 ]; do
|
|
||||||
echo ""
|
|
||||||
read -p " 输入授权码 (BW-YYYYMMDD-XXXXXX, 第 $((valid_attempts+1))/3 次): " AUTH_CODE
|
|
||||||
total_attempts=$((total_attempts + 1))
|
|
||||||
TOKEN=$(parse_authcode "$AUTH_CODE")
|
|
||||||
AUTH_CODE=""
|
|
||||||
if [ "$TOKEN" = "EXPIRED" ]; then
|
|
||||||
warn "授权码已过期, 请联系管理员获取新授权码"
|
|
||||||
continue # 不消耗有效次数
|
|
||||||
elif [ -z "$TOKEN" ]; then
|
|
||||||
warn "授权码格式错误 (格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX)"
|
|
||||||
continue # 不消耗有效次数
|
|
||||||
fi
|
|
||||||
valid_attempts=$((valid_attempts + 1))
|
|
||||||
ENC_FILE=$(resolve_secrets_file "$TOKEN")
|
|
||||||
if [ -z "$ENC_FILE" ]; then
|
|
||||||
FILE_ID="${TOKEN:0:8}"
|
|
||||||
warn "未找到对应凭证文件 (secrets-${FILE_ID}.enc / secrets.enc)"
|
|
||||||
warn "请联系管理员确认已推送对应文件,然后重新运行安装器"
|
|
||||||
TOKEN=""
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
DECRYPTED=$(_decrypt_secrets "$TOKEN" "$ENC_FILE") || true
|
|
||||||
TOKEN=""
|
|
||||||
if [ -n "$DECRYPTED" ]; then
|
|
||||||
# 白名单校验 (与 Windows 版对齐)
|
|
||||||
ALLOWED_KEYS="ANTHROPIC_API_KEY ANTHROPIC_BASE_URL GITHUB_PERSONAL_ACCESS_TOKEN SLACK_BOT_TOKEN ATLASSIAN_API_TOKEN BROWSERBASE_API_KEY FIRECRAWL_API_KEY GEMINI_API_KEY"
|
|
||||||
while IFS= read -r line; do
|
|
||||||
[ -z "$line" ] && continue
|
|
||||||
key="${line%%=*}"
|
|
||||||
value="${line#*=}"
|
|
||||||
key=$(echo "$key" | tr -d ' ')
|
|
||||||
if [ -n "$key" ] && [ -n "$value" ]; then
|
|
||||||
# 白名单 + 长度校验
|
|
||||||
if echo "$ALLOWED_KEYS" | grep -qw "$key" && [ ${#value} -lt 512 ]; then
|
|
||||||
export "$key=$value"
|
|
||||||
success "已注入: $key"
|
|
||||||
else
|
|
||||||
warn "跳过未知 key: $key"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$DECRYPTED"
|
|
||||||
DECRYPTED=""
|
|
||||||
|
|
||||||
# 自动缓存 (不再询问, 与 Windows 版对齐)
|
|
||||||
save_secrets_to_cache
|
|
||||||
break
|
|
||||||
else
|
|
||||||
if [ $valid_attempts -lt 3 ]; then
|
|
||||||
warn "授权码无效 (解密失败), 剩余重试: $((3 - valid_attempts)) 次"
|
|
||||||
else
|
|
||||||
fail "3 次授权码均无效, 凭证未解密"
|
|
||||||
warn "可稍后手动配置 API Key"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
else
|
|
||||||
if [ ! -f "$SECRETS_ENC" ]; then
|
|
||||||
warn "secrets.enc 不存在, 跳过凭证解密"
|
|
||||||
info "请联系管理员获取授权码"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── v3.0.2: 预填 ~/.claude.json 跳过 Claude Code 2.0.1 的登录选择页 ───
|
|
||||||
# Win auto-setup.ps1:1361-1369 等价逻辑: 两选项都走 anthropic.com OAuth, 国内不通
|
|
||||||
# 预填 hasCompletedOnboarding + customApiKeyResponses.approved 直接进主界面
|
|
||||||
if [ -n "$ANTHROPIC_API_KEY" ] && command -v node &>/dev/null; then
|
|
||||||
BW_KEY_PREFIX="${ANTHROPIC_API_KEY:0:20}" node -e '
|
|
||||||
const fs = require("fs"), p = require("path");
|
|
||||||
const H = process.env.HOME || process.env.USERPROFILE;
|
|
||||||
const f = p.join(H, ".claude.json");
|
|
||||||
const prefix = process.env.BW_KEY_PREFIX || "";
|
|
||||||
let d = {};
|
|
||||||
try { d = JSON.parse(fs.readFileSync(f, "utf8")); } catch (e) {}
|
|
||||||
d.hasCompletedOnboarding = true;
|
|
||||||
d.hasSeenWelcome = true;
|
|
||||||
d.bypassPermissionsModeAccepted = true;
|
|
||||||
d.customApiKeyResponses = d.customApiKeyResponses || { approved: [], rejected: [] };
|
|
||||||
if (prefix && !d.customApiKeyResponses.approved.includes(prefix)) {
|
|
||||||
d.customApiKeyResponses.approved.push(prefix);
|
|
||||||
}
|
|
||||||
if (d.numStartups === undefined) d.numStartups = 5;
|
|
||||||
if (!d.projects) d.projects = {};
|
|
||||||
fs.writeFileSync(f, JSON.stringify(d, null, 2));
|
|
||||||
' 2>/dev/null && success "Claude Code onboarding 已预填 (跳过 2.0.1 登录选择页)" || warn ".claude.json onboarding 预填失败 (首次启动可能需手工过登录画面)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── MCP 注入到 ~/.claude.json (Claude Code v2.1+ 正确位置) ──
|
|
||||||
INJECT_SCRIPT="$CLAUDE_DIR/inject-mcp.js"
|
|
||||||
MCP_INJECTED=false
|
|
||||||
|
|
||||||
# 方案 A: 调用 config 仓库里的 inject-mcp.js
|
|
||||||
if [ -f "$INJECT_SCRIPT" ] && command -v node &>/dev/null; then
|
|
||||||
MCP_OUT=$(node "$INJECT_SCRIPT" 2>&1) && {
|
|
||||||
success "$MCP_OUT"
|
|
||||||
MCP_INJECTED=true
|
|
||||||
} || warn "inject-mcp.js 执行失败"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 方案 B: 内嵌 fallback (git pull 失败时)
|
|
||||||
if [ "$MCP_INJECTED" = false ] && command -v node &>/dev/null; then
|
|
||||||
info "inject-mcp.js 不可用, 使用内嵌 MCP 注入..."
|
|
||||||
FALLBACK_JS=$(mktemp /tmp/bw-mcp-XXXXXX.js)
|
|
||||||
cat > "$FALLBACK_JS" << 'MCPEOF'
|
|
||||||
var fs=require("fs"),p=require("path");
|
|
||||||
var H=process.env.HOME||process.env.USERPROFILE;
|
|
||||||
var f=p.join(H,".claude.json");
|
|
||||||
var d={};try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch(e){}
|
|
||||||
var N="npx",Y="--yes",S={};
|
|
||||||
S.context7={command:N,args:[Y,"@upstash/context7-mcp@2.1.1"],type:"stdio"};
|
|
||||||
S.playwright={command:N,args:[Y,"@playwright/mcp@0.0.68","--headless"],type:"stdio"};
|
|
||||||
S["session-continuity"]={command:N,args:[Y,"claude-session-continuity-mcp@1.13.0"],type:"stdio"};
|
|
||||||
S["browser-mcp"]={command:N,args:[Y,"@browsermcp/mcp@latest"],type:"stdio"};
|
|
||||||
S["desktop-commander"]={command:N,args:[Y,"@wonderwhy-er/desktop-commander@latest"],type:"stdio"};
|
|
||||||
S["chrome-devtools"]={command:N,args:[Y,"chrome-devtools-mcp@0.18.1"],type:"stdio"};
|
|
||||||
S.github={command:N,args:[Y,"@modelcontextprotocol/server-github"],type:"stdio"};
|
|
||||||
S.slack={command:N,args:[Y,"@modelcontextprotocol/server-slack"],type:"stdio"};
|
|
||||||
S.firecrawl={command:N,args:[Y,"firecrawl-mcp"],type:"stdio"};
|
|
||||||
S["mcp-image"]={command:N,args:[Y,"mcp-image"],type:"stdio"};
|
|
||||||
S["google-drive"]={command:N,args:[Y,"@piotr-agier/google-drive-mcp"],type:"stdio"};
|
|
||||||
S.browserbase={command:N,args:[Y,"@anthropic-ai/browserbase-mcp"],type:"stdio"};
|
|
||||||
S.notebooklm={command:N,args:[Y,"notebooklm-mcp@latest"],type:"stdio"};
|
|
||||||
S.cloudflare={command:N,args:[Y,"mcp-remote","https://docs.mcp.cloudflare.com/sse"],type:"stdio"};
|
|
||||||
S.mobile={command:N,args:[Y,"@mobilenext/mobile-mcp@0.0.35"],type:"stdio"};
|
|
||||||
var K="@modelcontextprotocol/server-sequential-thinking";
|
|
||||||
S["sequential-thinking"]={command:N,args:[Y,K+"@2025.12.18"],type:"stdio"};
|
|
||||||
S.linear={type:"http",url:"https://mcp.linear.app/mcp"};
|
|
||||||
S.supabase={type:"http",url:"https://mcp.supabase.com/mcp?project_ref=oepmihbtoylosbsxlmfo"};
|
|
||||||
S.figma={type:"http",url:"https://mcp.figma.com/mcp"};
|
|
||||||
S["windows-mcp"]={command:"uvx",args:["--python","3.13","windows-mcp"],type:"stdio"};
|
|
||||||
S.atlassian={command:"uvx",args:["mcp-atlassian"],type:"stdio"};
|
|
||||||
S["computer-control-mcp"]={command:"uvx",args:["computer-control-mcp@latest"],type:"stdio"};
|
|
||||||
d.mcpServers=S;
|
|
||||||
fs.writeFileSync(f,JSON.stringify(d,null,2));
|
|
||||||
console.log("OK: "+Object.keys(S).length+" MCP servers (fallback)");
|
|
||||||
MCPEOF
|
|
||||||
MCP_OUT=$(node "$FALLBACK_JS" 2>&1) && success "$MCP_OUT" || warn "MCP fallback 注入失败"
|
|
||||||
rm -f "$FALLBACK_JS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 渲染 settings.json (替换占位符)
|
|
||||||
TEMPLATE_FILE="$CLAUDE_DIR/settings.template.json"
|
|
||||||
SETTINGS_FILE="$CLAUDE_DIR/settings.json"
|
|
||||||
if [ -f "$TEMPLATE_FILE" ]; then
|
|
||||||
CLAUDE_ROOT=$(echo "$CLAUDE_DIR" | sed 's/\\/\//g')
|
|
||||||
SHELL_BIN="${SHELL:-/bin/zsh}"
|
|
||||||
sed "s|{{CLAUDE_ROOT}}|$CLAUDE_ROOT|g; s|{{HOME}}|$HOME|g; s|{{PWSH_PATH}}|$SHELL_BIN|g" "$TEMPLATE_FILE" > "$SETTINGS_FILE"
|
|
||||||
success "settings.json 已渲染 (SHELL=$SHELL_BIN)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Step 5: 配置终端别名
|
|
||||||
# ============================================================
|
|
||||||
step 5 "配置终端快捷命令"
|
|
||||||
|
|
||||||
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='NO_PROXY="bww.letcareme.com,code.letcareme.com,localhost,127.0.0.1" ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-opus-4-7}" claude --dangerously-skip-permissions'
|
|
||||||
alias bw-update='cd ~/bookworm-boot && git pull && cd ~/.claude && git pull && echo "Updated!"'
|
|
||||||
ALIASES
|
|
||||||
success "已添加到 $SHELL_RC:"
|
|
||||||
info " bw -- 启动 Bookworm"
|
|
||||||
info " bw-update -- 更新 Bookworm"
|
|
||||||
else
|
|
||||||
# 更新旧别名 (bookworm → bw)
|
|
||||||
if grep -q "alias bookworm=" "$SHELL_RC" 2>/dev/null; then
|
|
||||||
# 删除旧别名块,然后追加新的
|
|
||||||
sed -i '' '/# Bookworm Portable aliases/,/^$/d' "$SHELL_RC" 2>/dev/null || true
|
|
||||||
cat >> "$SHELL_RC" << 'ALIASES'
|
|
||||||
|
|
||||||
# Bookworm Portable aliases
|
|
||||||
alias bw='NO_PROXY="bww.letcareme.com,code.letcareme.com,localhost,127.0.0.1" ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-opus-4-7}" claude --dangerously-skip-permissions'
|
|
||||||
alias bw-update='cd ~/bookworm-boot && git pull && cd ~/.claude && git pull && echo "Updated!"'
|
|
||||||
ALIASES
|
|
||||||
success "终端别名已更新 (bookworm → bw)"
|
|
||||||
else
|
|
||||||
success "终端别名已配置"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Step 6: 完成
|
|
||||||
# ============================================================
|
|
||||||
step 6 "安装完成"
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
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"
|
|
||||||
# v3.0.1: 默认模型 (中转站兼容, 默认 claude-sonnet-4-5 会 503)
|
|
||||||
export ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-opus-4-7}"
|
|
||||||
exec claude --dangerously-skip-permissions
|
|
||||||
fi
|
|
||||||
@ -6,8 +6,8 @@
|
|||||||
deploy-gitea.sh ECS Gitea 部署 (服务端,执行一次)
|
deploy-gitea.sh ECS Gitea 部署 (服务端,执行一次)
|
||||||
prepare-repo.ps1 仓库准备 (本机执行一次)
|
prepare-repo.ps1 仓库准备 (本机执行一次)
|
||||||
encrypt-secrets.ps1 凭证加密 (本机执行一次)
|
encrypt-secrets.ps1 凭证加密 (本机执行一次)
|
||||||
|
settings.template.json settings.json 模板
|
||||||
settings.local.template.json settings.local.json 模板 (权限白名单)
|
settings.local.template.json settings.local.json 模板 (权限白名单)
|
||||||
(settings.template.json 已由 build-portable.js 管理,存于 config 仓库)
|
|
||||||
install.ps1 安装/启动 (目标机执行)
|
install.ps1 安装/启动 (目标机执行)
|
||||||
stop.ps1 清理/卸载 (目标机执行)
|
stop.ps1 清理/卸载 (目标机执行)
|
||||||
|
|
||||||
|
|||||||
@ -1,482 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bookworm 授权码生成器 (管理员 GUI 工具)
|
|
||||||
.DESCRIPTION
|
|
||||||
内部调用 node gen-authcode.js, 提供可视化界面生成多用户授权码。
|
|
||||||
打包命令: 见 build.ps1 -Admin
|
|
||||||
#>
|
|
||||||
|
|
||||||
# 全局错误捕获 (PS2EXE -NoOutput 会吞掉所有错误, 这里确保弹窗显示)
|
|
||||||
try {
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
||||||
|
|
||||||
# ─── 路径 ─────────────────────────────────────────────
|
|
||||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot }
|
|
||||||
elseif ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName -match '\.exe$') {
|
|
||||||
Split-Path -Parent ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName)
|
|
||||||
} elseif ($MyInvocation.MyCommand.Path) {
|
|
||||||
Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
} else { $PWD.Path }
|
|
||||||
|
|
||||||
# gen-authcode.js / secrets.txt 查找: 当前目录 → 父目录 (dist/ 内运行时)
|
|
||||||
$GenScript = Join-Path $ScriptDir "gen-authcode.js"
|
|
||||||
if (-not (Test-Path $GenScript)) {
|
|
||||||
$parentDir = Split-Path $ScriptDir -Parent
|
|
||||||
$GenScript = Join-Path $parentDir "gen-authcode.js"
|
|
||||||
if (Test-Path $GenScript) { $ScriptDir = $parentDir } # 切到父目录
|
|
||||||
}
|
|
||||||
$SecretsTxt = Join-Path $ScriptDir "secrets.txt"
|
|
||||||
|
|
||||||
# ─── 品牌色 ───────────────────────────────────────────
|
|
||||||
$brandBlue = [System.Drawing.Color]::FromArgb(88, 101, 242)
|
|
||||||
$brandDark = [System.Drawing.Color]::FromArgb(24, 25, 38)
|
|
||||||
$brandLight = [System.Drawing.Color]::FromArgb(245, 246, 250)
|
|
||||||
$cardBg = [System.Drawing.Color]::FromArgb(248, 249, 253)
|
|
||||||
$successGreen = [System.Drawing.Color]::FromArgb(35, 134, 54)
|
|
||||||
$warningOrange = [System.Drawing.Color]::FromArgb(227, 137, 29)
|
|
||||||
$textPrimary = [System.Drawing.Color]::FromArgb(36, 41, 47)
|
|
||||||
$textSecondary = [System.Drawing.Color]::FromArgb(110, 119, 129)
|
|
||||||
$inputBorder = [System.Drawing.Color]::FromArgb(208, 215, 222)
|
|
||||||
|
|
||||||
# ─── 主窗口 ───────────────────────────────────────────
|
|
||||||
$form = New-Object System.Windows.Forms.Form
|
|
||||||
$form.Text = "Bookworm 授权码生成器"
|
|
||||||
$form.ClientSize = New-Object System.Drawing.Size(540, 594)
|
|
||||||
$form.StartPosition = "CenterScreen"
|
|
||||||
$form.FormBorderStyle = "FixedSingle"
|
|
||||||
$form.MaximizeBox = $false
|
|
||||||
$form.BackColor = [System.Drawing.Color]::White
|
|
||||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
|
||||||
|
|
||||||
# ─── 标题栏 (深色渐变 + 副标题) ──────────────────────
|
|
||||||
$header = New-Object System.Windows.Forms.Panel
|
|
||||||
$header.Dock = "Top"
|
|
||||||
$header.Size = New-Object System.Drawing.Size(540, 70)
|
|
||||||
$header.BackColor = $brandDark
|
|
||||||
$form.Controls.Add($header)
|
|
||||||
|
|
||||||
$titleLabel = New-Object System.Windows.Forms.Label
|
|
||||||
$titleLabel.Location = New-Object System.Drawing.Point(24, 12)
|
|
||||||
$titleLabel.Size = New-Object System.Drawing.Size(500, 28)
|
|
||||||
$titleLabel.Text = "Bookworm 授权码生成器"
|
|
||||||
$titleLabel.Font = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
|
|
||||||
$titleLabel.ForeColor = [System.Drawing.Color]::White
|
|
||||||
$header.Controls.Add($titleLabel)
|
|
||||||
|
|
||||||
$subtitleLabel = New-Object System.Windows.Forms.Label
|
|
||||||
$subtitleLabel.Location = New-Object System.Drawing.Point(24, 42)
|
|
||||||
$subtitleLabel.Size = New-Object System.Drawing.Size(500, 18)
|
|
||||||
$subtitleLabel.Text = "管理员专用 · 为每位用户生成独立授权码与加密凭证"
|
|
||||||
$subtitleLabel.Font = New-Object System.Drawing.Font("Segoe UI", 8.5)
|
|
||||||
$subtitleLabel.ForeColor = [System.Drawing.Color]::FromArgb(160, 170, 200)
|
|
||||||
$header.Controls.Add($subtitleLabel)
|
|
||||||
|
|
||||||
# ═══ 输入卡片 (Panel 模拟卡片) ═══════════════════════
|
|
||||||
$inputCard = New-Object System.Windows.Forms.Panel
|
|
||||||
$inputCard.Location = New-Object System.Drawing.Point(20, 84)
|
|
||||||
$inputCard.Size = New-Object System.Drawing.Size(500, 186)
|
|
||||||
$inputCard.BackColor = $cardBg
|
|
||||||
$form.Controls.Add($inputCard)
|
|
||||||
|
|
||||||
# ── 卡片标题
|
|
||||||
$inputTitle = New-Object System.Windows.Forms.Label
|
|
||||||
$inputTitle.Location = New-Object System.Drawing.Point(16, 10)
|
|
||||||
$inputTitle.Size = New-Object System.Drawing.Size(200, 20)
|
|
||||||
$inputTitle.Text = "用户信息"
|
|
||||||
$inputTitle.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
|
|
||||||
$inputTitle.ForeColor = $brandBlue
|
|
||||||
$inputCard.Controls.Add($inputTitle)
|
|
||||||
|
|
||||||
# ── 用户名
|
|
||||||
$lblUser = New-Object System.Windows.Forms.Label
|
|
||||||
$lblUser.Location = New-Object System.Drawing.Point(16, 40)
|
|
||||||
$lblUser.Size = New-Object System.Drawing.Size(90, 22)
|
|
||||||
$lblUser.Text = "用户标识"
|
|
||||||
$lblUser.ForeColor = $textPrimary
|
|
||||||
$lblUser.TextAlign = [System.Drawing.ContentAlignment]::MiddleRight
|
|
||||||
$inputCard.Controls.Add($lblUser)
|
|
||||||
$txtUser = New-Object System.Windows.Forms.TextBox
|
|
||||||
$txtUser.Location = New-Object System.Drawing.Point(114, 38)
|
|
||||||
$txtUser.Size = New-Object System.Drawing.Size(370, 26)
|
|
||||||
$txtUser.Font = New-Object System.Drawing.Font("Segoe UI", 10)
|
|
||||||
$txtUser.BorderStyle = "FixedSingle"
|
|
||||||
$inputCard.Controls.Add($txtUser)
|
|
||||||
|
|
||||||
# ── Relay Key
|
|
||||||
$lblKey = New-Object System.Windows.Forms.Label
|
|
||||||
$lblKey.Location = New-Object System.Drawing.Point(16, 74)
|
|
||||||
$lblKey.Size = New-Object System.Drawing.Size(90, 22)
|
|
||||||
$lblKey.Text = "Relay Key"
|
|
||||||
$lblKey.ForeColor = $textPrimary
|
|
||||||
$lblKey.TextAlign = [System.Drawing.ContentAlignment]::MiddleRight
|
|
||||||
$inputCard.Controls.Add($lblKey)
|
|
||||||
$txtKey = New-Object System.Windows.Forms.TextBox
|
|
||||||
$txtKey.Location = New-Object System.Drawing.Point(114, 72)
|
|
||||||
$txtKey.Size = New-Object System.Drawing.Size(370, 26)
|
|
||||||
$txtKey.Font = New-Object System.Drawing.Font("Segoe UI", 10)
|
|
||||||
$txtKey.PasswordChar = '*'
|
|
||||||
$txtKey.BorderStyle = "FixedSingle"
|
|
||||||
$inputCard.Controls.Add($txtKey)
|
|
||||||
|
|
||||||
$chkShowKey = New-Object System.Windows.Forms.CheckBox
|
|
||||||
$chkShowKey.Location = New-Object System.Drawing.Point(114, 102)
|
|
||||||
$chkShowKey.Size = New-Object System.Drawing.Size(100, 20)
|
|
||||||
$chkShowKey.Text = "显示 Key"
|
|
||||||
$chkShowKey.ForeColor = $textSecondary
|
|
||||||
$chkShowKey.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
|
||||||
$chkShowKey.Add_CheckedChanged({ $txtKey.PasswordChar = if ($chkShowKey.Checked) { [char]0 } else { '*' } })
|
|
||||||
$inputCard.Controls.Add($chkShowKey)
|
|
||||||
|
|
||||||
# ── 有效期
|
|
||||||
$lblDays = New-Object System.Windows.Forms.Label
|
|
||||||
$lblDays.Location = New-Object System.Drawing.Point(16, 132)
|
|
||||||
$lblDays.Size = New-Object System.Drawing.Size(90, 22)
|
|
||||||
$lblDays.Text = "有效期"
|
|
||||||
$lblDays.ForeColor = $textPrimary
|
|
||||||
$lblDays.TextAlign = [System.Drawing.ContentAlignment]::MiddleRight
|
|
||||||
$inputCard.Controls.Add($lblDays)
|
|
||||||
$cmbDays = New-Object System.Windows.Forms.ComboBox
|
|
||||||
$cmbDays.Location = New-Object System.Drawing.Point(114, 130)
|
|
||||||
$cmbDays.Size = New-Object System.Drawing.Size(80, 26)
|
|
||||||
$cmbDays.DropDownStyle = "DropDownList"
|
|
||||||
$cmbDays.Font = New-Object System.Drawing.Font("Segoe UI", 10)
|
|
||||||
$null = $cmbDays.Items.AddRange(@(7, 14, 30, 60, 90, 180, 365))
|
|
||||||
$cmbDays.SelectedIndex = 2
|
|
||||||
$inputCard.Controls.Add($cmbDays)
|
|
||||||
|
|
||||||
$lblDaysUnit = New-Object System.Windows.Forms.Label
|
|
||||||
$lblDaysUnit.Location = New-Object System.Drawing.Point(198, 132)
|
|
||||||
$lblDaysUnit.Size = New-Object System.Drawing.Size(30, 22)
|
|
||||||
$lblDaysUnit.Text = "天"
|
|
||||||
$lblDaysUnit.ForeColor = $textSecondary
|
|
||||||
$inputCard.Controls.Add($lblDaysUnit)
|
|
||||||
|
|
||||||
$lblDaysHint = New-Object System.Windows.Forms.Label
|
|
||||||
$lblDaysHint.Location = New-Object System.Drawing.Point(240, 132)
|
|
||||||
$lblDaysHint.Size = New-Object System.Drawing.Size(240, 22)
|
|
||||||
$lblDaysHint.Text = ""
|
|
||||||
$lblDaysHint.ForeColor = $textSecondary
|
|
||||||
$lblDaysHint.Font = New-Object System.Drawing.Font("Segoe UI", 8.5)
|
|
||||||
$inputCard.Controls.Add($lblDaysHint)
|
|
||||||
$cmbDays.Add_SelectedIndexChanged({
|
|
||||||
$d = [int]$cmbDays.SelectedItem
|
|
||||||
$lblDaysHint.Text = "-> 到期: " + (Get-Date).AddDays($d).ToString("yyyy-MM-dd")
|
|
||||||
})
|
|
||||||
$cmbDays.SelectedIndex = 2
|
|
||||||
|
|
||||||
# ── 环境状态行 ──
|
|
||||||
$nodeOK = [bool](Get-Command node -ErrorAction SilentlyContinue)
|
|
||||||
$secretsOK = Test-Path $SecretsTxt
|
|
||||||
|
|
||||||
$lblStatus1 = New-Object System.Windows.Forms.Label
|
|
||||||
$lblStatus1.Location = New-Object System.Drawing.Point(16, 160)
|
|
||||||
$lblStatus1.Size = New-Object System.Drawing.Size(230, 18)
|
|
||||||
$lblStatus1.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
|
||||||
if ($secretsOK) {
|
|
||||||
$lineCount = (Get-Content $SecretsTxt -ErrorAction SilentlyContinue | Where-Object { $_ -match '=' }).Count
|
|
||||||
$lblStatus1.Text = "secrets.txt: $lineCount 个凭证"
|
|
||||||
$lblStatus1.ForeColor = $successGreen
|
|
||||||
} else {
|
|
||||||
$lblStatus1.Text = "secrets.txt: 未找到"
|
|
||||||
$lblStatus1.ForeColor = [System.Drawing.Color]::Red
|
|
||||||
}
|
|
||||||
$inputCard.Controls.Add($lblStatus1)
|
|
||||||
|
|
||||||
$lblStatus2 = New-Object System.Windows.Forms.Label
|
|
||||||
$lblStatus2.Location = New-Object System.Drawing.Point(250, 160)
|
|
||||||
$lblStatus2.Size = New-Object System.Drawing.Size(230, 18)
|
|
||||||
$lblStatus2.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
|
||||||
if ($nodeOK) {
|
|
||||||
$nodeVer = try { (& node --version 2>$null) } catch { "" }
|
|
||||||
$lblStatus2.Text = "Node.js: $nodeVer"
|
|
||||||
$lblStatus2.ForeColor = $successGreen
|
|
||||||
} else {
|
|
||||||
$lblStatus2.Text = "Node.js: 未安装"
|
|
||||||
$lblStatus2.ForeColor = [System.Drawing.Color]::Red
|
|
||||||
}
|
|
||||||
$inputCard.Controls.Add($lblStatus2)
|
|
||||||
|
|
||||||
# ═══ 操作按钮 ═════════════════════════════════════════
|
|
||||||
$btnGenerate = New-Object System.Windows.Forms.Button
|
|
||||||
$btnGenerate.Location = New-Object System.Drawing.Point(138, 282)
|
|
||||||
$btnGenerate.Size = New-Object System.Drawing.Size(180, 42)
|
|
||||||
$btnGenerate.Text = " 生成授权码"
|
|
||||||
$btnGenerate.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Bold)
|
|
||||||
$btnGenerate.FlatStyle = "Flat"
|
|
||||||
$btnGenerate.BackColor = $brandBlue
|
|
||||||
$btnGenerate.ForeColor = [System.Drawing.Color]::White
|
|
||||||
$btnGenerate.FlatAppearance.BorderSize = 0
|
|
||||||
$btnGenerate.Cursor = [System.Windows.Forms.Cursors]::Hand
|
|
||||||
$btnGenerate.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
|
||||||
$form.Controls.Add($btnGenerate)
|
|
||||||
|
|
||||||
$btnClear = New-Object System.Windows.Forms.Button
|
|
||||||
$btnClear.Location = New-Object System.Drawing.Point(330, 282)
|
|
||||||
$btnClear.Size = New-Object System.Drawing.Size(72, 42)
|
|
||||||
$btnClear.Text = "清空"
|
|
||||||
$btnClear.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
|
||||||
$btnClear.FlatStyle = "Flat"
|
|
||||||
$btnClear.BackColor = [System.Drawing.Color]::White
|
|
||||||
$btnClear.ForeColor = $textSecondary
|
|
||||||
$btnClear.FlatAppearance.BorderColor = $inputBorder
|
|
||||||
$btnClear.FlatAppearance.BorderSize = 1
|
|
||||||
$form.Controls.Add($btnClear)
|
|
||||||
|
|
||||||
# ═══ 结果卡片 ═════════════════════════════════════════
|
|
||||||
$resultCard = New-Object System.Windows.Forms.Panel
|
|
||||||
$resultCard.Location = New-Object System.Drawing.Point(20, 338)
|
|
||||||
$resultCard.Size = New-Object System.Drawing.Size(500, 220)
|
|
||||||
$resultCard.BackColor = $cardBg
|
|
||||||
$form.Controls.Add($resultCard)
|
|
||||||
|
|
||||||
$lblResultTitle = New-Object System.Windows.Forms.Label
|
|
||||||
$lblResultTitle.Location = New-Object System.Drawing.Point(16, 10)
|
|
||||||
$lblResultTitle.Size = New-Object System.Drawing.Size(200, 20)
|
|
||||||
$lblResultTitle.Text = "生成结果"
|
|
||||||
$lblResultTitle.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
|
|
||||||
$lblResultTitle.ForeColor = $brandBlue
|
|
||||||
$resultCard.Controls.Add($lblResultTitle)
|
|
||||||
|
|
||||||
$txtAuthCode = New-Object System.Windows.Forms.TextBox
|
|
||||||
$txtAuthCode.Location = New-Object System.Drawing.Point(16, 38)
|
|
||||||
$txtAuthCode.Size = New-Object System.Drawing.Size(380, 34)
|
|
||||||
$txtAuthCode.Font = New-Object System.Drawing.Font("Consolas", 13, [System.Drawing.FontStyle]::Bold)
|
|
||||||
$txtAuthCode.ReadOnly = $true
|
|
||||||
$txtAuthCode.BackColor = [System.Drawing.Color]::White
|
|
||||||
$txtAuthCode.ForeColor = $brandDark
|
|
||||||
$txtAuthCode.BorderStyle = "FixedSingle"
|
|
||||||
$txtAuthCode.Text = ""
|
|
||||||
$resultCard.Controls.Add($txtAuthCode)
|
|
||||||
|
|
||||||
$btnCopy = New-Object System.Windows.Forms.Button
|
|
||||||
$btnCopy.Location = New-Object System.Drawing.Point(404, 38)
|
|
||||||
$btnCopy.Size = New-Object System.Drawing.Size(80, 34)
|
|
||||||
$btnCopy.Text = "复制"
|
|
||||||
$btnCopy.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
|
||||||
$btnCopy.FlatStyle = "Flat"
|
|
||||||
$btnCopy.BackColor = $brandBlue
|
|
||||||
$btnCopy.ForeColor = [System.Drawing.Color]::White
|
|
||||||
$btnCopy.FlatAppearance.BorderSize = 0
|
|
||||||
$btnCopy.Cursor = [System.Windows.Forms.Cursors]::Hand
|
|
||||||
$btnCopy.Enabled = $false
|
|
||||||
$resultCard.Controls.Add($btnCopy)
|
|
||||||
|
|
||||||
$lblDetails = New-Object System.Windows.Forms.Label
|
|
||||||
$lblDetails.Location = New-Object System.Drawing.Point(16, 82)
|
|
||||||
$lblDetails.Size = New-Object System.Drawing.Size(468, 66)
|
|
||||||
$lblDetails.Text = "点击「生成授权码」后,结果将显示在此处。"
|
|
||||||
$lblDetails.ForeColor = $textSecondary
|
|
||||||
$lblDetails.Font = New-Object System.Drawing.Font("Segoe UI", 8.5)
|
|
||||||
$resultCard.Controls.Add($lblDetails)
|
|
||||||
|
|
||||||
# 推送到 Gitea 按钮
|
|
||||||
$btnPush = New-Object System.Windows.Forms.Button
|
|
||||||
$btnPush.Location = New-Object System.Drawing.Point(16, 156)
|
|
||||||
$btnPush.Size = New-Object System.Drawing.Size(468, 38)
|
|
||||||
$btnPush.Text = "推送到 Gitea (git add + commit + push)"
|
|
||||||
$btnPush.Font = New-Object System.Drawing.Font("Segoe UI", 10)
|
|
||||||
$btnPush.FlatStyle = "Flat"
|
|
||||||
$btnPush.BackColor = $successGreen
|
|
||||||
$btnPush.ForeColor = [System.Drawing.Color]::White
|
|
||||||
$btnPush.FlatAppearance.BorderSize = 0
|
|
||||||
$btnPush.Cursor = [System.Windows.Forms.Cursors]::Hand
|
|
||||||
$btnPush.Enabled = $false
|
|
||||||
$resultCard.Controls.Add($btnPush)
|
|
||||||
|
|
||||||
# ═══ 状态栏 ═══════════════════════════════════════════
|
|
||||||
$statusBar = New-Object System.Windows.Forms.Label
|
|
||||||
$statusBar.Location = New-Object System.Drawing.Point(0, 568)
|
|
||||||
$statusBar.Size = New-Object System.Drawing.Size(540, 26)
|
|
||||||
$statusBar.BackColor = $brandDark
|
|
||||||
$statusBar.ForeColor = [System.Drawing.Color]::FromArgb(160, 170, 200)
|
|
||||||
$statusBar.Text = " 就绪 | $ScriptDir"
|
|
||||||
$statusBar.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
|
||||||
$statusBar.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
|
|
||||||
$form.Controls.Add($statusBar)
|
|
||||||
|
|
||||||
# ─── 事件处理 ─────────────────────────────────────────
|
|
||||||
|
|
||||||
$btnCopy.Add_Click({
|
|
||||||
if ($txtAuthCode.Text) {
|
|
||||||
[System.Windows.Forms.Clipboard]::SetText($txtAuthCode.Text)
|
|
||||||
$statusBar.Text = " 已复制到剪贴板"
|
|
||||||
$statusBar.ForeColor = $successGreen
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$btnPush.Add_Click({
|
|
||||||
$btnPush.Enabled = $false
|
|
||||||
$btnPush.Text = "推送中..."
|
|
||||||
$statusBar.Text = " git add + commit + push ..."
|
|
||||||
$statusBar.ForeColor = $warningOrange
|
|
||||||
[System.Windows.Forms.Application]::DoEvents()
|
|
||||||
|
|
||||||
try {
|
|
||||||
$gitExe = (Get-Command git -ErrorAction Stop).Source
|
|
||||||
$userName = if ($global:lastGenUser) { $global:lastGenUser } else { "user" }
|
|
||||||
|
|
||||||
# git add secrets-*.enc
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = $gitExe
|
|
||||||
$psi.Arguments = "add secrets-*.enc"
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
$psi.WorkingDirectory = $ScriptDir
|
|
||||||
$p = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$p.WaitForExit(15000)
|
|
||||||
|
|
||||||
# git commit
|
|
||||||
$psi.Arguments = "commit -m `"add user $userName`""
|
|
||||||
$p = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$p.StandardOutput.ReadToEnd() | Out-Null
|
|
||||||
$p.WaitForExit(15000)
|
|
||||||
|
|
||||||
# git push
|
|
||||||
$statusBar.Text = " git push 中 (可能需要几秒)..."
|
|
||||||
[System.Windows.Forms.Application]::DoEvents()
|
|
||||||
$psi.Arguments = "push"
|
|
||||||
$p = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$pushOut = $p.StandardError.ReadToEnd() # git push 输出在 stderr
|
|
||||||
$p.WaitForExit(30000)
|
|
||||||
|
|
||||||
if ($p.ExitCode -eq 0) {
|
|
||||||
$btnPush.Text = "已推送"
|
|
||||||
$btnPush.BackColor = [System.Drawing.Color]::FromArgb(200, 220, 200)
|
|
||||||
$btnPush.ForeColor = $successGreen
|
|
||||||
$statusBar.Text = " 推送成功 — 现在可以把授权码发给 $userName"
|
|
||||||
$statusBar.ForeColor = $successGreen
|
|
||||||
} else {
|
|
||||||
throw "git push 失败: $pushOut"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$btnPush.Text = "推送失败 (点击重试)"
|
|
||||||
$btnPush.BackColor = [System.Drawing.Color]::FromArgb(220, 200, 200)
|
|
||||||
$btnPush.ForeColor = [System.Drawing.Color]::Red
|
|
||||||
$btnPush.Enabled = $true
|
|
||||||
$statusBar.Text = " 推送失败: $_"
|
|
||||||
$statusBar.ForeColor = [System.Drawing.Color]::Red
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("推送失败:`n$_`n`n请检查 Git 配置和网络。", "Git 错误", "OK", "Error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$btnClear.Add_Click({
|
|
||||||
$txtUser.Text = ""
|
|
||||||
$txtKey.Text = ""
|
|
||||||
$txtAuthCode.Text = ""
|
|
||||||
$lblDetails.Text = ""
|
|
||||||
$btnCopy.Enabled = $false
|
|
||||||
$statusBar.Text = " 已清空"
|
|
||||||
$statusBar.ForeColor = [System.Drawing.Color]::Gray
|
|
||||||
})
|
|
||||||
|
|
||||||
$btnGenerate.Add_Click({
|
|
||||||
# 校验
|
|
||||||
$user = $txtUser.Text.Trim()
|
|
||||||
$key = $txtKey.Text.Trim()
|
|
||||||
$days = $cmbDays.SelectedItem.ToString()
|
|
||||||
|
|
||||||
if (-not $user) {
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("请输入用户标识", "缺少用户名", "OK", "Warning")
|
|
||||||
$txtUser.Focus(); return
|
|
||||||
}
|
|
||||||
if (-not $key) {
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("请输入 Relay Sub-Key`n(从中转站后台为用户创建)", "缺少 Sub-Key", "OK", "Warning")
|
|
||||||
$txtKey.Focus(); return
|
|
||||||
}
|
|
||||||
if (-not (Test-Path $SecretsTxt)) {
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("secrets.txt 不存在:`n$SecretsTxt`n`n请先创建 secrets.txt (每行 KEY=VALUE)", "缺少凭证文件", "OK", "Error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (-not $nodeOK) {
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("Node.js 未安装。`n请先安装: https://nodejs.org", "缺少 Node.js", "OK", "Error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$statusBar.Text = " 生成中..."
|
|
||||||
$statusBar.ForeColor = $warningOrange
|
|
||||||
$btnGenerate.Enabled = $false
|
|
||||||
[System.Windows.Forms.Application]::DoEvents()
|
|
||||||
|
|
||||||
try {
|
|
||||||
# 用 Process + UTF8 编码读取 (PS2EXE 默认 GBK 会乱码)
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = (Get-Command node -ErrorAction Stop).Source
|
|
||||||
$psi.Arguments = "`"$GenScript`" $days -k `"$key`" -u `"$user`""
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
$psi.WorkingDirectory = $ScriptDir
|
|
||||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
$stdout = $proc.StandardOutput.ReadToEnd()
|
|
||||||
$stderr = $proc.StandardError.ReadToEnd()
|
|
||||||
$proc.WaitForExit()
|
|
||||||
|
|
||||||
if ($proc.ExitCode -ne 0) {
|
|
||||||
throw "gen-authcode.js 退出码 $($proc.ExitCode)`n$stderr"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解析输出 (编码无关: PS2EXE 下中文可能乱码, 只匹配 ASCII 格式)
|
|
||||||
$authCode = if ($stdout -match '(BW-\d{8}-[A-F0-9]{24})') { $Matches[1] } else { "" }
|
|
||||||
$fileId = if ($stdout -match '(secrets-[a-f0-9]{8}\.enc)') { $Matches[1] } else { "" }
|
|
||||||
$expiry = if ($stdout -match '(\d{4}-\d{2}-\d{2})') { $Matches[1] } else { "" }
|
|
||||||
|
|
||||||
if ($authCode) {
|
|
||||||
$txtAuthCode.Text = $authCode
|
|
||||||
$lblDetails.Text = "用户: $user`n加密文件: $fileId`n有效期: $days 天 (至 $expiry)`n路径: $ScriptDir\$fileId"
|
|
||||||
$btnCopy.Enabled = $true
|
|
||||||
$btnPush.Enabled = $true
|
|
||||||
$global:lastGenUser = $user
|
|
||||||
$global:lastGenFile = $fileId
|
|
||||||
$statusBar.Text = " 生成成功 — 点击「推送到 Gitea」完成部署"
|
|
||||||
$statusBar.ForeColor = $successGreen
|
|
||||||
|
|
||||||
# 追加历史记录 (仅本机, 已在 .gitignore)
|
|
||||||
$historyFile = Join-Path $ScriptDir "authcode-history.log"
|
|
||||||
$logLine = "$(Get-Date -Format 'yyyy-MM-dd HH:mm') $($user.PadRight(12)) $fileId $($days)天 至$expiry $authCode"
|
|
||||||
try { Add-Content -Path $historyFile -Value $logLine -Encoding utf8 } catch {}
|
|
||||||
} else {
|
|
||||||
throw "无法解析授权码输出:`n$stdout"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$txtAuthCode.Text = ""
|
|
||||||
$lblDetails.Text = "错误: $_"
|
|
||||||
$btnCopy.Enabled = $false
|
|
||||||
$statusBar.Text = " 生成失败"
|
|
||||||
$statusBar.ForeColor = [System.Drawing.Color]::Red
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("生成失败:`n$_", "错误", "OK", "Error")
|
|
||||||
} finally {
|
|
||||||
$btnGenerate.Enabled = $true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# ─── 启动 ─────────────────────────────────────────────
|
|
||||||
$form.Add_Shown({ $txtUser.Focus() })
|
|
||||||
[System.Windows.Forms.Application]::Run($form)
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
# 全局错误弹窗 (PS2EXE 下唯一的错误可见方式)
|
|
||||||
$errMsg = "Bookworm AuthGen 启动失败:`n`n" +
|
|
||||||
"错误: $($_.Exception.Message)`n" +
|
|
||||||
"行号: $($_.InvocationInfo.ScriptLineNumber)`n" +
|
|
||||||
"代码: $($_.InvocationInfo.Line.Trim())`n`n" +
|
|
||||||
"ScriptDir: $ScriptDir`n" +
|
|
||||||
"GenScript: $GenScript`n" +
|
|
||||||
"SecretsTxt: $SecretsTxt"
|
|
||||||
try {
|
|
||||||
[System.Windows.Forms.MessageBox]::Show($errMsg, "AuthGen 错误", "OK", "Error")
|
|
||||||
} catch {
|
|
||||||
# 如果连 MsgBox 都失败 (WinForms 未加载), 写文件
|
|
||||||
$errMsg | Out-File "$env:TEMP\bookworm-authgen-error.txt" -Encoding utf8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 604 B |
Binary file not shown.
|
Before Width: | Height: | Size: 9.3 KiB |
@ -1,118 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
|
|
||||||
<!-- ── FILTER: Center Glow ── -->
|
|
||||||
<filter id="center-glow" x="-60%" y="-60%" width="220%" height="220%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
|
|
||||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<filter id="center-glow-sm" x="-80%" y="-80%" width="260%" height="260%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
|
|
||||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<filter id="dot-glow" x="-100%" y="-100%" width="300%" height="300%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.5" result="blur"/>
|
|
||||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<!-- ── RADIAL BG ── -->
|
|
||||||
<radialGradient id="bg-gradient" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#141420"/>
|
|
||||||
<stop offset="100%" stop-color="#0C0C14"/>
|
|
||||||
</radialGradient>
|
|
||||||
|
|
||||||
<!-- ── CENTER GLOW GRADIENT ── -->
|
|
||||||
<radialGradient id="center-gradient" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="1"/>
|
|
||||||
<stop offset="30%" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
|
||||||
<stop offset="60%" stop-color="#C8D8FF" stop-opacity="0.4"/>
|
|
||||||
<stop offset="100%" stop-color="#A0C0FF" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
|
|
||||||
<!-- ── MONOCHROME GRADIENT ── -->
|
|
||||||
<radialGradient id="mono-center" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#FFFFFF"/>
|
|
||||||
<stop offset="30%" stop-color="#FFE88A" stop-opacity="0.9"/>
|
|
||||||
<stop offset="100%" stop-color="#C8A050" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════
|
|
||||||
FULL QUANTUM ROTATION SYMBOL (512×512 viewBox)
|
|
||||||
Fibonacci Spiral: r = 18 × e^(bθ), b = ln(φ)/(π/2)
|
|
||||||
32 dots from outermost to center
|
|
||||||
════════════════════════════════════════════ -->
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Background -->
|
|
||||||
<rect width="512" height="512" fill="url(#bg-gradient)"/>
|
|
||||||
|
|
||||||
<!-- Golden spiral path — r(θ)=3.5×e^(0.3063θ), θ∈[0,13.5], ~2.15 turns -->
|
|
||||||
<path d="M 259.5,256 L 259.6,255.8 L 259.6,255.5 L 259.6,255.3 L 259.7,255 L 259.7,254.7 L 259.6,254.4 L 259.6,254.2 L 259.5,253.9 L 259.5,253.6 L 259.4,253.3 L 259.2,253 L 259.1,252.8 L 258.9,252.5 L 258.7,252.2 L 258.5,252 L 258.3,251.7 L 258,251.5 L 257.8,251.2 L 257.5,251 L 257.2,250.8 L 256.8,250.7 L 256.5,250.5 L 256.1,250.4 L 255.7,250.3 L 255.3,250.2 L 254.9,250.1 L 254.5,250.1 L 254,250.1 L 253.6,250.1 L 253.1,250.2 L 252.7,250.2 L 252.2,250.4 L 251.8,250.5 L 251.3,250.7 L 250.9,250.9 L 250.4,251.2 L 250,251.5 L 249.6,251.8 L 249.2,252.2 L 248.8,252.6 L 248.4,253 L 248,253.5 L 247.7,254 L 247.4,254.5 L 247.2,255.1 L 246.9,255.7 L 246.8,256.3 L 246.6,256.9 L 246.5,257.6 L 246.4,258.3 L 246.4,259 L 246.4,259.7 L 246.5,260.4 L 246.6,261.2 L 246.8,261.9 L 247.1,262.6 L 247.3,263.4 L 247.7,264.1 L 248.1,264.8 L 248.6,265.5 L 249.1,266.2 L 249.7,266.9 L 250.3,267.5 L 251,268.1 L 251.7,268.7 L 252.5,269.3 L 253.4,269.7 L 254.3,270.2 L 255.2,270.6 L 256.2,270.9 L 257.2,271.1 L 258.3,271.3 L 259.4,271.5 L 260.5,271.5 L 261.7,271.5 L 262.8,271.4 L 264,271.2 L 265.2,270.9 L 266.4,270.6 L 267.6,270.1 L 268.8,269.6 L 270,269 L 271.1,268.3 L 272.3,267.4 L 273.3,266.5 L 274.4,265.5 L 275.4,264.4 L 276.3,263.3 L 277.2,262 L 278,260.7 L 278.8,259.2 L 279.4,257.7 L 279.9,256.1 L 280.4,254.5 L 280.8,252.8 L 281,251 L 281.1,249.2 L 281.1,247.3 L 281,245.5 L 280.7,243.5 L 280.3,241.6 L 279.8,239.7 L 279.1,237.7 L 278.3,235.8 L 277.3,233.9 L 276.2,232 L 274.9,230.2 L 273.5,228.4 L 271.9,226.7 L 270.2,225 L 268.3,223.5 L 266.3,222 L 264.1,220.7 L 261.9,219.5 L 259.4,218.4 L 256.9,217.5 L 254.3,216.7 L 251.6,216.1 L 248.7,215.7 L 245.8,215.4 L 242.8,215.4 L 239.8,215.5 L 236.7,215.9 L 233.6,216.5 L 230.4,217.3 L 227.3,218.3 L 224.2,219.6 L 221.1,221.1 L 218,222.9 L 215,224.9 L 212.1,227.1 L 209.3,229.6 L 206.6,232.3 L 204.1,235.3 L 201.7,238.5 L 199.5,241.9 L 197.4,245.5 L 195.6,249.3 L 194.1,253.4 L 192.7,257.6 L 191.7,262 L 190.9,266.5 L 190.4,271.2 L 190.2,276 L 190.4,280.9 L 190.9,285.9 L 191.7,290.9 L 192.9,296 L 194.5,301.1 L 196.5,306.2 L 198.8,311.2 L 201.6,316.1 L 204.7,321 L 208.2,325.7 L 212.1,330.3 L 216.4,334.7 L 221.1,338.9 L 226.2,342.9 L 231.6,346.5 L 237.4,349.9 L 243.5,352.9 L 250,355.6 L 256.7,357.8 L 263.7,359.7 L 271,361.1 L 278.5,362 L 286.2,362.4 L 294.1,362.3 L 302.2,361.6 L 310.3,360.4 L 318.5,358.6 L 326.7,356.2 L 334.9,353.2 L 343.1,349.6 L 351.2,345.3 L 359.1,340.4 L 366.8,334.9 L 374.3,328.7 L 381.5,322 L 388.4,314.5 L 394.9,306.5 L 400.9,297.9 L 406.5,288.7 L 411.5,278.9 L 416,268.6 L 419.8,257.9 L 423,246.6 L 425.5,234.9 L 427.2,222.9 L 428.1,210.5 L 428.1,197.8 L 427.3,184.9 L 425.6,171.7 L 422.9,158.5 L 419.3,145.2 L 414.7,131.9 L 409.1,118.6 L 402.5,105.6 L 394.9,92.7 L 386.2,80.1"
|
|
||||||
fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
|
|
||||||
<!-- 48 quantum dots — mathematically placed along golden spiral -->
|
|
||||||
<!-- OUTER → teal/cyan (small dots) -->
|
|
||||||
<circle cx="386.2" cy="80.1" r="3" fill="#20E0C0" opacity="0.75"/>
|
|
||||||
<circle cx="416" cy="135.3" r="3.2" fill="#20E0C0" opacity="0.76"/>
|
|
||||||
<circle cx="427.8" cy="191.5" r="3.5" fill="#20E0C0" opacity="0.76"/>
|
|
||||||
<circle cx="423.7" cy="243.9" r="3.7" fill="#28D0D0" opacity="0.77"/>
|
|
||||||
<circle cx="406.4" cy="288.9" r="3.9" fill="#30C8E8" opacity="0.77"/>
|
|
||||||
<!-- BLUE band -->
|
|
||||||
<circle cx="379.5" cy="323.9" r="4.2" fill="#4080FF" opacity="0.78"/>
|
|
||||||
<circle cx="346.9" cy="347.7" r="4.4" fill="#4080FF" opacity="0.78"/>
|
|
||||||
<circle cx="312" cy="360.1" r="4.6" fill="#4080FF" opacity="0.79"/>
|
|
||||||
<circle cx="278.2" cy="362" r="4.9" fill="#4888FF" opacity="0.79"/>
|
|
||||||
<circle cx="248" cy="354.8" r="5.1" fill="#5070FF" opacity="0.8"/>
|
|
||||||
<!-- INDIGO band -->
|
|
||||||
<circle cx="223.3" cy="340.7" r="5.3" fill="#6060F0" opacity="0.8" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="205.3" cy="321.9" r="5.6" fill="#6060F0" opacity="0.81" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="194.4" cy="300.8" r="5.8" fill="#7050EC" opacity="0.81" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="190.3" cy="279.3" r="6" fill="#7848E8" opacity="0.82" filter="url(#dot-glow)"/>
|
|
||||||
<!-- PURPLE band -->
|
|
||||||
<circle cx="192.2" cy="259.4" r="6.3" fill="#8838E4" opacity="0.82" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="199.1" cy="242.5" r="6.5" fill="#A020E0" opacity="0.83" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="209.5" cy="229.4" r="6.7" fill="#A820E0" opacity="0.83" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="222.1" cy="220.6" r="7" fill="#B018D8" opacity="0.84" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="235.4" cy="216.1" r="7.2" fill="#B818D0" opacity="0.84" filter="url(#dot-glow)"/>
|
|
||||||
<!-- MAGENTA band -->
|
|
||||||
<circle cx="248.3" cy="215.6" r="7.4" fill="#C828C0" opacity="0.85" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="259.7" cy="218.5" r="7.7" fill="#D030A8" opacity="0.85" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="269" cy="224" r="7.9" fill="#D83898" opacity="0.86" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="275.7" cy="231.3" r="8.1" fill="#E03888" opacity="0.86" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="279.7" cy="239.4" r="8.4" fill="#E84078" opacity="0.87" filter="url(#dot-glow)"/>
|
|
||||||
<!-- HOT PINK → ORANGE transition -->
|
|
||||||
<circle cx="281.1" cy="247.6" r="8.6" fill="#F04878" opacity="0.87" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="280.2" cy="255.1" r="8.9" fill="#F05068" opacity="0.88" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="277.5" cy="261.5" r="9.1" fill="#F05858" opacity="0.88" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="273.5" cy="266.4" r="9.3" fill="#F06048" opacity="0.89" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="268.6" cy="269.7" r="9.6" fill="#F06838" opacity="0.89" filter="url(#dot-glow)"/>
|
|
||||||
<!-- ORANGE band -->
|
|
||||||
<circle cx="263.5" cy="271.3" r="9.8" fill="#FF7818" opacity="0.9" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="258.7" cy="271.4" r="10" fill="#FF8418" opacity="0.9" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="254.3" cy="270.2" r="10.3" fill="#FF9018" opacity="0.91" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="250.9" cy="268" r="10.5" fill="#FF9C18" opacity="0.91" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="248.4" cy="265.2" r="10.7" fill="#FFA820" opacity="0.92" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="246.9" cy="262.1" r="11" fill="#FFB420" opacity="0.92" filter="url(#dot-glow)"/>
|
|
||||||
<!-- GOLD band (innermost dots) -->
|
|
||||||
<circle cx="246.4" cy="259" r="11.2" fill="#FFC028" opacity="0.93" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="246.8" cy="256.2" r="11.4" fill="#FFC830" opacity="0.93" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="247.9" cy="253.8" r="11.7" fill="#FFD038" opacity="0.94" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="249.4" cy="251.9" r="11.9" fill="#FFD840" opacity="0.94" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="251.3" cy="250.7" r="12.1" fill="#FFE040" opacity="0.95" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="253.2" cy="250.1" r="12.4" fill="#FFE448" opacity="0.95" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="255.1" cy="250.1" r="12.6" fill="#FFE850" opacity="0.96" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="256.7" cy="250.6" r="12.8" fill="#FFE858" opacity="0.96" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="258" cy="251.5" r="13.1" fill="#FFE860" opacity="0.97" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="259" cy="252.5" r="13.3" fill="#FFE860" opacity="0.97" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="259.5" cy="253.7" r="13.5" fill="#FFE860" opacity="0.98" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="259.7" cy="254.9" r="13.8" fill="#FFE860" opacity="0.98" filter="url(#dot-glow)"/>
|
|
||||||
<circle cx="259.5" cy="256" r="14" fill="#FFE860" opacity="0.99" filter="url(#dot-glow)"/>
|
|
||||||
|
|
||||||
<!-- CENTER — white core with glow -->
|
|
||||||
<circle cx="256" cy="256" r="50" fill="url(#center-gradient)" filter="url(#center-glow)"/>
|
|
||||||
<circle cx="256" cy="256" r="18" fill="#FFFFFF" filter="url(#center-glow)"/>
|
|
||||||
<circle cx="256" cy="256" r="10" fill="#FFFFFF"/>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
2801
auto-setup.ps1
2801
auto-setup.ps1
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
BIN
bookworm.ico
BIN
bookworm.ico
Binary file not shown.
|
Before Width: | Height: | Size: 135 KiB |
183
build.ps1
183
build.ps1
@ -1,183 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bookworm Portable — 打包工具 (管理员使用)
|
|
||||||
.DESCRIPTION
|
|
||||||
将 auto-setup.ps1 打包为 Bookworm-Setup.exe (PS2EXE)
|
|
||||||
将 gen-authcode.js 打包为 gen-authcode.exe (pkg)
|
|
||||||
输出到 dist\ 目录
|
|
||||||
.USAGE
|
|
||||||
.\build.ps1 # 打包两个
|
|
||||||
.\build.ps1 -Setup # 只打包用户安装器
|
|
||||||
.\build.ps1 -Admin # 只打包管理员工具
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[switch]$Setup, # 只打 Bookworm-Setup.exe
|
|
||||||
[switch]$Admin # 只打 gen-authcode.exe
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
$DistDir = Join-Path $ScriptDir "dist"
|
|
||||||
|
|
||||||
# 默认两个都打
|
|
||||||
$buildSetup = $Setup -or (-not $Setup -and -not $Admin)
|
|
||||||
$buildAdmin = $Admin -or (-not $Setup -and -not $Admin)
|
|
||||||
|
|
||||||
function Write-Step($msg) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " ── $msg" -ForegroundColor Cyan
|
|
||||||
}
|
|
||||||
function Write-OK($msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
|
||||||
function Write-Warn($msg) { Write-Host " [!] $msg" -ForegroundColor Yellow }
|
|
||||||
function Write-Fail($msg) { Write-Host " [!!] $msg" -ForegroundColor Red }
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
Write-Host " | Bookworm Portable — Build Script |" -ForegroundColor Cyan
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
if (-not (Test-Path $DistDir)) {
|
|
||||||
New-Item -ItemType Directory $DistDir | Out-Null
|
|
||||||
Write-OK "创建 dist\ 目录"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════
|
|
||||||
# 1. Bookworm-Setup.exe (auto-setup.ps1 → PS2EXE)
|
|
||||||
# ════════════════════════════════════════════════════════
|
|
||||||
if ($buildSetup) {
|
|
||||||
Write-Step "打包 Bookworm-Setup.exe (PS2EXE)"
|
|
||||||
|
|
||||||
# 安装/检查 PS2EXE
|
|
||||||
if (-not (Get-Command Invoke-ps2exe -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Warn "PS2EXE 未安装,正在安装..."
|
|
||||||
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
|
|
||||||
Import-Module ps2exe
|
|
||||||
}
|
|
||||||
|
|
||||||
$inputPs1 = Join-Path $ScriptDir "auto-setup.ps1"
|
|
||||||
$outputExe = Join-Path $DistDir "Bookworm-Setup.exe"
|
|
||||||
|
|
||||||
if (-not (Test-Path $inputPs1)) {
|
|
||||||
Write-Fail "找不到 auto-setup.ps1"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host " 输入: $inputPs1" -ForegroundColor Gray
|
|
||||||
Write-Host " 输出: $outputExe" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# 从 auto-setup.ps1 读取版本号
|
|
||||||
$versionLine = Select-String -Path $inputPs1 -Pattern '^\$BWVersion\s*=\s*"([^"]+)"' | Select-Object -First 1
|
|
||||||
$bwVer = if ($versionLine) { $versionLine.Matches[0].Groups[1].Value } else { "0.0.0" }
|
|
||||||
Write-Host " 版本: $bwVer" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# 优先用桌面专用 B 圆图标, 回退到 galaxy
|
|
||||||
$iconFile = Join-Path $ScriptDir "bookworm-desktop.ico"
|
|
||||||
if (-not (Test-Path $iconFile)) {
|
|
||||||
$iconFile = Join-Path $ScriptDir "bookworm.ico"
|
|
||||||
}
|
|
||||||
$ps2exeArgs = @{
|
|
||||||
InputFile = $inputPs1
|
|
||||||
OutputFile = $outputExe
|
|
||||||
Title = "Bookworm Portable Setup v$bwVer"
|
|
||||||
Description = "Bookworm Smart Assistant 安装向导 v$bwVer"
|
|
||||||
Company = "Bookworm"
|
|
||||||
Version = "$bwVer.0"
|
|
||||||
NoConsole = $true
|
|
||||||
NoOutput = $true
|
|
||||||
NoError = $true
|
|
||||||
}
|
|
||||||
if (Test-Path $iconFile) {
|
|
||||||
$ps2exeArgs.IconFile = $iconFile
|
|
||||||
Write-Host " 图标: $iconFile" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
Invoke-ps2exe @ps2exeArgs
|
|
||||||
|
|
||||||
if (Test-Path $outputExe) {
|
|
||||||
$sizeKB = [math]::Round((Get-Item $outputExe).Length / 1KB)
|
|
||||||
Write-OK "Bookworm-Setup.exe 打包完成 (${sizeKB} KB)"
|
|
||||||
} else {
|
|
||||||
Write-Fail "Bookworm-Setup.exe 打包失败"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════
|
|
||||||
# 2. Bookworm-AuthGen.exe (admin-authcode-gui.ps1 → PS2EXE)
|
|
||||||
# ════════════════════════════════════════════════════════
|
|
||||||
if ($buildAdmin) {
|
|
||||||
Write-Step "打包 Bookworm-AuthGen.exe (PS2EXE GUI)"
|
|
||||||
|
|
||||||
$inputPs1 = Join-Path $ScriptDir "admin-authcode-gui.ps1"
|
|
||||||
$outputExe = Join-Path $DistDir "Bookworm-AuthGen.exe"
|
|
||||||
|
|
||||||
if (-not (Test-Path $inputPs1)) {
|
|
||||||
Write-Fail "找不到 admin-authcode-gui.ps1"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host " 输入: $inputPs1" -ForegroundColor Gray
|
|
||||||
Write-Host " 输出: $outputExe" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# 优先用书虫学者图标, 回退到 B 圆
|
|
||||||
$adminIcon = Join-Path $ScriptDir "admin-authcode.ico"
|
|
||||||
if (-not (Test-Path $adminIcon)) { $adminIcon = Join-Path $ScriptDir "bookworm-desktop.ico" }
|
|
||||||
|
|
||||||
$ps2exeArgs = @{
|
|
||||||
InputFile = $inputPs1
|
|
||||||
OutputFile = $outputExe
|
|
||||||
Title = "Bookworm AuthCode Generator"
|
|
||||||
Description = "Bookworm 授权码生成器 (管理员工具)"
|
|
||||||
Company = "Bookworm"
|
|
||||||
Version = "1.5.1.0"
|
|
||||||
NoConsole = $true
|
|
||||||
NoOutput = $true
|
|
||||||
NoError = $true
|
|
||||||
}
|
|
||||||
if (Test-Path $adminIcon) {
|
|
||||||
$ps2exeArgs.IconFile = $adminIcon
|
|
||||||
Write-Host " 图标: $adminIcon" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
Invoke-ps2exe @ps2exeArgs
|
|
||||||
|
|
||||||
if (Test-Path $outputExe) {
|
|
||||||
$sizeKB = [math]::Round((Get-Item $outputExe).Length / 1KB)
|
|
||||||
Write-OK "Bookworm-AuthGen.exe 打包完成 (${sizeKB} KB)"
|
|
||||||
} else {
|
|
||||||
Write-Fail "Bookworm-AuthGen.exe 打包失败"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════
|
|
||||||
# 完成
|
|
||||||
# ════════════════════════════════════════════════════════
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " ============================================" -ForegroundColor Green
|
|
||||||
Write-Host " 打包完成!输出目录: dist\" -ForegroundColor Green
|
|
||||||
Write-Host " ============================================" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# v3.1.1: build 后自动跑 E2E 行为测试 (闭合 L8: 防 v3.0.10 -or 类运行时 bug)
|
|
||||||
$e2eTest = Join-Path $ScriptDir "tools\test-launcher-e2e.ps1"
|
|
||||||
if (Test-Path $e2eTest) {
|
|
||||||
Write-Host " ── 运行 E2E 行为测试 (build 后自动护栏)" -ForegroundColor Cyan
|
|
||||||
& pwsh -NoProfile -File $e2eTest 2>&1 | ForEach-Object { Write-Host " $_" }
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " [!] E2E 测试失败 (exit $LASTEXITCODE)" -ForegroundColor Red
|
|
||||||
Write-Host " EXE 已生成但启动器契约/wrapper 有问题, 修复后重打包" -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Get-ChildItem $DistDir | ForEach-Object {
|
|
||||||
$sizeMB = [math]::Round($_.Length / 1MB, 1)
|
|
||||||
Write-Host " $($_.Name.PadRight(30)) ${sizeMB} MB" -ForegroundColor White
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 分发说明:" -ForegroundColor Gray
|
|
||||||
Write-Host " Bookworm-Setup.exe → 用户安装器 (公开下载)" -ForegroundColor Gray
|
|
||||||
Write-Host " Bookworm-AuthGen.exe → 管理员授权码生成器 (勿对外分发)" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
457
bw-doctor.ps1
457
bw-doctor.ps1
@ -1,457 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bookworm Portable 体检工具 (v3.1.3)
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
13 维度自检, 覆盖 auto-setup.ps1 7 阶段所有安装产物.
|
|
||||||
输出彩色 PASS/WARN/FAIL 报告, 不修改任何文件.
|
|
||||||
|
|
||||||
维度:
|
|
||||||
[1] PowerShell 7 (pwsh)
|
|
||||||
[2] Node.js
|
|
||||||
[3] Git
|
|
||||||
[4] Claude Code (claude.ps1 可达)
|
|
||||||
[5] 桌面 .lnk (启动/更新 + Args 契约)
|
|
||||||
[6] DPAPI 凭证 (HKCU CachedEnv)
|
|
||||||
[7] Profile BW_CRED 块 (PS7 + PS5.1)
|
|
||||||
[8] Profile BW_CLIP 块 (PS7)
|
|
||||||
[9] 环境变量 (ANTHROPIC_*)
|
|
||||||
[10] ~/.claude 完整性 (CLAUDE.md / Skills / Hooks / Settings)
|
|
||||||
[11] API 中转站连通 (bww.letcareme.com)
|
|
||||||
[12] Worker 连通 (bookworm-router)
|
|
||||||
[13] Gitea 连通 (code.letcareme.com)
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
用法: pwsh -NoProfile -ExecutionPolicy Bypass -File bw-doctor.ps1
|
|
||||||
日志: $env:TEMP\bw-doctor.log
|
|
||||||
退出码: 0 = 全 PASS / 1 = 有 FAIL
|
|
||||||
#>
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Continue"
|
|
||||||
|
|
||||||
$doctorLog = Join-Path $env:TEMP "bw-doctor.log"
|
|
||||||
$pass = 0; $warn = 0; $fail = 0
|
|
||||||
$results = @()
|
|
||||||
|
|
||||||
function Log-Doctor {
|
|
||||||
param([string]$Msg)
|
|
||||||
try { "[$([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))] $Msg" | Out-File -FilePath $doctorLog -Append -Encoding UTF8 -EA SilentlyContinue } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Report {
|
|
||||||
param([string]$Dim, [string]$Status, [string]$Detail)
|
|
||||||
$color = switch ($Status) { 'PASS' { 'Green' } 'WARN' { 'Yellow' } 'FAIL' { 'Red' } default { 'Gray' } }
|
|
||||||
$icon = switch ($Status) { 'PASS' { [char]0x2714 } 'WARN' { '!' } 'FAIL' { [char]0x2718 } default { '?' } }
|
|
||||||
$line = " $icon [$Status] $Dim"
|
|
||||||
if ($Detail) { $line += " — $Detail" }
|
|
||||||
Write-Host $line -ForegroundColor $color
|
|
||||||
Log-Doctor "$Status $Dim $Detail"
|
|
||||||
switch ($Status) {
|
|
||||||
'PASS' { $script:pass++ }
|
|
||||||
'WARN' { $script:warn++ }
|
|
||||||
'FAIL' { $script:fail++ }
|
|
||||||
}
|
|
||||||
$script:results += @{ Dim = $Dim; Status = $Status; Detail = $Detail }
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-Cmd($name) { return [bool](Get-Command $name -EA SilentlyContinue) }
|
|
||||||
|
|
||||||
# ── Banner ──
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
Write-Host " | Bookworm Doctor v3.1.3 |" -ForegroundColor Cyan
|
|
||||||
Write-Host " | 13 维度健康体检 |" -ForegroundColor Cyan
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
Log-Doctor "=== Bookworm Doctor v3.1.3 START ==="
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [1/13] PowerShell 7
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [1/13] PowerShell 7" -ForegroundColor Cyan
|
|
||||||
$pwshCmd = Get-Command pwsh -EA SilentlyContinue
|
|
||||||
if ($pwshCmd) {
|
|
||||||
try {
|
|
||||||
$pwshVer = (& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>$null).Trim()
|
|
||||||
Report "[1] PowerShell 7" "PASS" "v$pwshVer ($($pwshCmd.Source))"
|
|
||||||
} catch {
|
|
||||||
Report "[1] PowerShell 7" "WARN" "pwsh 可达但版本查询失败"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[1] PowerShell 7" "FAIL" "pwsh 不在 PATH — 桌面 .lnk 强依赖 pwsh.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [2/13] Node.js
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [2/13] Node.js" -ForegroundColor Cyan
|
|
||||||
if (Test-Cmd "node") {
|
|
||||||
try {
|
|
||||||
$nodeVer = (& node --version 2>$null).Trim()
|
|
||||||
Report "[2] Node.js" "PASS" "$nodeVer"
|
|
||||||
} catch {
|
|
||||||
Report "[2] Node.js" "WARN" "node 可达但版本查询失败"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[2] Node.js" "FAIL" "node 不在 PATH — Claude Code 强依赖"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [3/13] Git
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [3/13] Git" -ForegroundColor Cyan
|
|
||||||
if (Test-Cmd "git") {
|
|
||||||
try {
|
|
||||||
$gitVer = (& git --version 2>$null).Trim()
|
|
||||||
Report "[3] Git" "PASS" "$gitVer"
|
|
||||||
} catch {
|
|
||||||
Report "[3] Git" "WARN" "git 可达但版本查询失败"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[3] Git" "FAIL" "git 不在 PATH — 配置同步强依赖"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [4/13] Claude Code
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [4/13] Claude Code" -ForegroundColor Cyan
|
|
||||||
$claudePs1Found = $null
|
|
||||||
|
|
||||||
$claudeCmd = Get-Command claude -EA SilentlyContinue
|
|
||||||
if ($claudeCmd -and $claudeCmd.Source -and $claudeCmd.Source.EndsWith('.ps1') -and (Test-Path $claudeCmd.Source)) {
|
|
||||||
$claudePs1Found = $claudeCmd.Source
|
|
||||||
}
|
|
||||||
if (-not $claudePs1Found) {
|
|
||||||
try {
|
|
||||||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
|
||||||
$candidate = Join-Path $npmPrefix "claude.ps1"
|
|
||||||
if (Test-Path $candidate) { $claudePs1Found = $candidate }
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (-not $claudePs1Found) {
|
|
||||||
foreach ($p in @("$env:APPDATA\npm\claude.ps1", "$env:ProgramFiles\nodejs\claude.ps1", "$env:LOCALAPPDATA\npm\claude.ps1")) {
|
|
||||||
if (Test-Path $p) { $claudePs1Found = $p; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($claudePs1Found) {
|
|
||||||
try {
|
|
||||||
$claudeVer = (& claude --version 2>$null | Select-Object -First 1).Trim()
|
|
||||||
Report "[4] Claude Code" "PASS" "$claudeVer ($claudePs1Found)"
|
|
||||||
} catch {
|
|
||||||
Report "[4] Claude Code" "PASS" "claude.ps1 存在: $claudePs1Found (版本查询跳过)"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[4] Claude Code" "FAIL" "claude.ps1 不可达 — 运行 npm i -g @anthropic-ai/claude-code"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [5/13] 桌面 .lnk
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [5/13] 桌面 .lnk" -ForegroundColor Cyan
|
|
||||||
$desktop = [Environment]::GetFolderPath('Desktop')
|
|
||||||
$requiredLnks = @('启动Bookworm.lnk', '更新Bookworm.lnk')
|
|
||||||
$optionalLnks = @('体检Bookworm.lnk', '卸载Bookworm.lnk')
|
|
||||||
$lnkMissing = @()
|
|
||||||
$lnkPresent = @()
|
|
||||||
|
|
||||||
foreach ($n in $requiredLnks) {
|
|
||||||
$p = Join-Path $desktop $n
|
|
||||||
if (Test-Path $p) { $lnkPresent += $n } else { $lnkMissing += $n }
|
|
||||||
}
|
|
||||||
|
|
||||||
$optPresent = @()
|
|
||||||
foreach ($n in $optionalLnks) {
|
|
||||||
$p = Join-Path $desktop $n
|
|
||||||
if (Test-Path $p) { $optPresent += $n }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lnkMissing.Count -eq 0) {
|
|
||||||
# 验证启动 .lnk Args 契约 (4 项: pwsh TargetPath / bw-launch.ps1 / --dangerously-skip-permissions / -ExecutionPolicy Bypass)
|
|
||||||
$launchLnk = Join-Path $desktop '启动Bookworm.lnk'
|
|
||||||
$argsOK = $true
|
|
||||||
$argsDetail = ""
|
|
||||||
try {
|
|
||||||
$shell = New-Object -ComObject WScript.Shell
|
|
||||||
$sc = $shell.CreateShortcut($launchLnk)
|
|
||||||
$checks = @(
|
|
||||||
@{ Name = "TargetPath=pwsh"; OK = ($sc.TargetPath -match 'pwsh\.exe$') }
|
|
||||||
@{ Name = "bw-launch.ps1"; OK = ($sc.Arguments -match 'bw-launch\.ps1') }
|
|
||||||
@{ Name = "--dangerously-skip-permissions"; OK = ($sc.Arguments -match '--dangerously-skip-permissions') }
|
|
||||||
@{ Name = "-ExecutionPolicy Bypass"; OK = ($sc.Arguments -match '-ExecutionPolicy Bypass') }
|
|
||||||
)
|
|
||||||
$badChecks = $checks | Where-Object { -not $_.OK }
|
|
||||||
if ($badChecks) {
|
|
||||||
$argsOK = $false
|
|
||||||
$argsDetail = "契约失败: " + (($badChecks | ForEach-Object { $_.Name }) -join ', ')
|
|
||||||
}
|
|
||||||
} catch { $argsOK = $false; $argsDetail = "读取 .lnk 异常" }
|
|
||||||
|
|
||||||
if ($argsOK) {
|
|
||||||
$extra = if ($optPresent.Count -gt 0) { " + $($optPresent -join '/')" } else { "" }
|
|
||||||
Report "[5] 桌面 .lnk" "PASS" "$($lnkPresent -join ' + ')$extra — 4 项契约 OK"
|
|
||||||
} else {
|
|
||||||
Report "[5] 桌面 .lnk" "FAIL" "$argsDetail"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[5] 桌面 .lnk" "FAIL" "缺失: $($lnkMissing -join ', ') — 重跑 Bookworm-Setup.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [6/13] DPAPI 凭证
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [6/13] DPAPI 凭证" -ForegroundColor Cyan
|
|
||||||
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
|
||||||
if (Test-Path $regPath) {
|
|
||||||
try {
|
|
||||||
Add-Type -AssemblyName System.Security -EA Stop
|
|
||||||
$props = Get-ItemProperty $regPath -EA Stop
|
|
||||||
$envNames = $props.PSObject.Properties | Where-Object { $_.Name -match '^[A-Z_]+$' }
|
|
||||||
$decrypted = 0
|
|
||||||
foreach ($ev in $envNames) {
|
|
||||||
try {
|
|
||||||
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
|
||||||
[Convert]::FromBase64String($ev.Value), $null,
|
|
||||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
||||||
$decrypted++
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if ($envNames.Count -gt 0 -and $decrypted -eq $envNames.Count) {
|
|
||||||
Report "[6] DPAPI 凭证" "PASS" "$decrypted/$($envNames.Count) 密钥可解密"
|
|
||||||
} elseif ($decrypted -gt 0) {
|
|
||||||
Report "[6] DPAPI 凭证" "WARN" "$decrypted/$($envNames.Count) 可解密 (部分失败)"
|
|
||||||
} else {
|
|
||||||
Report "[6] DPAPI 凭证" "FAIL" "0/$($envNames.Count) 可解密 — DPAPI 可能跨用户失效"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Report "[6] DPAPI 凭证" "WARN" "HKCU 存在但读取异常: $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[6] DPAPI 凭证" "FAIL" "HKCU:\Software\Bookworm\CachedEnv 不存在 — 未安装或已卸载"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [7/13] Profile BW_CRED 块
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [7/13] Profile BW_CRED" -ForegroundColor Cyan
|
|
||||||
$credBlockOK = 0; $credBlockMissing = @()
|
|
||||||
foreach ($entry in @(
|
|
||||||
@{ Name = "PS7"; Path = (Join-Path $env:USERPROFILE "Documents\PowerShell\profile.ps1") }
|
|
||||||
@{ Name = "PS5.1"; Path = (Join-Path $env:USERPROFILE "Documents\WindowsPowerShell\profile.ps1") }
|
|
||||||
)) {
|
|
||||||
if (Test-Path $entry.Path) {
|
|
||||||
$c = Get-Content $entry.Path -Raw -EA SilentlyContinue
|
|
||||||
if ($c -match 'BW_CRED_START' -and $c -match 'BW_CRED_END') {
|
|
||||||
$credBlockOK++
|
|
||||||
} else {
|
|
||||||
$credBlockMissing += $entry.Name
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$credBlockMissing += "$($entry.Name)(文件不存在)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($credBlockOK -ge 1 -and $credBlockMissing.Count -eq 0) {
|
|
||||||
Report "[7] Profile BW_CRED" "PASS" "PS7 + PS5.1 双 profile sentinel 完整"
|
|
||||||
} elseif ($credBlockOK -ge 1) {
|
|
||||||
Report "[7] Profile BW_CRED" "WARN" "部分缺失: $($credBlockMissing -join ', ')"
|
|
||||||
} else {
|
|
||||||
Report "[7] Profile BW_CRED" "FAIL" "BW_CRED 块不存在 — 启动时无法自动加载凭证"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [8/13] Profile BW_CLIP 块
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [8/13] Profile BW_CLIP" -ForegroundColor Cyan
|
|
||||||
$clipProfile = Join-Path $env:USERPROFILE "Documents\PowerShell\profile.ps1"
|
|
||||||
if (Test-Path $clipProfile) {
|
|
||||||
$c = Get-Content $clipProfile -Raw -EA SilentlyContinue
|
|
||||||
if ($c -match 'BW_CLIP_START' -and $c -match 'BW_CLIP_END') {
|
|
||||||
Report "[8] Profile BW_CLIP" "PASS" "截图粘贴助手 sentinel 完整"
|
|
||||||
} else {
|
|
||||||
Report "[8] Profile BW_CLIP" "WARN" "BW_CLIP 块缺失 (截图粘贴功能不可用, 非核心)"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Report "[8] Profile BW_CLIP" "WARN" "PS7 profile.ps1 不存在"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [9/13] 凭证注入链路 (DPAPI → profile → 运行时 Key)
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [9/13] 凭证注入链路" -ForegroundColor Cyan
|
|
||||||
# Bookworm 的设计: DPAPI 加密存 HKCU → profile.ps1 启动时解密 export → 运行时 env 可用
|
|
||||||
# 不应检查 User 持久环境变量 (Key 明文存 User env 反而不安全)
|
|
||||||
$chainOK = $true; $chainDetail = @()
|
|
||||||
|
|
||||||
# 环节 1: DPAPI 存储有 Key
|
|
||||||
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
|
||||||
$dpapiHasKey = $false
|
|
||||||
if (Test-Path $regPath) {
|
|
||||||
try {
|
|
||||||
$props = Get-ItemProperty $regPath -EA SilentlyContinue
|
|
||||||
$apiKeyProp = $props.PSObject.Properties | Where-Object { $_.Name -eq 'ANTHROPIC_API_KEY' }
|
|
||||||
if ($apiKeyProp) { $dpapiHasKey = $true; $chainDetail += "DPAPI=OK" }
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (-not $dpapiHasKey) { $chainOK = $false; $chainDetail += "DPAPI=MISSING" }
|
|
||||||
|
|
||||||
# 环节 2: profile 有 BW_CRED 块
|
|
||||||
$ps7Profile = Join-Path $env:USERPROFILE "Documents\PowerShell\profile.ps1"
|
|
||||||
$profileHasCred = $false
|
|
||||||
if (Test-Path $ps7Profile) {
|
|
||||||
$c = Get-Content $ps7Profile -Raw -EA SilentlyContinue
|
|
||||||
if ($c -match 'BW_CRED_START') { $profileHasCred = $true; $chainDetail += "Profile=OK" }
|
|
||||||
}
|
|
||||||
if (-not $profileHasCred) { $chainOK = $false; $chainDetail += "Profile=MISSING" }
|
|
||||||
|
|
||||||
# 环节 3: BASE_URL (可选但推荐)
|
|
||||||
$baseUrl = [Environment]::GetEnvironmentVariable('ANTHROPIC_BASE_URL', 'User')
|
|
||||||
if (-not $baseUrl) { $baseUrl = $env:ANTHROPIC_BASE_URL }
|
|
||||||
if ($baseUrl) { $chainDetail += "BaseURL=$baseUrl" } else { $chainDetail += "BaseURL=default" }
|
|
||||||
|
|
||||||
if ($chainOK) {
|
|
||||||
Report "[9] 凭证注入链路" "PASS" ($chainDetail -join ' → ')
|
|
||||||
} else {
|
|
||||||
Report "[9] 凭证注入链路" "FAIL" ($chainDetail -join ' → ') + " — 重跑 Bookworm-Setup.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [10/13] ~/.claude 完整性
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [10/13] ~/.claude 完整性" -ForegroundColor Cyan
|
|
||||||
$claudeDir = Join-Path $env:USERPROFILE ".claude"
|
|
||||||
$intChecks = @()
|
|
||||||
|
|
||||||
# CLAUDE.md
|
|
||||||
$claudeMdPath = Join-Path $claudeDir "CLAUDE.md"
|
|
||||||
$claudeMdOK = $false
|
|
||||||
if (Test-Path $claudeMdPath) {
|
|
||||||
$cm = Get-Content $claudeMdPath -Raw -EA SilentlyContinue
|
|
||||||
$claudeMdOK = $cm -match "Bookworm"
|
|
||||||
}
|
|
||||||
$intChecks += @{ Name = "CLAUDE.md"; OK = $claudeMdOK }
|
|
||||||
|
|
||||||
# Skills
|
|
||||||
$skillsDir = Join-Path $claudeDir "skills"
|
|
||||||
$skillCount = 0
|
|
||||||
if (Test-Path $skillsDir) { $skillCount = @(Get-ChildItem $skillsDir -Directory -EA SilentlyContinue).Count }
|
|
||||||
$intChecks += @{ Name = "Skills ($skillCount, 需>=10)"; OK = ($skillCount -ge 10) }
|
|
||||||
|
|
||||||
# Hooks
|
|
||||||
$hooksDir = Join-Path $claudeDir "hooks"
|
|
||||||
$hookCount = 0
|
|
||||||
if (Test-Path $hooksDir) { $hookCount = @(Get-ChildItem $hooksDir -Filter "*.js" -File -EA SilentlyContinue).Count }
|
|
||||||
$intChecks += @{ Name = "Hooks ($hookCount, 需>=3)"; OK = ($hookCount -ge 3) }
|
|
||||||
|
|
||||||
# Settings hooks
|
|
||||||
$settingsFile = Join-Path $claudeDir "settings.json"
|
|
||||||
$settingsOK = $false
|
|
||||||
if (Test-Path $settingsFile) {
|
|
||||||
$sc = Get-Content $settingsFile -Raw -EA SilentlyContinue
|
|
||||||
$settingsOK = $sc -match '"hooks"'
|
|
||||||
}
|
|
||||||
$intChecks += @{ Name = "Settings hooks"; OK = $settingsOK }
|
|
||||||
|
|
||||||
$intFails = $intChecks | Where-Object { -not $_.OK }
|
|
||||||
if ($intFails.Count -eq 0) {
|
|
||||||
Report "[10] ~/.claude 完整性" "PASS" "CLAUDE.md + $skillCount Skills + $hookCount Hooks + Settings"
|
|
||||||
} else {
|
|
||||||
$failNames = ($intFails | ForEach-Object { $_.Name }) -join ', '
|
|
||||||
Report "[10] ~/.claude 完整性" "FAIL" "缺失: $failNames"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [11/13] API 中转站连通
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [11/13] API 中转站" -ForegroundColor Cyan
|
|
||||||
$apiBaseUrl = [Environment]::GetEnvironmentVariable('ANTHROPIC_BASE_URL', 'User')
|
|
||||||
if (-not $apiBaseUrl) { $apiBaseUrl = $env:ANTHROPIC_BASE_URL }
|
|
||||||
if (-not $apiBaseUrl) { $apiBaseUrl = "https://bww.letcareme.com" }
|
|
||||||
|
|
||||||
try {
|
|
||||||
# 中转站对境外 IP 返 503, NO_PROXY 直连
|
|
||||||
$savedProxy = $env:NO_PROXY
|
|
||||||
$env:NO_PROXY = "bww.letcareme.com,letcareme.com,localhost,127.0.0.1"
|
|
||||||
|
|
||||||
$resp = Invoke-WebRequest -Uri "$apiBaseUrl/v1/models" -Method Head -TimeoutSec 10 -UseBasicParsing -EA Stop
|
|
||||||
$env:NO_PROXY = $savedProxy
|
|
||||||
Report "[11] API 中转站" "PASS" "$apiBaseUrl (HTTP $($resp.StatusCode))"
|
|
||||||
} catch {
|
|
||||||
$env:NO_PROXY = $savedProxy
|
|
||||||
$errMsg = $_.Exception.Message
|
|
||||||
if ($errMsg -match '40[0-9]|301|302|503') {
|
|
||||||
Report "[11] API 中转站" "PASS" "$apiBaseUrl 可达 (HTTP 错误码 = 网络通)"
|
|
||||||
} else {
|
|
||||||
Report "[11] API 中转站" "FAIL" "$apiBaseUrl 不可达: $errMsg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [12/13] Worker 连通
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [12/13] Worker" -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
$workerUrl = "https://bookworm-router.bookworm-api.workers.dev/config"
|
|
||||||
$resp = Invoke-RestMethod -Uri $workerUrl -TimeoutSec 10 -EA Stop
|
|
||||||
Report "[12] Worker" "PASS" "bookworm-router /config 可达"
|
|
||||||
} catch {
|
|
||||||
$errMsg = $_.Exception.Message
|
|
||||||
if ($errMsg -match '403|404') {
|
|
||||||
Report "[12] Worker" "PASS" "Worker 可达 (HTTP 错误码 = 网络通)"
|
|
||||||
} else {
|
|
||||||
Report "[12] Worker" "WARN" "Worker 不可达: $errMsg (非核心, 仅影响默认 model)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# [13/13] Gitea 连通
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
Write-Host " [13/13] Gitea" -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
$savedProxy = $env:NO_PROXY
|
|
||||||
$env:NO_PROXY = "code.letcareme.com,letcareme.com,localhost,127.0.0.1"
|
|
||||||
$resp = Invoke-WebRequest -Uri "https://code.letcareme.com" -Method Head -TimeoutSec 10 -UseBasicParsing -EA Stop
|
|
||||||
$env:NO_PROXY = $savedProxy
|
|
||||||
Report "[13] Gitea" "PASS" "code.letcareme.com 可达"
|
|
||||||
} catch {
|
|
||||||
$env:NO_PROXY = $savedProxy
|
|
||||||
$errMsg = $_.Exception.Message
|
|
||||||
if ($errMsg -match '40[0-9]|301|302|503') {
|
|
||||||
Report "[13] Gitea" "PASS" "code.letcareme.com 可达 (HTTP 错误码 = 网络通)"
|
|
||||||
} else {
|
|
||||||
Report "[13] Gitea" "FAIL" "code.letcareme.com 不可达: $errMsg — 配置同步将失败"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
# Summary
|
|
||||||
# ══════════════════════════════════════════════════════
|
|
||||||
$total = $pass + $warn + $fail
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
Write-Host " | 体检报告 |" -ForegroundColor Cyan
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
$passLine = " | PASS: $pass / $total"
|
|
||||||
$warnLine = " | WARN: $warn"
|
|
||||||
$failLine = " | FAIL: $fail"
|
|
||||||
|
|
||||||
Write-Host $passLine -ForegroundColor Green
|
|
||||||
if ($warn -gt 0) { Write-Host $warnLine -ForegroundColor Yellow }
|
|
||||||
if ($fail -gt 0) { Write-Host $failLine -ForegroundColor Red }
|
|
||||||
|
|
||||||
Write-Host " |" -ForegroundColor Cyan
|
|
||||||
if ($fail -eq 0 -and $warn -eq 0) {
|
|
||||||
Write-Host " | [ALL GREEN] Bookworm 完全健康" -ForegroundColor Green
|
|
||||||
} elseif ($fail -eq 0) {
|
|
||||||
Write-Host " | [HEALTHY] 核心功能正常, 有 $warn 项可优化" -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
Write-Host " | [NEEDS FIX] $fail 项异常需修复" -ForegroundColor Red
|
|
||||||
Write-Host " | 修复: 重跑 Bookworm-Setup.exe 或联系管理员" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
Write-Host " | 日志: $doctorLog" -ForegroundColor Gray
|
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Log-Doctor "=== DONE: PASS=$pass WARN=$warn FAIL=$fail ==="
|
|
||||||
|
|
||||||
if ($fail -gt 0) { exit 1 } else { exit 0 }
|
|
||||||
163
bw-launch.ps1
163
bw-launch.ps1
@ -1,163 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bookworm 启动 wrapper (v3.1.0 引入)
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
桌面 .lnk 调用此 wrapper 而非直调 claude.ps1, 让启动逻辑可热修复 (改 wrapper 不需重 bake .lnk).
|
|
||||||
|
|
||||||
职责:
|
|
||||||
1. 动态查找 claude.ps1 真实路径 (npm config get prefix → 兜底候选)
|
|
||||||
2. claude.ps1 stale (npm uninstall/换版本/换 prefix) → 弹清晰 GUI 引导, 不再"快捷方式失效"
|
|
||||||
3. 失败时不静默, 写日志 + GUI 弹窗
|
|
||||||
|
|
||||||
与 .lnk 的契约:
|
|
||||||
.lnk Args = -NoLogo -NoExit -ExecutionPolicy Bypass -File "<bw-launch.ps1 绝对路径>"
|
|
||||||
--dangerously-skip-permissions
|
|
||||||
$args[0..N] 转发给 claude.ps1
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
v3.1.0 (2026-04-25) — 引入 wrapper 模式 (闭合 L4 局限: bake claude.ps1 路径 stale)
|
|
||||||
v3.1.2 (2026-04-26) — 加 nvm/fnm/volta shim 探测 (闭合 L9)
|
|
||||||
分发: Phase 7 安装时复制到 $BootDir\bw-launch.ps1
|
|
||||||
#>
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Continue"
|
|
||||||
$bwLaunchLog = Join-Path $env:TEMP "bw-launch.log"
|
|
||||||
|
|
||||||
function Write-BwLaunchLog {
|
|
||||||
param([string]$Level, [string]$Msg)
|
|
||||||
try {
|
|
||||||
$line = "[$([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))] [$Level] $Msg"
|
|
||||||
$line | Out-File -FilePath $bwLaunchLog -Append -Encoding UTF8 -EA SilentlyContinue
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# v3.1.2 L12: log rotation — >1MB 归档, 保留最近 3 个 .bak
|
|
||||||
foreach ($logName in @("bw-launch.log", "bw-crash.log", "bw-phase4-validate.log", "bw-doctor.log")) {
|
|
||||||
$logPath = Join-Path $env:TEMP $logName
|
|
||||||
try {
|
|
||||||
if ((Test-Path $logPath) -and ((Get-Item $logPath -EA SilentlyContinue).Length -gt 1MB)) {
|
|
||||||
$ts = [DateTime]::Now.ToString('yyyyMMdd-HHmmss')
|
|
||||||
Copy-Item $logPath "$logPath.bak.$ts" -Force -EA Stop
|
|
||||||
Remove-Item $logPath -Force -EA Stop
|
|
||||||
# 保留最近 3 个 .bak, 删旧的
|
|
||||||
$baks = Get-ChildItem "$logPath.bak.*" -EA SilentlyContinue | Sort-Object LastWriteTime -Descending
|
|
||||||
if ($baks.Count -gt 3) { $baks | Select-Object -Skip 3 | Remove-Item -Force -EA SilentlyContinue }
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Show-LaunchError {
|
|
||||||
param([string]$Title, [string]$Body)
|
|
||||||
Write-BwLaunchLog "ERROR" "$Title :: $Body"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " [!] $Title" -ForegroundColor Red
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host $Body -ForegroundColor Yellow
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 日志: $bwLaunchLog" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
try {
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms -EA Stop
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("$Body`n`n详情见: $bwLaunchLog", "Bookworm 启动失败 — $Title", 'OK', 'Error') | Out-Null
|
|
||||||
} catch {}
|
|
||||||
Read-Host "按回车关闭"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-BwLaunchLog "INFO" "bw-launch wrapper 启动 args=$($args -join ' ')"
|
|
||||||
|
|
||||||
# ── 1. PATH 三层重载 (即便桌面 .lnk 不依赖 PATH, 子 claude.ps1 调 node 仍依赖) ──
|
|
||||||
$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')
|
|
||||||
|
|
||||||
try {
|
|
||||||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
|
||||||
if ($npmPrefix -and (Test-Path $npmPrefix) -and ($env:Path -notlike "*$npmPrefix*")) {
|
|
||||||
$env:Path = "$npmPrefix;$env:Path"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-BwLaunchLog "WARN" "npm config get prefix 失败: $_"
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($p in @("$env:APPDATA\npm", "$env:ProgramFiles\nodejs", "$env:LOCALAPPDATA\npm")) {
|
|
||||||
if ((Test-Path $p) -and ($env:Path -notlike "*$p*")) {
|
|
||||||
$env:Path = "$p;$env:Path"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# v3.1.2 L9: nvm/fnm/volta shim 探测 (版本管理器装的 node 不在默认 PATH)
|
|
||||||
$shimPaths = @()
|
|
||||||
if ($env:NVM_HOME) { $shimPaths += "$env:NVM_HOME"; $shimPaths += (Join-Path $env:NVM_HOME "nodejs") }
|
|
||||||
if ($env:NVM_SYMLINK) { $shimPaths += $env:NVM_SYMLINK }
|
|
||||||
if ($env:FNM_DIR) { $shimPaths += (Join-Path $env:FNM_DIR "aliases\default") }
|
|
||||||
if ($env:FNM_MULTISHELL_PATH) { $shimPaths += $env:FNM_MULTISHELL_PATH }
|
|
||||||
if ($env:VOLTA_HOME) { $shimPaths += (Join-Path $env:VOLTA_HOME "bin") }
|
|
||||||
foreach ($sp in $shimPaths) {
|
|
||||||
if ($sp -and (Test-Path $sp) -and ($env:Path -notlike "*$sp*")) {
|
|
||||||
$env:Path = "$sp;$env:Path"
|
|
||||||
Write-BwLaunchLog "INFO" "shim PATH 补充: $sp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 2. 动态定位 claude.ps1 ──
|
|
||||||
$claudePs1 = $null
|
|
||||||
|
|
||||||
# 优先 Get-Command (PATH 已重载)
|
|
||||||
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
|
|
||||||
if ($claudeCmd -and $claudeCmd.Source -and $claudeCmd.Source.EndsWith('.ps1') -and (Test-Path $claudeCmd.Source)) {
|
|
||||||
$claudePs1 = $claudeCmd.Source
|
|
||||||
Write-BwLaunchLog "INFO" "claude.ps1 from Get-Command: $claudePs1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 兜底 npm config get prefix
|
|
||||||
if (-not $claudePs1) {
|
|
||||||
try {
|
|
||||||
$candidate = Join-Path $npmPrefix "claude.ps1"
|
|
||||||
if (Test-Path $candidate) {
|
|
||||||
$claudePs1 = $candidate
|
|
||||||
Write-BwLaunchLog "INFO" "claude.ps1 from npm prefix: $claudePs1"
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 最终硬编码兜底
|
|
||||||
if (-not $claudePs1) {
|
|
||||||
foreach ($p in @("$env:APPDATA\npm\claude.ps1", "$env:ProgramFiles\nodejs\claude.ps1", "$env:LOCALAPPDATA\npm\claude.ps1")) {
|
|
||||||
if (Test-Path $p) {
|
|
||||||
$claudePs1 = $p
|
|
||||||
Write-BwLaunchLog "INFO" "claude.ps1 from hardcoded: $claudePs1"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $claudePs1 -or -not (Test-Path $claudePs1)) {
|
|
||||||
$diag = "未找到 claude.ps1 文件.`n`n"
|
|
||||||
$diag += "诊断信息:`n"
|
|
||||||
$diag += " npm config get prefix: $(if ($npmPrefix) { $npmPrefix } else { '(查询失败)' })`n"
|
|
||||||
$diag += " PATH 中 npm/nodejs 片段:`n"
|
|
||||||
foreach ($p in (($env:Path -split ';') | Where-Object { $_ -match 'npm|nodejs' })) {
|
|
||||||
$diag += " $p`n"
|
|
||||||
}
|
|
||||||
$diag += "`n修复方案:`n"
|
|
||||||
$diag += " 方案 A: 命令行运行 npm i -g @anthropic-ai/claude-code`n"
|
|
||||||
$diag += " 方案 B: 重新双击 Bookworm-Setup.exe 修复安装"
|
|
||||||
Show-LaunchError "Claude Code 未找到" $diag
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 3. 启动 claude.ps1 + 转发 $args (--dangerously-skip-permissions 等) ──
|
|
||||||
Write-BwLaunchLog "INFO" "调用 claude.ps1 args=$($args -join ' ')"
|
|
||||||
try {
|
|
||||||
& $claudePs1 @args
|
|
||||||
$exitCode = $LASTEXITCODE
|
|
||||||
Write-BwLaunchLog "INFO" "claude.ps1 退出码: $exitCode"
|
|
||||||
if ($exitCode -ne 0) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " [!] Claude 进程退出码: $exitCode" -ForegroundColor Yellow
|
|
||||||
Write-Host " 日志: $bwLaunchLog" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
exit $exitCode
|
|
||||||
} catch {
|
|
||||||
Show-LaunchError "Claude 启动异常" "异常信息: $($_.Exception.Message)"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
387
bw-ota.ps1
387
bw-ota.ps1
@ -1,387 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bookworm Portable OTA 更新检查器 (启动时自动调用)
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
轻量版同步: 检查远端版本 → 用户确认 → 增量同步 → 验签 → 原子替换.
|
|
||||||
设计原则: fail-open (任何异常不阻断启动), 24h 冷却, 用户确认制.
|
|
||||||
|
|
||||||
与 bookworm-sync.ps1 的区别:
|
|
||||||
- Token 从 DPAPI 加密文件读取 (安装时写入)
|
|
||||||
- Pubkey 内嵌安装目录
|
|
||||||
- 失败不阻断启动
|
|
||||||
- 版本相同跳过
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
Author: Bookworm Admin
|
|
||||||
Version: 1.0.0 (2026-04-27)
|
|
||||||
License: Private
|
|
||||||
#>
|
|
||||||
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[switch]$Force,
|
|
||||||
[switch]$SkipConfirm,
|
|
||||||
[switch]$DryRun
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$ProgressPreference = 'SilentlyContinue'
|
|
||||||
|
|
||||||
$OtaDir = Join-Path $env:USERPROFILE '.claude\.bw-ota'
|
|
||||||
$ClaudeRoot = Join-Path $env:USERPROFILE '.claude'
|
|
||||||
$ConfigFile = Join-Path $OtaDir 'config.json'
|
|
||||||
$CredFile = Join-Path $OtaDir 'pull-cred.dpapi'
|
|
||||||
$PubKeyFile = Join-Path $OtaDir 'signing-pubkey.pem'
|
|
||||||
|
|
||||||
# ========== 日志 ==========
|
|
||||||
function Write-Ota ($m, $c = 'Cyan') { Write-Host "[Bookworm OTA] $m" -ForegroundColor $c }
|
|
||||||
function Write-OtaOk ($m) { Write-Ota $m 'Green' }
|
|
||||||
function Write-OtaWarn ($m) { Write-Ota $m 'Yellow' }
|
|
||||||
function Write-OtaErr ($m) { Write-Ota $m 'Red' }
|
|
||||||
|
|
||||||
# ========== OTA 基础设施检查 ==========
|
|
||||||
function Test-OtaReady {
|
|
||||||
if (-not (Test-Path $OtaDir)) { return $false }
|
|
||||||
if (-not (Test-Path $ConfigFile)) { return $false }
|
|
||||||
if (-not (Test-Path $CredFile)) { return $false }
|
|
||||||
if (-not (Test-Path $PubKeyFile)) { return $false }
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 配置读写 ==========
|
|
||||||
function Read-OtaConfig {
|
|
||||||
try {
|
|
||||||
$raw = Get-Content -Raw $ConfigFile
|
|
||||||
if ($raw.Length -gt 0 -and [int][char]$raw[0] -eq 0xFEFF) { $raw = $raw.Substring(1) }
|
|
||||||
return $raw | ConvertFrom-Json
|
|
||||||
} catch {
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Save-OtaConfig ($cfg) {
|
|
||||||
$json = $cfg | ConvertTo-Json -Depth 4
|
|
||||||
[IO.File]::WriteAllText($ConfigFile, $json, [Text.UTF8Encoding]::new($false))
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 冷却检查 (24h) ==========
|
|
||||||
function Test-Cooldown ($cfg) {
|
|
||||||
if ($Force) { return $false }
|
|
||||||
$interval = if ($cfg.checkInterval) { $cfg.checkInterval } else { 86400 }
|
|
||||||
$lastCheck = if ($cfg.lastCheck) { $cfg.lastCheck } else { 0 }
|
|
||||||
$now = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
|
||||||
return ($now - $lastCheck) -lt $interval
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== DPAPI 凭证解密 ==========
|
|
||||||
function Read-Credential {
|
|
||||||
try {
|
|
||||||
Add-Type -AssemblyName System.Security
|
|
||||||
$encrypted = [IO.File]::ReadAllBytes($CredFile)
|
|
||||||
$plain = [Security.Cryptography.ProtectedData]::Unprotect(
|
|
||||||
$encrypted,
|
|
||||||
[Text.Encoding]::UTF8.GetBytes('bookworm-ota-salt'),
|
|
||||||
[Security.Cryptography.DataProtectionScope]::CurrentUser
|
|
||||||
)
|
|
||||||
$json = [Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
|
||||||
return $json
|
|
||||||
} catch {
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 语义版本比较 (major.minor.patch) ==========
|
|
||||||
function Compare-SemVer ($local, $remote) {
|
|
||||||
$lParts = ($local -replace '^v', '') -split '\.' | ForEach-Object { [int]$_ }
|
|
||||||
$rParts = ($remote -replace '^v', '') -split '\.' | ForEach-Object { [int]$_ }
|
|
||||||
for ($i = 0; $i -lt [Math]::Max($lParts.Count, $rParts.Count); $i++) {
|
|
||||||
$l = if ($i -lt $lParts.Count) { $lParts[$i] } else { 0 }
|
|
||||||
$r = if ($i -lt $rParts.Count) { $rParts[$i] } else { 0 }
|
|
||||||
if ($r -gt $l) { return 1 }
|
|
||||||
if ($r -lt $l) { return -1 }
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 本地版本号 ==========
|
|
||||||
function Get-LocalVersion {
|
|
||||||
$versionFile = Join-Path $ClaudeRoot 'VERSION'
|
|
||||||
if (Test-Path $versionFile) {
|
|
||||||
return (Get-Content -Raw $versionFile).Trim()
|
|
||||||
}
|
|
||||||
$statsFile = Join-Path $ClaudeRoot 'stats-compiled.json'
|
|
||||||
if (Test-Path $statsFile) {
|
|
||||||
try {
|
|
||||||
$stats = Get-Content -Raw $statsFile | ConvertFrom-Json
|
|
||||||
return $stats.summary.version
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== Basic Auth 头构造 ==========
|
|
||||||
function Get-AuthHeaders ($cred) {
|
|
||||||
$pair = "$($cred.user):$($cred.pass)"
|
|
||||||
$bytes = [Text.Encoding]::UTF8.GetBytes($pair)
|
|
||||||
$b64 = [Convert]::ToBase64String($bytes)
|
|
||||||
return @{ Authorization = "Basic $b64" }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 远端版本查询 (Gitea raw, 3s 超时) ==========
|
|
||||||
function Get-RemoteVersion ($cred, $cfg) {
|
|
||||||
$repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' }
|
|
||||||
$apiUrl = "$repoUrl/raw/branch/main/VERSION"
|
|
||||||
try {
|
|
||||||
$headers = Get-AuthHeaders $cred
|
|
||||||
$resp = Invoke-WebRequest -Uri $apiUrl -Headers $headers -UseBasicParsing -TimeoutSec 3
|
|
||||||
return $resp.Content.Trim()
|
|
||||||
} catch {
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 远端最新 tag 查询 (备用) ==========
|
|
||||||
function Get-RemoteLatestTag ($cred, $cfg) {
|
|
||||||
$repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' }
|
|
||||||
$host_ = ([Uri]$repoUrl).Host
|
|
||||||
$repoPath = ([Uri]$repoUrl).AbsolutePath.TrimStart('/')
|
|
||||||
$apiUrl = "https://$host_/api/v1/repos/$repoPath/tags?limit=1"
|
|
||||||
try {
|
|
||||||
$headers = Get-AuthHeaders $cred
|
|
||||||
$resp = Invoke-WebRequest -Uri $apiUrl -Headers $headers -UseBasicParsing -TimeoutSec 3
|
|
||||||
$tags = $resp.Content | ConvertFrom-Json
|
|
||||||
if ($tags.Count -gt 0) { return $tags[0].name }
|
|
||||||
} catch { }
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 同步核心 (精简版 bookworm-sync.ps1) ==========
|
|
||||||
function Invoke-OtaSync ($cred, $cfg, $remoteVersion) {
|
|
||||||
$repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' }
|
|
||||||
$host_ = ([Uri]$repoUrl).Host
|
|
||||||
$repoPath = ([Uri]$repoUrl).AbsolutePath.TrimStart('/')
|
|
||||||
$ref = if ($remoteVersion -and $remoteVersion -match '^v?\d') { $remoteVersion } else { 'main' }
|
|
||||||
if ($ref -notmatch '^v') { $ref = "v$ref" }
|
|
||||||
|
|
||||||
$stageDir = Join-Path $env:TEMP "bw-ota-$([Guid]::NewGuid().ToString().Substring(0,8))"
|
|
||||||
|
|
||||||
# 1. Clone (凭证通过 credential manager 传递, 不嵌入 URL)
|
|
||||||
Write-Ota "下载 $ref ..."
|
|
||||||
$plainUrl = "https://${host_}/${repoPath}.git"
|
|
||||||
$credInput = "protocol=https`nhost=${host_}`nusername=$($cred.user)`npassword=$($cred.pass)`n`n"
|
|
||||||
$credInput | & git credential approve 2>$null
|
|
||||||
$cloneArgs = @('-c', 'core.longpaths=true', 'clone', '--depth', '1', '--branch', $ref, '--single-branch', $plainUrl, $stageDir)
|
|
||||||
& git @cloneArgs 2>&1 | Out-Null
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "clone 失败 (exit $LASTEXITCODE)" }
|
|
||||||
$gitDir = Join-Path $stageDir '.git'
|
|
||||||
if (Test-Path $gitDir) { Remove-Item -Recurse -Force $gitDir }
|
|
||||||
|
|
||||||
# 2. 验签 (Ed25519)
|
|
||||||
Write-Ota "验证签名..."
|
|
||||||
$integrityPath = Join-Path $stageDir 'INTEGRITY.sha256'
|
|
||||||
$sigPath = Join-Path $stageDir 'INTEGRITY.sha256.sig'
|
|
||||||
if (-not (Test-Path $integrityPath) -or -not (Test-Path $sigPath)) {
|
|
||||||
throw "包缺失签名文件"
|
|
||||||
}
|
|
||||||
$verifyScript = @'
|
|
||||||
const fs=require('fs'),crypto=require('crypto');
|
|
||||||
const d=fs.readFileSync(process.argv[2]),s=Buffer.from(fs.readFileSync(process.argv[3],'utf8').trim(),'hex');
|
|
||||||
const k=crypto.createPublicKey(fs.readFileSync(process.argv[4],'utf8'));
|
|
||||||
process.exit(crypto.verify(null,d,k,s)?0:1);
|
|
||||||
'@
|
|
||||||
$tmpV = Join-Path $env:TEMP "bw-ota-verify-$([Guid]::NewGuid().ToString().Substring(0,8)).js"
|
|
||||||
[IO.File]::WriteAllText($tmpV, $verifyScript, [Text.UTF8Encoding]::new($false))
|
|
||||||
node $tmpV $integrityPath $sigPath $PubKeyFile 2>&1 | Out-Null
|
|
||||||
$sigOk = $LASTEXITCODE -eq 0
|
|
||||||
Remove-Item $tmpV -Force -ErrorAction SilentlyContinue
|
|
||||||
if (-not $sigOk) { throw "Ed25519 验签失败! 包可能被篡改." }
|
|
||||||
Write-OtaOk "签名验证通过"
|
|
||||||
|
|
||||||
# 3. 逐文件哈希
|
|
||||||
Write-Ota "校验文件完整性..."
|
|
||||||
$lines = Get-Content $integrityPath
|
|
||||||
$fail = 0
|
|
||||||
foreach ($line in $lines) {
|
|
||||||
if ($line -notmatch '^([a-f0-9]{64})\s{2}(.+)$') { throw "INTEGRITY 格式错误" }
|
|
||||||
$expected = $Matches[1]; $relPath = $Matches[2]
|
|
||||||
$abs = Join-Path $stageDir $relPath
|
|
||||||
if (-not (Test-Path $abs -PathType Leaf)) { continue }
|
|
||||||
$actual = (Get-FileHash -Path $abs -Algorithm SHA256).Hash.ToLower()
|
|
||||||
if ($actual -ne $expected) { $fail++ }
|
|
||||||
}
|
|
||||||
if ($fail -gt 0) { throw "完整性校验失败: $fail 处哈希不匹配" }
|
|
||||||
Write-OtaOk "文件完整性校验通过 ($($lines.Count) 个文件)"
|
|
||||||
|
|
||||||
# 4. 渲染 settings.template.json
|
|
||||||
$tplPath = Join-Path $stageDir 'settings.template.json'
|
|
||||||
if (Test-Path $tplPath) {
|
|
||||||
$raw = Get-Content -Raw $tplPath
|
|
||||||
if ($raw.Length -gt 0 -and [int][char]$raw[0] -eq 0xFEFF) { $raw = $raw.Substring(1) }
|
|
||||||
$rootFwd = $ClaudeRoot -replace '\\', '/'
|
|
||||||
$homeFwd = $env:USERPROFILE -replace '\\', '/'
|
|
||||||
$rendered = $raw -replace '\{\{CLAUDE_ROOT\}\}', $rootFwd -replace '\{\{HOME\}\}', $homeFwd
|
|
||||||
$outPath = Join-Path $stageDir 'settings.json'
|
|
||||||
[IO.File]::WriteAllText($outPath, $rendered, [Text.UTF8Encoding]::new($false))
|
|
||||||
}
|
|
||||||
|
|
||||||
# 5. 保留本机私有
|
|
||||||
$preserveList = @(
|
|
||||||
'memory', 'projects', 'sessions', 'session-env', 'session-state', 'sessions.db',
|
|
||||||
'tasks', 'teams',
|
|
||||||
'pinned-sessions.json', 'pinned-sessions.json.tmp',
|
|
||||||
'history.jsonl', 'evolution-log.jsonl',
|
|
||||||
'.credentials.json', '.bw-token', '.hmac-key', '.skill-cache',
|
|
||||||
'file-history', 'image-cache', 'paste-cache', 'debug', 'telemetry',
|
|
||||||
'cache', 'plans', 'plugins', 'shell-snapshots', 'vendor',
|
|
||||||
'repos', 'backups', 'archives',
|
|
||||||
'mcp-servers', 'node_modules',
|
|
||||||
'settings.local.json', 'settings.json.bak.*',
|
|
||||||
'auto-sync-repos.json', 'scheduled_tasks.lock',
|
|
||||||
'.bw-ota'
|
|
||||||
)
|
|
||||||
$preserved = 0
|
|
||||||
if (Test-Path $ClaudeRoot) {
|
|
||||||
foreach ($pat in $preserveList) {
|
|
||||||
$items = @(Get-ChildItem -Path $ClaudeRoot -Filter $pat -Force -ErrorAction SilentlyContinue)
|
|
||||||
foreach ($item in $items) {
|
|
||||||
$target = Join-Path $stageDir $item.Name
|
|
||||||
if (Test-Path $target) { Remove-Item -Recurse -Force $target }
|
|
||||||
Copy-Item -Path $item.FullName -Destination $target -Recurse -Force
|
|
||||||
$preserved++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Ota "已保留 $preserved 项本机数据 (copy)"
|
|
||||||
|
|
||||||
# DryRun: 验证到此为止, 不做原子替换
|
|
||||||
if ($DryRun) {
|
|
||||||
$fileCount = (Get-ChildItem -Path $stageDir -Recurse -File).Count
|
|
||||||
Write-OtaOk "[DryRun] 验证通过! staging 含 $fileCount 个文件, 跳过原子替换"
|
|
||||||
Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# 6. 备份 + 原子替换 (先备份再替换, 任一步失败不丢数据)
|
|
||||||
$bakFull = $null
|
|
||||||
if (Test-Path $ClaudeRoot) {
|
|
||||||
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
||||||
$bakLeaf = ".claude.bak-$ts"
|
|
||||||
$bakFull = Join-Path (Split-Path -Parent $ClaudeRoot) $bakLeaf
|
|
||||||
try {
|
|
||||||
Rename-Item -Path $ClaudeRoot -NewName $bakLeaf -ErrorAction Stop
|
|
||||||
} catch {
|
|
||||||
throw "无法备份 .claude (可能被占用): $_"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Copy-Item -Path $stageDir -Destination $ClaudeRoot -Recurse -Force -ErrorAction Stop
|
|
||||||
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
} catch {
|
|
||||||
if ($bakFull -and (Test-Path $bakFull)) {
|
|
||||||
Remove-Item -Path $ClaudeRoot -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Rename-Item -Path $bakFull -NewName (Split-Path -Leaf $ClaudeRoot)
|
|
||||||
Write-OtaWarn "替换失败, 已回滚到原版本"
|
|
||||||
}
|
|
||||||
throw "替换失败: $_"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 7. 清理旧备份 (保留最近 3 个)
|
|
||||||
$parent = Split-Path -Parent $ClaudeRoot
|
|
||||||
$baks = @(Get-ChildItem -Path $parent -Directory -Filter ".claude.bak-*" -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
|
|
||||||
if ($baks.Count -gt 3) {
|
|
||||||
$baks | Select-Object -Skip 3 | ForEach-Object {
|
|
||||||
Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 8. 写入 VERSION 到新 .claude
|
|
||||||
$versionPath = Join-Path $ClaudeRoot 'VERSION'
|
|
||||||
if (-not (Test-Path $versionPath)) {
|
|
||||||
[IO.File]::WriteAllText($versionPath, "$remoteVersion`n", [Text.UTF8Encoding]::new($false))
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-OtaOk "更新完成! $ref"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 主流程 (fail-open 包裹) ==========
|
|
||||||
function Invoke-OtaMain {
|
|
||||||
try {
|
|
||||||
# 基础设施检查
|
|
||||||
if (-not (Test-OtaReady)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$cfg = Read-OtaConfig
|
|
||||||
if (-not $cfg) { return }
|
|
||||||
|
|
||||||
# 冷却检查
|
|
||||||
if (Test-Cooldown $cfg) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解密凭证
|
|
||||||
$cred = Read-Credential
|
|
||||||
if (-not $cred -or -not $cred.user -or -not $cred.pass) {
|
|
||||||
Write-OtaWarn "凭证解密失败, 跳过更新检查"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# 本地 vs 远端版本
|
|
||||||
$localVer = Get-LocalVersion
|
|
||||||
if ($DryRun) { Write-Ota "[DryRun 模式] 仅验证, 不替换文件" 'Yellow' }
|
|
||||||
Write-Ota "当前版本: $localVer"
|
|
||||||
|
|
||||||
$remoteVer = Get-RemoteVersion $cred $cfg
|
|
||||||
if (-not $remoteVer) {
|
|
||||||
$remoteVer = Get-RemoteLatestTag $cred $cfg
|
|
||||||
if ($remoteVer) { $remoteVer = $remoteVer -replace '^v', '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
# 更新 lastCheck
|
|
||||||
$cfg.lastCheck = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
|
||||||
Save-OtaConfig $cfg
|
|
||||||
|
|
||||||
if (-not $remoteVer) {
|
|
||||||
Write-OtaWarn "无法获取远端版本, 跳过"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$localClean = ($localVer -replace '^v', '').Trim()
|
|
||||||
$remoteClean = ($remoteVer -replace '^v', '').Trim()
|
|
||||||
|
|
||||||
if ($localClean -eq $remoteClean) {
|
|
||||||
Write-OtaOk "v$localClean 已是最新"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$cmp = Compare-SemVer $localClean $remoteClean
|
|
||||||
if ($cmp -le 0) {
|
|
||||||
Write-OtaOk "v$localClean 已是最新 (远端 v$remoteClean 非更高版本)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# 发现新版本 (远端 > 本地)
|
|
||||||
Write-Host ""
|
|
||||||
Write-Ota "发现新版本 v$remoteClean (当前 v$localClean)" 'Magenta'
|
|
||||||
|
|
||||||
if (-not $SkipConfirm) {
|
|
||||||
Write-Host "[Bookworm OTA] 按 Enter 更新 / Ctrl+C 跳过: " -ForegroundColor Yellow -NoNewline
|
|
||||||
try { [void][Console]::ReadLine() }
|
|
||||||
catch {
|
|
||||||
Write-Ota "已跳过更新"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Invoke-OtaSync $cred $cfg $remoteClean
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-OtaWarn "更新检查异常: $($_.Exception.Message)"
|
|
||||||
Write-OtaWarn "跳过更新, 继续启动..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Invoke-OtaMain
|
|
||||||
158
crypto-helper.js
158
crypto-helper.js
@ -1,158 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
'use strict';
|
|
||||||
/**
|
|
||||||
* Bookworm Portable - Node.js 凭证加解密工具
|
|
||||||
* 替代 openssl enc, 跨平台跨版本 100% 一致
|
|
||||||
*
|
|
||||||
* 加密: echo "KEY=VALUE" | node crypto-helper.js encrypt <password> > secrets.enc
|
|
||||||
* 解密: node crypto-helper.js decrypt <password> < 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 <password>'); 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 <password> [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 <password> > secrets.enc');
|
|
||||||
console.error(' decrypt: node crypto-helper.js decrypt <password> < secrets.enc');
|
|
||||||
console.error(' interactive: node crypto-helper.js decrypt-file secrets.enc');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
227
deploy-gitea.sh
227
deploy-gitea.sh
@ -1,227 +0,0 @@
|
|||||||
#!/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 "========================================="
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Bookworm tool_use 诊断脚本
|
|
||||||
* 测试中转站是否正确透传 Anthropic tool_use 协议
|
|
||||||
* 用法: node diagnose-tooluse.js
|
|
||||||
*/
|
|
||||||
const https = require('https');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
||||||
const BASE_URL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
|
|
||||||
|
|
||||||
if (!API_KEY) {
|
|
||||||
console.error('[FAIL] ANTHROPIC_API_KEY 未设置');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[INFO] 中转站: ${BASE_URL}`);
|
|
||||||
console.log(`[INFO] API Key: ${API_KEY.substring(0, 8)}...`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// 测试 1: 非流式 tool_use
|
|
||||||
async function testToolUse() {
|
|
||||||
const url = new URL('/v1/messages', BASE_URL);
|
|
||||||
const isHttps = url.protocol === 'https:';
|
|
||||||
const lib = isHttps ? https : http;
|
|
||||||
|
|
||||||
const body = JSON.stringify({
|
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
max_tokens: 200,
|
|
||||||
tools: [{
|
|
||||||
name: 'get_disk_info',
|
|
||||||
description: 'Get disk space information',
|
|
||||||
input_schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { drive: { type: 'string', description: 'Drive letter' } },
|
|
||||||
required: ['drive']
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
tool_choice: { type: 'tool', name: 'get_disk_info' },
|
|
||||||
messages: [{ role: 'user', content: 'Check disk C:' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = lib.request(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': API_KEY,
|
|
||||||
'anthropic-version': '2023-06-01'
|
|
||||||
}
|
|
||||||
}, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(data);
|
|
||||||
resolve({ status: res.statusCode, body: json });
|
|
||||||
} catch (e) {
|
|
||||||
resolve({ status: res.statusCode, raw: data.substring(0, 500) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
||||||
req.write(body);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试 2: 流式 tool_use
|
|
||||||
async function testStreamToolUse() {
|
|
||||||
const url = new URL('/v1/messages', BASE_URL);
|
|
||||||
const isHttps = url.protocol === 'https:';
|
|
||||||
const lib = isHttps ? https : http;
|
|
||||||
|
|
||||||
const body = JSON.stringify({
|
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
max_tokens: 200,
|
|
||||||
stream: true,
|
|
||||||
tools: [{
|
|
||||||
name: 'get_disk_info',
|
|
||||||
description: 'Get disk space information',
|
|
||||||
input_schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { drive: { type: 'string', description: 'Drive letter' } },
|
|
||||||
required: ['drive']
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
tool_choice: { type: 'tool', name: 'get_disk_info' },
|
|
||||||
messages: [{ role: 'user', content: 'Check disk C:' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = lib.request(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': API_KEY,
|
|
||||||
'anthropic-version': '2023-06-01'
|
|
||||||
}
|
|
||||||
}, (res) => {
|
|
||||||
let data = '';
|
|
||||||
let hasToolUseStart = false;
|
|
||||||
let hasInputJsonDelta = false;
|
|
||||||
|
|
||||||
res.on('data', chunk => {
|
|
||||||
const text = chunk.toString();
|
|
||||||
data += text;
|
|
||||||
if (text.includes('"type":"tool_use"') || text.includes('"type": "tool_use"')) hasToolUseStart = true;
|
|
||||||
if (text.includes('input_json_delta')) hasInputJsonDelta = true;
|
|
||||||
});
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve({
|
|
||||||
status: res.statusCode,
|
|
||||||
hasToolUseStart,
|
|
||||||
hasInputJsonDelta,
|
|
||||||
sample: data.substring(0, 800)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
||||||
req.write(body);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
// 测试 1: 非流式
|
|
||||||
console.log('=== 测试 1: 非流式 tool_use ===');
|
|
||||||
try {
|
|
||||||
const r = await testToolUse();
|
|
||||||
if (r.status !== 200) {
|
|
||||||
console.log(`[FAIL] HTTP ${r.status}`);
|
|
||||||
console.log(JSON.stringify(r.body || r.raw, null, 2).substring(0, 300));
|
|
||||||
} else {
|
|
||||||
const content = r.body?.content || [];
|
|
||||||
const toolUseBlock = content.find(b => b.type === 'tool_use');
|
|
||||||
if (toolUseBlock) {
|
|
||||||
console.log(`[PASS] 收到 tool_use block: name=${toolUseBlock.name}, id=${toolUseBlock.id}`);
|
|
||||||
console.log(` input: ${JSON.stringify(toolUseBlock.input)}`);
|
|
||||||
} else {
|
|
||||||
console.log('[FAIL] 未收到 tool_use block!');
|
|
||||||
console.log(' content types:', content.map(b => b.type).join(', ') || '(empty)');
|
|
||||||
console.log(' stop_reason:', r.body?.stop_reason);
|
|
||||||
if (content[0]?.text) console.log(' text:', content[0].text.substring(0, 200));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[FAIL] 请求失败: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// 测试 2: 流式
|
|
||||||
console.log('=== 测试 2: 流式 tool_use (Claude Code 实际使用模式) ===');
|
|
||||||
try {
|
|
||||||
const r = await testStreamToolUse();
|
|
||||||
if (r.status !== 200) {
|
|
||||||
console.log(`[FAIL] HTTP ${r.status}`);
|
|
||||||
console.log(r.sample?.substring(0, 300));
|
|
||||||
} else {
|
|
||||||
console.log(` tool_use block in stream: ${r.hasToolUseStart ? '[PASS]' : '[FAIL]'}`);
|
|
||||||
console.log(` input_json_delta events: ${r.hasInputJsonDelta ? '[PASS]' : '[FAIL]'}`);
|
|
||||||
if (!r.hasToolUseStart) {
|
|
||||||
console.log(' stream sample:', r.sample?.substring(0, 400));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[FAIL] 请求失败: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('=== 诊断结论 ===');
|
|
||||||
console.log('如果两项测试均 PASS: 中转站 tool_use 正常');
|
|
||||||
console.log('如果非流式 PASS 流式 FAIL: 中转站流式 SSE 处理有 bug, 联系管理员升级 NewAPI');
|
|
||||||
console.log('如果均 FAIL: 中转站不支持 Anthropic tool_use, 需开启原生 Anthropic 透传模式');
|
|
||||||
})();
|
|
||||||
124
download.html
Normal file
124
download.html
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bookworm - 下载安装</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-family: -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3rem;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
|
||||||
|
h1 span { color: #58a6ff; }
|
||||||
|
.subtitle { color: #8b949e; margin-bottom: 2rem; font-size: 0.95rem; }
|
||||||
|
.badges {
|
||||||
|
display: flex; gap: 0.5rem; justify-content: center;
|
||||||
|
margin-bottom: 2rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; border-radius: 20px;
|
||||||
|
padding: 0.25rem 0.75rem; font-size: 0.8rem; color: #8b949e;
|
||||||
|
}
|
||||||
|
.badge b { color: #e6edf3; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #238636, #2ea043);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.9rem 2.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(35,134,54,0.4); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
.size { color: #8b949e; font-size: 0.8rem; margin-bottom: 2rem; }
|
||||||
|
.steps {
|
||||||
|
text-align: left;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
.steps .num {
|
||||||
|
display: inline-block;
|
||||||
|
background: #58a6ff;
|
||||||
|
color: #000;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.steps .dim { color: #8b949e; }
|
||||||
|
.req {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
.req b { color: #d29922; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">📚</div>
|
||||||
|
<h1>Bookworm <span>Portable</span></h1>
|
||||||
|
<p class="subtitle">AI 编程助手 — 一键安装,即刻使用</p>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge"><b>97</b> Skills</span>
|
||||||
|
<span class="badge"><b>18</b> Agents</span>
|
||||||
|
<span class="badge"><b>28</b> Hooks</span>
|
||||||
|
<span class="badge"><b>AES-256</b> 加密</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="btn" href="https://code.letcareme.com/bookworm/bookworm-boot/raw/branch/main/Bookworm-Setup.bat" download="Bookworm-Setup.bat">
|
||||||
|
⬇ 下载安装程序
|
||||||
|
</a>
|
||||||
|
<p class="size">Bookworm-Setup.bat (4 KB) — 双击即可安装</p>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<span class="num">1</span> 下载上方 .bat 文件<br>
|
||||||
|
<span class="num">2</span> 双击运行 <span class="dim">(如提示安全警告,选 "仍要运行")</span><br>
|
||||||
|
<span class="num">3</span> 输入管理员提供的密码<br>
|
||||||
|
<span class="num">✓</span> 完成!桌面出现 Bookworm 图标
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="req">
|
||||||
|
<b>⚠ 前置要求:</b><br>
|
||||||
|
• Node.js (<a href="https://nodejs.org" style="color:#58a6ff">下载</a>) + Git (<a href="https://git-scm.com" style="color:#58a6ff">下载</a>)<br>
|
||||||
|
• 代理/VPN 软件 (国内必须,用于首次连接验证)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
166
gen-authcode.js
166
gen-authcode.js
@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
'use strict';
|
|
||||||
/**
|
|
||||||
* Bookworm 授权码生成工具 (管理员使用)
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* node gen-authcode.js <days> [选项]
|
|
||||||
*
|
|
||||||
* 选项:
|
|
||||||
* --relay-key, -k <key> 中转站限额子 Key (替换 ANTHROPIC_API_KEY)
|
|
||||||
* --user, -u <name> 用户标识 (仅用于显示, 不影响加密)
|
|
||||||
* [secrets.txt路径] 明文凭证文件 (默认: ./secrets.txt)
|
|
||||||
*
|
|
||||||
* 示例:
|
|
||||||
* node gen-authcode.js 30 # 共享 Key (单用户)
|
|
||||||
* node gen-authcode.js 30 --relay-key sk-relay-xxx # 独立限额 Key
|
|
||||||
* node gen-authcode.js 90 -k sk-relay-xxx -u alice # 指定用户名
|
|
||||||
*
|
|
||||||
* 原理:
|
|
||||||
* 1. 生成随机 24位Hex Token (96bit 熵)
|
|
||||||
* 2. Token 前8位 = 文件 ID → 输出 secrets-XXXXXXXX.enc (多用户模式)
|
|
||||||
* 无 --relay-key 时 → 输出 secrets.enc (单用户/共享模式)
|
|
||||||
* 3. 授权码 BW-YYYYMMDD-TOKEN (发给用户)
|
|
||||||
* 4. 安全说明: Token = 解密密钥, YYYYMMDD = 客户端到期校验
|
|
||||||
*/
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ALGO = 'aes-256-cbc';
|
|
||||||
const ITERATIONS = 600000;
|
|
||||||
const DIGEST = 'sha256';
|
|
||||||
const SALT_LEN = 16;
|
|
||||||
const KEY_LEN = 32;
|
|
||||||
const IV_LEN = 16;
|
|
||||||
|
|
||||||
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()]);
|
|
||||||
return Buffer.concat([Buffer.from('BWENC1'), salt, encrypted]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 解析 CLI 参数 ───────────────────────────────────────
|
|
||||||
const rawArgs = process.argv.slice(2);
|
|
||||||
const DAYS = parseInt(rawArgs[0]);
|
|
||||||
|
|
||||||
if (!DAYS || DAYS < 1 || DAYS > 3650) {
|
|
||||||
console.error('用法: node gen-authcode.js <有效天数> [选项]');
|
|
||||||
console.error('选项:');
|
|
||||||
console.error(' --relay-key, -k <key> 中转站限额子 Key');
|
|
||||||
console.error(' --user, -u <name> 用户标识 (仅显示)');
|
|
||||||
console.error(' [secrets.txt路径] 默认: ./secrets.txt');
|
|
||||||
console.error('');
|
|
||||||
console.error('示例:');
|
|
||||||
console.error(' node gen-authcode.js 30 # 共享模式');
|
|
||||||
console.error(' node gen-authcode.js 30 -k sk-relay-xxx -u alice # 独立限额');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let relayKey = null;
|
|
||||||
let userName = null;
|
|
||||||
let secretsTxtArg = null;
|
|
||||||
|
|
||||||
for (let i = 1; i < rawArgs.length; i++) {
|
|
||||||
const a = rawArgs[i];
|
|
||||||
if (a === '--relay-key' || a === '-k') { relayKey = rawArgs[++i]; }
|
|
||||||
else if (a === '--user' || a === '-u') { userName = rawArgs[++i]; }
|
|
||||||
else if (!a.startsWith('-')) { secretsTxtArg = a; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// pkg 打包后 __filename 指向虚拟快照路径,需用 process.execPath 获取真实目录
|
|
||||||
const SCRIPT_DIR = path.dirname(
|
|
||||||
typeof process.pkg !== 'undefined' ? process.execPath : path.resolve(__filename)
|
|
||||||
);
|
|
||||||
const SECRETS_TXT = secretsTxtArg || path.join(SCRIPT_DIR, 'secrets.txt');
|
|
||||||
|
|
||||||
if (!fs.existsSync(SECRETS_TXT)) {
|
|
||||||
console.error(`[错误] 找不到 secrets.txt: ${SECRETS_TXT}`);
|
|
||||||
console.error('请先创建 secrets.txt, 每行格式: KEY=VALUE');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relayKey && !/^[A-Za-z0-9\-_.]+$/.test(relayKey)) {
|
|
||||||
console.error('[错误] --relay-key 格式不合法');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 生成到期日 ──────────────────────────────────────────
|
|
||||||
const expiry = new Date();
|
|
||||||
expiry.setDate(expiry.getDate() + DAYS);
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
const expiryStr = `${expiry.getFullYear()}${pad(expiry.getMonth()+1)}${pad(expiry.getDate())}`;
|
|
||||||
const expiryDisplay = `${expiryStr.slice(0,4)}-${expiryStr.slice(4,6)}-${expiryStr.slice(6,8)}`;
|
|
||||||
|
|
||||||
// ─── 生成随机 Token ──────────────────────────────────────
|
|
||||||
const token = crypto.randomBytes(12).toString('hex'); // 小写 24位
|
|
||||||
const authCode = `BW-${expiryStr}-${token.toUpperCase()}`;
|
|
||||||
const fileId = token.slice(0, 8); // 前8位作为文件 ID
|
|
||||||
|
|
||||||
// ─── 构建待加密内容 ──────────────────────────────────────
|
|
||||||
let secretsPlain = fs.readFileSync(SECRETS_TXT, 'utf8').trim();
|
|
||||||
|
|
||||||
const multiUser = !!relayKey;
|
|
||||||
|
|
||||||
if (multiUser) {
|
|
||||||
// 用中转站 relay key 替换 ANTHROPIC_API_KEY
|
|
||||||
if (/^ANTHROPIC_API_KEY=/m.test(secretsPlain)) {
|
|
||||||
secretsPlain = secretsPlain.replace(
|
|
||||||
/^ANTHROPIC_API_KEY=.*/m,
|
|
||||||
`ANTHROPIC_API_KEY=${relayKey}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
secretsPlain = `ANTHROPIC_API_KEY=${relayKey}\n${secretsPlain}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 加密并写出 ──────────────────────────────────────────
|
|
||||||
const encBuffer = encrypt(secretsPlain, token);
|
|
||||||
|
|
||||||
let outFileName, outFilePath;
|
|
||||||
if (multiUser) {
|
|
||||||
outFileName = `secrets-${fileId}.enc`;
|
|
||||||
} else {
|
|
||||||
outFileName = 'secrets.enc';
|
|
||||||
}
|
|
||||||
outFilePath = path.join(SCRIPT_DIR, outFileName);
|
|
||||||
fs.writeFileSync(outFilePath, encBuffer);
|
|
||||||
|
|
||||||
// ─── 输出 ────────────────────────────────────────────────
|
|
||||||
const modeLabel = multiUser
|
|
||||||
? `多用户独立 Key (${userName || '未命名'})`
|
|
||||||
: '共享 Key (单/多用户共享)';
|
|
||||||
|
|
||||||
console.log('\n═══════════════════════════════════════════════════');
|
|
||||||
console.log(' Bookworm 授权码生成完毕');
|
|
||||||
console.log('═══════════════════════════════════════════════════');
|
|
||||||
console.log('');
|
|
||||||
console.log(` 模式: ${modeLabel}`);
|
|
||||||
if (userName) console.log(` 用户: ${userName}`);
|
|
||||||
console.log(` 授权码: ${authCode}`);
|
|
||||||
console.log(` 有效期: ${DAYS} 天 (至 ${expiryDisplay})`);
|
|
||||||
if (multiUser) {
|
|
||||||
console.log(` Relay Key: ${relayKey.slice(0,12)}... (已替换 ANTHROPIC_API_KEY)`);
|
|
||||||
console.log(` 文件 ID: ${fileId} → ${outFileName}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
console.log(' ▶ 操作步骤:');
|
|
||||||
console.log(` 1. 将授权码发给用户: ${authCode}`);
|
|
||||||
console.log(` 2. 推送 ${outFileName} 到 Gitea:`);
|
|
||||||
console.log(` git add ${outFileName} && git commit -m "add user ${userName || fileId}" && git push`);
|
|
||||||
console.log('');
|
|
||||||
console.log(' ⚠ 安全提醒:');
|
|
||||||
console.log(' - 授权码即解密密钥, 请勿通过不安全渠道明文发送');
|
|
||||||
console.log(` - 到期日 (${expiryDisplay}) 后自动失效`);
|
|
||||||
if (multiUser) {
|
|
||||||
console.log(` - 各用户 secrets-XXXXXXXX.enc 独立, 轮换互不影响`);
|
|
||||||
}
|
|
||||||
console.log('═══════════════════════════════════════════════════\n');
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<#
|
|
||||||
.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.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
|
|
||||||
1319
guide-unified.html
1319
guide-unified.html
File diff suppressed because it is too large
Load Diff
780
guide.html
Normal file
780
guide.html
Normal file
@ -0,0 +1,780 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bookworm Portable - 保姆式安装手册</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--card: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--yellow: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--cyan: #39d2c0;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #1a1f35 0%, #0d1117 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header pre {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem; }
|
||||||
|
.header h1 span { color: var(--accent); }
|
||||||
|
.header p { color: var(--text-dim); font-size: 1.05rem; }
|
||||||
|
.badge-row { display: flex; gap: 0.8rem; justify-content: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 20px; padding: 0.3rem 0.9rem; font-size: 0.85rem; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.badge strong { color: var(--text); }
|
||||||
|
|
||||||
|
.container { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
|
||||||
|
|
||||||
|
.section { margin-bottom: 2.5rem; }
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1.4rem; margin-bottom: 1rem; padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.section h2 .num {
|
||||||
|
background: var(--accent); color: #000; width: 28px; height: 28px;
|
||||||
|
border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.85rem; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: #0d1117; border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 1rem 1.2rem; margin: 0.8rem 0; overflow-x: auto; position: relative;
|
||||||
|
cursor: pointer; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.code-block:hover { border-color: var(--accent); }
|
||||||
|
.code-block::after {
|
||||||
|
content: "点击复制"; position: absolute; top: 0.4rem; right: 0.6rem;
|
||||||
|
font-size: 0.7rem; color: var(--text-dim); background: var(--card);
|
||||||
|
padding: 0.15rem 0.5rem; border-radius: 4px; border: 1px solid var(--border);
|
||||||
|
opacity: 0; transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.code-block:hover::after { opacity: 1; }
|
||||||
|
.code-block.copied::after { content: "已复制!"; color: var(--green); opacity: 1; }
|
||||||
|
.code-block code {
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.9rem; line-height: 1.6; color: var(--text);
|
||||||
|
white-space: pre-wrap; word-break: break-all;
|
||||||
|
}
|
||||||
|
.code-block .label {
|
||||||
|
position: absolute; top: 0.4rem; left: 0.6rem;
|
||||||
|
font-size: 0.65rem; color: var(--text-dim); background: var(--card);
|
||||||
|
padding: 0.1rem 0.5rem; border-radius: 4px; border: 1px solid var(--border);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.code-block.has-label { padding-top: 2rem; }
|
||||||
|
.cmd { color: var(--green); }
|
||||||
|
.flag { color: var(--yellow); }
|
||||||
|
.url { color: var(--accent); }
|
||||||
|
.comment { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 1.2rem 1.5rem; margin: 0.8rem 0;
|
||||||
|
}
|
||||||
|
.card h3 { font-size: 1rem; margin-bottom: 0.5rem; color: var(--accent); }
|
||||||
|
.card p { color: var(--text-dim); font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.step { display: flex; gap: 1rem; margin: 1.2rem 0; align-items: flex-start; }
|
||||||
|
.step-icon {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.2rem; font-weight: 700; flex-shrink: 0; margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.step-icon.green { background: rgba(63,185,80,0.15); border: 1px solid rgba(63,185,80,0.3); color: var(--green); }
|
||||||
|
.step-icon.blue { background: rgba(88,166,255,0.15); border: 1px solid rgba(88,166,255,0.3); color: var(--accent); }
|
||||||
|
.step-icon.yellow { background: rgba(210,153,34,0.15); border: 1px solid rgba(210,153,34,0.3); color: var(--yellow); }
|
||||||
|
.step-icon.red { background: rgba(248,81,73,0.15); border: 1px solid rgba(248,81,73,0.3); color: var(--red); }
|
||||||
|
.step-icon.purple { background: rgba(188,140,255,0.15); border: 1px solid rgba(188,140,255,0.3); color: var(--purple); }
|
||||||
|
.step-content { flex: 1; }
|
||||||
|
.step-content h4 { font-size: 1.05rem; margin-bottom: 0.3rem; }
|
||||||
|
.step-content p { color: var(--text-dim); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 0.8rem 0; font-size: 0.9rem; }
|
||||||
|
th, td { padding: 0.6rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
th { color: var(--text-dim); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
td code { background: var(--bg); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85rem; border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
border-radius: 8px; padding: 1rem 1.2rem; margin: 1rem 0;
|
||||||
|
font-size: 0.9rem; display: flex; gap: 0.8rem; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.alert-icon { font-size: 1.2rem; flex-shrink: 0; line-height: 1.5; }
|
||||||
|
.alert.warning { background: rgba(210,153,34,0.08); border: 1px solid rgba(210,153,34,0.3); }
|
||||||
|
.alert.danger { background: rgba(248,81,73,0.08); border: 1px solid rgba(248,81,73,0.3); }
|
||||||
|
.alert.info { background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.3); }
|
||||||
|
.alert.success { background: rgba(63,185,80,0.08); border: 1px solid rgba(63,185,80,0.3); }
|
||||||
|
|
||||||
|
.checklist { list-style: none; padding: 0; }
|
||||||
|
.checklist li {
|
||||||
|
padding: 0.5rem 0; padding-left: 2rem; position: relative;
|
||||||
|
border-bottom: 1px solid rgba(48,54,61,0.5); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.checklist li::before {
|
||||||
|
content: ""; position: absolute; left: 0; top: 0.65rem;
|
||||||
|
width: 18px; height: 18px; border: 2px solid var(--border); border-radius: 4px;
|
||||||
|
}
|
||||||
|
.checklist li.done::before {
|
||||||
|
background: var(--green); border-color: var(--green);
|
||||||
|
content: ""; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
|
||||||
|
background-size: 14px; background-position: center; background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.checklist li.done { color: var(--text); }
|
||||||
|
.checklist li strong { color: var(--text); }
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 0; margin: 1.5rem 0; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.flow-node {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem; font-size: 0.85rem; text-align: center; min-width: 100px;
|
||||||
|
}
|
||||||
|
.flow-node.active { border-color: var(--green); box-shadow: 0 0 8px rgba(63,185,80,0.2); }
|
||||||
|
.flow-arrow { color: var(--text-dim); font-size: 1.2rem; padding: 0 0.3rem; }
|
||||||
|
|
||||||
|
.screenshot-note {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0.8rem 1rem; margin: 0.5rem 0; font-size: 0.85rem; color: var(--text-dim);
|
||||||
|
border-left: 3px solid var(--yellow);
|
||||||
|
}
|
||||||
|
.screenshot-note strong { color: var(--yellow); }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center; padding: 2rem; color: var(--text-dim);
|
||||||
|
font-size: 0.8rem; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header { padding: 2rem 1rem; }
|
||||||
|
.header pre { font-size: 0.5rem; }
|
||||||
|
.header h1 { font-size: 1.5rem; }
|
||||||
|
.container { padding: 1.5rem 1rem; }
|
||||||
|
.flow { flex-direction: column; }
|
||||||
|
.flow-arrow { transform: rotate(90deg); }
|
||||||
|
.step { flex-direction: column; gap: 0.5rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<pre>
|
||||||
|
____ _
|
||||||
|
| __ ) ___ ___ | | ____ _____ _ __ _ __ ___
|
||||||
|
| _ \ / _ \ / _ \| |/ /\ \ /\ / / _ \| '__| '_ ` _ \
|
||||||
|
| |_) | (_) | (_) | < \ V V / (_) | | | | | | | |
|
||||||
|
|____/ \___/ \___/|_|\_\ \_/\_/ \___/|_| |_| |_| |_|
|
||||||
|
</pre>
|
||||||
|
<h1>Bookworm <span>Portable</span> 保姆式安装手册</h1>
|
||||||
|
<p>从零开始,一步步教你在任意 Windows 电脑上激活 Bookworm</p>
|
||||||
|
<div class="badge-row">
|
||||||
|
<span class="badge"><strong>92</strong> Skills</span>
|
||||||
|
<span class="badge"><strong>18</strong> Agents</span>
|
||||||
|
<span class="badge"><strong>29</strong> Hooks</span>
|
||||||
|
<span class="badge"><strong>AES-256</strong> 加密</span>
|
||||||
|
<span class="badge"><strong>HTTPS</strong> 传输</span>
|
||||||
|
</div>
|
||||||
|
<a href="/Bookworm-Setup.bat" download style="display:inline-block;margin-top:1.2rem;padding:0.7rem 2rem;background:var(--accent);color:#000;font-weight:700;border-radius:8px;font-size:1rem;text-decoration:none;transition:opacity 0.2s" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">⬇ 下载一键安装器</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- 整体流程 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>整体流程概览</h2>
|
||||||
|
|
||||||
|
<div class="card" style="text-align:center;border-color:var(--green);margin-bottom:1.5rem">
|
||||||
|
<h3 style="color:var(--green);margin-bottom:0.5rem">最快方式:一键安装器</h3>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.9rem;margin-bottom:0.8rem">获取 <strong>Bookworm-Setup.bat</strong> (4KB) → 双击运行 → 输入密码 → 完成</p>
|
||||||
|
<p style="font-size:0.8rem;color:var(--text-dim)">安装器自动检测依赖、下载配置、创建桌面快捷方式、启动 Claude Code</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align:center;color:var(--text-dim);font-size:0.85rem;margin-bottom:0.5rem">手动安装流程:</p>
|
||||||
|
<div class="flow">
|
||||||
|
<div class="flow-node">安装依赖<br><small style="color:var(--text-dim)">Node.js + Git</small></div>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<div class="flow-node">安装 Claude Code<br><small style="color:var(--text-dim)">npm 全局安装</small></div>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<div class="flow-node">双击安装器<br><small style="color:var(--text-dim)">或 git clone</small></div>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<div class="flow-node">输入密码<br><small style="color:var(--text-dim)">主密码</small></div>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<div class="flow-node active">开始使用<br><small style="color:var(--green)">Bookworm 激活</small></div>
|
||||||
|
</div>
|
||||||
|
<p style="text-align:center;color:var(--text-dim);font-size:0.9rem">首次安装约 10 分钟(含依赖下载),之后每次双击启动约 10-30 秒</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 第一步:安装依赖 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">1</span>安装依赖软件</h2>
|
||||||
|
|
||||||
|
<div class="alert danger">
|
||||||
|
<span class="alert-icon">⚠</span>
|
||||||
|
<div>
|
||||||
|
<strong>国内必须:代理/VPN 软件</strong><br>
|
||||||
|
Claude Code 启动时会检查 <code>api.anthropic.com</code>,国内无法直连。<br>
|
||||||
|
请先安装并启动代理软件(Clash / V2Ray / 快柠檬 / 任意 VPN),安装脚本会<strong>自动检测</strong>系统代理。<br>
|
||||||
|
无代理 = Claude Code 无法启动。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert info">
|
||||||
|
<span class="alert-icon">💡</span>
|
||||||
|
<div>
|
||||||
|
<strong>中转站不走代理</strong><br>
|
||||||
|
API 中转站 <code>bww.letcareme.com</code> 部署在国内阿里云,<strong>不需要通过代理访问</strong>。<br>
|
||||||
|
安装脚本已自动设置 <code>NO_PROXY=bww.letcareme.com,code.letcareme.com</code>,无需手动配置。<br>
|
||||||
|
如果代理软件有"绕过规则"设置,建议把 <code>*.letcareme.com</code> 加入直连列表。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>需要安装以下软件。如果已装过可跳到下一步。</p>
|
||||||
|
|
||||||
|
<!-- Node.js -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon blue">A</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>安装 Node.js(必须)</h4>
|
||||||
|
<p>去官网下载 LTS 版本安装包,双击安装,一路 Next 即可。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>方式一:官网下载(推荐)</h3>
|
||||||
|
<p>打开浏览器访问 <a href="https://nodejs.org/zh-cn" target="_blank">https://nodejs.org</a>,点击绿色的 <strong>"LTS 推荐"</strong> 按钮下载,双击 .msi 文件安装,全部默认 Next。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>方式二:PowerShell 命令安装</h3>
|
||||||
|
<p>右键开始菜单 → 选择 <strong>"PowerShell (管理员)"</strong> 或 <strong>"终端 (管理员)"</strong>,然后执行:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block has-label" onclick="copyCode(this)">
|
||||||
|
<span class="label">PowerShell (管理员)</span>
|
||||||
|
<code><span class="comment"># 下载 Node.js 安装包</span>
|
||||||
|
Invoke-WebRequest -Uri "https://nodejs.org/dist/v22.15.0/node-v22.15.0-x64.msi" -OutFile "$env:TEMP\node-install.msi"
|
||||||
|
|
||||||
|
<span class="comment"># 运行安装(会弹出安装向导,一路 Next)</span>
|
||||||
|
Start-Process msiexec.exe -ArgumentList "/i $env:TEMP\node-install.msi" -Wait</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert warning">
|
||||||
|
<span class="alert-icon">⚠</span>
|
||||||
|
<div>
|
||||||
|
<strong>安装完成后必须重开 PowerShell!</strong><br>
|
||||||
|
关闭当前 PowerShell 窗口,重新打开一个新的,否则 <code>node</code> 和 <code>npm</code> 命令找不到。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top:0.8rem">验证安装成功:</p>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">node</span> -v <span class="comment"># 应显示 v22.x.x</span>
|
||||||
|
<span class="cmd">npm</span> -v <span class="comment"># 应显示 10.x.x</span></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screenshot-note">
|
||||||
|
<strong>如果 npm 报 "执行策略" 错误?</strong> 执行以下命令后重试:<br>
|
||||||
|
<code>Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned</code><br>
|
||||||
|
提示确认时输入 <strong>Y</strong> 回车。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Git -->
|
||||||
|
<div class="step" style="margin-top:1.5rem">
|
||||||
|
<div class="step-icon blue">B</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>安装 Git(必须)</h4>
|
||||||
|
<p>去官网下载安装,全部默认设置即可。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p>打开 <a href="https://git-scm.com/download/win" target="_blank">https://git-scm.com/download/win</a>,下载 <strong>"64-bit Git for Windows Setup"</strong>,双击安装,全部 Next。</p>
|
||||||
|
</div>
|
||||||
|
<p>验证:</p>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">git</span> --version <span class="comment"># 应显示 git version 2.x.x</span></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PowerShell 7 -->
|
||||||
|
<div class="step" style="margin-top:1.5rem">
|
||||||
|
<div class="step-icon yellow">C</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>安装 PowerShell 7(推荐)</h4>
|
||||||
|
<p>Windows 自带的 PowerShell 5.1 有中文兼容问题,建议升级到 7。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="code-block has-label" onclick="copyCode(this)">
|
||||||
|
<span class="label">PowerShell (管理员)</span>
|
||||||
|
<code><span class="cmd">winget</span> install Microsoft.PowerShell</code>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem">如果没有 winget,去 <a href="https://github.com/PowerShell/PowerShell/releases" target="_blank">GitHub Releases</a> 下载 .msi 安装包。安装后用 <code>pwsh</code> 命令启动新版 PowerShell。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 第二步:安装 Claude Code -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">2</span>安装 Claude Code</h2>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon green">1</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>全局安装</h4>
|
||||||
|
<p>在 PowerShell 中执行(不需要管理员权限):</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">npm</span> i -g @anthropic-ai/claude-code</code>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem">安装过程需要几分钟,等待完成即可。</p>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon green">2</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>验证安装</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">claude</span> --version <span class="comment"># 应显示版本号</span></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert info">
|
||||||
|
<span class="alert-icon">💡</span>
|
||||||
|
<div>
|
||||||
|
<strong>不需要登录 Claude 账号!</strong><br>
|
||||||
|
Bookworm 使用中转站 API,安装 Claude Code 后直接进入下一步,不用执行 <code>claude login</code>。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 第三步:安装 Bookworm -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">3</span>安装 Bookworm(核心步骤)</h2>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon purple">1</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>克隆引导仓库</h4>
|
||||||
|
<p>在 PowerShell 中执行以下命令。系统会提示输入用户名和密码。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">git clone</span> <span class="url">https://code.letcareme.com/bookworm/bookworm-boot.git</span>
|
||||||
|
<span class="cmd">cd</span> bookworm-boot</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screenshot-note">
|
||||||
|
<strong>弹出用户名密码?</strong> 输入管理员提供给你的 Gitea 账号密码。这是 Gitea 的密码,不是主密码。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon purple">2</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>双击运行安装脚本</h4>
|
||||||
|
<p>双击文件夹里的 <strong>更新并启动Bookworm.bat</strong>,脚本会自动完成所有配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>或者用命令行运行</h3>
|
||||||
|
<p>如果双击 .bat 不起作用,在 PowerShell 中手动执行:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">pwsh</span> <span class="flag">-ExecutionPolicy Bypass</span> -File install.ps1</code>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem">没有 pwsh 可用 <code>powershell</code> 替代。</p>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon purple">3</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>输入主密码</h4>
|
||||||
|
<p>脚本会提示 <strong>"输入主密码解密凭证"</strong>,输入管理员提供的<strong>主密码</strong>(不是 Gitea 密码),按回车。</p>
|
||||||
|
<p>密码输入时不显示字符,这是正常的。<strong>输错了可以重试,最多 3 次。</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screenshot-note">
|
||||||
|
<strong>两个密码不要搞混:</strong><br>
|
||||||
|
<strong>Gitea 密码</strong> = 克隆仓库时输入的,用于下载文件<br>
|
||||||
|
<strong>主密码</strong> = 解密 API 凭证时输入的,用于启动 Claude Code
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-icon green">4</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h4>等待完成</h4>
|
||||||
|
<p>脚本会显示步骤进度 [1/9] 到 [9/9],自动完成:</p>
|
||||||
|
<ul style="color:var(--text-dim);font-size:0.9rem;padding-left:1.2rem;margin-top:0.3rem">
|
||||||
|
<li>[1/9] 前置检查 (Claude Code / Node.js / Git)</li>
|
||||||
|
<li>[2/9] 自动检测代理 + 设置 NO_PROXY</li>
|
||||||
|
<li>[3/9] 解密凭证 (输入主密码)</li>
|
||||||
|
<li>[4/9] 同步配置 (下载 92 个 Skills)</li>
|
||||||
|
<li>[5/9] 完整性校验 (SHA256 哈希验证)</li>
|
||||||
|
<li>[6/9] 渲染配置模板</li>
|
||||||
|
<li>[7/9] 初始化本地目录</li>
|
||||||
|
<li>[8/9] Bookworm 系统验证 + MCP 检查</li>
|
||||||
|
<li>[9/9] 启动 Claude Code</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert success">
|
||||||
|
<span class="alert-icon">✓</span>
|
||||||
|
<div>
|
||||||
|
<strong>看到 "Bookworm 就绪" 绿色横幅就说明成功了!</strong><br>
|
||||||
|
Claude Code 启动后,脚本会验证 Skills/Hooks/配置 完整性,全部 [OK] 后进入 Bookworm 模式。<br>
|
||||||
|
所有 API 请求通过中转站转发,不需要自己的 Claude 账号。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert warning">
|
||||||
|
<span class="alert-icon">⚠</span>
|
||||||
|
<div>
|
||||||
|
<strong>看到 "原生模式启动" 黄色横幅?</strong><br>
|
||||||
|
说明 Bookworm 配置不完整。请不加 -StartOnly 重新运行安装脚本,或联系管理员。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 日常使用 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">4</span>日常使用</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>方法一:双击 .bat 文件(推荐,最简单)</h3>
|
||||||
|
<p>bookworm-boot 文件夹里有两个 .bat 文件:</p>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>文件</th><th>作用</th><th>适用场景</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>启动Bookworm.bat</strong></td>
|
||||||
|
<td>快速启动,不更新配置</td>
|
||||||
|
<td>每天日常使用</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>更新并启动Bookworm.bat</strong></td>
|
||||||
|
<td>先同步最新 Skills 再启动</td>
|
||||||
|
<td>管理员通知有更新时</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem;margin-top:0.5rem">双击即可,无需打开 PowerShell,无需记命令。脚本会自动检测 PowerShell 7/5.1。</p>
|
||||||
|
|
||||||
|
<div class="alert info">
|
||||||
|
<span class="alert-icon">💡</span>
|
||||||
|
<div>
|
||||||
|
<strong>启动时显示 "有 N 个新更新可用"?</strong><br>
|
||||||
|
说明管理员更新了 Skills 或 Hooks。双击 <strong>更新并启动Bookworm.bat</strong> 即可同步。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<h3>方法二:命令行(备用)</h3>
|
||||||
|
<p>如果 .bat 文件无法运行,在 PowerShell 中手动执行:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="comment"># 快速启动</span>
|
||||||
|
<span class="cmd">cd</span> bookworm-boot
|
||||||
|
<span class="cmd">pwsh</span> <span class="flag">-ExecutionPolicy Bypass</span> -File install.ps1 <span class="flag">-StartOnly</span>
|
||||||
|
|
||||||
|
<span class="comment"># 同步更新后启动</span>
|
||||||
|
<span class="cmd">cd</span> bookworm-boot
|
||||||
|
<span class="cmd">pwsh</span> <span class="flag">-ExecutionPolicy Bypass</span> -File install.ps1</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 使用完毕 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 密码说明 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">5</span>密码说明</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>本系统有两个密码,不要搞混</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>名称</th><th>用途</th><th>何时输入</th></tr>
|
||||||
|
<tr><td><strong>Gitea 密码</strong></td><td>下载文件(克隆仓库)</td><td>首次安装时 git 弹出要求</td></tr>
|
||||||
|
<tr><td><strong>主密码</strong></td><td>解密 API 凭证</td><td>每次启动脚本提示输入</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert info">
|
||||||
|
<span class="alert-icon">💡</span>
|
||||||
|
<div>
|
||||||
|
<strong>密码输错了?</strong> 最多可以重试 3 次,不用紧张。3 次都错才会退出。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:0.8rem">
|
||||||
|
<h3>本日免密功能</h3>
|
||||||
|
<p>首次解密成功后,脚本会询问 <strong>"今日内免密启动? (y/n)"</strong></p>
|
||||||
|
<p>选 <strong>y</strong> 后,当天再次启动无需输入主密码,次日自动过期。</p>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem;margin-top:0.3rem">凭证缓存在 Windows Credential Manager 中(DPAPI 加密,仅当前用户可读)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert warning" style="margin-top:0.8rem">
|
||||||
|
<span class="alert-icon">🔒</span>
|
||||||
|
<div><strong>主密码无法找回</strong> — 忘记后联系管理员重新生成 secrets.enc。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 使用完毕 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">6</span>使用完毕 — 清理 / 卸载</h2>
|
||||||
|
|
||||||
|
<div class="card" style="border-color:var(--green)">
|
||||||
|
<h3 style="color:var(--green)">最简单:双击 卸载Bookworm.bat</h3>
|
||||||
|
<p>bookworm-boot 文件夹里的 <strong>卸载Bookworm.bat</strong>,双击即可一键完整卸载:终止进程 + 清除凭证 + 恢复原始配置 + 删除桌面快捷方式。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top:1rem;color:var(--text-dim);font-size:0.9rem">或者用命令行精细控制:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>场景</th><th>命令</th><th>说明</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>基础清理</strong></td>
|
||||||
|
<td><code>pwsh -File stop.ps1</code></td>
|
||||||
|
<td>清除环境变量,保留配置供下次快速启动</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>完整恢复</strong></td>
|
||||||
|
<td><code>pwsh -File stop.ps1 -Restore</code></td>
|
||||||
|
<td>删除 Bookworm,恢复电脑原始状态</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>深度清理</strong></td>
|
||||||
|
<td><code>pwsh -File stop.ps1 -Restore -Deep</code></td>
|
||||||
|
<td>完整恢复 + 清除历史 + 清除 Git/凭证缓存</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="alert danger">
|
||||||
|
<span class="alert-icon">⚠</span>
|
||||||
|
<div>
|
||||||
|
<strong>在他人电脑/公用电脑上务必卸载:</strong><br>
|
||||||
|
双击 <strong>卸载Bookworm.bat</strong> 或执行 <code>pwsh -File stop.ps1 -Restore -Deep</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 故障排查 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2><span class="num">!</span>常见问题排查</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ 输入 node -v 或 npm -v 提示 "无法识别"</h3>
|
||||||
|
<p><strong>原因:</strong>安装 Node.js 后没有重开 PowerShell 窗口,PATH 没刷新。</p>
|
||||||
|
<p><strong>解决:</strong>关闭当前 PowerShell,重新打开一个新的窗口再试。</p>
|
||||||
|
<p>如果还不行,手动刷新 PATH:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code>$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||||
|
<span class="cmd">node</span> -v</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ npm 报 "执行策略" / "Execution Policy" 错误</h3>
|
||||||
|
<p><strong>原因:</strong>Windows 默认禁止运行脚本。</p>
|
||||||
|
<p><strong>解决:</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code>Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned</code>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem">提示确认时输入 <strong>Y</strong> 回车。之后 npm 和 pwsh 脚本都能正常运行。</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ 提示 "openssl 未找到"</h3>
|
||||||
|
<p><strong>原因:</strong>解密凭证需要 openssl,它随 Git for Windows 一起安装。</p>
|
||||||
|
<p><strong>解决:</strong>确认 Git 已安装。脚本会自动搜索 <code>C:\Program Files\Git</code> 和 <code>D:\Git</code> 下的 openssl。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ git clone 失败 / 认证失败</h3>
|
||||||
|
<p><strong>解决步骤:</strong></p>
|
||||||
|
<ol style="color:var(--text-dim);padding-left:1.5rem;margin-top:0.3rem">
|
||||||
|
<li>浏览器打开 <code>https://code.letcareme.com</code> 确认网站可访问</li>
|
||||||
|
<li>确认用户名密码正确(区分大小写)</li>
|
||||||
|
<li>如果开了 2FA,需要用 Access Token 替代密码</li>
|
||||||
|
<li>检查网络是否需要代理</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ 解密凭证失败 / 主密码错误</h3>
|
||||||
|
<p><strong>原因:</strong>主密码区分大小写,且无法找回。</p>
|
||||||
|
<p><strong>解决:</strong>仔细检查密码是否正确。如确认忘记,联系管理员重新生成 <code>secrets.enc</code>。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ Claude Code 启动后没有 Bookworm 横幅</h3>
|
||||||
|
<p><strong>原因:</strong>配置文件未正确同步。</p>
|
||||||
|
<p><strong>解决:</strong>不加 <code>-StartOnly</code> 重新运行安装脚本,让它重新 clone:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="cmd">pwsh</span> <span class="flag">-ExecutionPolicy Bypass</span> -File install.ps1</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ 安装包下载太慢</h3>
|
||||||
|
<p><strong>解决:</strong>Node.js 官网在国内可能较慢,可以用淘宝镜像:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="comment"># 设置 npm 淘宝镜像(加速下载)</span>
|
||||||
|
<span class="cmd">npm</span> config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
<span class="comment"># 然后重新安装 Claude Code</span>
|
||||||
|
<span class="cmd">npm</span> i -g @anthropic-ai/claude-code</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ ECONNRESET / "Unable to connect to API"</h3>
|
||||||
|
<p><strong>原因:</strong>代理软件把国内中转站 <code>bww.letcareme.com</code> 的流量也走了国际线路,导致连接被重置。</p>
|
||||||
|
<p><strong>解决:</strong>在 PowerShell 中手动设置 NO_PROXY 后重试:</p>
|
||||||
|
</div>
|
||||||
|
<div class="code-block" onclick="copyCode(this)">
|
||||||
|
<code><span class="comment"># 设置中转站直连(不走代理)</span>
|
||||||
|
$env:NO_PROXY = "bww.letcareme.com,code.letcareme.com"
|
||||||
|
|
||||||
|
<span class="comment"># 重新启动</span>
|
||||||
|
<span class="cmd">cd</span> bookworm-boot
|
||||||
|
<span class="cmd">pwsh</span> <span class="flag">-ExecutionPolicy Bypass</span> -File install.ps1 <span class="flag">-StartOnly</span></code>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem">新版安装脚本已自动设置 NO_PROXY,<code>git pull</code> 更新后此问题不再出现。</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ "Not logged in" / 直接运行 claude 报错</h3>
|
||||||
|
<p><strong>原因:</strong>API 凭证是进程级环境变量,只在安装脚本启动的进程中有效。新开 PowerShell 窗口直接运行 <code>claude</code> 没有凭证。</p>
|
||||||
|
<p><strong>解决:</strong><strong>不要直接运行 <code>claude</code></strong>,必须通过以下方式启动:</p>
|
||||||
|
<ul style="color:var(--text-dim);font-size:0.9rem;padding-left:1.2rem;margin-top:0.3rem">
|
||||||
|
<li>双击桌面 <strong>Bookworm</strong> 快捷方式</li>
|
||||||
|
<li>双击 <strong>启动Bookworm.bat</strong></li>
|
||||||
|
<li>命令行:<code>pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ 完整性校验不匹配(大量文件 WARN)</h3>
|
||||||
|
<p><strong>原因:</strong>本地配置文件已被更新(管理员推送了新版本),但 <code>integrity.sha256</code> 未同步更新。</p>
|
||||||
|
<p><strong>解决:</strong>选 <strong>y</strong> 继续即可,不影响使用。管理员会在下个版本同步哈希文件。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>❌ 需要自己的 Claude 账号吗?</h3>
|
||||||
|
<p><strong>不需要。</strong>所有 API 请求通过中转站转发,消耗中转站额度。目标机不需要任何 Anthropic 账号或订阅。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 安装检查清单 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>安装检查清单</h2>
|
||||||
|
<p style="color:var(--text-dim);margin-bottom:0.5rem">逐项确认,全部打勾即可开始使用:</p>
|
||||||
|
<ul class="checklist">
|
||||||
|
<li><strong>Node.js 已安装</strong> — <code>node -v</code> 显示版本号</li>
|
||||||
|
<li><strong>Git 已安装</strong> — <code>git --version</code> 显示版本号</li>
|
||||||
|
<li><strong>npm 可用</strong> — <code>npm -v</code> 显示版本号(如报错先设 ExecutionPolicy)</li>
|
||||||
|
<li><strong>Claude Code 已安装</strong> — <code>claude --version</code> 显示版本号</li>
|
||||||
|
<li><strong>PowerShell 7 已安装</strong> — <code>pwsh --version</code> 显示 7.x(推荐但非必须)</li>
|
||||||
|
<li><strong>已获取 Gitea 账号密码</strong> — 管理员提供</li>
|
||||||
|
<li><strong>已获取主密码</strong> — 管理员提供(用于解密 API 凭证)</li>
|
||||||
|
<li><strong>能访问 code.letcareme.com</strong> — 浏览器打开确认</li>
|
||||||
|
<li><strong>代理/VPN 已启动</strong> — 国内必须,脚本自动检测 (Clash/V2Ray/快柠檬等)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 快速参考卡片 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>快速参考</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>操作</th><th>最简方式</th><th>命令行方式</th></tr>
|
||||||
|
<tr><td>首次安装</td><td>git clone + 双击<br><strong>更新并启动Bookworm.bat</strong></td><td><code>pwsh -ExecutionPolicy Bypass -File install.ps1</code></td></tr>
|
||||||
|
<tr><td>快速启动</td><td>双击 <strong>启动Bookworm.bat</strong></td><td><code>pwsh -File install.ps1 -StartOnly</code></td></tr>
|
||||||
|
<tr><td>同步更新</td><td>双击 <strong>更新并启动Bookworm.bat</strong></td><td><code>pwsh -File install.ps1</code></td></tr>
|
||||||
|
<tr><td>基础清理</td><td colspan="2"><code>pwsh -ExecutionPolicy Bypass -File stop.ps1</code></td></tr>
|
||||||
|
<tr><td>完整恢复</td><td colspan="2"><code>pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore</code></td></tr>
|
||||||
|
<tr><td>深度清理</td><td colspan="2"><code>pwsh -ExecutionPolicy Bypass -File stop.ps1 -Restore -Deep</code></td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 安全须知 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>安全须知</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>特性</th><th>规格</th></tr>
|
||||||
|
<tr><td>凭证加密</td><td>AES-256-CBC + PBKDF2 (600,000 迭代)</td></tr>
|
||||||
|
<tr><td>传输加密</td><td>HTTPS (TLS 1.2+, Let's Encrypt 证书)</td></tr>
|
||||||
|
<tr><td>凭证存储</td><td>进程级环境变量 + 可选本日缓存 (Windows Credential Manager, DPAPI 加密, 当日 23:59 过期)</td></tr>
|
||||||
|
<tr><td>登录保护</td><td>fail2ban (5 次失败/小时 → 封禁 24 小时)</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="alert warning" style="margin-top:1rem">
|
||||||
|
<span class="alert-icon">🔒</span>
|
||||||
|
<div>
|
||||||
|
<strong>主密码无法找回</strong> — 请妥善保管。忘记后需管理员重新生成加密凭证。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Bookworm Portable v1.5 — 保姆式安装手册<br>
|
||||||
|
© 2026 Bookworm Smart Assistant
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyCode(el) {
|
||||||
|
const code = el.querySelector('code').innerText;
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
el.classList.add('copied');
|
||||||
|
setTimeout(() => el.classList.remove('copied'), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
inject-mcp.js
104
inject-mcp.js
@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bookworm MCP 注入脚本 v2 — 同步 Bookworm v6.5.1 全部 22 个 portable MCP
|
|
||||||
* 安全合并到 ~/.claude.json (保留所有现有字段)
|
|
||||||
* 用法: node inject-mcp.js
|
|
||||||
*/
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const HOME = process.env.USERPROFILE || process.env.HOME || "";
|
|
||||||
|
|
||||||
const f = path.join(HOME, ".claude.json");
|
|
||||||
let d = {};
|
|
||||||
try { d = JSON.parse(fs.readFileSync(f, "utf8")); } catch (e) {}
|
|
||||||
|
|
||||||
d.mcpServers = {
|
|
||||||
// ── Tier 1: npx (无需 API Key) ──
|
|
||||||
"context7": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@upstash/context7-mcp@2.1.1"], type: "stdio"
|
|
||||||
},
|
|
||||||
"sequential-thinking": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@modelcontextprotocol/server-sequential-thinking@2025.12.18"], type: "stdio"
|
|
||||||
},
|
|
||||||
"playwright": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@playwright/mcp@0.0.68", "--headless"], type: "stdio"
|
|
||||||
},
|
|
||||||
"session-continuity": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "claude-session-continuity-mcp@1.13.0"], type: "stdio"
|
|
||||||
},
|
|
||||||
"browser-mcp": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@browsermcp/mcp@latest"], type: "stdio"
|
|
||||||
},
|
|
||||||
"desktop-commander": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@wonderwhy-er/desktop-commander@latest"], type: "stdio",
|
|
||||||
env: { PUPPETEER_SKIP_DOWNLOAD: "true", PUPPETEER_EXECUTABLE_PATH: "C:/Program Files/Google/Chrome/Application/chrome.exe" }
|
|
||||||
},
|
|
||||||
"chrome-devtools": {
|
|
||||||
command: "npx.cmd",
|
|
||||||
args: ["--yes", "chrome-devtools-mcp@0.18.1",
|
|
||||||
"--executablePath", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
||||||
"--viewport", "1280x720", "--proxyServer", "http://127.0.0.1:7893"],
|
|
||||||
type: "stdio"
|
|
||||||
},
|
|
||||||
"mobile": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@mobilenext/mobile-mcp@0.0.35"], type: "stdio",
|
|
||||||
env: { ANDROID_HOME: path.join(HOME, "android-sdk") }
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Tier 2: npx + API Key (凭证从环境变量读取) ──
|
|
||||||
"github": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@modelcontextprotocol/server-github"], type: "stdio",
|
|
||||||
env: { GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_PERSONAL_ACCESS_TOKEN || "" }
|
|
||||||
},
|
|
||||||
"slack": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@modelcontextprotocol/server-slack"], type: "stdio",
|
|
||||||
env: { SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN || "", SLACK_TEAM_ID: "T0A4L1JLEER" }
|
|
||||||
},
|
|
||||||
"firecrawl": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "firecrawl-mcp"], type: "stdio",
|
|
||||||
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || "" }
|
|
||||||
},
|
|
||||||
"mcp-image": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "mcp-image"], type: "stdio",
|
|
||||||
env: { GEMINI_API_KEY: process.env.GEMINI_API_KEY || "", IMAGE_OUTPUT_DIR: path.join(HOME, "Pictures/mcp-images") }
|
|
||||||
},
|
|
||||||
"google-drive": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@piotr-agier/google-drive-mcp"], type: "stdio",
|
|
||||||
env: { GOOGLE_DRIVE_OAUTH_CREDENTIALS: path.join(HOME, ".config/google-drive-mcp/gcp-oauth.keys.json") }
|
|
||||||
},
|
|
||||||
"browserbase": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "@anthropic-ai/browserbase-mcp"], type: "stdio",
|
|
||||||
env: { BROWSERBASE_API_KEY: process.env.BROWSERBASE_API_KEY || "", BROWSERBASE_PROJECT_ID: "d3dbb32f-be2f-4e3a-b9ec-68e27474763c" }
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Tier 3: npx + 代理 (需要外网访问) ──
|
|
||||||
"notebooklm": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "notebooklm-mcp@latest"], type: "stdio",
|
|
||||||
env: { https_proxy: "http://127.0.0.1:7893", http_proxy: "http://127.0.0.1:7893" }
|
|
||||||
},
|
|
||||||
"cloudflare": {
|
|
||||||
command: "npx.cmd", args: ["--yes", "mcp-remote", "https://docs.mcp.cloudflare.com/sse"], type: "stdio",
|
|
||||||
env: { https_proxy: "http://127.0.0.1:7893", http_proxy: "http://127.0.0.1:7893" }
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Tier 4: HTTP (零安装, 云端托管) ──
|
|
||||||
"linear": { type: "http", url: "https://mcp.linear.app/mcp" },
|
|
||||||
"supabase": { type: "http", url: "https://mcp.supabase.com/mcp?project_ref=oepmihbtoylosbsxlmfo" },
|
|
||||||
"figma": { type: "http", url: "https://mcp.figma.com/mcp" },
|
|
||||||
|
|
||||||
// ── Tier 5: Python/uvx (需要 Python + uv) ──
|
|
||||||
"windows-mcp": { command: "uvx", args: ["--python", "3.13", "windows-mcp"], type: "stdio" },
|
|
||||||
"atlassian": {
|
|
||||||
command: "uvx", args: ["mcp-atlassian"], type: "stdio",
|
|
||||||
env: {
|
|
||||||
JIRA_URL: "https://huakoh.atlassian.net", JIRA_USERNAME: "huakoh449@gmail.com",
|
|
||||||
JIRA_API_TOKEN: process.env.ATLASSIAN_API_TOKEN || "",
|
|
||||||
CONFLUENCE_URL: "https://huakoh.atlassian.net/wiki", CONFLUENCE_USERNAME: "huakoh449@gmail.com",
|
|
||||||
CONFLUENCE_API_TOKEN: process.env.ATLASSIAN_API_TOKEN || ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"computer-control-mcp": { command: "uvx", args: ["computer-control-mcp@latest"], type: "stdio" }
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(f, JSON.stringify(d, null, 2));
|
|
||||||
const count = Object.keys(d.mcpServers).length;
|
|
||||||
console.log("OK: " + count + " MCP servers -> " + f);
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# install-mac.sh — 重定向到 Bookworm-Setup.sh
|
|
||||||
#
|
|
||||||
# 多个文档引用此文件名,实际安装逻辑在 Bookworm-Setup.sh 中。
|
|
||||||
# ============================================================
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
exec bash "$SCRIPT_DIR/Bookworm-Setup.sh" "$@"
|
|
||||||
300
install.ps1
300
install.ps1
@ -19,7 +19,6 @@ param(
|
|||||||
[string]$GitUrl = "https://code.letcareme.com/bookworm/bookworm-config.git",
|
[string]$GitUrl = "https://code.letcareme.com/bookworm/bookworm-config.git",
|
||||||
[switch]$StartOnly,
|
[switch]$StartOnly,
|
||||||
[switch]$SkipSecrets,
|
[switch]$SkipSecrets,
|
||||||
[switch]$SkipLaunch, # 仅安装不启动 (由调用方负责启动)
|
|
||||||
[switch]$AutoAccept # 豁免所有人工确认环节
|
[switch]$AutoAccept # 豁免所有人工确认环节
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -50,8 +49,8 @@ if (-not $opensslCmd) {
|
|||||||
function Write-Banner {
|
function Write-Banner {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
||||||
Write-Host " | Bookworm Portable Installer v1.6 |" -ForegroundColor Cyan
|
Write-Host " | Bookworm Portable Installer v1.4 |" -ForegroundColor Cyan
|
||||||
Write-Host " | Claude Code 国内一键就绪 |" -ForegroundColor Cyan
|
Write-Host " | 92 Skills / 18 Agents / 29 Hooks |" -ForegroundColor Cyan
|
||||||
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
}
|
}
|
||||||
@ -65,24 +64,14 @@ function Get-CachedSecrets {
|
|||||||
try {
|
try {
|
||||||
$cred = cmdkey /list 2>$null | Select-String "bookworm-secrets"
|
$cred = cmdkey /list 2>$null | Select-String "bookworm-secrets"
|
||||||
if ($cred) {
|
if ($cred) {
|
||||||
|
# 从 Credential Manager 读取缓存的环境变量
|
||||||
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
||||||
if (Test-Path $regPath) {
|
if (Test-Path $regPath) {
|
||||||
Add-Type -AssemblyName System.Security
|
|
||||||
$props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
|
$props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
|
||||||
$loaded = 0
|
$loaded = 0
|
||||||
foreach ($p in $props.PSObject.Properties) {
|
foreach ($p in $props.PSObject.Properties) {
|
||||||
if ($p.Name -match '^[A-Z_]+$') {
|
if ($p.Name -match '^[A-Z_]+$') {
|
||||||
$val = $p.Value
|
[System.Environment]::SetEnvironmentVariable($p.Name, $p.Value, "Process")
|
||||||
try {
|
|
||||||
# DPAPI 解密 (Base64 → byte[] → 明文)
|
|
||||||
$bytes = [Security.Cryptography.ProtectedData]::Unprotect(
|
|
||||||
[Convert]::FromBase64String($val), $null,
|
|
||||||
[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
||||||
$val = [Text.Encoding]::UTF8.GetString($bytes)
|
|
||||||
} catch {
|
|
||||||
# 回退: 旧版明文缓存兼容
|
|
||||||
}
|
|
||||||
[System.Environment]::SetEnvironmentVariable($p.Name, $val, "Process")
|
|
||||||
$loaded++
|
$loaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,26 +87,21 @@ function Get-CachedSecrets {
|
|||||||
|
|
||||||
function Save-SecretsToCache {
|
function Save-SecretsToCache {
|
||||||
try {
|
try {
|
||||||
|
# 用 Credential Manager 标记缓存存在
|
||||||
cmdkey /generic:bookworm-secrets /user:bw /pass:cached 2>$null | Out-Null
|
cmdkey /generic:bookworm-secrets /user:bw /pass:cached 2>$null | Out-Null
|
||||||
|
# 用 HKCU 注册表存凭证值 (DPAPI 保护, 仅当前用户可读)
|
||||||
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
|
||||||
if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null }
|
if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null }
|
||||||
Add-Type -AssemblyName System.Security
|
|
||||||
$envKeys = @("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "GITHUB_PERSONAL_ACCESS_TOKEN",
|
$envKeys = @("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
"SLACK_BOT_TOKEN", "ATLASSIAN_API_TOKEN", "BROWSERBASE_API_KEY",
|
"SLACK_BOT_TOKEN", "ATLASSIAN_API_TOKEN", "BROWSERBASE_API_KEY", "FIRECRAWL_API_KEY")
|
||||||
"FIRECRAWL_API_KEY", "GEMINI_API_KEY")
|
|
||||||
foreach ($k in $envKeys) {
|
foreach ($k in $envKeys) {
|
||||||
$v = [System.Environment]::GetEnvironmentVariable($k, "Process")
|
$v = [System.Environment]::GetEnvironmentVariable($k, "Process")
|
||||||
if ($v) {
|
if ($v) { Set-ItemProperty $regPath -Name $k -Value $v -Force }
|
||||||
# DPAPI 加密: 明文 → byte[] → ProtectedData → Base64 存入注册表
|
|
||||||
$bytes = [Text.Encoding]::UTF8.GetBytes($v)
|
|
||||||
$enc = [Security.Cryptography.ProtectedData]::Protect(
|
|
||||||
$bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
||||||
Set-ItemProperty $regPath -Name $k -Value ([Convert]::ToBase64String($enc)) -Force
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
# 设置过期时间 (今日 23:59:59)
|
||||||
$expiry = (Get-Date).Date.AddDays(1).ToString("o")
|
$expiry = (Get-Date).Date.AddDays(1).ToString("o")
|
||||||
Set-ItemProperty $regPath -Name "_expiry" -Value $expiry -Force
|
Set-ItemProperty $regPath -Name "_expiry" -Value $expiry -Force
|
||||||
Write-Host " [OK] 凭证已缓存至今日 23:59 (DPAPI 加密, 下次免密)" -ForegroundColor Green
|
Write-Host " [OK] 凭证已缓存至今日 23:59 (下次免密)" -ForegroundColor Green
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,156 +156,60 @@ function Install-MissingDeps {
|
|||||||
|
|
||||||
# ─── 桌面快捷方式 ────────────────────────────────────
|
# ─── 桌面快捷方式 ────────────────────────────────────
|
||||||
function New-DesktopShortcuts {
|
function New-DesktopShortcuts {
|
||||||
# v3.0.11 架构重构: .lnk 直调 pwsh + claude.ps1 绝对路径 (1 跳直链)
|
|
||||||
$desktop = [System.Environment]::GetFolderPath("Desktop")
|
$desktop = [System.Environment]::GetFolderPath("Desktop")
|
||||||
$bootDir = $ScriptDir
|
$bootDir = $ScriptDir
|
||||||
$lnkPath = Join-Path $desktop "启动Bookworm.lnk"
|
|
||||||
|
|
||||||
# 定位 pwsh.exe
|
|
||||||
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
|
|
||||||
if (-not $pwshExe) {
|
|
||||||
foreach ($p in @("$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe")) {
|
|
||||||
if (Test-Path $p) { $pwshExe = $p; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (-not $pwshExe) {
|
|
||||||
Write-Host " [!] pwsh.exe 未找到, 跳过桌面快捷方式 (建议先装 PS7)" -ForegroundColor Yellow
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# v3.1.0: 优先用 wrapper bw-launch.ps1, 闭合 claude.ps1 路径 stale (L4)
|
|
||||||
$bwLaunchPs1 = Join-Path $bootDir "bw-launch.ps1"
|
|
||||||
if (-not (Test-Path $bwLaunchPs1)) {
|
|
||||||
Write-Host " [!] bw-launch.ps1 wrapper 未找到, 跳过桌面快捷方式" -ForegroundColor Yellow
|
|
||||||
Write-Host " $bwLaunchPs1 应由 bookworm-boot git 仓库提供" -ForegroundColor Gray
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# 装机时自检 claude.ps1 当前可达 (运行时由 wrapper 兜底)
|
|
||||||
$claudeCheck = $null
|
|
||||||
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
|
|
||||||
if ($claudeCmd -and $claudeCmd.Source -and $claudeCmd.Source.EndsWith('.ps1')) { $claudeCheck = $claudeCmd.Source }
|
|
||||||
if (-not $claudeCheck) {
|
|
||||||
try {
|
|
||||||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
|
||||||
$cand = Join-Path $npmPrefix "claude.ps1"
|
|
||||||
if (Test-Path $cand) { $claudeCheck = $cand }
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (-not $claudeCheck) {
|
|
||||||
Write-Host " [!] claude.ps1 装机时不可达, 跳过桌面快捷方式" -ForegroundColor Yellow
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# 启动Bookworm 快捷方式 — 优先 pwsh (PS7),回退 powershell (PS5)
|
||||||
|
$lnkPath = Join-Path $desktop "Bookworm.lnk"
|
||||||
|
if (-not (Test-Path $lnkPath)) {
|
||||||
try {
|
try {
|
||||||
$shell = New-Object -ComObject WScript.Shell
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
$shortcut = $shell.CreateShortcut($lnkPath)
|
$shortcut = $shell.CreateShortcut($lnkPath)
|
||||||
$shortcut.TargetPath = $pwshExe
|
$scriptPath = Join-Path $bootDir "install.ps1"
|
||||||
$shortcut.Arguments = "-NoLogo -NoExit -ExecutionPolicy Bypass -File `"$bwLaunchPs1`" --dangerously-skip-permissions"
|
$hasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue)
|
||||||
$shortcut.WorkingDirectory = $env:USERPROFILE
|
$psExe = if ($hasPwsh) { (Get-Command pwsh).Source } else { "powershell.exe" }
|
||||||
$shortcut.Description = "Bookworm Smart Assistant (v3.1.0 wrapper)"
|
$shortcut.TargetPath = $psExe
|
||||||
$iconPath = Join-Path $bootDir "bookworm-desktop.ico"
|
$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`""
|
||||||
if (-not (Test-Path $iconPath)) { $iconPath = Join-Path $bootDir "bookworm.ico" }
|
$shortcut.WorkingDirectory = $bootDir
|
||||||
if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" }
|
$shortcut.Description = "Bookworm Smart Assistant"
|
||||||
$shortcut.Save()
|
$shortcut.Save()
|
||||||
|
$psVer = if ($hasPwsh) { "PowerShell 7" } else { "PowerShell 5.1" }
|
||||||
# 自验证 (4 项)
|
Write-Host " [OK] 桌面快捷方式已创建: Bookworm ($psVer)" -ForegroundColor Green
|
||||||
$verify = $shell.CreateShortcut($lnkPath)
|
|
||||||
$okTarget = $verify.TargetPath -eq $pwshExe
|
|
||||||
$okPath = $verify.Arguments -match [regex]::Escape($bwLaunchPs1)
|
|
||||||
$okPerm = $verify.Arguments -match "--dangerously-skip-permissions"
|
|
||||||
$okBypass = $verify.Arguments -match "-ExecutionPolicy Bypass"
|
|
||||||
if ($okTarget -and $okPath -and $okPerm -and $okBypass) {
|
|
||||||
Write-Host " [OK] 桌面快捷方式已创建并通过 4 项自验证 (v3.1.0 wrapper)" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host " [!] 桌面快捷方式自验证失败 (Target=$okTarget Path=$okPath Perm=$okPerm Bypass=$okBypass)" -ForegroundColor Yellow
|
|
||||||
Remove-Item $lnkPath -Force -EA SilentlyContinue
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [!] 桌面快捷方式创建失败: $_" -ForegroundColor Gray
|
Write-Host " [!] 桌面快捷方式创建失败 (不影响使用)" -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
|
|
||||||
# 迁移清理老 Bookworm.lnk
|
|
||||||
$oldLnk = Join-Path $desktop "Bookworm.lnk"
|
|
||||||
if ((Test-Path $oldLnk) -and (Test-Path $lnkPath)) {
|
|
||||||
try { Remove-Item -LiteralPath $oldLnk -Force -ErrorAction Stop } catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Parse-AuthCode {
|
|
||||||
param([string]$code)
|
|
||||||
$code = $code.Trim()
|
|
||||||
# 格式: BW-YYYYMMDD-24位HexToken
|
|
||||||
if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') {
|
|
||||||
Write-Host " [!!] 格式错误,应为 BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX" -ForegroundColor Red
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
$expiryStr = $Matches[1]
|
|
||||||
$token = $Matches[2].ToLower() # 解密用小写
|
|
||||||
$today = (Get-Date).ToString("yyyyMMdd")
|
|
||||||
if ([int]$expiryStr -lt [int]$today) {
|
|
||||||
$d = "$($expiryStr.Substring(0,4))-$($expiryStr.Substring(4,2))-$($expiryStr.Substring(6,2))"
|
|
||||||
Write-Host " [!!] 授权码已过期 (有效期至 $d)" -ForegroundColor Red
|
|
||||||
Write-Host " 请联系管理员获取新授权码" -ForegroundColor Yellow
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
return $token
|
|
||||||
}
|
|
||||||
|
|
||||||
function Resolve-SecretsFile {
|
|
||||||
param([string]$token)
|
|
||||||
# 优先找 secrets-{token前8位}.enc (多用户独立 Key),回退 secrets.enc
|
|
||||||
$fileId = $token.Substring(0, 8)
|
|
||||||
$perUser = Join-Path $ScriptDir "secrets-$fileId.enc"
|
|
||||||
if (Test-Path $perUser) { return $perUser }
|
|
||||||
if (Test-Path $SecretsEnc) { return $SecretsEnc }
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Decrypt-Secrets {
|
function Decrypt-Secrets {
|
||||||
if ($SkipSecrets) { return }
|
if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) {
|
||||||
|
Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow
|
||||||
# 优先用 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
|
return
|
||||||
}
|
}
|
||||||
$cryptoHelper = Join-Path $ScriptDir "crypto-helper.js"
|
if (-not $opensslCmd) {
|
||||||
|
Write-Host " [!] openssl 未找到,跳过凭证解密" -ForegroundColor Yellow
|
||||||
$validAttempts = 0
|
return
|
||||||
$totalAttempts = 0
|
|
||||||
while ($validAttempts -lt 3 -and $totalAttempts -lt 10) {
|
|
||||||
$totalAttempts++
|
|
||||||
$label = if ($validAttempts -gt 0) { " 重新输入授权码 (第 $($validAttempts+1)/3 次)" } else { " 输入授权码 (格式: BW-YYYYMMDD-...)" }
|
|
||||||
$authCodeRaw = Read-Host $label
|
|
||||||
$plainPwd = Parse-AuthCode $authCodeRaw
|
|
||||||
if (-not $plainPwd) { continue }
|
|
||||||
$validAttempts++
|
|
||||||
|
|
||||||
# 按 token 前8位定位 .enc 文件
|
|
||||||
$encFile = Resolve-SecretsFile $plainPwd
|
|
||||||
if (-not $encFile) {
|
|
||||||
Write-Host " [!!] 未找到对应的凭证文件 (secrets-*.enc / secrets.enc)" -ForegroundColor Red
|
|
||||||
Write-Host " 请确认管理员已推送对应文件到 Gitea 并重新拉取" -ForegroundColor Yellow
|
|
||||||
$plainPwd = $null
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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
|
$prevEAP = $ErrorActionPreference
|
||||||
$ErrorActionPreference = "Continue"
|
$ErrorActionPreference = "Continue"
|
||||||
|
$decrypted = $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass stdin 2>&1
|
||||||
if ($useNode) {
|
|
||||||
$decrypted = & node $cryptoHelper decrypt $plainPwd $encFile 2>&1
|
|
||||||
$decExit = $LASTEXITCODE
|
$decExit = $LASTEXITCODE
|
||||||
} else {
|
|
||||||
$decrypted = $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -md sha256 -in $encFile -pass stdin 2>&1
|
|
||||||
$decExit = $LASTEXITCODE
|
|
||||||
}
|
|
||||||
$ErrorActionPreference = $prevEAP
|
$ErrorActionPreference = $prevEAP
|
||||||
$plainPwd = $null
|
|
||||||
|
|
||||||
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') {
|
# 清除内存中的密码
|
||||||
|
$plainPwd = $null
|
||||||
|
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||||
|
|
||||||
|
if ($decExit -eq 0 -and $decrypted) {
|
||||||
|
# 解密成功,注入环境变量
|
||||||
$decrypted -split "`n" | ForEach-Object {
|
$decrypted -split "`n" | ForEach-Object {
|
||||||
$line = $_.Trim()
|
$line = $_.Trim()
|
||||||
if ($line -and $line.Contains('=')) {
|
if ($line -and $line.Contains('=')) {
|
||||||
@ -335,15 +223,18 @@ function Decrypt-Secrets {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
$remaining = 3 - $validAttempts
|
# 解密失败
|
||||||
|
$remaining = $maxRetries - $attempt
|
||||||
if ($remaining -gt 0) {
|
if ($remaining -gt 0) {
|
||||||
Write-Host " [!!] 授权码无效 (解密失败),剩余重试: $remaining 次" -ForegroundColor Red
|
Write-Host " [!!] 密码错误,剩余重试: $remaining 次" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 3次全部失败
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " [ABORT] 3 次授权码均无效,凭证未解密" -ForegroundColor Red
|
Write-Host " [ABORT] 3 次密码均错误" -ForegroundColor Red
|
||||||
Write-Host " 请确认授权码是否正确,或联系管理员重新生成" -ForegroundColor Yellow
|
Write-Host " 请确认主密码是否正确 (区分大小写,至少12位)" -ForegroundColor Yellow
|
||||||
|
Write-Host " 如忘记密码,请联系管理员重新生成 secrets.enc" -ForegroundColor Yellow
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,26 +245,22 @@ function Render-SettingsTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$claudeRoot = $ClaudeTarget.Replace('\', '/')
|
$claudeRoot = $ClaudeTarget.Replace('\', '/')
|
||||||
$homeDir = $env:USERPROFILE.Replace('\', '/')
|
# HOME 保留反斜杠格式,与 Claude Code 原始行为一致
|
||||||
# 定位 pwsh 路径 (正斜杠供 JSON)
|
$homeDir = $env:USERPROFILE
|
||||||
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue)
|
|
||||||
$pwshJsonPath = if ($pwshExe) { $pwshExe.Source.Replace('\', '/') } else { "pwsh" }
|
|
||||||
|
|
||||||
$content = Get-Content $TemplateFile -Raw
|
$content = Get-Content $TemplateFile -Raw
|
||||||
$content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
|
$content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
|
||||||
$content = $content -replace '\{\{HOME\}\}', $homeDir
|
$content = $content -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\')
|
||||||
$content = $content -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath
|
|
||||||
|
|
||||||
Set-Content $SettingsFile -Value $content -Encoding UTF8
|
Set-Content $SettingsFile -Value $content -Encoding UTF8
|
||||||
Write-Host " [OK] settings.json 已渲染 (ROOT=$claudeRoot, SHELL=$pwshJsonPath)" -ForegroundColor Green
|
Write-Host " [OK] settings.json 已渲染 (ROOT=$claudeRoot)" -ForegroundColor Green
|
||||||
|
|
||||||
# 渲染 settings.local.template.json (如果存在)
|
# 渲染 settings.local.template.json (如果存在)
|
||||||
if (Test-Path $LocalTplFile) {
|
if (Test-Path $LocalTplFile) {
|
||||||
$localContent = Get-Content $LocalTplFile -Raw
|
$localContent = Get-Content $LocalTplFile -Raw
|
||||||
$localContent = $localContent -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
|
$localContent = $localContent -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
|
||||||
$localContent = $localContent -replace '\{\{HOME\}\}', $homeDir
|
$localContent = $localContent -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\')
|
||||||
$localContent = $localContent -replace '\{\{USERNAME\}\}', $env:USERNAME
|
$localContent = $localContent -replace '\{\{USERNAME\}\}', $env:USERNAME
|
||||||
$localContent = $localContent -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath
|
|
||||||
Set-Content $LocalSetFile -Value $localContent -Encoding UTF8
|
Set-Content $LocalSetFile -Value $localContent -Encoding UTF8
|
||||||
Write-Host " [OK] settings.local.json 已渲染" -ForegroundColor Green
|
Write-Host " [OK] settings.local.json 已渲染" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
@ -419,15 +306,12 @@ function Detect-SystemProxy {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
# 方法3: 扫描常见代理端口 (500ms 超时,避免阻塞)
|
# 方法3: 扫描常见代理端口
|
||||||
$commonPorts = @(7890, 7891, 7893, 10792, 10793, 10808, 10809, 1080, 1087, 8080, 8118)
|
$commonPorts = @(7890, 7891, 7893, 10792, 10793, 10808, 10809, 1080, 1087, 8080, 8118)
|
||||||
foreach ($port in $commonPorts) {
|
foreach ($port in $commonPorts) {
|
||||||
try {
|
try {
|
||||||
$tcp = New-Object System.Net.Sockets.TcpClient
|
$tcp = New-Object System.Net.Sockets.TcpClient
|
||||||
$result = $tcp.BeginConnect("127.0.0.1", $port, $null, $null)
|
$tcp.Connect("127.0.0.1", $port)
|
||||||
$success = $result.AsyncWaitHandle.WaitOne(500)
|
|
||||||
if (-not $success) { $tcp.Close(); continue }
|
|
||||||
$tcp.EndConnect($result)
|
|
||||||
$tcp.Close()
|
$tcp.Close()
|
||||||
$env:HTTPS_PROXY = "http://127.0.0.1:$port"
|
$env:HTTPS_PROXY = "http://127.0.0.1:$port"
|
||||||
$env:HTTP_PROXY = "http://127.0.0.1:$port"
|
$env:HTTP_PROXY = "http://127.0.0.1:$port"
|
||||||
@ -469,46 +353,14 @@ foreach ($c in $checks) {
|
|||||||
if (-not (Test-Command "claude") -or -not (Test-Command "node") -or -not (Test-Command "git")) {
|
if (-not (Test-Command "claude") -or -not (Test-Command "node") -or -not (Test-Command "git")) {
|
||||||
Install-MissingDeps
|
Install-MissingDeps
|
||||||
}
|
}
|
||||||
# v3.0.5: 再次验证 — StartOnly 场景加 GUI 弹窗 (防止 console 闪退用户看不见)
|
# 再次验证
|
||||||
# 触发条件: 用户双击老快捷方式, 但 Phase 1 之前失败导致 claude/node 未装
|
|
||||||
function Show-MissingDepGui {
|
|
||||||
param([string]$depName, [string]$installCmd)
|
|
||||||
try {
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms -EA Stop
|
|
||||||
$msg = @"
|
|
||||||
检测到 $depName 未安装,无法启动 Bookworm。
|
|
||||||
|
|
||||||
最可能原因:
|
|
||||||
上次安装器未完成 (Phase 1 环境检测被中断)
|
|
||||||
|
|
||||||
【推荐修复】
|
|
||||||
1. 双击桌面或下载目录的 Bookworm-Setup.exe
|
|
||||||
2. 安装器会自动补装缺失的依赖
|
|
||||||
3. 已装好的部分会被跳过, 不会重复
|
|
||||||
|
|
||||||
【手动修复】
|
|
||||||
$installCmd
|
|
||||||
|
|
||||||
完成后再次点击启动快捷方式即可。
|
|
||||||
"@
|
|
||||||
[System.Windows.Forms.MessageBox]::Show($msg, "Bookworm 启动失败 — $depName 未安装", 'OK', 'Error') | Out-Null
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Command "claude")) {
|
if (-not (Test-Command "claude")) {
|
||||||
Write-Host "`n [ABORT] Claude Code 未安装" -ForegroundColor Red
|
Write-Host "`n [ABORT] Claude Code 未安装" -ForegroundColor Red
|
||||||
Write-Host " 安装: npm i -g @anthropic-ai/claude-code" -ForegroundColor Gray
|
Write-Host " 安装: npm i -g @anthropic-ai/claude-code" -ForegroundColor Gray
|
||||||
if ($StartOnly) { Show-MissingDepGui "Claude Code" "npm i -g @anthropic-ai/claude-code" }
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
if (-not (Test-Command "node")) {
|
if (-not (Test-Command "node")) {
|
||||||
Write-Host "`n [ABORT] Node.js 未安装" -ForegroundColor Red
|
Write-Host "`n [ABORT] Node.js 未安装" -ForegroundColor Red
|
||||||
if ($StartOnly) { Show-MissingDepGui "Node.js" "https://nodejs.org/zh-cn/download 下载 LTS .msi" }
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (-not (Test-Command "git")) {
|
|
||||||
Write-Host "`n [ABORT] Git 未安装" -ForegroundColor Red
|
|
||||||
if ($StartOnly) { Show-MissingDepGui "Git" "https://git-scm.com/download/win 下载 64-bit" }
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -563,10 +415,9 @@ if (-not $StartOnly) {
|
|||||||
$prevEAP = $ErrorActionPreference
|
$prevEAP = $ErrorActionPreference
|
||||||
$ErrorActionPreference = "Continue"
|
$ErrorActionPreference = "Continue"
|
||||||
try {
|
try {
|
||||||
$stashOutput = git stash 2>&1
|
git stash 2>&1 | Out-Null
|
||||||
$hasStash = $stashOutput -notmatch 'No local changes'
|
|
||||||
git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" }
|
git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" }
|
||||||
if ($hasStash) { git stash pop 2>&1 | Out-Null }
|
git stash pop 2>&1 | Out-Null
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Host " [WARN] git pull 失败: $_" -ForegroundColor Yellow
|
Write-Host " [WARN] git pull 失败: $_" -ForegroundColor Yellow
|
||||||
@ -720,20 +571,19 @@ if (Test-Path $claudeMd) {
|
|||||||
$bwChecks += @{ Name = "CLAUDE.md (文件不存在!)"; OK = $false }
|
$bwChecks += @{ Name = "CLAUDE.md (文件不存在!)"; OK = $false }
|
||||||
}
|
}
|
||||||
|
|
||||||
# v3.0.5: 阈值按脱敏分发版 (bookworm-portable-config.git) 实际内容定
|
# 检查 Skills
|
||||||
# 管理员自用的完整版 (bookworm-config.git) 含 90+ skills, 分发版精简到核心 14+
|
|
||||||
$skillCount = 0
|
$skillCount = 0
|
||||||
if (Test-Path $skillsDir) {
|
if (Test-Path $skillsDir) {
|
||||||
$skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count
|
$skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count
|
||||||
}
|
}
|
||||||
$bwChecks += @{ Name = "Skills ($skillCount 个)"; OK = ($skillCount -ge 10) }
|
$bwChecks += @{ Name = "Skills ($skillCount 个)"; OK = ($skillCount -gt 50) }
|
||||||
|
|
||||||
# 检查 Hooks
|
# 检查 Hooks
|
||||||
$hookCount = 0
|
$hookCount = 0
|
||||||
if (Test-Path $hooksDir) {
|
if (Test-Path $hooksDir) {
|
||||||
$hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count
|
$hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count
|
||||||
}
|
}
|
||||||
$bwChecks += @{ Name = "Hooks ($hookCount 个)"; OK = ($hookCount -ge 3) }
|
$bwChecks += @{ Name = "Hooks ($hookCount 个)"; OK = ($hookCount -gt 10) }
|
||||||
|
|
||||||
# 检查 settings.json hooks 配置
|
# 检查 settings.json hooks 配置
|
||||||
$hasHooks = $false
|
$hasHooks = $false
|
||||||
@ -755,12 +605,12 @@ foreach ($c in $bwChecks) {
|
|||||||
if (-not $allOK) {
|
if (-not $allOK) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Yellow
|
Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Yellow
|
||||||
Write-Host " ║ [!] 警告: Bookworm 系统核心资产不足 ║" -ForegroundColor Yellow
|
Write-Host " ║ [!] 警告: Bookworm 系统不完整 ║" -ForegroundColor Yellow
|
||||||
Write-Host " ║ Claude Code 将以原生模式启动 (无 Skills/Hooks) ║" -ForegroundColor Yellow
|
Write-Host " ║ Claude Code 将以原生模式启动 (无 Skills/Hooks) ║" -ForegroundColor Yellow
|
||||||
Write-Host " ║ 建议: 检查网络后不加 -StartOnly 重新运行同步 ║" -ForegroundColor Yellow
|
Write-Host " ║ 建议: 不加 -StartOnly 重新运行 install.ps1 同步 ║" -ForegroundColor Yellow
|
||||||
Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Yellow
|
Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Yellow
|
||||||
} else {
|
} else {
|
||||||
Write-Host " [OK] Bookworm 分发版就绪 ($skillCount Skills / $hookCount Hooks / Settings)" -ForegroundColor Green
|
Write-Host " [OK] Bookworm 系统完整 ($skillCount Skills / $hookCount Hooks)" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- MCP 依赖检查 (中文提醒) ---
|
# --- MCP 依赖检查 (中文提醒) ---
|
||||||
@ -775,15 +625,12 @@ if (-not $hasPython) {
|
|||||||
$mcpWarnings += " 安装: https://www.python.org/downloads/ 或 winget install Python.Python.3.12"
|
$mcpWarnings += " 安装: https://www.python.org/downloads/ 或 winget install Python.Python.3.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Playwright (浏览器自动化 MCP) — 轻量检测,避免 npx 触发安装
|
# Playwright (浏览器自动化 MCP)
|
||||||
$hasPlaywright = $false
|
$hasPlaywright = $false
|
||||||
try {
|
try { $null = npx --yes @playwright/mcp --help 2>$null; $hasPlaywright = $true } catch {}
|
||||||
$pwPath = & npm list -g @playwright/mcp 2>$null
|
|
||||||
if ($pwPath -and $pwPath -notmatch 'empty') { $hasPlaywright = $true }
|
|
||||||
} catch {}
|
|
||||||
if (-not $hasPlaywright) {
|
if (-not $hasPlaywright) {
|
||||||
$mcpWarnings += " [!] Playwright MCP 未安装 - 浏览器自动化功能不可用"
|
$mcpWarnings += " [!] Playwright MCP 未安装 - 浏览器自动化功能不可用"
|
||||||
$mcpWarnings += " 安装: npm i -g @playwright/mcp && npx playwright install"
|
$mcpWarnings += " 安装: npx playwright install"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查关键 API Key 环境变量
|
# 检查关键 API Key 环境变量
|
||||||
@ -855,15 +702,6 @@ if (-not $StartOnly) {
|
|||||||
Start-Process $guidePath
|
Start-Process $guidePath
|
||||||
Write-Host " [OK] 使用教程已在浏览器打开" -ForegroundColor Gray
|
Write-Host " [OK] 使用教程已在浏览器打开" -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
# StartOnly 路径 (老 Bookworm.lnk 指向此): 跑幂等迁移, 单次 ~10ms
|
|
||||||
# 让只从不点「更新Bookworm」的老用户也自动完成快捷方式命名统一
|
|
||||||
New-DesktopShortcuts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 启动 Claude Code (同步执行, 窗口类型由调用方 .bat 决定)
|
& claude
|
||||||
if ($SkipLaunch) {
|
|
||||||
Write-Host " [OK] 安装完成 (由调用方负责启动)" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
& claude --dangerously-skip-permissions
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
'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' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
'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,
|
|
||||||
};
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
#!/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 "========================================="
|
|
||||||
@ -1,611 +0,0 @@
|
|||||||
'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('</think>');
|
|
||||||
if (endIdx !== -1) { inThink = false; content = content.slice(endIdx + 8); }
|
|
||||||
else content = '';
|
|
||||||
}
|
|
||||||
if (!inThink && content.includes('<think>')) {
|
|
||||||
const startIdx = content.indexOf('<think>');
|
|
||||||
const endIdx = content.indexOf('</think>', 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,
|
|
||||||
};
|
|
||||||
@ -1,349 +0,0 @@
|
|||||||
'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-opus-4-7',
|
|
||||||
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 };
|
|
||||||
@ -1,217 +0,0 @@
|
|||||||
#!/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 = '''<a href="/Bookworm-Setup.bat" download style="display:inline-block;margin-top:1.2rem;padding:0.7rem 2rem;background:var(--accent);color:#000;font-weight:700;border-radius:8px;font-size:1rem;text-decoration:none;transition:opacity 0.2s" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">⬇ 下载一键安装器</a>'''
|
|
||||||
|
|
||||||
new_download = '''<div style="display:flex;gap:0.8rem;justify-content:center;margin-top:1.2rem;flex-wrap:wrap">
|
|
||||||
<a href="/download/Bookworm-OneClick.bat" download style="display:inline-flex;align-items:center;gap:0.4rem;padding:0.7rem 1.5rem;background:var(--accent);color:#000;font-weight:700;border-radius:8px;font-size:0.95rem;text-decoration:none;transition:opacity 0.2s" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">⬇ Windows 11 一键安装</a>
|
|
||||||
<a href="/download/Bookworm-OneClick-Win10.bat" download style="display:inline-flex;align-items:center;gap:0.4rem;padding:0.7rem 1.5rem;background:var(--purple);color:#000;font-weight:700;border-radius:8px;font-size:0.95rem;text-decoration:none;transition:opacity 0.2s" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">⬇ Windows 10 一键安装</a>
|
|
||||||
<a href="/download/Bookworm-OneClick-Mac.sh" download style="display:inline-flex;align-items:center;gap:0.4rem;padding:0.7rem 1.5rem;background:var(--green);color:#000;font-weight:700;border-radius:8px;font-size:0.95rem;text-decoration:none;transition:opacity 0.2s" onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">⬇ macOS 一键安装</a>
|
|
||||||
</div>
|
|
||||||
<p style="text-align:center;color:var(--text-dim);font-size:0.8rem;margin-top:0.5rem">全新电脑? 双击即装 — 自动安装 Node.js + Git + Claude Code + Bookworm 配置</p>'''
|
|
||||||
|
|
||||||
html = html.replace(old_download, new_download)
|
|
||||||
|
|
||||||
# (d) 更新 "最快方式" 卡片 — 提及全自动
|
|
||||||
old_fastest = '''<h3 style="color:var(--green);margin-bottom:0.5rem">最快方式:一键安装器</h3>
|
|
||||||
<p style="color:var(--text-dim);font-size:0.9rem;margin-bottom:0.8rem">获取 <strong>Bookworm-Setup.bat</strong> (4KB) → 双击运行 → 输入密码 → 完成</p>
|
|
||||||
<p style="font-size:0.8rem;color:var(--text-dim)">安装器自动检测依赖、下载配置、创建桌面快捷方式、启动 Claude Code</p>'''
|
|
||||||
|
|
||||||
new_fastest = '''<h3 style="color:var(--green);margin-bottom:0.5rem">最快方式:全自动一键安装器 v2.0</h3>
|
|
||||||
<p style="color:var(--text-dim);font-size:0.9rem;margin-bottom:0.8rem">下载 <strong>OneClick 安装器</strong> → 双击运行 → 输入密码 → 完成</p>
|
|
||||||
<p style="font-size:0.8rem;color:var(--text-dim)">全新电脑零依赖:自动安装 Node.js + Git + Claude Code + 下载配置 + 创建桌面快捷方式</p>
|
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.6rem;flex-wrap:wrap;font-size:0.8rem">
|
|
||||||
<span style="background:rgba(88,166,255,0.1);border:1px solid rgba(88,166,255,0.3);padding:0.2rem 0.6rem;border-radius:12px">Win 11: winget 自动装</span>
|
|
||||||
<span style="background:rgba(188,140,255,0.1);border:1px solid rgba(188,140,255,0.3);padding:0.2rem 0.6rem;border-radius:12px">Win 10: 下载 MSI 静默装</span>
|
|
||||||
<span style="background:rgba(63,185,80,0.1);border:1px solid rgba(63,185,80,0.3);padding:0.2rem 0.6rem;border-radius:12px">macOS: Homebrew 自动装</span>
|
|
||||||
</div>'''
|
|
||||||
|
|
||||||
html = html.replace(old_fastest, new_fastest)
|
|
||||||
|
|
||||||
# (e) 更新手动流程提示
|
|
||||||
old_manual = '<p style="text-align:center;color:var(--text-dim);font-size:0.85rem;margin-bottom:0.5rem">手动安装流程:</p>'
|
|
||||||
new_manual = '<p style="text-align:center;color:var(--text-dim);font-size:0.85rem;margin-bottom:0.5rem">如需手动安装(一键安装器失败时的备选方案):</p>'
|
|
||||||
html = html.replace(old_manual, new_manual)
|
|
||||||
|
|
||||||
# (f) 更新首次安装行 "快速参考" 表
|
|
||||||
old_ref = '<tr><td>首次安装</td><td>git clone + 双击<br><strong>更新并启动Bookworm.bat</strong></td><td><code>pwsh -ExecutionPolicy Bypass -File install.ps1</code></td></tr>'
|
|
||||||
new_ref = '<tr><td>首次安装</td><td>双击 <strong>Bookworm-OneClick.bat</strong><br><span style="font-size:0.8rem;color:var(--text-dim)">自动装 Node.js + Git + Claude Code</span></td><td><code>pwsh -ExecutionPolicy Bypass -File install.ps1</code></td></tr>'
|
|
||||||
html = html.replace(old_ref, new_ref)
|
|
||||||
|
|
||||||
# (g) 在 "安装检查清单" 前插入 OneClick 安装段
|
|
||||||
oneclick_section = '''
|
|
||||||
<!-- ============================================================ -->
|
|
||||||
<!-- 全自动安装 (新增 v2.0) -->
|
|
||||||
<!-- ============================================================ -->
|
|
||||||
<div class="section" id="oneclick">
|
|
||||||
<h2><span class="num" style="background:var(--green)">0</span>全自动一键安装 (推荐)</h2>
|
|
||||||
|
|
||||||
<div class="alert success">
|
|
||||||
<span class="alert-icon">⚡</span>
|
|
||||||
<div>
|
|
||||||
<strong>全新电脑? 从这里开始!</strong><br>
|
|
||||||
OneClick 安装器会自动完成所有步骤:安装 Node.js、Git、Claude Code,下载 Bookworm 配置,创建桌面快捷方式。<br>
|
|
||||||
你只需要输入 Gitea 密码和主密码。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr><th>系统</th><th>安装文件</th><th>安装方式</th><th>前提条件</th></tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Windows 11</strong></td>
|
|
||||||
<td><a href="/download/Bookworm-OneClick.bat" download>Bookworm-OneClick.bat</a></td>
|
|
||||||
<td>winget 自动安装</td>
|
|
||||||
<td>无 (winget 内置)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Windows 10</strong></td>
|
|
||||||
<td><a href="/download/Bookworm-OneClick-Win10.bat" download>Bookworm-OneClick-Win10.bat</a></td>
|
|
||||||
<td>winget 优先, 回退下载 MSI/EXE</td>
|
|
||||||
<td>无 (1809+)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>macOS 12+</strong></td>
|
|
||||||
<td><a href="/download/Bookworm-OneClick-Mac.sh" download>Bookworm-OneClick-Mac.sh</a></td>
|
|
||||||
<td>Homebrew 自动安装</td>
|
|
||||||
<td>无 (自动装 Homebrew)</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-icon green">1</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<h4>Windows: 下载并双击</h4>
|
|
||||||
<p>下载对应版本 → 双击运行 → UAC 点"是" → 按提示输入 Gitea 密码和主密码 → 完成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-icon green">2</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<h4>macOS: 终端一行命令</h4>
|
|
||||||
<p>打开终端,粘贴以下命令并回车:</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="code-block" onclick="copyCode(this)">
|
|
||||||
<code><span class="cmd">curl</span> -fsSL -o ~/bookworm-install.sh https://bookworm.letcareme.com/download/Bookworm-OneClick-Mac.sh && <span class="cmd">bash</span> ~/bookworm-install.sh</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert info">
|
|
||||||
<span class="alert-icon">💡</span>
|
|
||||||
<div>
|
|
||||||
<strong>OneClick 安装器已包含以下所有步骤</strong><br>
|
|
||||||
如果一键安装成功,可跳过下方的手动步骤 1-3,直接查看<a href="#daily">每日使用</a>部分。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
# 在 "安装检查清单" 部分前插入
|
|
||||||
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 = '''<div class="flow">
|
|
||||||
<div class="flow-node">开代理</div>
|
|
||||||
<span class="flow-arrow">➔</span>
|
|
||||||
<div class="flow-node">双击 <strong>Bookworm</strong></div>
|
|
||||||
<span class="flow-arrow">➔</span>
|
|
||||||
<div class="flow-node">等横幅出现</div>
|
|
||||||
<span class="flow-arrow">➔</span>
|
|
||||||
<div class="flow-node active">开始工作</div>
|
|
||||||
</div>'''
|
|
||||||
|
|
||||||
new_flow = '''<div class="flow">
|
|
||||||
<div class="flow-node">开代理</div>
|
|
||||||
<span class="flow-arrow">➔</span>
|
|
||||||
<div class="flow-node">双击 <strong>Bookworm</strong></div>
|
|
||||||
<span class="flow-arrow">➔</span>
|
|
||||||
<div class="flow-node">等横幅出现</div>
|
|
||||||
<span class="flow-arrow">➔</span>
|
|
||||||
<div class="flow-node active">开始工作</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card highlight" style="margin-bottom:6px">
|
|
||||||
<h3>全新电脑? 用 OneClick 一键安装器</h3>
|
|
||||||
<p>下载 <code>Bookworm-OneClick.bat</code> (Win11) / <code>*-Win10.bat</code> (Win10) / <code>*-Mac.sh</code> (macOS) → 双击即装<br>
|
|
||||||
<span style="font-size:8pt">自动安装 Node.js + Git + Claude Code + Bookworm,零依赖</span></p>
|
|
||||||
</div>'''
|
|
||||||
|
|
||||||
qhtml = qhtml.replace(old_flow, new_flow)
|
|
||||||
|
|
||||||
# (c) 启动表添加 OneClick 行
|
|
||||||
old_startup_table_end = '<tr><td><span class="dot red"></span><strong>命令行启动</strong></td>'
|
|
||||||
new_startup_row = '''<tr><td><span class="dot green"></span><strong>首次安装</strong></td><td>双击 <code>Bookworm-OneClick.bat</code> (全自动装依赖+配置+启动)</td></tr>
|
|
||||||
<tr><td><span class="dot red"></span><strong>命令行启动</strong></td>'''
|
|
||||||
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] 所有页面更新完成')
|
|
||||||
197
prepare-repo.ps1
197
prepare-repo.ps1
@ -1,197 +0,0 @@
|
|||||||
<#
|
|
||||||
.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 (由 build-portable.js 自动生成,无需手动拷贝)
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
Write-Host "[1/6] settings.template.json 由 build-portable.js 管理,跳过" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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
|
|
||||||
@ -47,7 +47,7 @@
|
|||||||
# [1/6] 前置检查 (缺依赖自动提示 winget 安装)
|
# [1/6] 前置检查 (缺依赖自动提示 winget 安装)
|
||||||
# [2/6] 代理自动检测 (无需手动找端口)
|
# [2/6] 代理自动检测 (无需手动找端口)
|
||||||
# [3/6] 解密凭证 (输入主密码, 最多3次重试, 可选本日免密)
|
# [3/6] 解密凭证 (输入主密码, 最多3次重试, 可选本日免密)
|
||||||
# [4/6] 同步配置 (git clone 92 Skills / 18 Agents / 34 Hooks)
|
# [4/6] 同步配置 (git clone 92 Skills / 18 Agents / 29 Hooks)
|
||||||
# [5/6] 渲染模板 + 初始化 + Bookworm 完整性验证 + MCP 检查
|
# [5/6] 渲染模板 + 初始化 + Bookworm 完整性验证 + MCP 检查
|
||||||
# [6/6] 启动 Claude Code
|
# [6/6] 启动 Claude Code
|
||||||
|
|
||||||
@ -146,30 +146,30 @@
|
|||||||
---- 6.2 更新 boot 仓库脚本 ----
|
---- 6.2 更新 boot 仓库脚本 ----
|
||||||
|
|
||||||
cd C:\Users\leesu\AppData\Local\Temp\bookworm-boot
|
cd C:\Users\leesu\AppData\Local\Temp\bookworm-boot
|
||||||
cp C:\Users\leesu\Documents\bookworm-portable\install.ps1 .
|
cp C:\Users\leesu\Desktop\bookworm-portable\install.ps1 .
|
||||||
cp C:\Users\leesu\Documents\bookworm-portable\stop.ps1 .
|
cp C:\Users\leesu\Desktop\bookworm-portable\stop.ps1 .
|
||||||
cp C:\Users\leesu\Documents\bookworm-portable\guide.html .
|
cp C:\Users\leesu\Desktop\bookworm-portable\guide.html .
|
||||||
cp C:\Users\leesu\Documents\bookworm-portable\secrets.enc .
|
cp C:\Users\leesu\Desktop\bookworm-portable\secrets.enc .
|
||||||
cp "C:\Users\leesu\Documents\bookworm-portable\启动Bookworm.bat" .
|
cp "C:\Users\leesu\Desktop\bookworm-portable\启动Bookworm.bat" .
|
||||||
cp "C:\Users\leesu\Documents\bookworm-portable\更新并启动Bookworm.bat" .
|
cp "C:\Users\leesu\Desktop\bookworm-portable\更新并启动Bookworm.bat" .
|
||||||
cp "C:\Users\leesu\Documents\bookworm-portable\卸载Bookworm.bat" .
|
cp "C:\Users\leesu\Desktop\bookworm-portable\卸载Bookworm.bat" .
|
||||||
cp "C:\Users\leesu\Documents\bookworm-portable\Bookworm-Setup.bat" .
|
cp "C:\Users\leesu\Desktop\bookworm-portable\Bookworm-Setup.bat" .
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "update boot scripts"
|
git commit -m "update boot scripts"
|
||||||
git push https://bookworm:[密码]@code.letcareme.com/bookworm/bookworm-boot.git main
|
git push https://bookworm:[密码]@code.letcareme.com/bookworm/bookworm-boot.git main
|
||||||
|
|
||||||
---- 6.3 重新加密凭证 ----
|
---- 6.3 重新加密凭证 ----
|
||||||
|
|
||||||
pwsh -ExecutionPolicy Bypass -File C:\Users\leesu\Documents\bookworm-portable\encrypt-secrets.ps1
|
pwsh -ExecutionPolicy Bypass -File C:\Users\leesu\Desktop\bookworm-portable\encrypt-secrets.ps1
|
||||||
# 加密完成后同步到 boot 仓库 (参照 6.2)
|
# 加密完成后同步到 boot 仓库 (参照 6.2)
|
||||||
|
|
||||||
---- 6.4 解密验证 ----
|
---- 6.4 解密验证 ----
|
||||||
|
|
||||||
pwsh -ExecutionPolicy Bypass -File C:\Users\leesu\Documents\bookworm-portable\encrypt-secrets.ps1 -Decrypt
|
pwsh -ExecutionPolicy Bypass -File C:\Users\leesu\Desktop\bookworm-portable\encrypt-secrets.ps1 -Decrypt
|
||||||
|
|
||||||
---- 6.5 更新 Bookworm Web 下载页的安装器 ----
|
---- 6.5 更新 Bookworm Web 下载页的安装器 ----
|
||||||
|
|
||||||
scp C:\Users\leesu\Documents\bookworm-portable\Bookworm-Setup.bat root@8.138.11.105:/opt/bookworm-web/public/
|
scp C:\Users\leesu\Desktop\bookworm-portable\Bookworm-Setup.bat root@8.138.11.105:/opt/bookworm-web/public/
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
七、SSH 服务器管理
|
七、SSH 服务器管理
|
||||||
@ -279,6 +279,7 @@
|
|||||||
├── deploy-gitea.sh Gitea 部署 (ECS)
|
├── deploy-gitea.sh Gitea 部署 (ECS)
|
||||||
├── setup-https.sh HTTPS 配置 (ECS)
|
├── setup-https.sh HTTPS 配置 (ECS)
|
||||||
├── secure-firewall.sh 防火墙加固 (ECS)
|
├── secure-firewall.sh 防火墙加固 (ECS)
|
||||||
|
├── settings.template.json settings.json 模板
|
||||||
├── settings.local.template.json settings.local.json 模板
|
├── settings.local.template.json settings.local.json 模板
|
||||||
├── guide.html HTML 保姆式教程
|
├── guide.html HTML 保姆式教程
|
||||||
├── quick-reference.txt 本文档
|
├── quick-reference.txt 本文档
|
||||||
|
|||||||
@ -74,31 +74,7 @@
|
|||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Bookworm <span>Portable</span> 日常速查卡</h1>
|
<h1>Bookworm <span>Portable</span> 日常速查卡</h1>
|
||||||
<div class="ver">v1.5 | 92 Skills / 18 Agents / 34 Hooks<br>打印后贴在显示器旁边</div>
|
<div class="ver">v1.5 | 92 Skills / 18 Agents / 29 Hooks<br>打印后贴在显示器旁边</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ==================== 安装前准备 ==================== -->
|
|
||||||
<div class="section">
|
|
||||||
<span class="section-title red">安装前准备 (仅首次)</span>
|
|
||||||
|
|
||||||
<div class="card warn">
|
|
||||||
<h3>⚠ 首次运行 Bookworm-Setup.exe 前,请确认以下两项</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th style="width:30%">检查项</th><th>操作</th></tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>1. PowerShell 执行策略</strong></td>
|
|
||||||
<td>按 <kbd>Win</kbd>+<kbd>R</kbd> 输入 <code>powershell</code> 回车,执行:<br>
|
|
||||||
<code>Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force</code><br>
|
|
||||||
<span style="font-size:8pt;color:#64748b">否则 npm/node 脚本会被系统阻止运行</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>2. 代理已开启</strong></td>
|
|
||||||
<td>启动 Clash / V2Ray / 快柠檬(任选一个),安装器需要下载依赖</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="font-size:8pt;color:#64748b;margin-top:4px">如果电脑没有 winget(Win10 旧版本),安装器可能无法自动装 Node.js 和 Git。请手动安装:<br>
|
|
||||||
Node.js → <code>https://nodejs.org</code> | Git → <code>https://git-scm.com</code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== 打开 PowerShell ==================== -->
|
<!-- ==================== 打开 PowerShell ==================== -->
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
#!/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 <HOST>
|
|
||||||
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 "========================================="
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
{
|
|
||||||
"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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
163
settings.template.json
Normal file
163
settings.template.json
Normal file
@ -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
|
||||||
|
}
|
||||||
616
setup-all.js
616
setup-all.js
@ -1,616 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
});
|
|
||||||
119
setup-https.sh
119
setup-https.sh
@ -1,119 +0,0 @@
|
|||||||
#!/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 "========================================="
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* sync-version.js — 从 stats-compiled.json 同步版本号到 boot 仓库文件
|
|
||||||
*
|
|
||||||
* 用法: node sync-version.js [--dry-run]
|
|
||||||
*
|
|
||||||
* 替换规则:
|
|
||||||
* {N} Skills → 从 stats.summary.skills
|
|
||||||
* {N} Agents → 从 stats.summary.agents
|
|
||||||
* {N} Hooks → 从 stats.summary.hooks (settings.json 注册的总数)
|
|
||||||
*/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const DRY_RUN = process.argv.includes('--dry-run');
|
|
||||||
const BOOT_DIR = __dirname;
|
|
||||||
const STATS_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'stats-compiled.json');
|
|
||||||
|
|
||||||
// 读取 stats
|
|
||||||
let stats;
|
|
||||||
try {
|
|
||||||
stats = JSON.parse(fs.readFileSync(STATS_PATH, 'utf8'));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(` [FAIL] 无法读取 ${STATS_PATH}: ${e.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const skills = stats.summary.skills;
|
|
||||||
const agents = stats.summary.agents;
|
|
||||||
const hooks = stats.summary.hooks;
|
|
||||||
const version = stats.summary?.version || stats.version || 'v6.5.1';
|
|
||||||
|
|
||||||
console.log(` sync-version: ${skills} Skills / ${agents} Agents / ${hooks} Hooks (${version})`);
|
|
||||||
|
|
||||||
// 需要更新的文件 (相对于 boot 仓库)
|
|
||||||
const FILES = [
|
|
||||||
'Bookworm-Setup.sh',
|
|
||||||
'Bookworm-Setup.bat',
|
|
||||||
'Bookworm-OneClick.bat',
|
|
||||||
'Bookworm-OneClick-Win10.bat',
|
|
||||||
'Bookworm-OneClick-Mac.sh',
|
|
||||||
'install.ps1',
|
|
||||||
'guide.html',
|
|
||||||
'guide-mac.html',
|
|
||||||
'quick-start.html',
|
|
||||||
'quick-reference.txt',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 替换模式: 匹配 "数字 Skills"、"数字 Agents"、"数字 Hooks"
|
|
||||||
// 支持多种分隔格式: "92 Skills"、"92 个 Skills"、"<strong>92</strong> Skills"
|
|
||||||
const REPLACEMENTS = [
|
|
||||||
// 纯文本格式: "92 Skills"、"92 个 Skills"
|
|
||||||
{ pattern: /\b\d+ Skills/g, replacement: `${skills} Skills` },
|
|
||||||
{ pattern: /\b\d+ Agents/g, replacement: `${agents} Agents` },
|
|
||||||
{ pattern: /\b\d+ Hooks/g, replacement: `${hooks} Hooks` },
|
|
||||||
{ pattern: /\b\d+ 个 Skills/g, replacement: `${skills} 个 Skills` },
|
|
||||||
// HTML badge 格式: <strong>92</strong> Skills
|
|
||||||
{ pattern: /<strong>\d+<\/strong> Skills/g, replacement: `<strong>${skills}</strong> Skills` },
|
|
||||||
{ pattern: /<strong>\d+<\/strong> Agents/g, replacement: `<strong>${agents}</strong> Agents` },
|
|
||||||
{ pattern: /<strong>\d+<\/strong> Hooks/g, replacement: `<strong>${hooks}</strong> Hooks` },
|
|
||||||
// PS1 格式: "92 Skills / 18 Agents / 34 Hooks"
|
|
||||||
{ pattern: /\b\d+ Skills \/ \d+ Agents \/ \d+ Hooks/g, replacement: `${skills} Skills / ${agents} Agents / ${hooks} Hooks` },
|
|
||||||
// bat 格式: "92 Skills / 18 Agents"
|
|
||||||
{ pattern: /\b\d+ Skills \/ \d+ Agents/g, replacement: `${skills} Skills / ${agents} Agents` },
|
|
||||||
// "Bookworm (92 Skills)" / "Bookworm - 92 Skills"
|
|
||||||
{ pattern: /Bookworm \(\d+ Skills\)/g, replacement: `Bookworm (${skills} Skills)` },
|
|
||||||
{ pattern: /Bookworm - \d+ Skills/g, replacement: `Bookworm - ${skills} Skills` },
|
|
||||||
];
|
|
||||||
|
|
||||||
let totalChanged = 0;
|
|
||||||
|
|
||||||
for (const file of FILES) {
|
|
||||||
const filePath = path.join(BOOT_DIR, file);
|
|
||||||
if (!fs.existsSync(filePath)) continue;
|
|
||||||
|
|
||||||
let content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const original = content;
|
|
||||||
|
|
||||||
for (const { pattern, replacement } of REPLACEMENTS) {
|
|
||||||
content = content.replace(pattern, replacement);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content !== original) {
|
|
||||||
if (!DRY_RUN) {
|
|
||||||
fs.writeFileSync(filePath, content, 'utf8');
|
|
||||||
}
|
|
||||||
totalChanged++;
|
|
||||||
console.log(` ${DRY_RUN ? '[DRY] ' : ''}更新: ${file}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` 同步完成: ${totalChanged}/${FILES.length} 个文件更新`);
|
|
||||||
if (DRY_RUN) console.log(' [DRY RUN] 未实际写入');
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
# Bookworm Portable 启动器 bat 生成工具 (v3.0.6)
|
|
||||||
# 用途: 从单一明文 PowerShell 脚本生成两个 bat, 避免手工同步 Base64 字符串不一致
|
|
||||||
# 用法: pwsh -NoProfile -File tools/gen-launcher-bats.ps1
|
|
||||||
# 输出: 启动Bookworm.bat + 更新并启动Bookworm.bat (覆盖写入)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
$launchBat = Join-Path $repoRoot "启动Bookworm.bat"
|
|
||||||
$updateBat = Join-Path $repoRoot "更新并启动Bookworm.bat"
|
|
||||||
|
|
||||||
# ─── 明文: 三层 PATH 修复 + DPAPI 加载 + claude 诊断 + 启动 ─────────
|
|
||||||
# v3.0.9: 增加 npm config get prefix 动态查询, 兼容 nvm/fnm/Program Files 等非标准 npm 位置
|
|
||||||
$plainScript = @'
|
|
||||||
Add-Type -AssemblyName System.Security
|
|
||||||
# 层 1: Machine + User env PATH (标准 Windows 环境变量)
|
|
||||||
$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')
|
|
||||||
# 层 2: npm config get prefix (真实 npm 全局目录, 兼容 nvm/fnm/标准安装/Program Files)
|
|
||||||
try {
|
|
||||||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
|
||||||
if ($npmPrefix -and (Test-Path $npmPrefix) -and ($env:Path -notlike "*$npmPrefix*")) {
|
|
||||||
$env:Path = "$npmPrefix;$env:Path"
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
# 层 3: 常见 npm global 硬编码兜底 (npm 本身不在 PATH 时无法 query)
|
|
||||||
$npmCandidates = @(
|
|
||||||
"$env:APPDATA\npm",
|
|
||||||
"$env:ProgramFiles\nodejs",
|
|
||||||
"${env:ProgramFiles(x86)}\nodejs",
|
|
||||||
"$env:LOCALAPPDATA\npm"
|
|
||||||
)
|
|
||||||
foreach ($p in $npmCandidates) {
|
|
||||||
if (-not (Test-Path $p)) { continue }
|
|
||||||
$hasClaude = (Test-Path (Join-Path $p 'claude.ps1')) -or (Test-Path (Join-Path $p 'claude.cmd')) -or (Test-Path (Join-Path $p 'claude'))
|
|
||||||
if ($hasClaude -and ($env:Path -notlike "*$p*")) {
|
|
||||||
$env:Path = "$p;$env:Path"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# DPAPI 加载缓存凭证
|
|
||||||
$r = 'HKCU:\Software\Bookworm\CachedEnv'
|
|
||||||
try {
|
|
||||||
(Get-ItemProperty $r -EA Stop).PSObject.Properties | Where-Object { $_.Name -match '^[A-Z_]+$' } | ForEach-Object {
|
|
||||||
$v = $_.Value
|
|
||||||
try {
|
|
||||||
$b = [Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($v), $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
||||||
$v = [Text.Encoding]::UTF8.GetString($b)
|
|
||||||
} catch {}
|
|
||||||
[Environment]::SetEnvironmentVariable($_.Name, $v, 'Process')
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
if (-not (Get-Command claude -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host ' [!] claude 命令未找到 (已尝试 3 层 PATH 修复仍失败)' -ForegroundColor Red
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host ' 诊断信息:' -ForegroundColor Yellow
|
|
||||||
Write-Host " npm prefix: $(try { (& npm config get prefix 2>$null) } catch { '(npm 不可用)' })" -ForegroundColor Gray
|
|
||||||
Write-Host ' PATH 片段 (npm/nodejs/pwsh/Git):' -ForegroundColor Gray
|
|
||||||
($env:Path -split ';') | Where-Object { $_ -match 'npm|nodejs|pwsh|Git' } | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host ' 修复: 重新运行 Bookworm-Setup.exe (v3.0.9+) 即可自动补全' -ForegroundColor Green
|
|
||||||
Write-Host ''
|
|
||||||
Read-Host '按回车关闭'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
& claude --dangerously-skip-permissions
|
|
||||||
'@
|
|
||||||
|
|
||||||
# ─── Base64-UTF-16LE 编码 ─────────────────────────────────
|
|
||||||
$bytes = [System.Text.Encoding]::Unicode.GetBytes($plainScript)
|
|
||||||
$enc = [Convert]::ToBase64String($bytes)
|
|
||||||
|
|
||||||
# 健康检查
|
|
||||||
if ($enc.Length -gt 7500) { throw "Base64 长度 $($enc.Length) 超 bat 变量安全上限 7500" }
|
|
||||||
$bad = $enc -replace '[A-Za-z0-9+/=]', ''
|
|
||||||
if ($bad) { throw "Base64 含非法字符: [$bad]" }
|
|
||||||
|
|
||||||
Write-Host "[gen-launcher-bats] Base64 长度: $($enc.Length), 纯字符集检查 OK" -ForegroundColor Green
|
|
||||||
|
|
||||||
# ─── bat 1: 启动Bookworm.bat ──────────────────────────────
|
|
||||||
$launch = @"
|
|
||||||
@echo off
|
|
||||||
chcp 65001 > nul
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
:: 中转站在国内,不走代理
|
|
||||||
set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1
|
|
||||||
set no_proxy=%NO_PROXY%
|
|
||||||
|
|
||||||
:: 静默自动更新 (bookworm-boot + .claude 配置, 失败不阻断启动)
|
|
||||||
echo [..] 检查更新...
|
|
||||||
git pull --rebase >nul 2>nul
|
|
||||||
git -C "%USERPROFILE%\.claude" pull --rebase >nul 2>nul
|
|
||||||
|
|
||||||
set USE_WT=0
|
|
||||||
where wt >nul 2>nul && set USE_WT=1
|
|
||||||
|
|
||||||
set USE_PWSH7=0
|
|
||||||
where pwsh >nul 2>nul && set USE_PWSH7=1
|
|
||||||
|
|
||||||
:: v3.0.6: Base64-UTF-16LE (PATH 重载 + DPAPI 凭证加载 + claude 诊断 + 启动)
|
|
||||||
:: 纯 A-Za-z0-9+/= 字符集, 避免 wt.exe 的 ';' 切 tab 误切 (修复 64856bc 症状一)
|
|
||||||
:: -d "%CD%" 无尾反斜杠, 避免 -d "%~dp0" 的转义引号 (修复 0c33109 症状二)
|
|
||||||
:: 重新生成: pwsh -NoProfile -File tools/gen-launcher-bats.ps1
|
|
||||||
set ENC=$enc
|
|
||||||
|
|
||||||
:: 优先路径: wt + pwsh7
|
|
||||||
if %USE_WT% equ 1 if %USE_PWSH7% equ 1 (
|
|
||||||
start "" wt new-tab --title "Bookworm Smart Assistant" -d "%CD%" -- pwsh -NoLogo -NoExit -EncodedCommand %ENC%
|
|
||||||
exit
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 路径 2: wt + powershell 5.1
|
|
||||||
if %USE_WT% equ 1 if %USE_PWSH7% equ 0 (
|
|
||||||
start "" wt new-tab --title "Bookworm Smart Assistant" -d "%CD%" -- powershell -NoLogo -ExecutionPolicy Bypass -NoExit -EncodedCommand %ENC%
|
|
||||||
exit
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 路径 3: conhost + pwsh7 (无 wt 就不会有 ; 切 tab 问题, 但仍用 Base64 统一)
|
|
||||||
if %USE_PWSH7% equ 1 (
|
|
||||||
start "Bookworm Smart Assistant" pwsh -NoLogo -NoExit -EncodedCommand %ENC%
|
|
||||||
exit
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 路径 4: 回退 PowerShell 5.1 (最低保障, 交给 install.ps1 -StartOnly 处理)
|
|
||||||
title Bookworm Portable
|
|
||||||
echo.
|
|
||||||
echo [!] PowerShell 7 未安装, 使用 PowerShell 5.1
|
|
||||||
echo.
|
|
||||||
powershell -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo.
|
|
||||||
echo 启动失败,按任意键退出...
|
|
||||||
pause > nul
|
|
||||||
)
|
|
||||||
"@
|
|
||||||
|
|
||||||
# ─── bat 2: 更新并启动Bookworm.bat ───────────────────────
|
|
||||||
$update = @"
|
|
||||||
@echo off
|
|
||||||
chcp 65001 > nul
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
:: 中转站在国内,不走代理
|
|
||||||
set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1
|
|
||||||
set no_proxy=%NO_PROXY%
|
|
||||||
|
|
||||||
:: 静默自动更新 (bookworm-boot + .claude 配置)
|
|
||||||
echo [..] 同步更新...
|
|
||||||
git pull --rebase >nul 2>nul
|
|
||||||
git -C "%USERPROFILE%\.claude" pull --rebase >nul 2>nul
|
|
||||||
|
|
||||||
:: v3.0.6: 同启动Bookworm.bat 的 Base64 (DPAPI + PATH 重载 + claude 启动)
|
|
||||||
set ENC=$enc
|
|
||||||
|
|
||||||
:: 检测 pwsh7 可用性
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
:: pwsh7: 先同步配置 (SkipLaunch 不启动 claude), 再用 -EncodedCommand 在新窗口启动
|
|
||||||
pwsh -NoLogo -ExecutionPolicy Bypass -File "%~dp0install.ps1" -AutoAccept -SkipLaunch
|
|
||||||
start "Bookworm Smart Assistant" pwsh -NoLogo -NoExit -EncodedCommand %ENC%
|
|
||||||
exit
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 回退 PowerShell 5.1: 一次调用完成更新+加载凭证+启动 (消除双次调用)
|
|
||||||
title Bookworm Portable
|
|
||||||
powershell -ExecutionPolicy Bypass -File "%~dp0install.ps1" -AutoAccept
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo.
|
|
||||||
echo 启动失败,按任意键退出...
|
|
||||||
pause > nul
|
|
||||||
)
|
|
||||||
"@
|
|
||||||
|
|
||||||
# ─── 写入 ─────────────────────────────────────────────────
|
|
||||||
# bat 文件默认期望 GBK/ANSI, 但脚本顶部 chcp 65001 已切换到 UTF-8, 用无 BOM UTF-8 写入
|
|
||||||
[System.IO.File]::WriteAllText($launchBat, $launch, [System.Text.UTF8Encoding]::new($false))
|
|
||||||
[System.IO.File]::WriteAllText($updateBat, $update, [System.Text.UTF8Encoding]::new($false))
|
|
||||||
Write-Host "[gen-launcher-bats] ✓ 启动Bookworm.bat ($((Get-Item $launchBat).Length) bytes)" -ForegroundColor Green
|
|
||||||
Write-Host "[gen-launcher-bats] ✓ 更新并启动Bookworm.bat ($((Get-Item $updateBat).Length) bytes)" -ForegroundColor Green
|
|
||||||
|
|
||||||
# ─── Round-trip 验证 (v3.0.10: 除了 PARSE 还要 lint + 实跑不启动 claude) ────
|
|
||||||
$decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($enc))
|
|
||||||
$err = $null
|
|
||||||
[void][System.Management.Automation.Language.Parser]::ParseInput($decoded, [ref]$null, [ref]$err)
|
|
||||||
if ($err) { throw "解码后脚本 PARSE ERR: $($err[0])" }
|
|
||||||
Write-Host "[gen-launcher-bats] ✓ PARSE OK ($($decoded.Length) chars)" -ForegroundColor Green
|
|
||||||
|
|
||||||
# 实跑验证 (v3.0.10: 截断到 & claude 之前, 只跑 PATH 修复 + DPAPI 加载, 不启动 claude)
|
|
||||||
# 这能抓出 PARSE 通过但运行时报错的 bug (例如 -or 被当 Test-Path 参数)
|
|
||||||
$runnable = $decoded -replace '& claude --dangerously-skip-permissions', 'Write-Host "__BW_DRYRUN_OK__"'
|
|
||||||
$tmpPs1 = Join-Path $env:TEMP "bw-launcher-dryrun-$(Get-Random).ps1"
|
|
||||||
Set-Content -Path $tmpPs1 -Value $runnable -Encoding UTF8
|
|
||||||
try {
|
|
||||||
$dryRunOutput = (& pwsh -NoProfile -ExecutionPolicy Bypass -File $tmpPs1 2>&1 | Out-String)
|
|
||||||
# 只抓真正的 PS 错误 (ErrorRecord / cannot be found / parameter name / 等)
|
|
||||||
$errorPatterns = @(
|
|
||||||
'cannot be found that matches parameter name',
|
|
||||||
'A parameter cannot be found',
|
|
||||||
'CommandNotFoundException',
|
|
||||||
'ParameterBindingException',
|
|
||||||
'is not recognized as',
|
|
||||||
'cannot find.*because it does not exist',
|
|
||||||
'RuntimeException'
|
|
||||||
)
|
|
||||||
$hasError = $false
|
|
||||||
foreach ($pat in $errorPatterns) {
|
|
||||||
if ($dryRunOutput -match $pat) { $hasError = $true; break }
|
|
||||||
}
|
|
||||||
# 必须看到 dry-run 成功标记才算通过
|
|
||||||
$reachedEnd = $dryRunOutput -match '__BW_DRYRUN_OK__'
|
|
||||||
if ($hasError -or -not $reachedEnd) {
|
|
||||||
Write-Host "[gen-launcher-bats] ✗ 实跑验证失败:" -ForegroundColor Red
|
|
||||||
Write-Host $dryRunOutput -ForegroundColor DarkRed
|
|
||||||
throw "Base64 解码后脚本运行时错误 (hasError=$hasError, reachedEnd=$reachedEnd)"
|
|
||||||
}
|
|
||||||
Write-Host "[gen-launcher-bats] ✓ 实跑通过 (dry-run 到达 __BW_DRYRUN_OK__ 标记)" -ForegroundColor Green
|
|
||||||
} finally {
|
|
||||||
Remove-Item $tmpPs1 -Force -EA SilentlyContinue
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
v3.1.1: 启动器 .lnk 端到端行为测试
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
模拟双击桌面 .lnk → pwsh → bw-launch.ps1 → claude.ps1 链路.
|
|
||||||
检测点:
|
|
||||||
1. PS 进程能拉起 (无 ExecutionPolicy 拒绝 / 无 wt 切 tab 错)
|
|
||||||
2. bw-launch.ps1 PATH 三层重载执行无错
|
|
||||||
3. claude.ps1 解析能找到 (不一定真启动 Claude TUI, 用 --version 探测)
|
|
||||||
4. 整体退出码 = 0 (任何运行时错就拒绝)
|
|
||||||
|
|
||||||
替代手动双击, 集成到 build.ps1 后自动跑, 提前抓 v3.0.10 -or 类 bug.
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
用法: pwsh -NoProfile -File tools/test-launcher-e2e.ps1
|
|
||||||
退出码: 0=PASS / 1=FAIL
|
|
||||||
日志: $env:TEMP\bw-e2e-test.log
|
|
||||||
#>
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$bwLaunchPs1 = Join-Path (Split-Path -Parent $PSScriptRoot) "bw-launch.ps1"
|
|
||||||
$logFile = Join-Path $env:TEMP "bw-e2e-test.log"
|
|
||||||
|
|
||||||
if (-not (Test-Path $bwLaunchPs1)) {
|
|
||||||
Write-Host "[FAIL] bw-launch.ps1 缺失: $bwLaunchPs1" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[e2e] 测试目标: $bwLaunchPs1" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# Test 1: PS 解析 wrapper 文件 (静态 syntax)
|
|
||||||
$err = $null
|
|
||||||
[void][System.Management.Automation.Language.Parser]::ParseFile($bwLaunchPs1, [ref]$null, [ref]$err)
|
|
||||||
if ($err) {
|
|
||||||
Write-Host "[FAIL] bw-launch.ps1 PARSE 错: $($err.Count)" -ForegroundColor Red
|
|
||||||
$err | Select-Object -First 3 | ForEach-Object { Write-Host " L$($_.Extent.StartLineNumber): $($_.Message)" -ForegroundColor Red }
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "[e2e ✓] Test 1 — bw-launch.ps1 PARSE OK" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Test 2: 实跑 wrapper, claude 不可用场景 (期望 GUI 弹窗 + 退出码 1, 不是闪退)
|
|
||||||
# 用临时 PATH 隔离 claude
|
|
||||||
$pwshExe = (Get-Command pwsh -EA SilentlyContinue).Source
|
|
||||||
if (-not $pwshExe) {
|
|
||||||
Write-Host "[SKIP] pwsh 不可用, 跳过实跑测试" -ForegroundColor Yellow
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 给 wrapper 一个无 claude 的 PATH 子环境, 验证 stale 路径分支不闪退
|
|
||||||
$testScript = @'
|
|
||||||
# 隔离 PATH 让 claude 找不到
|
|
||||||
$env:Path = "C:\Windows\System32"
|
|
||||||
# 关掉 GUI 弹窗 (CI 无 desktop)
|
|
||||||
[System.Reflection.Assembly]::Load('System.Windows.Forms') | Out-Null
|
|
||||||
$env:BW_E2E_NOGUI = "1"
|
|
||||||
'@
|
|
||||||
|
|
||||||
Write-Host "[e2e] Test 2 — wrapper 静态分析 (运行时缺 claude 应清晰报错而非闪退)" -ForegroundColor Cyan
|
|
||||||
$content = Get-Content $bwLaunchPs1 -Raw
|
|
||||||
$expectedFeatures = @{
|
|
||||||
"PATH 三层重载" = ($content -match 'GetEnvironmentVariable.*Machine' -and $content -match 'npm config get prefix')
|
|
||||||
"claude.ps1 fallback 链" = ($content -match 'claude\.ps1' -and $content -match 'Get-Command claude')
|
|
||||||
"失败 GUI 弹窗" = ($content -match 'MessageBox\]::Show')
|
|
||||||
"失败日志写入" = ($content -match 'bw-launch\.log')
|
|
||||||
"args 转发到 claude" = ($content -match '\$claudePs1.*@args' -or $content -match '\$args')
|
|
||||||
"exit code 传播" = ($content -match 'exit \$exitCode' -or $content -match 'exit \$LASTEXITCODE')
|
|
||||||
}
|
|
||||||
$failedFeatures = $expectedFeatures.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
|
|
||||||
if ($failedFeatures) {
|
|
||||||
Write-Host "[FAIL] wrapper 缺关键特性:" -ForegroundColor Red
|
|
||||||
$failedFeatures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "[e2e ✓] Test 2 — wrapper 6 项特性齐全" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Test 3: .lnk Args 4 项契约验证 (auto-setup.ps1 / install.ps1 一致性)
|
|
||||||
Write-Host "[e2e] Test 3 — .lnk Args 契约 (auto-setup + install 双向一致)" -ForegroundColor Cyan
|
|
||||||
$autoSetup = Get-Content (Join-Path (Split-Path -Parent $PSScriptRoot) "auto-setup.ps1") -Raw
|
|
||||||
$installPs1 = Get-Content (Join-Path (Split-Path -Parent $PSScriptRoot) "install.ps1") -Raw
|
|
||||||
|
|
||||||
$contractChecks = @{
|
|
||||||
"auto-setup.ps1 .lnk Args 含 -ExecutionPolicy Bypass" = ($autoSetup -match '\$shortcut\.Arguments\s*=[^\r\n]*-ExecutionPolicy Bypass')
|
|
||||||
"auto-setup.ps1 .lnk Args 含 bwLaunchPs1 变量" = ($autoSetup -match '\$shortcut\.Arguments\s*=[^\r\n]*\$bwLaunchPs1')
|
|
||||||
"auto-setup.ps1 .lnk Args 含 --skip-permissions" = ($autoSetup -match '\$shortcut\.Arguments\s*=[^\r\n]*dangerously-skip-permissions')
|
|
||||||
"install.ps1 .lnk Args 含 -ExecutionPolicy Bypass" = ($installPs1 -match '\$shortcut\.Arguments\s*=[^\r\n]*-ExecutionPolicy Bypass')
|
|
||||||
"install.ps1 .lnk Args 含 bwLaunchPs1 变量" = ($installPs1 -match '\$shortcut\.Arguments\s*=[^\r\n]*\$bwLaunchPs1')
|
|
||||||
"install.ps1 .lnk Args 含 --skip-permissions" = ($installPs1 -match '\$shortcut\.Arguments\s*=[^\r\n]*dangerously-skip-permissions')
|
|
||||||
}
|
|
||||||
$contractFails = $contractChecks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
|
|
||||||
if ($contractFails) {
|
|
||||||
Write-Host "[FAIL] .lnk Args 契约不一致:" -ForegroundColor Red
|
|
||||||
$contractFails | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "[e2e ✓] Test 3 — .lnk Args 6 项契约一致" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Test 4: profile 双注入契约
|
|
||||||
Write-Host "[e2e] Test 4 — profile 双注入契约 (PS7 + PS5.1)" -ForegroundColor Cyan
|
|
||||||
$profileChecks = @{
|
|
||||||
"auto-setup.ps1 注入 PS7 profile (Documents\PowerShell)" = ($autoSetup -match 'Documents\\PowerShell.*profile\.ps1' -or $autoSetup -match 'Documents\\\\PowerShell')
|
|
||||||
"auto-setup.ps1 注入 PS5.1 profile (Documents\WindowsPowerShell)" = ($autoSetup -match 'Documents\\WindowsPowerShell' -or $autoSetup -match 'WindowsPowerShell')
|
|
||||||
"auto-setup.ps1 sentinel BW_CRED_START v3.1.0" = ($autoSetup -match 'BW_CRED_START v3\.1\.0')
|
|
||||||
"auto-setup.ps1 字面替换 (String.Replace)" = ($autoSetup -match '\.Replace\(\$match\.Value')
|
|
||||||
}
|
|
||||||
$profileFails = $profileChecks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
|
|
||||||
if ($profileFails) {
|
|
||||||
Write-Host "[FAIL] profile 注入契约缺失:" -ForegroundColor Red
|
|
||||||
$profileFails | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "[e2e ✓] Test 4 — profile 双注入契约齐全" -ForegroundColor Green
|
|
||||||
|
|
||||||
# All passed
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "━━━ E2E 测试 SUMMARY ━━━" -ForegroundColor Green
|
|
||||||
Write-Host " Test 1: bw-launch.ps1 PARSE ✓"
|
|
||||||
Write-Host " Test 2: wrapper 6 项特性齐全 ✓"
|
|
||||||
Write-Host " Test 3: .lnk Args 6 项契约一致 ✓"
|
|
||||||
Write-Host " Test 4: profile 双注入契约齐全 ✓"
|
|
||||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
|
|
||||||
Write-Host "[PASS] E2E 测试通过 (4/4)" -ForegroundColor Green
|
|
||||||
exit 0
|
|
||||||
181
uninstall-mac.sh
181
uninstall-mac.sh
@ -1,181 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# Bookworm Portable - macOS 卸载脚本
|
|
||||||
# 对标 Windows 版 stop.ps1 + 卸载Bookworm.bat
|
|
||||||
#
|
|
||||||
# 用法:
|
|
||||||
# bash uninstall-mac.sh # 基础清理 (保留配置)
|
|
||||||
# bash uninstall-mac.sh --restore # 完整恢复 (删除 Bookworm)
|
|
||||||
# bash uninstall-mac.sh --deep # 深度清理 (含历史+凭证)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
# 路径
|
|
||||||
CLAUDE_DIR="$HOME/.claude"
|
|
||||||
BACKUP_DIR="$HOME/.claude.bw-backup"
|
|
||||||
BOOT_DIR="$HOME/bookworm-boot"
|
|
||||||
|
|
||||||
# 参数解析
|
|
||||||
RESTORE=false
|
|
||||||
DEEP=false
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--restore) RESTORE=true ;;
|
|
||||||
--deep) DEEP=true ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
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"; }
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN} Bookworm Portable - macOS 卸载${NC}"
|
|
||||||
echo -e "${CYAN} ================================${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if $RESTORE || $DEEP; then
|
|
||||||
echo -e " 将执行:"
|
|
||||||
echo -e " - 终止 Claude Code 进程"
|
|
||||||
echo -e " - 清除环境变量"
|
|
||||||
$RESTORE && echo -e " - 删除 ~/.claude 配置目录"
|
|
||||||
$RESTORE && echo -e " - 恢复原始 .claude 备份 (如有)"
|
|
||||||
$DEEP && echo -e " - 清除 shell 历史敏感条目"
|
|
||||||
$DEEP && echo -e " - 清除 Git 凭证 (Keychain)"
|
|
||||||
$DEEP && echo -e " - 清除终端别名"
|
|
||||||
echo ""
|
|
||||||
read -p " 确认卸载? (y/n): " CONFIRM
|
|
||||||
if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
|
|
||||||
echo " 已取消"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ─── 1/5: 终止 Claude Code 进程 ───
|
|
||||||
echo -e "${BOLD} [1/5]${NC} 终止 Claude Code 进程..."
|
|
||||||
|
|
||||||
CLAUDE_PIDS=$(pgrep -f "claude" 2>/dev/null || true)
|
|
||||||
if [ -n "$CLAUDE_PIDS" ]; then
|
|
||||||
kill $CLAUDE_PIDS 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
# 强制终止残留
|
|
||||||
kill -9 $CLAUDE_PIDS 2>/dev/null || true
|
|
||||||
success "进程已终止"
|
|
||||||
else
|
|
||||||
info "无 Claude Code 进程运行"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 清理残留 node hook 进程
|
|
||||||
NODE_HOOK_PIDS=$(pgrep -f "\.claude/hooks" 2>/dev/null || true)
|
|
||||||
if [ -n "$NODE_HOOK_PIDS" ]; then
|
|
||||||
kill $NODE_HOOK_PIDS 2>/dev/null || true
|
|
||||||
info "Hook 子进程已清理"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 2/5: 清除环境变量 ───
|
|
||||||
echo -e "${BOLD} [2/5]${NC} 清除环境变量..."
|
|
||||||
|
|
||||||
ENV_VARS=(
|
|
||||||
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
|
|
||||||
)
|
|
||||||
CLEARED=0
|
|
||||||
for v in "${ENV_VARS[@]}"; do
|
|
||||||
if [ -n "${!v}" ]; then
|
|
||||||
unset "$v"
|
|
||||||
CLEARED=$((CLEARED + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
success "已清除 $CLEARED 个环境变量"
|
|
||||||
|
|
||||||
# ─── 3/5: Git 凭证清除 ───
|
|
||||||
echo -e "${BOLD} [3/5]${NC} 清除 Git 凭证缓存..."
|
|
||||||
|
|
||||||
# macOS Keychain 凭证
|
|
||||||
for host in code.letcareme.com 8.138.11.105; do
|
|
||||||
security delete-internet-password -s "$host" 2>/dev/null && info "Keychain: $host 已清除" || true
|
|
||||||
# git credential reject
|
|
||||||
printf "protocol=https\nhost=%s\n\n" "$host" | git credential reject 2>/dev/null || true
|
|
||||||
done
|
|
||||||
success "Git 凭证已清除"
|
|
||||||
|
|
||||||
# ─── 4/5: 恢复/删除 .claude 目录 ───
|
|
||||||
if $RESTORE; then
|
|
||||||
echo -e "${BOLD} [4/5]${NC} 恢复原始 .claude 目录..."
|
|
||||||
|
|
||||||
if [ -d "$CLAUDE_DIR" ]; then
|
|
||||||
rm -rf "$CLAUDE_DIR"
|
|
||||||
info "已删除 Bookworm 配置"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$BACKUP_DIR" ]; then
|
|
||||||
mv "$BACKUP_DIR" "$CLAUDE_DIR"
|
|
||||||
success "原始 .claude 已恢复"
|
|
||||||
else
|
|
||||||
warn "无备份可恢复 (.claude.bw-backup 不存在)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 删除 boot 仓库
|
|
||||||
if [ -d "$BOOT_DIR" ]; then
|
|
||||||
rm -rf "$BOOT_DIR"
|
|
||||||
info "已删除 ~/bookworm-boot"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${BOLD} [4/5]${NC} 保留 Bookworm 配置 (使用 --restore 可恢复原始)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 5/5: 深度清理 ───
|
|
||||||
if $DEEP; then
|
|
||||||
echo -e "${BOLD} [5/5]${NC} 深度清理..."
|
|
||||||
|
|
||||||
# 清除 shell 历史中的敏感条目
|
|
||||||
for histfile in "$HOME/.zsh_history" "$HOME/.bash_history"; do
|
|
||||||
if [ -f "$histfile" ]; then
|
|
||||||
BEFORE=$(wc -l < "$histfile")
|
|
||||||
grep -v -i -E 'secrets\.enc|ANTHROPIC_API_KEY|api[_-]?key|bookworm-portable|主密码' "$histfile" > "${histfile}.tmp" 2>/dev/null || true
|
|
||||||
mv "${histfile}.tmp" "$histfile" 2>/dev/null || true
|
|
||||||
AFTER=$(wc -l < "$histfile")
|
|
||||||
info "$(basename $histfile): 清理 $((BEFORE - AFTER)) 条敏感记录"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 清除终端别名
|
|
||||||
for rcfile in "$HOME/.zshrc" "$HOME/.bashrc"; do
|
|
||||||
if [ -f "$rcfile" ] && grep -q "Bookworm Portable aliases" "$rcfile" 2>/dev/null; then
|
|
||||||
# 删除别名块 (标记行 + 后续 alias 行)
|
|
||||||
sed -i '' '/# Bookworm Portable aliases/,/^$/d' "$rcfile" 2>/dev/null || true
|
|
||||||
info "$(basename $rcfile): 别名已清除"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 清除 macOS Keychain 中的 Bookworm 凭证
|
|
||||||
security delete-generic-password -s "bookworm-secrets" 2>/dev/null && info "Keychain: bookworm-secrets 已清除" || true
|
|
||||||
|
|
||||||
success "深度清理完成"
|
|
||||||
else
|
|
||||||
echo -e "${BOLD} [5/5]${NC} 跳过深度清理 (使用 --deep 可清理历史+凭证)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 完成 ───
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN} ================================${NC}"
|
|
||||||
if $RESTORE; then
|
|
||||||
echo -e "${GREEN} Bookworm 已完全卸载${NC}"
|
|
||||||
echo -e "${GREEN} 可安全删除 bookworm-boot 文件夹${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${GREEN} Bookworm 已清理完毕${NC}"
|
|
||||||
echo -e "${GREEN} 配置保留,可重新启动${NC}"
|
|
||||||
fi
|
|
||||||
echo -e "${GREEN} ================================${NC}"
|
|
||||||
echo ""
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
v3.1.3: Bookworm 完整卸载实<EFBFBD><EFBFBD>脚本
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
由 卸载Bookworm.bat 调用. 集中所有 PS 清理逻辑, 避免 cmd 多行 PS 转义.
|
|
||||||
|
|
||||||
清理项:
|
|
||||||
[1] 桌面 4-5 个 .lnk (启动/更新/体检/卸载/旧 Bookworm.lnk)
|
|
||||||
[2] ~/.claude/ 整个目录 (skills/hooks/agents/凭证)
|
|
||||||
[3] HKCU:\Software\Bookworm (DPAPI 缓存 + Toast 备份)
|
|
||||||
[4] PS7 + PS5.1 双 profile.ps1 的 BW_CRED + BW_CLIP 块
|
|
||||||
[5] HKCU 截图 Toast 设置还原 (从备份)
|
|
||||||
[6] ANTHROPIC_* + BW_LICENSE_KEY User 环境变量
|
|
||||||
[7] 提示用户手动删 bookworm-boot 目录
|
|
||||||
|
|
||||||
保留 (公共依赖, 不主动卸):
|
|
||||||
- Node.js / Git / PowerShell 7 / Claude Code (npm i -g)
|
|
||||||
#>
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Continue"
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms -EA SilentlyContinue
|
|
||||||
|
|
||||||
# ── 二次确认 ──
|
|
||||||
$msg = "确定要卸载 Bookworm 吗?`n`n"
|
|
||||||
$msg += "将删除:`n"
|
|
||||||
$msg += " - 桌面 4-5 个快捷方式`n"
|
|
||||||
$msg += " - ~/.claude 中 Bookworm 注入的内容 (skills/hooks/agents/scripts)`n"
|
|
||||||
$msg += " - HKCU DPAPI 凭证缓存`n"
|
|
||||||
$msg += " - PowerShell profile (PS7+PS5.1) 中的 BW_* 块`n"
|
|
||||||
$msg += " - 还原截图 Toast 默认设置`n"
|
|
||||||
$msg += " - ANTHROPIC_* User 环境变量`n`n"
|
|
||||||
$msg += "保留:`n"
|
|
||||||
$msg += " - ~/.claude/CLAUDE.md, memory/, projects/ (用户自有内容)`n"
|
|
||||||
$msg += " - Node.js / Git / PowerShell 7 / Claude Code"
|
|
||||||
|
|
||||||
$confirm = [System.Windows.Forms.MessageBox]::Show($msg, 'Bookworm 卸载二次确认', 'YesNo', 'Warning')
|
|
||||||
if ($confirm -ne 'Yes') {
|
|
||||||
Write-Host "Bookworm 卸载已取消" -ForegroundColor Yellow
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
$logLines = @()
|
|
||||||
function Log { param([string]$m); Write-Host $m -ForegroundColor Cyan; $script:logLines += $m }
|
|
||||||
|
|
||||||
# ── [1/7] 桌面 .lnk ──
|
|
||||||
Log "[1/7] 删桌面快捷方式"
|
|
||||||
$desk = [Environment]::GetFolderPath('Desktop')
|
|
||||||
foreach ($n in @('启动Bookworm.lnk','更新Bookworm.lnk','体检Bookworm.lnk','卸载Bookworm.lnk','Bookworm.lnk')) {
|
|
||||||
$p = Join-Path $desk $n
|
|
||||||
if (Test-Path $p) {
|
|
||||||
try { Remove-Item $p -Force -EA Stop; Log " ✓ 删 $n" }
|
|
||||||
catch { Log " [!] 删 $n 失败: $_" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── [2/7] ~/.claude Bookworm 注入内容 (精准删除, 保留用户自有配置) ──
|
|
||||||
Log "[2/7] 清 ~/.claude/ 中 Bookworm 注入的文件"
|
|
||||||
$claudeDir = Join-Path $env:USERPROFILE '.claude'
|
|
||||||
if (Test-Path $claudeDir) {
|
|
||||||
# 只删 Bookworm 管理的子目录和文件, 不删用户自己的 CLAUDE.md / memory / projects
|
|
||||||
$bwManagedDirs = @('skills', 'hooks', 'agents', 'scripts', 'constitution', 'patches', 'session-state')
|
|
||||||
foreach ($d in $bwManagedDirs) {
|
|
||||||
$dp = Join-Path $claudeDir $d
|
|
||||||
if (Test-Path $dp) {
|
|
||||||
try { Remove-Item $dp -Recurse -Force -EA Stop; Log " ✓ 删 $d/" }
|
|
||||||
catch { Log " [!] 删 $d/ 失败: $_" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# 删 Bookworm 的配置文件 (settings.json 含 hooks 注册)
|
|
||||||
foreach ($f in @('settings.json', 'settings.local.json', 'stats-compiled.json')) {
|
|
||||||
$fp = Join-Path $claudeDir $f
|
|
||||||
if (Test-Path $fp) {
|
|
||||||
try { Remove-Item $fp -Force -EA Stop; Log " ✓ 删 $f" }
|
|
||||||
catch { Log " [!] 删 $f 失败: $_" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log " · 保留 ~/.claude/CLAUDE.md, memory/, projects/ (用户自有内容)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── [3/7] HKCU DPAPI 凭证 + Toast 备份元数据 ──
|
|
||||||
Log "[3/7] 清 HKCU:\Software\Bookworm DPAPI 凭证"
|
|
||||||
# 先读 Toast 备份, 后面 [5] 用
|
|
||||||
$toastOrig = $null
|
|
||||||
$bak = 'HKCU:\Software\Bookworm\ToastBackup'
|
|
||||||
if (Test-Path $bak) {
|
|
||||||
try { $toastOrig = (Get-ItemProperty -Path $bak -Name 'ScreenSketchToast_Original' -EA SilentlyContinue).ScreenSketchToast_Original } catch {}
|
|
||||||
}
|
|
||||||
foreach ($r in @('HKCU:\Software\Bookworm\CachedEnv','HKCU:\Software\Bookworm\ToastBackup','HKCU:\Software\Bookworm')) {
|
|
||||||
if (Test-Path $r) {
|
|
||||||
try { Remove-Item $r -Recurse -Force -EA Stop; Log " ✓ 删 $r" }
|
|
||||||
catch { Log " [!] 删 $r 失败: $_" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── [4/7] profile.ps1 BW_CRED + BW_CLIP 块清理 (PS7+PS5.1) ──
|
|
||||||
Log "[4/7] 清 profile.ps1 BW_CRED + BW_CLIP 块"
|
|
||||||
foreach ($pdir in @(
|
|
||||||
(Join-Path $env:USERPROFILE 'Documents\PowerShell'),
|
|
||||||
(Join-Path $env:USERPROFILE 'Documents\WindowsPowerShell')
|
|
||||||
)) {
|
|
||||||
$pp = Join-Path $pdir 'profile.ps1'
|
|
||||||
if (-not (Test-Path $pp)) { continue }
|
|
||||||
try {
|
|
||||||
$c = Get-Content $pp -Raw -Encoding UTF8
|
|
||||||
$orig = $c.Length
|
|
||||||
# 兼容 v3.0.x / v3.1.x 所有 sentinel 版本
|
|
||||||
$c = [regex]::Replace($c, '# BW_CRED_START[\s\S]*?# BW_CRED_END\r?\n?', '')
|
|
||||||
$c = [regex]::Replace($c, '# BW_CLIP_START[\s\S]*?# BW_CLIP_END\r?\n?', '')
|
|
||||||
if ($c.Length -ne $orig) {
|
|
||||||
[System.IO.File]::WriteAllText($pp, $c, [System.Text.UTF8Encoding]::new($false))
|
|
||||||
Log " ✓ 清理 $pp (移除 $($orig - $c.Length) 字节)"
|
|
||||||
} else {
|
|
||||||
Log " · $pp 无 BW_* 块, 跳过"
|
|
||||||
}
|
|
||||||
} catch { Log " [!] $pp 处理失败: $_" }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── [5/7] 还原截图 Toast ──
|
|
||||||
Log "[5/7] 还原截图 Toast 设置"
|
|
||||||
$toastReg = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\Microsoft.ScreenSketch_8wekyb3d8bbwe!App'
|
|
||||||
if ($toastOrig -eq '__ABSENT__') {
|
|
||||||
if (Test-Path $toastReg) {
|
|
||||||
try { Remove-ItemProperty -Path $toastReg -Name 'Enabled' -EA Stop; Log " ✓ Toast Enabled 项已删除 (恢复默认)" }
|
|
||||||
catch { Log " [!] Toast 还原失败: $_" }
|
|
||||||
}
|
|
||||||
} elseif ($toastOrig) {
|
|
||||||
try {
|
|
||||||
if (-not (Test-Path $toastReg)) { New-Item -Path $toastReg -Force | Out-Null }
|
|
||||||
Set-ItemProperty -Path $toastReg -Name 'Enabled' -Value ([int]$toastOrig) -Type DWord -Force
|
|
||||||
Log " ✓ Toast Enabled 还原为 $toastOrig"
|
|
||||||
} catch { Log " [!] Toast 还原失败: $_" }
|
|
||||||
} else {
|
|
||||||
Log " · 无 Toast 备份记录, 跳过"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── [6/7] ANTHROPIC_* User env ──
|
|
||||||
Log "[6/7] 清 ANTHROPIC_* / BW_LICENSE_KEY User 环境变量"
|
|
||||||
foreach ($ev in @('ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL','BW_LICENSE_KEY')) {
|
|
||||||
$v = [Environment]::GetEnvironmentVariable($ev, 'User')
|
|
||||||
if ($v) {
|
|
||||||
try { [Environment]::SetEnvironmentVariable($ev, $null, 'User'); Log " ✓ 清 $ev" }
|
|
||||||
catch { Log " [!] 清 $ev 失败: $_" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── [7/7] 提示手动删 bookworm-boot ──
|
|
||||||
Log "[7/7] 手动收尾"
|
|
||||||
Log " 请手动删除 bookworm-boot 目录 (本脚本无法删除自身所在目录):"
|
|
||||||
Log " 例如: $env:USERPROFILE\Downloads\bookworm-boot"
|
|
||||||
|
|
||||||
# ── 完成弹窗 ──
|
|
||||||
$endMsg = "Bookworm 卸载完成.`n`n"
|
|
||||||
$endMsg += "执行摘要:`n"
|
|
||||||
$endMsg += ($logLines -join "`n")
|
|
||||||
$endMsg += "`n`n保留的公共依赖 (如不再需要可手动卸):`n"
|
|
||||||
$endMsg += " - Node.js: %ProgramFiles%\nodejs`n"
|
|
||||||
$endMsg += " - Git for Windows: %ProgramFiles%\Git`n"
|
|
||||||
$endMsg += " - PowerShell 7: %ProgramFiles%\PowerShell\7`n"
|
|
||||||
$endMsg += " - Claude Code: 命令行运行 npm uninstall -g @anthropic-ai/claude-code"
|
|
||||||
[System.Windows.Forms.MessageBox]::Show($endMsg, 'Bookworm 卸载完成', 'OK', 'Information') | Out-Null
|
|
||||||
exit 0
|
|
||||||
@ -1,22 +1,49 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 > nul
|
chcp 65001 > nul
|
||||||
:: v3.1.3: Bookworm 完整卸载脚本
|
title Bookworm Portable - 卸载
|
||||||
:: 删除: 桌面 .lnk × 4 / ~/.claude / DPAPI HKCU 凭证 /
|
cd /d "%~dp0"
|
||||||
:: 双 profile (PS7+PS5.1) 的 BW_CRED + BW_CLIP 块 / Toast 备份还原 /
|
|
||||||
:: ANTHROPIC_* User 环境变量
|
|
||||||
:: 保留: Node/Git/PS7/Claude Code (公共依赖, 不主动卸)
|
|
||||||
:: 调用配套 PS 脚本完成所有清理 (cmd 不擅长 PS 多行)
|
|
||||||
|
|
||||||
setlocal
|
echo.
|
||||||
where pwsh >nul 2>nul && set "PSH=pwsh" || set "PSH=powershell"
|
echo ====================================
|
||||||
|
echo Bookworm Portable - 完整卸载
|
||||||
|
echo ====================================
|
||||||
|
echo.
|
||||||
|
echo 将执行:
|
||||||
|
echo - 终止 Claude Code 进程
|
||||||
|
echo - 清除所有环境变量和凭证缓存
|
||||||
|
echo - 恢复原始 .claude 目录
|
||||||
|
echo - 清除 PowerShell 历史和 Git 凭证
|
||||||
|
echo - 删除桌面快捷方式
|
||||||
|
echo.
|
||||||
|
|
||||||
set "UNINST_PS1=%~dp0卸载Bookworm-impl.ps1"
|
:: AutoAccept: 卸载确认已豁免
|
||||||
if not exist "%UNINST_PS1%" (
|
:: set /p confirm=" 确认卸载? (y/n): "
|
||||||
echo [!] 卸载实现脚本缺失: %UNINST_PS1%
|
:: if /i not "%confirm%"=="y" (
|
||||||
echo [!] 请重跑 Bookworm-Setup.exe Phase 3 重新克隆 bookworm-boot
|
:: echo 已取消
|
||||||
pause
|
:: pause
|
||||||
exit /b 1
|
:: 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
|
||||||
)
|
)
|
||||||
|
|
||||||
%PSH% -NoProfile -ExecutionPolicy Bypass -File "%UNINST_PS1%"
|
:: 删除桌面快捷方式
|
||||||
endlocal
|
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
|
||||||
|
|||||||
@ -1,44 +1,27 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 > nul
|
chcp 65001 > nul
|
||||||
:: v3.0.11 架构重构: 桌面 .lnk 已直调 pwsh + claude.ps1 绝对路径, 不再走本 bat.
|
title Bookworm Portable - 启动
|
||||||
:: 此文件仅保留作为兼容入口 (老用户已经把 .bat 加到收藏夹/开始菜单的场景),
|
cd /d "%~dp0"
|
||||||
:: 内部行为简化为转发到桌面 .lnk 触发统一启动路径.
|
|
||||||
::
|
|
||||||
:: 如果用户双击本 .bat (而非桌面 .lnk), 直接 invoke pwsh + claude.ps1 启动:
|
|
||||||
|
|
||||||
setlocal
|
:: 中转站在国内,不走代理
|
||||||
|
set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1
|
||||||
|
set no_proxy=%NO_PROXY%
|
||||||
|
|
||||||
:: 1. 定位 pwsh.exe (PS7 必需, 启动器 v3.0.11 强依赖)
|
echo.
|
||||||
set "PWSH_EXE="
|
echo ====================================
|
||||||
where pwsh >nul 2>nul && for /f "delims=" %%i in ('where pwsh') do if not defined PWSH_EXE set "PWSH_EXE=%%i"
|
echo Bookworm Portable - 快速启动
|
||||||
if not defined PWSH_EXE if exist "%ProgramFiles%\PowerShell\7\pwsh.exe" set "PWSH_EXE=%ProgramFiles%\PowerShell\7\pwsh.exe"
|
echo ====================================
|
||||||
if not defined PWSH_EXE if exist "%LOCALAPPDATA%\Microsoft\PowerShell\pwsh.exe" set "PWSH_EXE=%LOCALAPPDATA%\Microsoft\PowerShell\pwsh.exe"
|
echo.
|
||||||
if not defined PWSH_EXE (
|
|
||||||
echo [!] PowerShell 7 未安装. 请先重跑 Bookworm-Setup.exe 装好 PS7.
|
where pwsh >nul 2>nul
|
||||||
pause
|
if %errorlevel% equ 0 (
|
||||||
exit /b 1
|
pwsh -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept
|
||||||
|
) else (
|
||||||
|
powershell -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept
|
||||||
)
|
)
|
||||||
|
|
||||||
:: 2. 定位 claude.ps1 (优先 npm prefix, 兜底常见位置)
|
if %errorlevel% neq 0 (
|
||||||
set "CLAUDE_PS1="
|
echo.
|
||||||
for /f "delims=" %%i in ('npm config get prefix 2^>nul') do set "NPM_PREFIX=%%i"
|
echo 启动失败,按任意键退出...
|
||||||
if defined NPM_PREFIX if exist "%NPM_PREFIX%\claude.ps1" set "CLAUDE_PS1=%NPM_PREFIX%\claude.ps1"
|
pause > nul
|
||||||
if not defined CLAUDE_PS1 if exist "%APPDATA%\npm\claude.ps1" set "CLAUDE_PS1=%APPDATA%\npm\claude.ps1"
|
|
||||||
if not defined CLAUDE_PS1 if exist "%ProgramFiles%\nodejs\claude.ps1" set "CLAUDE_PS1=%ProgramFiles%\nodejs\claude.ps1"
|
|
||||||
if not defined CLAUDE_PS1 (
|
|
||||||
echo [!] claude.ps1 未找到. 请重跑 Bookworm-Setup.exe 修复 Claude Code 安装.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
:: 3. OTA 自动更新检查 (fail-open: 脚本不存在或报错均不阻断启动)
|
|
||||||
set "OTA_SCRIPT=%USERPROFILE%\.claude\.bw-ota\bw-ota.ps1"
|
|
||||||
if exist "%OTA_SCRIPT%" (
|
|
||||||
"%PWSH_EXE%" -NoLogo -ExecutionPolicy Bypass -File "%OTA_SCRIPT%"
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 4. 直调 pwsh + claude.ps1 (无 wt / 无 Base64 / 无 DPAPI in-bat)
|
|
||||||
:: 凭证由 pwsh profile.ps1 BW_CRED_START..END 块自动加载
|
|
||||||
"%PWSH_EXE%" -NoLogo -NoExit -File "%CLAUDE_PS1%" --dangerously-skip-permissions
|
|
||||||
|
|
||||||
endlocal
|
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 >nul
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ========================================================
|
|
||||||
echo Create Admin PowerShell Shortcut for Claude Code
|
|
||||||
echo ========================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Check admin privileges
|
|
||||||
net session >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [!] Requesting administrator privileges...
|
|
||||||
powershell -Command "Start-Process '%~f0' -Verb RunAs"
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Detect PowerShell path
|
|
||||||
set "PS_PATH="
|
|
||||||
for %%p in (
|
|
||||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
|
||||||
"%ProgramFiles%\PowerShell\7\pwsh.exe"
|
|
||||||
"%ProgramFiles(x86)%\PowerShell\7\pwsh.exe"
|
|
||||||
) do (
|
|
||||||
if exist %%p (
|
|
||||||
set "PS_PATH=%%p"
|
|
||||||
goto :ps_found
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
:ps_found
|
|
||||||
if "!PS_PATH!" == "" (
|
|
||||||
echo [ERROR] PowerShell not found
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [OK] PowerShell: !PS_PATH!
|
|
||||||
|
|
||||||
:: Create admin shortcut
|
|
||||||
set "SHORTCUT_PATH=%USERPROFILE%\Desktop\Claude Code (Admin Terminal).lnk"
|
|
||||||
|
|
||||||
powershell -NoProfile -Command "$WshShell = New-Object -ComObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%SHORTCUT_PATH%'); $Shortcut.TargetPath = '!PS_PATH!'; $Shortcut.Arguments = '-NoExit -Command \"Write-Host ''Claude Code Admin Terminal Ready'' -ForegroundColor Green; Write-Host ''Type: claude'' -ForegroundColor Yellow\"'; $Shortcut.WorkingDirectory = '%USERPROFILE%'; $Shortcut.Description = 'PowerShell with Admin Rights for Claude Code'; $Shortcut.Save(); $bytes = [System.IO.File]::ReadAllBytes('%SHORTCUT_PATH%'); $bytes[0x15] = $bytes[0x15] -bor 0x20; [System.IO.File]::WriteAllBytes('%SHORTCUT_PATH%', $bytes)"
|
|
||||||
|
|
||||||
if exist "%SHORTCUT_PATH%" (
|
|
||||||
echo.
|
|
||||||
echo [SUCCESS] Admin terminal shortcut created!
|
|
||||||
echo.
|
|
||||||
echo [Usage]
|
|
||||||
echo 1. Double-click "Claude Code (Admin Terminal)" on desktop
|
|
||||||
echo 2. Type: claude
|
|
||||||
echo 3. Claude Code will run with administrator privileges
|
|
||||||
echo.
|
|
||||||
) else (
|
|
||||||
echo [ERROR] Failed to create shortcut
|
|
||||||
)
|
|
||||||
|
|
||||||
pause
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
:: Check for administrator privileges
|
|
||||||
net session >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo Requesting administrator privileges...
|
|
||||||
powershell -Command "Start-Process '%~f0' -Verb RunAs"
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
|
|
||||||
title PowerShell 7 Auto Setup
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ========================================================
|
|
||||||
echo PowerShell 7 Auto Setup
|
|
||||||
echo Automatic Installation and Configuration
|
|
||||||
echo ========================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Check if PowerShell 7 is already installed
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo [OK] PowerShell 7 is already installed
|
|
||||||
for /f "tokens=*" %%i in ('pwsh -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"') do set PWSH_VERSION=%%i
|
|
||||||
echo [INFO] Version: !PWSH_VERSION!
|
|
||||||
echo.
|
|
||||||
goto :configure
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [INFO] PowerShell 7 not found. Starting installation...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Method 1: Try winget (Windows 10 1809+)
|
|
||||||
echo [1/3] Trying winget installation...
|
|
||||||
winget --version >nul 2>nul
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo [INFO] Installing via winget...
|
|
||||||
winget install --id Microsoft.PowerShell --silent --accept-package-agreements --accept-source-agreements
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo [OK] PowerShell 7 installed successfully via winget
|
|
||||||
goto :verify_install
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Method 2: Try Chocolatey
|
|
||||||
echo [2/3] Trying Chocolatey installation...
|
|
||||||
where choco >nul 2>nul
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo [INFO] Installing via Chocolatey...
|
|
||||||
choco install powershell-core -y
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo [OK] PowerShell 7 installed successfully via Chocolatey
|
|
||||||
goto :verify_install
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Method 3: Direct MSI download
|
|
||||||
echo [3/3] Downloading PowerShell 7 MSI installer...
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; $latestRelease = Invoke-RestMethod 'https://api.github.com/repos/PowerShell/PowerShell/releases/latest'; $msiAsset = $latestRelease.assets | Where-Object { $_.name -like '*win-x64.msi' } | Select-Object -First 1; if ($msiAsset) { Write-Host '[INFO] Downloading' $msiAsset.name; Invoke-WebRequest -Uri $msiAsset.browser_download_url -OutFile '%TEMP%\PowerShell-7.msi'; exit 0; } else { Write-Host '[ERROR] Failed to find MSI asset'; exit 1; }"
|
|
||||||
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Failed to download PowerShell 7
|
|
||||||
echo.
|
|
||||||
echo Please install manually from: https://github.com/PowerShell/PowerShell/releases
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [INFO] Installing PowerShell 7...
|
|
||||||
msiexec /i "%TEMP%\PowerShell-7.msi" /quiet /norestart ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1 ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1 ENABLE_PSREMOTING=1 REGISTER_MANIFEST=1
|
|
||||||
set INSTALL_EXIT=%errorlevel%
|
|
||||||
del "%TEMP%\PowerShell-7.msi" >nul 2>&1
|
|
||||||
|
|
||||||
if %INSTALL_EXIT% neq 0 (
|
|
||||||
echo [ERROR] Installation failed with exit code %INSTALL_EXIT%
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
:verify_install
|
|
||||||
echo.
|
|
||||||
echo [INFO] Verifying installation...
|
|
||||||
timeout /t 2 /nobreak >nul
|
|
||||||
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] PowerShell 7 installation verification failed
|
|
||||||
echo [INFO] Trying to refresh PATH...
|
|
||||||
set "PATH=%PATH%;C:\Program Files\PowerShell\7"
|
|
||||||
where pwsh >nul 2>nul
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Still cannot find pwsh.exe
|
|
||||||
echo [INFO] Please restart your computer and try again
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for /f "tokens=*" %%i in ('pwsh -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"') do set PWSH_VERSION=%%i
|
|
||||||
echo [OK] PowerShell 7 installed successfully
|
|
||||||
echo [INFO] Version: !PWSH_VERSION!
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:configure
|
|
||||||
echo ========================================================
|
|
||||||
echo Configuring Default Terminal Settings...
|
|
||||||
echo ========================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Get PowerShell 7 installation path
|
|
||||||
for /f "tokens=*" %%i in ('where pwsh') do set PWSH_PATH=%%i
|
|
||||||
echo [INFO] PowerShell 7 path: !PWSH_PATH!
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Configure Windows Terminal default profile (if installed)
|
|
||||||
echo [1/4] Configuring Windows Terminal...
|
|
||||||
reg query "HKCU\Console\%%Startup" >nul 2>&1
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
reg add "HKCU\Console\%%Startup" /v DelegationConsole /t REG_SZ /d "{574e775e-4f2a-5b96-ac1e-a2962a402336}" /f >nul 2>&1
|
|
||||||
reg add "HKCU\Console\%%Startup" /v DelegationTerminal /t REG_SZ /d "{574e775e-4f2a-5b96-ac1e-a2962a402336}" /f >nul 2>&1
|
|
||||||
echo [OK] Windows Terminal configured
|
|
||||||
) else (
|
|
||||||
echo [SKIP] Windows Terminal not found
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Set PowerShell 7 as default for .ps1 files
|
|
||||||
echo [2/4] Configuring file associations...
|
|
||||||
assoc .ps1=Microsoft.PowerShellScript.1 >nul 2>&1
|
|
||||||
ftype Microsoft.PowerShellScript.1="!PWSH_PATH!" -NoLogo -ExecutionPolicy Bypass -File "%%1" %%* >nul 2>&1
|
|
||||||
echo [OK] .ps1 files associated with PowerShell 7
|
|
||||||
|
|
||||||
:: Add PowerShell 7 to App Paths (for Win+R)
|
|
||||||
echo [3/4] Configuring Win+R shortcut...
|
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\pwsh.exe" /ve /t REG_SZ /d "!PWSH_PATH!" /f >nul 2>&1
|
|
||||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\powershell.exe" /ve /t REG_SZ /d "!PWSH_PATH!" /f >nul 2>&1
|
|
||||||
echo [OK] Win+R configured (type 'pwsh' or 'powershell')
|
|
||||||
|
|
||||||
:: Set PowerShell 7 as default shell for developers
|
|
||||||
echo [4/4] Configuring developer settings...
|
|
||||||
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v DefaultTerminalApplication /t REG_SZ /d "{574e775e-4f2a-5b96-ac1e-a2962a402336}" /f >nul 2>&1
|
|
||||||
echo [OK] Developer settings configured
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ========================================================
|
|
||||||
echo Installation and Configuration Complete!
|
|
||||||
echo ========================================================
|
|
||||||
echo.
|
|
||||||
echo [OK] PowerShell 7 installed: !PWSH_VERSION!
|
|
||||||
echo [OK] Default terminal configured
|
|
||||||
echo [OK] File associations updated
|
|
||||||
echo [OK] Win+R shortcut configured
|
|
||||||
echo.
|
|
||||||
echo [IMPORTANT] Please restart your computer for all changes
|
|
||||||
echo to take effect, especially Win+R shortcut.
|
|
||||||
echo.
|
|
||||||
echo After restart:
|
|
||||||
echo - Press Win+R and type 'pwsh' or 'powershell'
|
|
||||||
echo - Right-click .ps1 files to run with PowerShell 7
|
|
||||||
echo - Windows Terminal will use PowerShell 7 by default
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
console.log('MCP Auto Loader v1.0');
|
|
||||||
console.log('====================\n');
|
|
||||||
|
|
||||||
const outputDir = 'E:\';
|
|
||||||
const services = {
|
|
||||||
playwright: '@modelcontextprotocol/server-playwright',
|
|
||||||
'chrome-devtools': '@executeautomation/chrome-devtools-mcp',
|
|
||||||
github: '@modelcontextprotocol/server-github',
|
|
||||||
slack: '@modelcontextprotocol/server-slack',
|
|
||||||
linear: '@modelcontextprotocol/server-linear'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mcpServers = {};
|
|
||||||
for (const [name, pkg] of Object.entries(services)) {
|
|
||||||
mcpServers[name] = {
|
|
||||||
command: 'cmd',
|
|
||||||
args: ['/c', 'npx', '-y', pkg]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(outputDir, 'mcp-configs-summary.json');
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2));
|
|
||||||
console.log('Config saved to:', configPath);
|
|
||||||
@ -1,265 +0,0 @@
|
|||||||
# MCP Configuration Wizard
|
|
||||||
|
|
||||||
Universal MCP configuration tool for Claude Code - works on any system, any user.
|
|
||||||
|
|
||||||
## 🎯 Features
|
|
||||||
|
|
||||||
- **Auto-Detection**: Automatically detects your system, existing configs, and environment
|
|
||||||
- **Interactive Wizard**: Step-by-step guided setup
|
|
||||||
- **Cross-Platform**: Works on Windows, macOS, and Linux
|
|
||||||
- **Safe Installation**: Backup existing configs before changes
|
|
||||||
- **Export/Import**: Share configurations between machines
|
|
||||||
- **19 MCP Services**: Pre-configured popular services
|
|
||||||
|
|
||||||
## 📦 Quick Start
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Detect Your System
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run detect
|
|
||||||
```
|
|
||||||
|
|
||||||
This will scan your system and detect:
|
|
||||||
- Claude Code installation
|
|
||||||
- Existing MCP configurations
|
|
||||||
- Environment variables
|
|
||||||
- Installed npm packages
|
|
||||||
|
|
||||||
### 3. Run Configuration Wizard
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Choose from 4 modes:
|
|
||||||
1. **Quick Setup** - Install recommended services (no API keys needed)
|
|
||||||
2. **Custom Setup** - Choose specific services
|
|
||||||
3. **Import** - Load configuration from file
|
|
||||||
4. **Export** - Save current config for another machine
|
|
||||||
|
|
||||||
### 4. Apply Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run apply
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
- Install MCP configuration to `~/.claude.json`
|
|
||||||
- Generate environment variable scripts
|
|
||||||
- Create installation guide
|
|
||||||
|
|
||||||
### 5. Restart Claude Code
|
|
||||||
|
|
||||||
Close and reopen Claude Code to load the new MCP services.
|
|
||||||
|
|
||||||
## 🔧 Configuration Modes
|
|
||||||
|
|
||||||
### Quick Setup (Recommended for beginners)
|
|
||||||
|
|
||||||
Installs 6 essential services that work without API keys:
|
|
||||||
- playwright - Browser automation
|
|
||||||
- chrome-devtools - Chrome DevTools integration
|
|
||||||
- context7 - Programming documentation
|
|
||||||
- deep-research - Research assistant
|
|
||||||
- sequential-thinking - Reasoning engine
|
|
||||||
- scrapling - Web scraping
|
|
||||||
|
|
||||||
### Custom Setup (For advanced users)
|
|
||||||
|
|
||||||
Choose from 19 services across categories:
|
|
||||||
- **Automation**: playwright, chrome-devtools, browserbase
|
|
||||||
- **Development**: github, vercel, firebase
|
|
||||||
- **Communication**: slack
|
|
||||||
- **Productivity**: linear, notion
|
|
||||||
- **Documentation**: context7
|
|
||||||
- **Research**: deep-research
|
|
||||||
- **Web**: scrapling, firecrawl
|
|
||||||
- **Database**: supabase
|
|
||||||
- **Monitoring**: sentry
|
|
||||||
- **Infrastructure**: cloudflare
|
|
||||||
|
|
||||||
### Import/Export
|
|
||||||
|
|
||||||
**Export** your configuration to share with another machine:
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
# Select option 4
|
|
||||||
```
|
|
||||||
|
|
||||||
**Import** a configuration file:
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
# Select option 3
|
|
||||||
# Enter path to config file
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Available Services
|
|
||||||
|
|
||||||
### No API Key Required (9 services)
|
|
||||||
|
|
||||||
| Service | Description | Category |
|
|
||||||
|---------|-------------|----------|
|
|
||||||
| playwright | Browser automation testing | Automation |
|
|
||||||
| chrome-devtools | Chrome DevTools integration | Automation |
|
|
||||||
| context7 | Programming documentation | Documentation |
|
|
||||||
| deep-research | Deep research assistant | Research |
|
|
||||||
| sequential-thinking | Sequential reasoning | Reasoning |
|
|
||||||
| scrapling | Web scraping tool | Web |
|
|
||||||
| figma | Figma design integration | Design |
|
|
||||||
|
|
||||||
### API Key Required (10 services)
|
|
||||||
|
|
||||||
| Service | Description | Setup Link |
|
|
||||||
|---------|-------------|------------|
|
|
||||||
| github | GitHub repository integration | [Get Token](https://github.com/settings/tokens) |
|
|
||||||
| slack | Slack team collaboration | [Create App](https://api.slack.com/apps) |
|
|
||||||
| linear | Linear project management | [API Settings](https://linear.app/settings/api) |
|
|
||||||
| browserbase | Cloud browser service | [Dashboard](https://browserbase.com) |
|
|
||||||
| cloudflare | Cloudflare CDN management | [API Tokens](https://dash.cloudflare.com/profile/api-tokens) |
|
|
||||||
| firecrawl | Intelligent web crawler | [Sign Up](https://firecrawl.dev) |
|
|
||||||
| supabase | Supabase database | [API Settings](https://supabase.com/dashboard) |
|
|
||||||
| sentry | Error monitoring | [Auth Tokens](https://sentry.io/settings/account/api/auth-tokens/) |
|
|
||||||
| notion | Notion knowledge base | [Integrations](https://www.notion.so/my-integrations) |
|
|
||||||
| vercel | Vercel deployment | [Tokens](https://vercel.com/account/tokens) |
|
|
||||||
| firebase | Firebase backend | [Service Accounts](https://console.firebase.google.com) |
|
|
||||||
|
|
||||||
## 🔐 Environment Variables
|
|
||||||
|
|
||||||
The wizard generates scripts to set environment variables:
|
|
||||||
|
|
||||||
**Windows (PowerShell):**
|
|
||||||
```powershell
|
|
||||||
.\apply-env.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Unix/Mac (Bash/Zsh):**
|
|
||||||
```bash
|
|
||||||
source ./apply-env.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Or set them manually from `generated-env.json`.
|
|
||||||
|
|
||||||
## 📁 Generated Files
|
|
||||||
|
|
||||||
After running the wizard, you'll get:
|
|
||||||
|
|
||||||
- `detection-result.json` - System detection results
|
|
||||||
- `generated-claude.json` - MCP configuration for Claude Code
|
|
||||||
- `generated-env.json` - Environment variables (JSON format)
|
|
||||||
- `apply-env.ps1` - PowerShell script (Windows)
|
|
||||||
- `apply-env.sh` - Shell script (Unix/Mac)
|
|
||||||
- `INSTALL.md` - Installation guide
|
|
||||||
|
|
||||||
## 🚀 Usage Examples
|
|
||||||
|
|
||||||
### Example 1: First-time setup on new machine
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run detect
|
|
||||||
npm start
|
|
||||||
# Choose option 1 (Quick Setup)
|
|
||||||
npm run apply
|
|
||||||
# Restart Claude Code
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 2: Add GitHub integration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
# Choose option 2 (Custom Setup)
|
|
||||||
# Enter: github
|
|
||||||
# Enter your GitHub token
|
|
||||||
npm run apply
|
|
||||||
# Restart Claude Code
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 3: Export config for another machine
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
# Choose option 4 (Export)
|
|
||||||
# Copy mcp-config-export.json to other machine
|
|
||||||
```
|
|
||||||
|
|
||||||
On the other machine:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
# Choose option 3 (Import)
|
|
||||||
# Enter path to mcp-config-export.json
|
|
||||||
npm run apply
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Manual Installation
|
|
||||||
|
|
||||||
If automatic installation fails:
|
|
||||||
|
|
||||||
1. Copy content from `generated-claude.json`
|
|
||||||
2. Paste into `~/.claude.json` (create if doesn't exist)
|
|
||||||
3. Set environment variables from `generated-env.json`
|
|
||||||
4. Restart Claude Code
|
|
||||||
|
|
||||||
## ⚠️ Troubleshooting
|
|
||||||
|
|
||||||
### "Cannot find module 'inquirer'"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Permission denied" on Unix/Mac
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x apply-env.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### PowerShell execution policy error
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
```
|
|
||||||
|
|
||||||
### MCP services not loading
|
|
||||||
|
|
||||||
1. Check `~/.claude.json` exists and has `mcpServers` section
|
|
||||||
2. Verify environment variables are set: `echo $GITHUB_PERSONAL_ACCESS_TOKEN`
|
|
||||||
3. Restart Claude Code completely
|
|
||||||
4. Check Claude Code logs for errors
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- [MCP Protocol](https://modelcontextprotocol.io/)
|
|
||||||
- [Claude Code](https://claude.ai/code)
|
|
||||||
- [Service Documentation](./docs/services.md)
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
Contributions welcome! To add a new MCP service:
|
|
||||||
|
|
||||||
1. Add service definition to `MCP_SERVICES` in `wizard.js`
|
|
||||||
2. Include package name, description, category, and required env vars
|
|
||||||
3. Test with `npm start`
|
|
||||||
4. Submit PR
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
||||||
## 🔗 Links
|
|
||||||
|
|
||||||
- [MCP Official Site](https://modelcontextprotocol.io/)
|
|
||||||
- [Claude Code](https://claude.ai/code)
|
|
||||||
- [GitHub Repository](https://github.com/your-repo/mcp-config-wizard)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Last Updated**: 2026-04-04
|
|
||||||
**Maintained by**: Bookworm Team
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 >nul
|
|
||||||
title MCP Configuration Wizard - Setup
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ╔════════════════════════════════════════════════════════╗
|
|
||||||
echo ║ MCP Configuration Wizard - First Time Setup ║
|
|
||||||
echo ╚════════════════════════════════════════════════════════╝
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM 检查 Node.js 是否安装
|
|
||||||
where node >nul 2>nul
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Node.js is not installed!
|
|
||||||
echo.
|
|
||||||
echo Please install Node.js from: https://nodejs.org/
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [OK] Node.js found:
|
|
||||||
node --version
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM 检查是否已安装依赖
|
|
||||||
if exist "node_modules\" (
|
|
||||||
echo [OK] Dependencies already installed
|
|
||||||
echo.
|
|
||||||
goto :run_wizard
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [INFO] Installing dependencies...
|
|
||||||
echo This may take a minute...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
call npm install
|
|
||||||
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo.
|
|
||||||
echo [ERROR] Failed to install dependencies
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [OK] Dependencies installed successfully
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:run_wizard
|
|
||||||
echo ════════════════════════════════════════════════════════
|
|
||||||
echo Starting Configuration Wizard...
|
|
||||||
echo ════════════════════════════════════════════════════════
|
|
||||||
echo.
|
|
||||||
|
|
||||||
node wizard.js
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ════════════════════════════════════════════════════════
|
|
||||||
echo Wizard completed
|
|
||||||
echo ════════════════════════════════════════════════════════
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# MCP Configuration Wizard - Launcher for Unix/Mac
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "╔════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ MCP Configuration Wizard - First Time Setup ║"
|
|
||||||
echo "╚════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 检查 Node.js
|
|
||||||
if ! command -v node &> /dev/null; then
|
|
||||||
echo "[ERROR] Node.js is not installed!"
|
|
||||||
echo ""
|
|
||||||
echo "Please install Node.js from: https://nodejs.org/"
|
|
||||||
echo ""
|
|
||||||
read -p "Press Enter to exit..."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[OK] Node.js found: $(node --version)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 检查依赖
|
|
||||||
if [ -d "node_modules" ]; then
|
|
||||||
echo "[OK] Dependencies already installed"
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "[INFO] Installing dependencies..."
|
|
||||||
echo "This may take a minute..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
npm install
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "[ERROR] Failed to install dependencies"
|
|
||||||
echo ""
|
|
||||||
read -p "Press Enter to exit..."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[OK] Dependencies installed successfully"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 运行向导
|
|
||||||
echo "════════════════════════════════════════════════════════"
|
|
||||||
echo "Starting Configuration Wizard..."
|
|
||||||
echo "════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
node wizard.js
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "════════════════════════════════════════════════════════"
|
|
||||||
echo "Wizard completed"
|
|
||||||
echo "════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
read -p "Press Enter to exit..."
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const readline = require('readline');
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout
|
|
||||||
});
|
|
||||||
|
|
||||||
function question(prompt) {
|
|
||||||
return new Promise(resolve => rl.question(prompt, resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ MCP Configuration Installer ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
// 检查生成的配置文件
|
|
||||||
const generatedConfig = path.join(__dirname, 'generated-claude.json');
|
|
||||||
if (!fs.existsSync(generatedConfig)) {
|
|
||||||
console.log('✗ No configuration found. Run "node wizard.js" first.');
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync(generatedConfig, 'utf-8'));
|
|
||||||
console.log(`Found configuration with ${Object.keys(config.mcpServers).length} MCP services\n`);
|
|
||||||
|
|
||||||
// 目标配置文件路径
|
|
||||||
const targetConfig = path.join(os.homedir(), '.claude.json');
|
|
||||||
let existingConfig = {};
|
|
||||||
|
|
||||||
// 检查现有配置
|
|
||||||
if (fs.existsSync(targetConfig)) {
|
|
||||||
console.log('⚠ Existing .claude.json found\n');
|
|
||||||
console.log('Installation options:');
|
|
||||||
console.log(' 1. Merge - Keep existing settings, add/update MCP servers');
|
|
||||||
console.log(' 2. Replace - Overwrite with new configuration');
|
|
||||||
console.log(' 3. Backup & Replace - Backup existing, then replace');
|
|
||||||
console.log(' 4. Cancel\n');
|
|
||||||
|
|
||||||
const choice = await question('Select option (1-4): ');
|
|
||||||
|
|
||||||
switch (choice) {
|
|
||||||
case '1':
|
|
||||||
existingConfig = JSON.parse(fs.readFileSync(targetConfig, 'utf-8'));
|
|
||||||
existingConfig.mcpServers = {
|
|
||||||
...(existingConfig.mcpServers || {}),
|
|
||||||
...config.mcpServers
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case '2':
|
|
||||||
existingConfig = config;
|
|
||||||
break;
|
|
||||||
case '3':
|
|
||||||
const backupPath = targetConfig + `.backup-${Date.now()}`;
|
|
||||||
fs.copyFileSync(targetConfig, backupPath);
|
|
||||||
console.log(`\n✓ Backup created: ${backupPath}`);
|
|
||||||
existingConfig = config;
|
|
||||||
break;
|
|
||||||
case '4':
|
|
||||||
console.log('\nInstallation cancelled');
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
console.log('\nInvalid option');
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
existingConfig = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入配置
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(targetConfig, JSON.stringify(existingConfig, null, 2));
|
|
||||||
console.log(`\n✓ Configuration installed to: ${targetConfig}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`\n✗ Failed to write configuration: ${e.message}`);
|
|
||||||
console.log('\nManual installation:');
|
|
||||||
console.log(` 1. Copy ${generatedConfig}`);
|
|
||||||
console.log(` 2. To ${targetConfig}`);
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用环境变量
|
|
||||||
const envFile = path.join(__dirname, 'generated-env.json');
|
|
||||||
if (fs.existsSync(envFile)) {
|
|
||||||
console.log('\n=== Environment Variables ===\n');
|
|
||||||
console.log('Environment variables need to be set manually:');
|
|
||||||
|
|
||||||
if (os.platform() === 'win32') {
|
|
||||||
console.log('\nWindows: Run the PowerShell script:');
|
|
||||||
console.log(' .\\apply-env.ps1');
|
|
||||||
} else {
|
|
||||||
console.log('\nUnix/Mac: Source the shell script:');
|
|
||||||
console.log(' source ./apply-env.sh');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nOr set them manually from: generated-env.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ Installation Complete ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
|
||||||
console.log('\n✓ MCP configuration installed successfully');
|
|
||||||
console.log('\n📋 Next steps:');
|
|
||||||
console.log(' 1. Set environment variables (if needed)');
|
|
||||||
console.log(' 2. Restart Claude Code');
|
|
||||||
console.log(' 3. Verify MCP services are loaded\n');
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
console.log('╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ MCP Configuration Wizard - System Detection ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
// 检测系统信息
|
|
||||||
const detection = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
system: {
|
|
||||||
platform: os.platform(),
|
|
||||||
username: os.userInfo().username,
|
|
||||||
homedir: os.homedir(),
|
|
||||||
nodeVersion: process.version
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
configDir: null,
|
|
||||||
configFile: null,
|
|
||||||
hasExistingConfig: false,
|
|
||||||
existingMcpServers: []
|
|
||||||
},
|
|
||||||
environment: {
|
|
||||||
variables: {},
|
|
||||||
mcpRelated: []
|
|
||||||
},
|
|
||||||
npm: {
|
|
||||||
globalPackages: [],
|
|
||||||
mcpPackages: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. 检测 Claude Code 配置目录
|
|
||||||
console.log('🔍 Detecting Claude Code configuration...');
|
|
||||||
const possibleConfigDirs = [
|
|
||||||
path.join(os.homedir(), '.claude'),
|
|
||||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Claude'),
|
|
||||||
path.join(os.homedir(), 'Library', 'Application Support', 'Claude')
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const dir of possibleConfigDirs) {
|
|
||||||
if (fs.existsSync(dir)) {
|
|
||||||
detection.claude.configDir = dir;
|
|
||||||
console.log(` ✓ Found: ${dir}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!detection.claude.configDir) {
|
|
||||||
console.log(' ⚠ Claude config directory not found, will create on apply');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检测现有配置文件
|
|
||||||
const configFile = path.join(os.homedir(), '.claude.json');
|
|
||||||
if (fs.existsSync(configFile)) {
|
|
||||||
detection.claude.configFile = configFile;
|
|
||||||
detection.claude.hasExistingConfig = true;
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
||||||
if (config.mcpServers) {
|
|
||||||
detection.claude.existingMcpServers = Object.keys(config.mcpServers);
|
|
||||||
console.log(` ✓ Existing config found with ${detection.claude.existingMcpServers.length} MCP servers`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(' ⚠ Config file exists but cannot be parsed');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(' ℹ No existing .claude.json found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检测环境变量
|
|
||||||
console.log('\n🔍 Detecting environment variables...');
|
|
||||||
const mcpEnvVars = [
|
|
||||||
'GITHUB_PERSONAL_ACCESS_TOKEN',
|
|
||||||
'SLACK_BOT_TOKEN', 'SLACK_TEAM_ID',
|
|
||||||
'LINEAR_API_KEY',
|
|
||||||
'BROWSERBASE_PROJECT_ID', 'BROWSERBASE_API_KEY',
|
|
||||||
'CLOUDFLARE_API_TOKEN',
|
|
||||||
'FIRECRAWL_API_KEY',
|
|
||||||
'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY',
|
|
||||||
'SENTRY_AUTH_TOKEN',
|
|
||||||
'NOTION_API_KEY',
|
|
||||||
'VERCEL_TOKEN',
|
|
||||||
'FIREBASE_PROJECT_ID', 'FIREBASE_PRIVATE_KEY', 'FIREBASE_CLIENT_EMAIL'
|
|
||||||
];
|
|
||||||
|
|
||||||
mcpEnvVars.forEach(varName => {
|
|
||||||
const value = process.env[varName];
|
|
||||||
if (value) {
|
|
||||||
detection.environment.mcpRelated.push(varName);
|
|
||||||
detection.environment.variables[varName] = value.substring(0, 10) + '...'; // 只保存前缀用于检测
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(` ✓ Found ${detection.environment.mcpRelated.length} MCP-related environment variables`);
|
|
||||||
detection.environment.mcpRelated.forEach(v => console.log(` - ${v}`));
|
|
||||||
|
|
||||||
// 3. 检测已安装的 npm 包
|
|
||||||
console.log('\n🔍 Detecting installed npm packages...');
|
|
||||||
try {
|
|
||||||
const globalList = execSync('npm list -g --depth=0 --json', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
||||||
const packages = JSON.parse(globalList);
|
|
||||||
if (packages.dependencies) {
|
|
||||||
detection.npm.globalPackages = Object.keys(packages.dependencies);
|
|
||||||
|
|
||||||
// 筛选 MCP 相关包
|
|
||||||
const mcpKeywords = ['mcp', 'modelcontextprotocol', 'claude'];
|
|
||||||
detection.npm.mcpPackages = detection.npm.globalPackages.filter(pkg =>
|
|
||||||
mcpKeywords.some(kw => pkg.toLowerCase().includes(kw))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (detection.npm.mcpPackages.length > 0) {
|
|
||||||
console.log(` ✓ Found ${detection.npm.mcpPackages.length} MCP packages installed globally`);
|
|
||||||
detection.npm.mcpPackages.forEach(p => console.log(` - ${p}`));
|
|
||||||
} else {
|
|
||||||
console.log(' ℹ No MCP packages found (will use npx)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(' ⚠ Cannot detect npm packages (npm may not be installed)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 保存检测结果
|
|
||||||
const outputFile = path.join(__dirname, 'detection-result.json');
|
|
||||||
fs.writeFileSync(outputFile, JSON.stringify(detection, null, 2));
|
|
||||||
|
|
||||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ Detection Complete ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
|
||||||
console.log(`\n📄 Results saved to: ${outputFile}`);
|
|
||||||
console.log('\n📊 Summary:');
|
|
||||||
console.log(` Platform: ${detection.system.platform}`);
|
|
||||||
console.log(` User: ${detection.system.username}`);
|
|
||||||
console.log(` Claude Config: ${detection.claude.configDir || 'Not found'}`);
|
|
||||||
console.log(` Existing MCP Servers: ${detection.claude.existingMcpServers.length}`);
|
|
||||||
console.log(` Environment Variables: ${detection.environment.mcpRelated.length}`);
|
|
||||||
console.log(` MCP Packages: ${detection.npm.mcpPackages.length}`);
|
|
||||||
|
|
||||||
console.log('\n💡 Next step: Run "node wizard.js" to configure MCP services');
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mcp-config-wizard",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Universal MCP Configuration Wizard for Claude Code",
|
|
||||||
"main": "wizard.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node wizard.js",
|
|
||||||
"detect": "node detect.js",
|
|
||||||
"apply": "node apply.js"
|
|
||||||
},
|
|
||||||
"keywords": ["mcp", "claude-code", "configuration"],
|
|
||||||
"author": "Bookworm Team",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inquirer": "^8.2.5",
|
|
||||||
"chalk": "^4.1.2",
|
|
||||||
"ora": "^5.4.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,496 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const readline = require('readline');
|
|
||||||
|
|
||||||
// MCP 服务定义
|
|
||||||
const MCP_SERVICES = {
|
|
||||||
// 无需环境变量的服务
|
|
||||||
'playwright': {
|
|
||||||
package: '@modelcontextprotocol/server-playwright',
|
|
||||||
description: '浏览器自动化测试',
|
|
||||||
category: 'automation',
|
|
||||||
envVars: []
|
|
||||||
},
|
|
||||||
'chrome-devtools': {
|
|
||||||
package: '@executeautomation/chrome-devtools-mcp',
|
|
||||||
description: 'Chrome 开发者工具集成',
|
|
||||||
category: 'automation',
|
|
||||||
envVars: []
|
|
||||||
},
|
|
||||||
'context7': {
|
|
||||||
package: 'context7-mcp',
|
|
||||||
description: '编程文档查询',
|
|
||||||
category: 'documentation',
|
|
||||||
envVars: []
|
|
||||||
},
|
|
||||||
'deep-research': {
|
|
||||||
package: 'deep-research-mcp',
|
|
||||||
description: '深度研究助手',
|
|
||||||
category: 'research',
|
|
||||||
envVars: []
|
|
||||||
},
|
|
||||||
'sequential-thinking': {
|
|
||||||
package: '@modelcontextprotocol/server-sequential-thinking',
|
|
||||||
description: '顺序思考推理',
|
|
||||||
category: 'reasoning',
|
|
||||||
envVars: []
|
|
||||||
},
|
|
||||||
'scrapling': {
|
|
||||||
package: 'scrapling-mcp',
|
|
||||||
description: '网页抓取工具',
|
|
||||||
category: 'web',
|
|
||||||
envVars: []
|
|
||||||
},
|
|
||||||
'figma': {
|
|
||||||
package: '@figma/mcp-server-figma',
|
|
||||||
description: 'Figma 设计工具集成',
|
|
||||||
category: 'design',
|
|
||||||
envVars: [],
|
|
||||||
oauth: true
|
|
||||||
},
|
|
||||||
|
|
||||||
// 需要环境变量的服务
|
|
||||||
'github': {
|
|
||||||
package: '@modelcontextprotocol/server-github',
|
|
||||||
description: 'GitHub 代码仓库集成',
|
|
||||||
category: 'development',
|
|
||||||
envVars: ['GITHUB_PERSONAL_ACCESS_TOKEN'],
|
|
||||||
setup: 'https://github.com/settings/tokens'
|
|
||||||
},
|
|
||||||
'slack': {
|
|
||||||
package: '@modelcontextprotocol/server-slack',
|
|
||||||
description: 'Slack 团队协作',
|
|
||||||
category: 'communication',
|
|
||||||
envVars: ['SLACK_BOT_TOKEN', 'SLACK_TEAM_ID'],
|
|
||||||
setup: 'https://api.slack.com/apps'
|
|
||||||
},
|
|
||||||
'linear': {
|
|
||||||
package: '@modelcontextprotocol/server-linear',
|
|
||||||
description: 'Linear 项目管理',
|
|
||||||
category: 'productivity',
|
|
||||||
envVars: ['LINEAR_API_KEY'],
|
|
||||||
setup: 'https://linear.app/settings/api'
|
|
||||||
},
|
|
||||||
'browserbase': {
|
|
||||||
package: '@browserbasehq/mcp-server-browserbase',
|
|
||||||
description: '云端浏览器服务',
|
|
||||||
category: 'automation',
|
|
||||||
envVars: ['BROWSERBASE_PROJECT_ID', 'BROWSERBASE_API_KEY'],
|
|
||||||
setup: 'https://browserbase.com'
|
|
||||||
},
|
|
||||||
'cloudflare': {
|
|
||||||
package: '@cloudflare/mcp-server-cloudflare',
|
|
||||||
description: 'Cloudflare CDN 管理',
|
|
||||||
category: 'infrastructure',
|
|
||||||
envVars: ['CLOUDFLARE_API_TOKEN'],
|
|
||||||
setup: 'https://dash.cloudflare.com/profile/api-tokens'
|
|
||||||
},
|
|
||||||
'firecrawl': {
|
|
||||||
package: '@mendable/firecrawl-mcp',
|
|
||||||
description: '智能网页爬虫',
|
|
||||||
category: 'web',
|
|
||||||
envVars: ['FIRECRAWL_API_KEY'],
|
|
||||||
setup: 'https://firecrawl.dev'
|
|
||||||
},
|
|
||||||
'supabase': {
|
|
||||||
package: '@supabase/mcp-server-supabase',
|
|
||||||
description: 'Supabase 数据库',
|
|
||||||
category: 'database',
|
|
||||||
envVars: ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY'],
|
|
||||||
setup: 'https://supabase.com/dashboard/project/_/settings/api'
|
|
||||||
},
|
|
||||||
'sentry': {
|
|
||||||
package: '@sentry/mcp-server',
|
|
||||||
description: 'Sentry 错误监控',
|
|
||||||
category: 'monitoring',
|
|
||||||
envVars: ['SENTRY_AUTH_TOKEN'],
|
|
||||||
setup: 'https://sentry.io/settings/account/api/auth-tokens/'
|
|
||||||
},
|
|
||||||
'notion': {
|
|
||||||
package: '@notionhq/mcp-server',
|
|
||||||
description: 'Notion 知识库',
|
|
||||||
category: 'productivity',
|
|
||||||
envVars: ['NOTION_API_KEY'],
|
|
||||||
setup: 'https://www.notion.so/my-integrations'
|
|
||||||
},
|
|
||||||
'vercel': {
|
|
||||||
package: '@vercel/mcp-server',
|
|
||||||
description: 'Vercel 部署平台',
|
|
||||||
category: 'deployment',
|
|
||||||
envVars: ['VERCEL_TOKEN'],
|
|
||||||
setup: 'https://vercel.com/account/tokens'
|
|
||||||
},
|
|
||||||
'firebase': {
|
|
||||||
package: '@firebase/mcp-server',
|
|
||||||
description: 'Firebase 后端服务',
|
|
||||||
category: 'backend',
|
|
||||||
envVars: ['FIREBASE_PROJECT_ID', 'FIREBASE_PRIVATE_KEY', 'FIREBASE_CLIENT_EMAIL'],
|
|
||||||
setup: 'https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout
|
|
||||||
});
|
|
||||||
|
|
||||||
function question(prompt) {
|
|
||||||
return new Promise(resolve => rl.question(prompt, resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ MCP Configuration Wizard v1.0 ║');
|
|
||||||
console.log('║ Universal Setup Tool for Claude Code ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
// 加载检测结果
|
|
||||||
let detection = null;
|
|
||||||
const detectionFile = path.join(__dirname, 'detection-result.json');
|
|
||||||
if (fs.existsSync(detectionFile)) {
|
|
||||||
detection = JSON.parse(fs.readFileSync(detectionFile, 'utf-8'));
|
|
||||||
console.log('✓ Loaded system detection results\n');
|
|
||||||
} else {
|
|
||||||
console.log('⚠ No detection results found. Run "node detect.js" first.\n');
|
|
||||||
const proceed = await question('Continue anyway? (y/n): ');
|
|
||||||
if (proceed.toLowerCase() !== 'y') {
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('This wizard will help you configure MCP services for Claude Code.\n');
|
|
||||||
console.log('Available configuration modes:');
|
|
||||||
console.log(' 1. Quick Setup - Install recommended services');
|
|
||||||
console.log(' 2. Custom Setup - Choose services manually');
|
|
||||||
console.log(' 3. Import from file - Load existing configuration');
|
|
||||||
console.log(' 4. Export current config - Save for another machine\n');
|
|
||||||
|
|
||||||
const mode = await question('Select mode (1-4): ');
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
mcpServers: {},
|
|
||||||
environment: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (mode) {
|
|
||||||
case '1':
|
|
||||||
await quickSetup(config, detection);
|
|
||||||
break;
|
|
||||||
case '2':
|
|
||||||
await customSetup(config, detection);
|
|
||||||
break;
|
|
||||||
case '3':
|
|
||||||
await importConfig(config);
|
|
||||||
break;
|
|
||||||
case '4':
|
|
||||||
await exportConfig(detection);
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
console.log('Invalid selection');
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成配置文件
|
|
||||||
await generateConfig(config, detection);
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function quickSetup(config, detection) {
|
|
||||||
console.log('\n=== Quick Setup ===\n');
|
|
||||||
console.log('Installing recommended services:');
|
|
||||||
|
|
||||||
const recommended = [
|
|
||||||
'playwright', 'chrome-devtools', 'context7',
|
|
||||||
'deep-research', 'sequential-thinking', 'scrapling'
|
|
||||||
];
|
|
||||||
|
|
||||||
recommended.forEach(name => {
|
|
||||||
const service = MCP_SERVICES[name];
|
|
||||||
console.log(` ✓ ${name} - ${service.description}`);
|
|
||||||
addService(config, name, service);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\nOptional services with API keys:');
|
|
||||||
console.log(' - github, slack, linear, supabase, etc.\n');
|
|
||||||
|
|
||||||
const addMore = await question('Configure services with API keys? (y/n): ');
|
|
||||||
if (addMore.toLowerCase() === 'y') {
|
|
||||||
await configureApiServices(config, detection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function customSetup(config, detection) {
|
|
||||||
console.log('\n=== Custom Setup ===\n');
|
|
||||||
console.log('Available services by category:\n');
|
|
||||||
|
|
||||||
const categories = {};
|
|
||||||
Object.entries(MCP_SERVICES).forEach(([name, service]) => {
|
|
||||||
if (!categories[service.category]) {
|
|
||||||
categories[service.category] = [];
|
|
||||||
}
|
|
||||||
categories[service.category].push({ name, ...service });
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [category, services] of Object.entries(categories)) {
|
|
||||||
console.log(`\n${category.toUpperCase()}:`);
|
|
||||||
services.forEach((s, i) => {
|
|
||||||
const envInfo = s.envVars.length > 0 ? ' (requires API key)' : '';
|
|
||||||
console.log(` ${i + 1}. ${s.name} - ${s.description}${envInfo}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nEnter service names separated by commas (or "all" for all services):');
|
|
||||||
const selection = await question('Services: ');
|
|
||||||
|
|
||||||
if (selection.toLowerCase() === 'all') {
|
|
||||||
Object.entries(MCP_SERVICES).forEach(([name, service]) => {
|
|
||||||
addService(config, name, service);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const selected = selection.split(',').map(s => s.trim());
|
|
||||||
selected.forEach(name => {
|
|
||||||
if (MCP_SERVICES[name]) {
|
|
||||||
addService(config, name, MCP_SERVICES[name]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await configureApiServices(config, detection);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function configureApiServices(config, detection) {
|
|
||||||
console.log('\n=== API Key Configuration ===\n');
|
|
||||||
|
|
||||||
for (const [name, service] of Object.entries(config.mcpServers)) {
|
|
||||||
const serviceDef = MCP_SERVICES[name];
|
|
||||||
if (serviceDef.envVars && serviceDef.envVars.length > 0) {
|
|
||||||
console.log(`\n${name} requires:`);
|
|
||||||
serviceDef.envVars.forEach(v => console.log(` - ${v}`));
|
|
||||||
|
|
||||||
if (serviceDef.setup) {
|
|
||||||
console.log(` Setup: ${serviceDef.setup}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否已有环境变量
|
|
||||||
const hasExisting = detection && serviceDef.envVars.every(v =>
|
|
||||||
detection.environment.mcpRelated.includes(v)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasExisting) {
|
|
||||||
console.log(' ✓ Found existing environment variables');
|
|
||||||
const useExisting = await question(' Use existing? (y/n): ');
|
|
||||||
if (useExisting.toLowerCase() === 'y') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const configure = await question(' Configure now? (y/n): ');
|
|
||||||
if (configure.toLowerCase() === 'y') {
|
|
||||||
for (const varName of serviceDef.envVars) {
|
|
||||||
const value = await question(` ${varName}: `);
|
|
||||||
if (value) {
|
|
||||||
config.environment[varName] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importConfig(config) {
|
|
||||||
console.log('\n=== Import Configuration ===\n');
|
|
||||||
const filePath = await question('Enter path to config file (.json): ');
|
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
const imported = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
||||||
if (imported.mcpServers) {
|
|
||||||
config.mcpServers = imported.mcpServers;
|
|
||||||
console.log(`✓ Imported ${Object.keys(config.mcpServers).length} services`);
|
|
||||||
}
|
|
||||||
if (imported.environment) {
|
|
||||||
config.environment = imported.environment;
|
|
||||||
console.log(`✓ Imported ${Object.keys(config.environment).length} environment variables`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('✗ File not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportConfig(detection) {
|
|
||||||
console.log('\n=== Export Configuration ===\n');
|
|
||||||
|
|
||||||
if (!detection || !detection.claude.hasExistingConfig) {
|
|
||||||
console.log('✗ No existing configuration found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configFile = path.join(os.homedir(), '.claude.json');
|
|
||||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
||||||
|
|
||||||
const exportData = {
|
|
||||||
mcpServers: config.mcpServers || {},
|
|
||||||
environment: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 导出环境变量
|
|
||||||
if (detection.environment.mcpRelated.length > 0) {
|
|
||||||
console.log('Export environment variables?');
|
|
||||||
const exportEnv = await question('(y/n): ');
|
|
||||||
if (exportEnv.toLowerCase() === 'y') {
|
|
||||||
detection.environment.mcpRelated.forEach(varName => {
|
|
||||||
const value = process.env[varName];
|
|
||||||
if (value) {
|
|
||||||
exportData.environment[varName] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputPath = path.join(__dirname, 'mcp-config-export.json');
|
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(exportData, null, 2));
|
|
||||||
console.log(`\n✓ Configuration exported to: ${outputPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addService(config, name, service) {
|
|
||||||
const isWindows = os.platform() === 'win32';
|
|
||||||
const command = isWindows ? 'cmd' : 'npx';
|
|
||||||
const args = isWindows
|
|
||||||
? ['/c', 'npx', '-y', service.package]
|
|
||||||
: ['-y', service.package];
|
|
||||||
|
|
||||||
config.mcpServers[name] = { command, args };
|
|
||||||
|
|
||||||
if (service.envVars && service.envVars.length > 0) {
|
|
||||||
config.mcpServers[name].env = {};
|
|
||||||
service.envVars.forEach(v => {
|
|
||||||
config.mcpServers[name].env[v] = `\${${v}}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateConfig(config, detection) {
|
|
||||||
console.log('\n=== Generating Configuration Files ===\n');
|
|
||||||
|
|
||||||
// 1. 生成 .claude.json
|
|
||||||
const claudeConfigPath = path.join(__dirname, 'generated-claude.json');
|
|
||||||
fs.writeFileSync(claudeConfigPath, JSON.stringify({ mcpServers: config.mcpServers }, null, 2));
|
|
||||||
console.log(`✓ Generated: ${claudeConfigPath}`);
|
|
||||||
|
|
||||||
// 2. 生成环境变量文件
|
|
||||||
if (Object.keys(config.environment).length > 0) {
|
|
||||||
const envPath = path.join(__dirname, 'generated-env.json');
|
|
||||||
fs.writeFileSync(envPath, JSON.stringify(config.environment, null, 2));
|
|
||||||
console.log(`✓ Generated: ${envPath}`);
|
|
||||||
|
|
||||||
// 生成 PowerShell 脚本(Windows)
|
|
||||||
if (os.platform() === 'win32') {
|
|
||||||
const psScript = generatePowerShellScript(config.environment);
|
|
||||||
const psPath = path.join(__dirname, 'apply-env.ps1');
|
|
||||||
fs.writeFileSync(psPath, psScript);
|
|
||||||
console.log(`✓ Generated: ${psPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成 Shell 脚本(Unix)
|
|
||||||
const shScript = generateShellScript(config.environment);
|
|
||||||
const shPath = path.join(__dirname, 'apply-env.sh');
|
|
||||||
fs.writeFileSync(shPath, shScript);
|
|
||||||
fs.chmodSync(shPath, '755');
|
|
||||||
console.log(`✓ Generated: ${shPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 生成安装说明
|
|
||||||
const readme = generateReadme(config, detection);
|
|
||||||
const readmePath = path.join(__dirname, 'INSTALL.md');
|
|
||||||
fs.writeFileSync(readmePath, readme);
|
|
||||||
console.log(`✓ Generated: ${readmePath}`);
|
|
||||||
|
|
||||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ Configuration Generated Successfully ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
|
||||||
console.log('\nNext steps:');
|
|
||||||
console.log(' 1. Review generated files');
|
|
||||||
console.log(' 2. Run "node apply.js" to install configuration');
|
|
||||||
console.log(' 3. Restart Claude Code');
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePowerShellScript(env) {
|
|
||||||
let script = '# MCP Environment Variables Setup\n';
|
|
||||||
script += '# Generated by MCP Configuration Wizard\n\n';
|
|
||||||
script += '$ErrorActionPreference = "Continue"\n\n';
|
|
||||||
script += 'Write-Host "Setting up MCP environment variables..." -ForegroundColor Cyan\n\n';
|
|
||||||
|
|
||||||
Object.entries(env).forEach(([key, value]) => {
|
|
||||||
script += `[System.Environment]::SetEnvironmentVariable("${key}", "${value}", [System.EnvironmentVariableTarget]::User)\n`;
|
|
||||||
script += `Write-Host "[OK] ${key}" -ForegroundColor Green\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
script += '\nWrite-Host "\\nEnvironment variables configured successfully!" -ForegroundColor Green\n';
|
|
||||||
script += 'Write-Host "Please restart Claude Code to apply changes" -ForegroundColor Yellow\n';
|
|
||||||
script += 'Read-Host "\\nPress Enter to exit"\n';
|
|
||||||
|
|
||||||
return script;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateShellScript(env) {
|
|
||||||
let script = '#!/bin/bash\n';
|
|
||||||
script += '# MCP Environment Variables Setup\n';
|
|
||||||
script += '# Generated by MCP Configuration Wizard\n\n';
|
|
||||||
|
|
||||||
Object.entries(env).forEach(([key, value]) => {
|
|
||||||
script += `export ${key}="${value}"\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
script += '\necho "Environment variables configured for this session"\n';
|
|
||||||
script += 'echo "To make permanent, add these exports to your ~/.bashrc or ~/.zshrc"\n';
|
|
||||||
|
|
||||||
return script;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateReadme(config, detection) {
|
|
||||||
let readme = '# MCP Configuration Installation Guide\n\n';
|
|
||||||
readme += 'Generated by MCP Configuration Wizard\n\n';
|
|
||||||
readme += `## System Information\n\n`;
|
|
||||||
|
|
||||||
if (detection) {
|
|
||||||
readme += `- Platform: ${detection.system.platform}\n`;
|
|
||||||
readme += `- User: ${detection.system.username}\n`;
|
|
||||||
readme += `- Node.js: ${detection.system.nodeVersion}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
readme += `## Configured Services (${Object.keys(config.mcpServers).length})\n\n`;
|
|
||||||
Object.keys(config.mcpServers).forEach(name => {
|
|
||||||
const service = MCP_SERVICES[name];
|
|
||||||
readme += `- **${name}**: ${service.description}\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
readme += '\n## Installation Steps\n\n';
|
|
||||||
readme += '### 1. Apply Environment Variables\n\n';
|
|
||||||
|
|
||||||
if (os.platform() === 'win32') {
|
|
||||||
readme += '**Windows:**\n```powershell\n.\\apply-env.ps1\n```\n\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
readme += '**Unix/Mac:**\n```bash\nsource ./apply-env.sh\n```\n\n';
|
|
||||||
|
|
||||||
readme += '### 2. Install MCP Configuration\n\n';
|
|
||||||
readme += '```bash\nnode apply.js\n```\n\n';
|
|
||||||
|
|
||||||
readme += '### 3. Restart Claude Code\n\n';
|
|
||||||
readme += 'Close and reopen Claude Code to load the new MCP services.\n\n';
|
|
||||||
|
|
||||||
readme += '## Manual Installation\n\n';
|
|
||||||
readme += 'If automatic installation fails:\n\n';
|
|
||||||
readme += '1. Copy `generated-claude.json` content to `~/.claude.json`\n';
|
|
||||||
readme += '2. Set environment variables from `generated-env.json`\n';
|
|
||||||
readme += '3. Restart Claude Code\n';
|
|
||||||
|
|
||||||
return readme;
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 > nul
|
|
||||||
cd /d "%~dp0"
|
|
||||||
:: v3.1.1 架构: 更新 .bat 仅做 git pull, 完成后弹 GUI 让用户决定是否立即启动.
|
|
||||||
:: 启动 claude 由独立的 启动Bookworm.lnk → pwsh + bw-launch.ps1 完成 (1 跳直链)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo Bookworm 配置同步
|
|
||||||
echo ============================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
set HAS_FAIL=0
|
|
||||||
|
|
||||||
:: 同步 bookworm-boot 仓库 (本目录)
|
|
||||||
echo [1/2] 同步启动器目录 (bookworm-boot)...
|
|
||||||
git pull --rebase 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [!] bookworm-boot git pull 失败 ^(不影响启动 lnk^)
|
|
||||||
set HAS_FAIL=1
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 同步 ~/.claude 配置仓库 (Skill/hook/agents)
|
|
||||||
echo.
|
|
||||||
echo [2/2] 同步 Claude 配置 (.claude/)...
|
|
||||||
git -C "%USERPROFILE%\.claude" stash -q 2>nul
|
|
||||||
git -C "%USERPROFILE%\.claude" pull --rebase 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [!] .claude git pull 失败 ^(不影响启动 lnk^)
|
|
||||||
set HAS_FAIL=1
|
|
||||||
)
|
|
||||||
git -C "%USERPROFILE%\.claude" stash pop -q 2>nul
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ============================================
|
|
||||||
if %HAS_FAIL% equ 0 (
|
|
||||||
echo [OK] 所有同步完成
|
|
||||||
) else (
|
|
||||||
echo [!] 部分同步失败 ^(详见上方日志, 启动仍可正常使用^)
|
|
||||||
)
|
|
||||||
echo ============================================
|
|
||||||
|
|
||||||
:: v3.1.1 (闭合 L6): 完成后 GUI 询问是否立即启动 Claude (闭合 "更新完了不知道下一步")
|
|
||||||
:: 用 PowerShell 弹 MessageBox YesNo, Yes → 触发桌面启动 lnk; No → 直接退出
|
|
||||||
where pwsh >nul 2>nul && set "PSH=pwsh" || set "PSH=powershell"
|
|
||||||
%PSH% -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; $r = [System.Windows.Forms.MessageBox]::Show('配置同步完成. 是否立即启动 Bookworm Claude?', 'Bookworm 同步完成', 'YesNo', 'Question'); if ($r -eq 'Yes') { $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) '启动Bookworm.lnk'; if (Test-Path $lnk) { Start-Process $lnk } else { Write-Host 'lnk 缺失, 请重跑 Bookworm-Setup.exe' -ForegroundColor Yellow; pause } }"
|
|
||||||
28
更新并启动Bookworm.bat
Normal file
28
更新并启动Bookworm.bat
Normal file
@ -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
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user