From 080ff71653f8e539509eae1cf8e472bbecbab200 Mon Sep 17 00:00:00 2001 From: Bookworm Date: Tue, 21 Apr 2026 01:54:42 +0800 Subject: [PATCH] sync: v3.0.1 auto-setup.ps1 + Setup.sh (P0 fixes) --- Bookworm-Setup.sh | 111 +- auto-setup.ps1 | 3735 ++++++++++++++++++++++++--------------------- 2 files changed, 2096 insertions(+), 1750 deletions(-) diff --git a/Bookworm-Setup.sh b/Bookworm-Setup.sh index 1268b1f..64bec31 100644 --- a/Bookworm-Setup.sh +++ b/Bookworm-Setup.sh @@ -211,6 +211,25 @@ done # ============================================================ 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)" @@ -349,6 +368,92 @@ else 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="" @@ -495,7 +600,7 @@ 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" claude --dangerously-skip-permissions' +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:" @@ -509,7 +614,7 @@ else cat >> "$SHELL_RC" << 'ALIASES' # Bookworm Portable aliases -alias bw='NO_PROXY="bww.letcareme.com,code.letcareme.com,localhost,127.0.0.1" claude --dangerously-skip-permissions' +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)" @@ -547,5 +652,7 @@ if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then 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 diff --git a/auto-setup.ps1 b/auto-setup.ps1 index f13147d..b0e027e 100644 --- a/auto-setup.ps1 +++ b/auto-setup.ps1 @@ -1,1748 +1,1987 @@ -<# -.SYNOPSIS - Bookworm Portable - 全自动一键安装器 -.DESCRIPTION - 全新电脑从零到 Bookworm 完全就绪,最大程度自动化。 - 7 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 启动 - 需要人工输入时弹出 GUI 对话框。 -.USAGE - .\auto-setup.ps1 - .\auto-setup.ps1 -SkipLaunch # 安装但不启动 -#> -param( - [switch]$SkipLaunch -) - -$ErrorActionPreference = "Stop" - -# ─── 版本号 (每次更新递增, build.ps1 自动读取) ────── -$BWVersion = "3.0.0-beta" - -# ─── B4: 单实例保护 (防止双击两次导致竞态) ───────── -$mutexCreated = $false -$global:BWMutex = [System.Threading.Mutex]::new($true, "Global\BookwormPortableSetup", [ref]$mutexCreated) -if (-not $mutexCreated) { - Add-Type -AssemblyName System.Windows.Forms - [System.Windows.Forms.MessageBox]::Show("Bookworm 安装器已在运行中。`n请勿重复启动。", "提示", "OK", "Information") | Out-Null - exit 0 -} - -# ─── 路径定义 ──────────────────────────────────────── -# PS2EXE 兼容: $MyInvocation.MyCommand.Path 在 EXE 启动时为空,需用 Process MainModule -$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 -} -$ClaudeDir = Join-Path $env:USERPROFILE ".claude" -$BackupDir = Join-Path $env:USERPROFILE ".claude.bw-backup" -$GitUrl = "https://code.letcareme.com/bookworm/bookworm-portable-config.git" # v3.0: 脱敏分发仓库 -$BootUrl = "https://code.letcareme.com/bookworm/bookworm-boot.git" -$BootDir = Join-Path $ScriptDir "bookworm-boot" -$SecretsEnc = Join-Path $BootDir "secrets.enc" -$TOTAL_PHASES = 7 - -# ─── GUI 初始化 ───────────────────────────────────── -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing -[System.Windows.Forms.Application]::EnableVisualStyles() - -# ─── 日志 + 进度 (PS2EXE -NoConsole -NoOutput 模式: console 全静默) ── -# 所有 Log-X 输出走文件 + GUI 进度窗口 (避免被 PS2EXE 弹窗化) -$BWLogFile = Join-Path $env:TEMP "bookworm-setup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -function Bw-Log($level, $msg) { - try { Add-Content -Path $BWLogFile -Value "[$(Get-Date -Format 'HH:mm:ss')] [$level] $msg" -Encoding utf8 } catch {} -} -function Log-OK($msg) { Bw-Log "OK" $msg; Update-Progress-SubStatus "$msg" } -function Log-Info($msg) { Bw-Log "INFO" $msg; Update-Progress-SubStatus "$msg" } -function Log-Warn($msg) { Bw-Log "WARN" $msg } -function Log-Fail($msg) { Bw-Log "FAIL" $msg } -function Log-Phase($n, $title) { - Bw-Log "PHASE" "[$n/$TOTAL_PHASES] $title" - Update-Progress $n $title -} - -# ─── GUI 进度窗口 (常驻顶部, 替代 console 输出) ──── -$global:BWProgressForm = $null -$global:BWPhaseLabel = $null -$global:BWStatusLabel = $null -$global:BWProgressBar = $null - -function Show-ProgressForm { - # 统一品牌色 - $brandBlue = [System.Drawing.Color]::FromArgb(88, 101, 242) # Bookworm 蓝紫 - $brandDark = [System.Drawing.Color]::FromArgb(30, 31, 46) - $uiFont = "Segoe UI" - - $global:BWProgressForm = New-Object System.Windows.Forms.Form - $global:BWProgressForm.Text = "Bookworm Portable Setup v$BWVersion" - $global:BWProgressForm.Size = New-Object System.Drawing.Size(520, 230) - $global:BWProgressForm.StartPosition = "CenterScreen" - $global:BWProgressForm.FormBorderStyle = "FixedDialog" - $global:BWProgressForm.MaximizeBox = $false - $global:BWProgressForm.MinimizeBox = $false - $global:BWProgressForm.TopMost = $false # P2: 不遮挡其他窗口 - $global:BWProgressForm.ControlBox = $true # P0 F1: 允许关闭 (触发确认) - $global:BWProgressForm.BackColor = [System.Drawing.Color]::White - - # X 按钮关闭时弹确认 - $global:BWProgressForm.Add_FormClosing({ - param($s, $e) - if (-not $global:BWInstallDone) { - $r = [System.Windows.Forms.MessageBox]::Show( - "安装尚未完成。`n确定要取消安装吗?", - "取消安装", "YesNo", "Warning") - if ($r -eq "No") { $e.Cancel = $true; return } - Bw-Log "ABORT" "用户手动取消安装" - } - }) - - $titleLabel = New-Object System.Windows.Forms.Label - $titleLabel.Location = New-Object System.Drawing.Point(20, 16) - $titleLabel.Size = New-Object System.Drawing.Size(480, 26) - $titleLabel.Text = "Bookworm 智能助手 — 自动安装中" - $titleLabel.Font = New-Object System.Drawing.Font($uiFont, 12, [System.Drawing.FontStyle]::Bold) - $titleLabel.ForeColor = $brandDark - $global:BWProgressForm.Controls.Add($titleLabel) - - $global:BWPhaseLabel = New-Object System.Windows.Forms.Label - $global:BWPhaseLabel.Location = New-Object System.Drawing.Point(20, 50) - $global:BWPhaseLabel.Size = New-Object System.Drawing.Size(480, 22) - $global:BWPhaseLabel.Text = "[0/$TOTAL_PHASES] 初始化..." - $global:BWPhaseLabel.Font = New-Object System.Drawing.Font($uiFont, 10) - $global:BWPhaseLabel.ForeColor = $brandBlue - $global:BWProgressForm.Controls.Add($global:BWPhaseLabel) - - $global:BWStatusLabel = New-Object System.Windows.Forms.Label - $global:BWStatusLabel.Location = New-Object System.Drawing.Point(20, 78) - $global:BWStatusLabel.Size = New-Object System.Drawing.Size(480, 22) - $global:BWStatusLabel.Text = "" - $global:BWStatusLabel.Font = New-Object System.Drawing.Font($uiFont, 9) - $global:BWStatusLabel.ForeColor = [System.Drawing.Color]::FromArgb(120, 120, 140) - $global:BWProgressForm.Controls.Add($global:BWStatusLabel) - - $global:BWProgressBar = New-Object System.Windows.Forms.ProgressBar - $global:BWProgressBar.Location = New-Object System.Drawing.Point(20, 112) - $global:BWProgressBar.Size = New-Object System.Drawing.Size(480, 20) - $global:BWProgressBar.Minimum = 0 - $global:BWProgressBar.Maximum = $TOTAL_PHASES - $global:BWProgressBar.Value = 0 - $global:BWProgressBar.Style = [System.Windows.Forms.ProgressBarStyle]::Continuous # P3: 平滑 - $global:BWProgressForm.Controls.Add($global:BWProgressBar) - - $global:BWElapsedLabel = New-Object System.Windows.Forms.Label - $global:BWElapsedLabel.Location = New-Object System.Drawing.Point(400, 136) - $global:BWElapsedLabel.Size = New-Object System.Drawing.Size(100, 18) - $global:BWElapsedLabel.Text = "" - $global:BWElapsedLabel.Font = New-Object System.Drawing.Font($uiFont, 8) - $global:BWElapsedLabel.ForeColor = [System.Drawing.Color]::Silver - $global:BWElapsedLabel.TextAlign = [System.Drawing.ContentAlignment]::TopRight - $global:BWProgressForm.Controls.Add($global:BWElapsedLabel) - - $hint = New-Object System.Windows.Forms.Label - $hint.Location = New-Object System.Drawing.Point(20, 136) - $hint.Size = New-Object System.Drawing.Size(380, 32) - $hint.Text = "首次安装约 5-10 分钟 (依赖下载)`n关闭窗口可取消安装" - $hint.Font = New-Object System.Drawing.Font($uiFont, 8) - $hint.ForeColor = [System.Drawing.Color]::Silver - $global:BWProgressForm.Controls.Add($hint) - - $global:BWProgressForm.Show() | Out-Null - $global:BWProgressForm.Refresh() - [System.Windows.Forms.Application]::DoEvents() -} - -# 全局安装完成标记 (Close-ProgressForm 前设为 $true, 避免 X 按钮弹确认) -$global:BWInstallDone = $false -# 全局计时器 -$global:BWStartTime = [System.Diagnostics.Stopwatch]::StartNew() - -function Update-Progress($phase, $title) { - if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) { - try { - $global:BWPhaseLabel.Text = "[$phase/$TOTAL_PHASES] $title" - $global:BWStatusLabel.Text = "" - $global:BWProgressBar.Value = [Math]::Min($phase, $TOTAL_PHASES) - $global:BWProgressForm.Refresh() - [System.Windows.Forms.Application]::DoEvents() - } catch {} - } -} - -function Update-Progress-SubStatus($msg) { - if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed -and $global:BWStatusLabel) { - try { - $shortMsg = if ($msg.Length -gt 70) { $msg.Substring(0, 67) + "..." } else { $msg } - $global:BWStatusLabel.Text = $shortMsg - # 刷新总耗时 - if ($global:BWElapsedLabel -and $global:BWStartTime) { - $sec = [int]$global:BWStartTime.Elapsed.TotalSeconds - $global:BWElapsedLabel.Text = "$([int]($sec / 60))m $($sec % 60)s" - } - $global:BWStatusLabel.Refresh() - [System.Windows.Forms.Application]::DoEvents() - } catch {} - } -} - -function Close-ProgressForm { - $global:BWInstallDone = $true # 关闭时不再弹 "取消安装?" 确认 - if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) { - try { $global:BWProgressForm.Close(); $global:BWProgressForm.Dispose() } catch {} - } -} - -function Test-Cmd($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) } - -# ─── 非阻塞子进程执行 (解决 PS2EXE UI 冻结) ─────────── -# 所有耗时子进程都必须经过这两个函数, 保持 GUI 消息泵活跃 - -function Wait-ProcessWithUI { - <# 替代 System.Diagnostics.Process.WaitForExit(N) - 在等待期间每 200ms 泵一次 DoEvents, 防止 "(未响应)" #> - param( - [System.Diagnostics.Process]$proc, - [int]$timeoutMs = 60000, - [string]$label = "" - ) - $sw = [System.Diagnostics.Stopwatch]::StartNew() - while (-not $proc.HasExited -and $sw.ElapsedMilliseconds -lt $timeoutMs) { - [System.Windows.Forms.Application]::DoEvents() - # 每 5 秒更新一次副状态, 显示等待耗时 - if ($label -and ($sw.ElapsedMilliseconds % 5000) -lt 250) { - $elapsed = [int]($sw.ElapsedMilliseconds / 1000) - Update-Progress-SubStatus "$label ($($elapsed)s)" - } - Start-Sleep -Milliseconds 200 - } - if (-not $proc.HasExited) { - try { $proc.Kill() } catch {} - Bw-Log "WARN" "子进程超时 ($timeoutMs ms): $label" - return $false - } - return $true -} - -function Run-CmdWithUI { - <# 替代 & cmd args 2>&1 | ForEach-Object { Write-Host } - 将阻塞调用转为 Start-Process + Wait-ProcessWithUI #> - param( - [string]$exe, - [string[]]$arguments, - [string]$label = "", - [int]$timeoutMs = 180000, # 默认 3 分钟 - [switch]$captureOutput # 返回 stdout 内容 - ) - # W-01: Windows Start-Process 兼容 — npm/npx 无扩展名是 Unix shell 脚本 - if ($exe -in @("npm", "npx") -and -not $exe.EndsWith(".cmd")) { - $exe = "$exe.cmd" - } - - # B1: 脱敏日志 (去除 URL 内嵌凭证 user:pass@) - $sanitizedArgs = ($arguments -join ' ') -replace '://[^@]+@', '://***@' - Bw-Log "CMD" "$exe $sanitizedArgs" - Update-Progress-SubStatus $label - - # V-04: 用 GetTempFileName (原子创建+加密随机) 替代 Get-Random - $outFile = [System.IO.Path]::GetTempFileName() - $errFile = [System.IO.Path]::GetTempFileName() - try { - $proc = Start-Process -FilePath $exe -ArgumentList $arguments ` - -NoNewWindow -PassThru ` - -RedirectStandardOutput $outFile ` - -RedirectStandardError $errFile - $ok = Wait-ProcessWithUI $proc $timeoutMs $label - $exitCode = if ($proc.HasExited) { $proc.ExitCode } else { -1 } - - # 日志记录 stdout/stderr (不超过 20 行) - if (Test-Path $outFile) { - $out = Get-Content $outFile -TotalCount 20 -ErrorAction SilentlyContinue - if ($out) { $out | ForEach-Object { Bw-Log "OUT" $_ } } - } - if (Test-Path $errFile) { - $err = Get-Content $errFile -TotalCount 10 -ErrorAction SilentlyContinue - if ($err) { $err | ForEach-Object { Bw-Log "ERR" $_ } } - } - - if ($captureOutput -and (Test-Path $outFile)) { - return @{ OK = ($ok -and $exitCode -eq 0); Output = (Get-Content $outFile -Raw -ErrorAction SilentlyContinue); ExitCode = $exitCode } - } - return @{ OK = ($ok -and $exitCode -eq 0); ExitCode = $exitCode } - } finally { - Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue - } -} - -# ─── GUI 对话框 ───────────────────────────────────── - -function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon = "Information") { - [System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon) -} - -function Parse-AuthCode-GUI { - param([string]$code) - $code = $code.Trim() - if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { return $null } - $expiryStr = $Matches[1] - $token = $Matches[2].ToLower() - $today = (Get-Date).ToString("yyyyMMdd") - if ([int]$expiryStr -lt [int]$today) { return 'EXPIRED' } - return $token -} - -function Show-AuthCodeDialog($attempt = 1, $maxAttempts = 3) { - $form = New-Object System.Windows.Forms.Form - $form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)" - $form.Size = New-Object System.Drawing.Size(480, 240) - $form.StartPosition = "CenterScreen" - $form.FormBorderStyle = "FixedDialog" - $form.MaximizeBox = $false - $form.MinimizeBox = $false - $form.TopMost = $true - - $label = New-Object System.Windows.Forms.Label - $label.Location = New-Object System.Drawing.Point(20, 18) - $label.Size = New-Object System.Drawing.Size(440, 36) - $label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX" - $label.Font = New-Object System.Drawing.Font("Segoe UI", 9) - $form.Controls.Add($label) - - # 授权码可见 (用于粘贴验证), 不用 PasswordChar - $codeBox = New-Object System.Windows.Forms.TextBox - $codeBox.Location = New-Object System.Drawing.Point(20, 65) - $codeBox.Size = New-Object System.Drawing.Size(430, 30) - $codeBox.Font = New-Object System.Drawing.Font("Consolas", 11) - $codeBox.CharacterCasing = "Upper" # 自动转大写 - $form.Controls.Add($codeBox) - - $hint = New-Object System.Windows.Forms.Label - $hint.Location = New-Object System.Drawing.Point(20, 100) - $hint.Size = New-Object System.Drawing.Size(440, 20) - $hint.Text = "提示: 直接粘贴管理员发送的授权码即可 (Ctrl+V)" - $hint.Font = New-Object System.Drawing.Font("Segoe UI", 8) - $hint.ForeColor = [System.Drawing.Color]::Gray - $form.Controls.Add($hint) - - $btnOK = New-Object System.Windows.Forms.Button - $btnOK.Location = New-Object System.Drawing.Point(250, 145) - $btnOK.Size = New-Object System.Drawing.Size(90, 35) - $btnOK.Text = "验证" - $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK - $form.AcceptButton = $btnOK - $form.Controls.Add($btnOK) - - $btnCancel = New-Object System.Windows.Forms.Button - $btnCancel.Location = New-Object System.Drawing.Point(350, 145) - $btnCancel.Size = New-Object System.Drawing.Size(90, 35) - $btnCancel.Text = "取消安装" - $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel - $form.CancelButton = $btnCancel - $form.Controls.Add($btnCancel) - - $form.Add_Shown({ $codeBox.Focus() }) - $result = $form.ShowDialog() - - if ($result -eq [System.Windows.Forms.DialogResult]::OK) { - return $codeBox.Text.Trim() - } - return $null -} - -# ─── 中转站 API Key 输入对话框 + 验证 (v2.3 新增) ── -function Show-ApiKeyDialog($attempt = 1, $maxAttempts = 3, $existingKey = "") { - $form = New-Object System.Windows.Forms.Form - $form.Text = "Bookworm - 中转站 API Key ($attempt/$maxAttempts)" - $form.Size = New-Object System.Drawing.Size(520, 280) - $form.StartPosition = "CenterScreen" - $form.FormBorderStyle = "FixedDialog" - $form.MaximizeBox = $false - $form.TopMost = $true - $form.BackColor = [System.Drawing.Color]::White - - $lblInfo = New-Object System.Windows.Forms.Label - $lblInfo.Location = New-Object System.Drawing.Point(20, 15) - $lblInfo.Size = New-Object System.Drawing.Size(470, 50) - $lblInfo.Text = "请粘贴你的中转站 API Key`n(bww.letcareme.com 注册后在后台获取, sk- 开头)" - $lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9) - $form.Controls.Add($lblInfo) - - $keyBox = New-Object System.Windows.Forms.TextBox - $keyBox.Location = New-Object System.Drawing.Point(20, 75) - $keyBox.Size = New-Object System.Drawing.Size(470, 30) - $keyBox.Font = New-Object System.Drawing.Font("Consolas", 10) - $keyBox.Text = $existingKey - $keyBox.PasswordChar = '*' - $form.Controls.Add($keyBox) - - $chkShow = New-Object System.Windows.Forms.CheckBox - $chkShow.Location = New-Object System.Drawing.Point(20, 110) - $chkShow.Size = New-Object System.Drawing.Size(150, 25) - $chkShow.Text = "显示 Key" - $chkShow.Add_CheckedChanged({ if ($chkShow.Checked) { $keyBox.PasswordChar = [char]0 } else { $keyBox.PasswordChar = '*' } }) - $form.Controls.Add($chkShow) - - $lblHint = New-Object System.Windows.Forms.Label - $lblHint.Location = New-Object System.Drawing.Point(20, 140) - $lblHint.Size = New-Object System.Drawing.Size(470, 40) - $lblHint.Text = "首次使用请先注册并充值:`nhttps://bww.letcareme.com" - $lblHint.Font = New-Object System.Drawing.Font("Segoe UI", 8) - $lblHint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130) - $form.Controls.Add($lblHint) - - $btnOK = New-Object System.Windows.Forms.Button - $btnOK.Location = New-Object System.Drawing.Point(280, 195) - $btnOK.Size = New-Object System.Drawing.Size(100, 35) - $btnOK.Text = "验证并保存" - $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK - $form.AcceptButton = $btnOK - $form.Controls.Add($btnOK) - - $btnCancel = New-Object System.Windows.Forms.Button - $btnCancel.Location = New-Object System.Drawing.Point(390, 195) - $btnCancel.Size = New-Object System.Drawing.Size(100, 35) - $btnCancel.Text = "取消" - $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel - $form.CancelButton = $btnCancel - $form.Controls.Add($btnCancel) - - $form.Add_Shown({ $keyBox.Focus() }) - $result = $form.ShowDialog() - - if ($result -eq [System.Windows.Forms.DialogResult]::OK) { - return $keyBox.Text.Trim() - } - return $null -} - -# 向中转站发一个最小请求验证 key 是否可用 -function Test-ApiKey([string]$apiKey, [string]$baseUrl = "https://bww.letcareme.com") { - if (-not $apiKey -or $apiKey.Length -lt 10) { return $false } - try { - $body = '{"model":"claude-sonnet-4-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' - $req = [System.Net.WebRequest]::Create("$baseUrl/v1/messages") - $req.Method = "POST" - $req.ContentType = "application/json" - $req.Headers["x-api-key"] = $apiKey - $req.Headers["anthropic-version"] = "2023-06-01" - $req.Timeout = 10000 - $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) - $req.ContentLength = $bytes.Length - $stream = $req.GetRequestStream() - $stream.Write($bytes, 0, $bytes.Length) - $stream.Close() - $resp = $req.GetResponse() - $resp.Close() - return $true - } catch [System.Net.WebException] { - # 401/403 = 认证失败, 其他网络错误也返回 false - $statusCode = 0 - try { $statusCode = [int]$_.Exception.Response.StatusCode } catch {} - # 如果返回 200/400 说明 key 有效 (400 可能是请求体问题, 但 key 本身通过) - if ($statusCode -ge 200 -and $statusCode -lt 500 -and $statusCode -ne 401 -and $statusCode -ne 403) { - return $true - } - return $false - } catch { - return $false - } -} - -function Show-GiteaCredentialDialog { - $form = New-Object System.Windows.Forms.Form - $form.Text = "Bookworm - Gitea 登录" - $form.Size = New-Object System.Drawing.Size(420, 300) - $form.StartPosition = "CenterScreen" - $form.FormBorderStyle = "FixedDialog" - $form.MaximizeBox = $false - $form.TopMost = $true - $form.BackColor = [System.Drawing.Color]::White - - $lblInfo = New-Object System.Windows.Forms.Label - $lblInfo.Location = New-Object System.Drawing.Point(20, 15) - $lblInfo.Size = New-Object System.Drawing.Size(360, 40) - $lblInfo.Text = "输入 Gitea 账号 (code.letcareme.com)`n用于下载 Bookworm 配置文件,由管理员提供。" - $lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9) - $form.Controls.Add($lblInfo) - - $lblUser = New-Object System.Windows.Forms.Label - $lblUser.Location = New-Object System.Drawing.Point(20, 65) - $lblUser.Size = New-Object System.Drawing.Size(80, 25) - $lblUser.Text = "用户名:" - $form.Controls.Add($lblUser) - - $txtUser = New-Object System.Windows.Forms.TextBox - $txtUser.Location = New-Object System.Drawing.Point(100, 63) - $txtUser.Size = New-Object System.Drawing.Size(280, 25) - $txtUser.Font = New-Object System.Drawing.Font("Consolas", 11) - $form.Controls.Add($txtUser) - - $lblPass = New-Object System.Windows.Forms.Label - $lblPass.Location = New-Object System.Drawing.Point(20, 105) - $lblPass.Size = New-Object System.Drawing.Size(80, 25) - $lblPass.Text = "密码:" - $form.Controls.Add($lblPass) - - $txtPass = New-Object System.Windows.Forms.TextBox - $txtPass.Location = New-Object System.Drawing.Point(100, 103) - $txtPass.Size = New-Object System.Drawing.Size(280, 25) - $txtPass.PasswordChar = '*' - $txtPass.Font = New-Object System.Drawing.Font("Consolas", 11) - $form.Controls.Add($txtPass) - - # P1 F10: 空值验证提示 - $lblError = New-Object System.Windows.Forms.Label - $lblError.Location = New-Object System.Drawing.Point(100, 135) - $lblError.Size = New-Object System.Drawing.Size(280, 20) - $lblError.Text = "" - $lblError.Font = New-Object System.Drawing.Font("Segoe UI", 8) - $lblError.ForeColor = [System.Drawing.Color]::Red - $form.Controls.Add($lblError) - - $btnOK = New-Object System.Windows.Forms.Button - $btnOK.Location = New-Object System.Drawing.Point(200, 165) - $btnOK.Size = New-Object System.Drawing.Size(90, 35) - $btnOK.Text = "登录" - $form.Controls.Add($btnOK) - - $btnCancel = New-Object System.Windows.Forms.Button - $btnCancel.Location = New-Object System.Drawing.Point(300, 165) - $btnCancel.Size = New-Object System.Drawing.Size(80, 35) - $btnCancel.Text = "取消" - $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel - $form.CancelButton = $btnCancel - $form.Controls.Add($btnCancel) - - # OK 按钮手动验证 (不用 DialogResult, 防止空值直接关闭) - $btnOK.Add_Click({ - if (-not $txtUser.Text.Trim() -or -not $txtPass.Text) { - $lblError.Text = "用户名和密码不能为空" - return - } - $form.DialogResult = [System.Windows.Forms.DialogResult]::OK - $form.Close() - }) - $form.AcceptButton = $btnOK - - $form.Add_Shown({ $txtUser.Focus() }) - $result = $form.ShowDialog() - - if ($result -eq [System.Windows.Forms.DialogResult]::OK) { - return @{ User = $txtUser.Text.Trim(); Pass = $txtPass.Text } - } - return $null -} - -# ─── openssl 检测 ──────────────────────────────────── -function Find-OpenSSL { - $cmd = Get-Command openssl -ErrorAction SilentlyContinue - if ($cmd) { return $cmd.Source } - $paths = @( - "C:\Program Files\Git\usr\bin\openssl.exe", - "D:\Git\usr\bin\openssl.exe", - "C:\Program Files\Git\mingw64\bin\openssl.exe", - "D:\Git\mingw64\bin\openssl.exe" - ) - return $paths | Where-Object { Test-Path $_ } | Select-Object -First 1 -} - -# ─── 凭证缓存 (DPAPI 加密, 绑定当前 Windows 用户) ── -# B2: 不再明文存注册表, 使用 ProtectedData 加密 -# B9: 读取时使用白名单, 不加载任意 KEY -Add-Type -AssemblyName System.Security - -$CacheAllowedKeys = @("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") - -function Protect-String([string]$plain) { - $bytes = [System.Text.Encoding]::UTF8.GetBytes($plain) - $enc = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, "CurrentUser") - return [Convert]::ToBase64String($enc) -} - -function Unprotect-String([string]$b64) { - $enc = [Convert]::FromBase64String($b64) - $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, "CurrentUser") - return [System.Text.Encoding]::UTF8.GetString($bytes) -} - -function Get-CachedSecrets { - try { - $regPath = "HKCU:\Software\Bookworm\CachedEnv" - if (-not (Test-Path $regPath)) { return $false } - $expiry = (Get-ItemProperty $regPath -Name "_expiry" -ErrorAction SilentlyContinue)._expiry - if (-not $expiry -or [datetime]$expiry -le (Get-Date)) { - Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue - return $false - } - $props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue - $loaded = 0 - $needMigrate = $false - foreach ($p in $props.PSObject.Properties) { - if ($CacheAllowedKeys -contains $p.Name) { - $val = $null - # 先尝试 DPAPI 解密 (新格式) - try { $val = Unprotect-String $p.Value } catch {} - # 回退: 旧版明文格式 (非 Base64 / DPAPI 失败) - if (-not $val -and $p.Value -and $p.Value.Length -lt 200) { - $val = $p.Value - $needMigrate = $true - } - if ($val) { - [System.Environment]::SetEnvironmentVariable($p.Name, $val, "Process") - [System.Environment]::SetEnvironmentVariable($p.Name, $val, "User") - $loaded++ - } - } - } - # 旧缓存自动迁移为 DPAPI 格式 - if ($needMigrate -and $loaded -gt 0) { - Save-SecretsToCache - Bw-Log "INFO" "旧版明文缓存已迁移为 DPAPI 加密" - } - return ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY) - } catch { return $false } -} - -function Save-SecretsToCache { - try { - $regPath = "HKCU:\Software\Bookworm\CachedEnv" - if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null } - foreach ($k in $CacheAllowedKeys) { - $v = [System.Environment]::GetEnvironmentVariable($k, "Process") - if ($v) { - $encrypted = Protect-String $v - Set-ItemProperty $regPath -Name $k -Value $encrypted -Force - } - } - Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToUniversalTime().ToString("o") -Force - } catch {} -} - -# ─── 桌面快捷方式 ────────────────────────────────── -function New-DesktopShortcuts { - try { - $shell = New-Object -ComObject WScript.Shell - $desktop = $shell.SpecialFolders("Desktop") - - # 桌面专用图标 (Bookworm 蓝紫渐变 B 圆, 多尺寸 ICO) - $iconPath = Join-Path $BootDir "bookworm-desktop.ico" - if (-not (Test-Path $iconPath)) { - # 回退到 EXE 图标 (bookworm.ico) - $iconPath = Join-Path $BootDir "bookworm.ico" - } - - # 快速启动 (bat 文件位于 bookworm-boot 仓库内) - $shortcut = $shell.CreateShortcut("$desktop\Bookworm.lnk") - $batPath = Join-Path $BootDir "启动Bookworm.bat" - if (-not (Test-Path $batPath)) { $batPath = Join-Path $BootDir "Bookworm-OneClick.bat" } - $shortcut.TargetPath = $batPath - $shortcut.WorkingDirectory = $BootDir - $shortcut.Description = "Bookworm Smart Assistant - 智能助手" - if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" } - $shortcut.Save() - - # 更新启动 - $shortcut2 = $shell.CreateShortcut("$desktop\更新Bookworm.lnk") - $updateBat = Join-Path $BootDir "更新并启动Bookworm.bat" - if (Test-Path $updateBat) { - $shortcut2.TargetPath = $updateBat - $shortcut2.WorkingDirectory = $BootDir - $shortcut2.Description = "更新并启动 Bookworm" - if (Test-Path $iconPath) { $shortcut2.IconLocation = "$iconPath,0" } - $shortcut2.Save() - } - Log-OK "桌面快捷方式已创建 (含 Bookworm 图标)" - } catch { Log-Warn "快捷方式创建失败: $_" } -} - -# ======================================================================== -# 启动: 显示 GUI 进度窗口 (替代 console banner, PS2EXE -NoOutput 兼容) -# ======================================================================== -Bw-Log "INIT" "Bookworm Portable Setup 启动 - 日志: $BWLogFile" -Show-ProgressForm - -# ======================================================================== -# Phase 1: 环境检测 + 依赖自动安装 -# ======================================================================== -Log-Phase 1 "环境检测 + 依赖自动安装" - -# 刷新 PATH -$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - -$deps = @( - # 核心依赖 (缺失则尝试自动安装) - @{ Name = "Node.js"; Cmd = "node"; WingetId = "OpenJS.NodeJS.LTS"; NpmPkg = $null; PipPkg = $null; Core = $true } - @{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null; PipPkg = $null; Core = $true } - @{ Name = "PowerShell 7"; Cmd = "pwsh"; WingetId = "Microsoft.PowerShell"; NpmPkg = $null; PipPkg = $null; Core = $false } - @{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code"; PipPkg = $null; Core = $true } - # Python 移到可选依赖 (不在此列表, 由 line 753 单独处理) -) - -$hasWinget = Test-Cmd "winget" -$installed = @() - -foreach ($dep in $deps) { - if (Test-Cmd $dep.Cmd) { - $ver = try { & $dep.Cmd --version 2>$null | Select-Object -First 1 } catch { "installed" } - Log-OK "$($dep.Name) $ver" - } else { - Log-Warn "$($dep.Name) 未安装, 正在自动安装..." - - if ($dep.WingetId -and $hasWinget) { - try { - $r = Run-CmdWithUI "winget" @("install", $dep.WingetId, "--accept-source-agreements", "--accept-package-agreements") "安装 $($dep.Name)" 300000 - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - if (Test-Cmd $dep.Cmd) { - Log-OK "$($dep.Name) 安装成功" - $installed += $dep.Name - } else { - Log-Fail "$($dep.Name) 安装后仍无法找到, 可能需要重启终端" - } - } catch { - Log-Fail "$($dep.Name) 安装失败: $_" - } - } - elseif ($dep.NpmPkg -and (Test-Cmd "npm")) { - try { - $r = Run-CmdWithUI "npm" @("i", "-g", $dep.NpmPkg) "npm 安装 $($dep.Name)" 120000 - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - if (Test-Cmd $dep.Cmd) { - Log-OK "$($dep.Name) 安装成功" - $installed += $dep.Name - } - } catch { Log-Fail "$($dep.Name) npm 安装失败: $_" } - } - elseif (-not $hasWinget) { - if ($dep.Core) { - Log-Fail "$($dep.Name) 需要手动安装 (winget 不可用)" - Show-MsgBox "$($dep.Name) 未安装且 winget 不可用。`n请手动安装后重新运行。`n`nNode.js: https://nodejs.org`nGit: https://git-scm.com" "缺少依赖" "OK" "Error" - } else { - Log-Info "$($dep.Name) 未安装 (可选, 不影响核心功能)" - } - } - } -} - -# ── bash PATH 自动修复 (Claude Code 的核心工具依赖 bash) ── -# Git 默认只把 cmd\ 加 PATH (有 git.exe), 但 bash.exe 在 bin\ 目录 -if ((Test-Cmd "git") -and -not (Test-Cmd "bash")) { - $gitBinPaths = @( - "$env:ProgramFiles\Git\bin", - "${env:ProgramFiles(x86)}\Git\bin", - "D:\Git\bin", - "$env:LOCALAPPDATA\Programs\Git\bin" - ) - $gitBin = $gitBinPaths | Where-Object { Test-Path (Join-Path $_ "bash.exe") } | Select-Object -First 1 - if ($gitBin) { - # 加入用户 PATH (永久) - $userPath = [System.Environment]::GetEnvironmentVariable("Path", "User") - if ($userPath -notmatch [regex]::Escape($gitBin)) { - [System.Environment]::SetEnvironmentVariable("Path", "$userPath;$gitBin", "User") - $env:Path += ";$gitBin" - Log-OK "bash 已加入 PATH: $gitBin" - } - } else { - Log-Warn "Git 已安装但找不到 bash.exe (Claude Code Bash 工具可能不可用)" - } -} elseif (Test-Cmd "bash") { - Log-OK "bash 已就绪" -} - -# Claude Code 依赖 npm, 需要在 Node.js 安装后再检查 -if (-not (Test-Cmd "claude") -and (Test-Cmd "npm")) { - Log-Info "安装 Claude Code..." - $r = Run-CmdWithUI "npm" @("i", "-g", "@anthropic-ai/claude-code") "安装 Claude Code (首次约 2 分钟)" 180000 - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - if (Test-Cmd "claude") { Log-OK "Claude Code 安装成功" } else { Log-Fail "Claude Code 安装失败" } -} - -# uv (Python 包管理器, 可选依赖) - 完全静默, 失败不阻断不弹窗 -# 安装策略: 1) winget astral-sh.uv 2) Astral 官方脚本 3) pip fallback -$uvLogFile = Join-Path $env:TEMP "bookworm-uv-install.log" -$uvInstalled = $false - -if (Test-Cmd "uv") { - $uvVer = try { (& uv --version 2>$null | Select-Object -First 1) } catch { "installed" } - Log-OK "uv $uvVer (已存在)" - $uvInstalled = $true -} else { - Log-Info "安装 uv (Python 包管理器, 可选)..." - - # B8: try/finally 确保 ErrorActionPreference 恢复 (防止后续 Phase 静默吞错) - $prevErrPref = $ErrorActionPreference - try { - $ErrorActionPreference = "SilentlyContinue" - - # 方案 A: winget (最可靠) - if (Test-Cmd "winget") { - try { - $null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 | - Out-File -FilePath $uvLogFile -Encoding utf8 -Append - } catch { "[winget] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - $uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links" - if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" } - if (Test-Cmd "uv") { $uvInstalled = $true } - } - - # 方案 B: Astral 官方一行脚本 - if (-not $uvInstalled) { - try { - $null = & powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | - Out-File -FilePath $uvLogFile -Encoding utf8 -Append - } catch { "[astral] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } - $localBin = Join-Path $env:USERPROFILE ".local\bin" - if (Test-Path $localBin) { $env:Path += ";$localBin" } - if (Test-Cmd "uv") { $uvInstalled = $true } - } - - # 方案 C: pip fallback - if (-not $uvInstalled -and (Test-Cmd "python")) { - try { - $null = & python -m pip install --quiet uv 2>&1 | - Out-File -FilePath $uvLogFile -Encoding utf8 -Append - } catch { "[pip] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } - try { - $pyScripts = Join-Path (Split-Path (& python -c "import sys; print(sys.executable)") -Parent) "Scripts" - if (Test-Path $pyScripts) { $env:Path += ";$pyScripts" } - } catch {} - if (Test-Cmd "uv") { $uvInstalled = $true } - } - } finally { - $ErrorActionPreference = $prevErrPref - } - - if ($uvInstalled) { - Log-OK "uv 安装成功" - $installed += "uv" - } else { - # 静默 fallback: 仅写日志文件, 不调 Log-Warn 避免 PS2EXE 弹窗 - "[fail] uv 三种安装方式均失败, Python MCP 将不可用. 详见上方日志." | Out-File -FilePath $uvLogFile -Encoding utf8 -Append - Log-Info "uv 未就绪 (可选, 不影响核心功能, 详情: $uvLogFile)" - } -} - -# OpenSSL (随 Git 安装) -$opensslCmd = Find-OpenSSL -if ($opensslCmd) { Log-OK "OpenSSL: $opensslCmd" } else { Log-Warn "OpenSSL 未找到 (凭证解密可能失败)" } - -# 最终检查 (仅 Node.js + Git + Claude Code 为硬性依赖, PowerShell 7 可选) -if (-not (Test-Cmd "node") -or -not (Test-Cmd "git") -or -not (Test-Cmd "claude")) { - $missing = @() - if (-not (Test-Cmd "node")) { $missing += "Node.js" } - if (-not (Test-Cmd "git")) { $missing += "Git" } - if (-not (Test-Cmd "claude")) { $missing += "Claude Code" } - Show-MsgBox "以下核心依赖安装失败: $($missing -join ', ')`n`n请手动安装后重新运行。`n`nNode.js: https://nodejs.org`nGit: https://git-scm.com" "安装中断" "OK" "Error" - exit 1 -} -if (-not (Test-Cmd "pwsh")) { - Log-Info "PowerShell 7 未安装 (可选, 用系统 PowerShell 5.1 替代)" -} - -# 定位 pwsh.exe 完整路径 (供后续 settings.json 配置使用) -$PwshPath = (Get-Command pwsh -ErrorAction SilentlyContinue).Source -if (-not $PwshPath) { - # winget 默认安装路径 - $defaultPaths = @( - "$env:ProgramFiles\PowerShell\7\pwsh.exe", - "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe", - "$env:LOCALAPPDATA\Microsoft\PowerShell\pwsh.exe" - ) - $PwshPath = $defaultPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 -} -if ($PwshPath) { - Log-OK "PowerShell 7 路径: $PwshPath" -} else { - Log-Warn "pwsh 可执行但无法定位完整路径, 使用 'pwsh'" - $PwshPath = "pwsh" -} - -# 可选依赖检查 (不阻断, 用 Log-Info 避免 PS2EXE 弹窗化) -$optionalMissing = @() -if (-not (Test-Cmd "python")) { $optionalMissing += "Python 3.12" } -if (-not (Test-Cmd "uv")) { $optionalMissing += "uv" } -if ($optionalMissing.Count -gt 0) { - Log-Info "可选依赖未就绪: $($optionalMissing -join ', ') — 仅影响 Python 类 MCP, 核心功能正常" -} - -if ($installed.Count -gt 0) { - Log-OK "本次新安装: $($installed -join ', ')" -} - -# ======================================================================== -# Phase 2: 网络诊断 -# ======================================================================== -Log-Phase 2 "网络诊断" - -# 代理检测 -$env:NO_PROXY = "bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" -$env:no_proxy = $env:NO_PROXY - -$proxyFound = $false - -# .NET 系统代理 -if (-not $env:HTTPS_PROXY) { - try { - $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") - if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { - $env:HTTPS_PROXY = "http://$($proxyUri.Authority)" - $env:HTTP_PROXY = $env:HTTPS_PROXY - Log-OK "系统代理: $($env:HTTPS_PROXY)" - $proxyFound = $true - } - } catch {} -} - -# 注册表 IE 代理 -if (-not $proxyFound -and -not $env:HTTPS_PROXY) { - try { - $reg = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -ErrorAction SilentlyContinue - if ($reg.ProxyEnable -eq 1 -and $reg.ProxyServer) { - $proxy = $reg.ProxyServer - if ($proxy -notmatch '^http') { $proxy = "http://$proxy" } - $env:HTTPS_PROXY = $proxy - $env:HTTP_PROXY = $proxy - Log-OK "IE 代理: $proxy" - $proxyFound = $true - } - } catch {} -} - -# 端口扫描 -if (-not $proxyFound -and -not $env:HTTPS_PROXY) { - $ports = @(7890,7891,7893,10792,10793,10808,10809,1080,1087,8080,8118) - foreach ($port in $ports) { - try { - $tcp = New-Object System.Net.Sockets.TcpClient - $ar = $tcp.BeginConnect("127.0.0.1", $port, $null, $null) - $ok = $ar.AsyncWaitHandle.WaitOne(500) - if ($ok) { $tcp.EndConnect($ar); $tcp.Close() - $env:HTTPS_PROXY = "http://127.0.0.1:$port" - $env:HTTP_PROXY = $env:HTTPS_PROXY - Log-OK "本地代理端口: $port" - $proxyFound = $true - break - } - $tcp.Close() - } catch {} - } -} - -if ($env:HTTPS_PROXY) { $proxyFound = $true } - -if (-not $proxyFound) { - Log-Warn "未检测到代理/VPN" - $r = Show-MsgBox "未检测到代理/VPN 软件。`n国内 Claude Code 需要代理才能启动。`n`n请先启动代理软件 (Clash / V2Ray / 快柠檬),`n然后点击 '重试'。`n`n或点击 '忽略' 继续 (可能失败)。" "网络警告" "AbortRetryIgnore" "Warning" - if ($r -eq "Retry") { - # 重试代理检测 - try { - $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") - if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { - $env:HTTPS_PROXY = "http://$($proxyUri.Authority)" - $env:HTTP_PROXY = $env:HTTPS_PROXY - Log-OK "系统代理: $($env:HTTPS_PROXY)" - } - } catch {} - } elseif ($r -eq "Abort") { exit 1 } -} - -Log-OK "NO_PROXY: bww.letcareme.com, code.letcareme.com" - -# 连通性测试 -Log-Info "测试网络连通性..." - -$netTests = @( - @{ Name = "Gitea 代码仓库"; Url = "https://code.letcareme.com"; Direct = $true } - @{ Name = "API 中转站"; Url = "https://bww.letcareme.com"; Direct = $true } - @{ Name = "Claude API"; Url = "https://api.anthropic.com"; Direct = $false } -) - -foreach ($t in $netTests) { - try { - $req = [System.Net.HttpWebRequest]::Create($t.Url) - $req.Timeout = 8000 - $req.Method = "HEAD" - if ($t.Direct) { $req.Proxy = [System.Net.GlobalProxySelection]::GetEmptyWebProxy() } - $resp = $req.GetResponse() - $code = [int]$resp.StatusCode - $resp.Close() - Log-OK "$($t.Name) ($($t.Url)) - HTTP $code" - } catch { - $errMsg = $_.Exception.InnerException.Message - if (-not $errMsg) { $errMsg = $_.Exception.Message } - # 非 200 但能连上也算成功 (如 401, 403) - if ($errMsg -match '40[0-9]|30[0-9]') { - Log-OK "$($t.Name) - 可达 (需认证)" - } else { - Log-Warn "$($t.Name) - 不可达: $($errMsg.Substring(0, [Math]::Min(60, $errMsg.Length)))" - } - } -} - -# ======================================================================== -# Phase 3: 仓库克隆 -# ======================================================================== -Log-Phase 3 "同步 Bookworm 配置" - -# B3: 使用 Windows Credential Manager (DPAPI 加密) 替代明文 store -git config --global credential.helper manager 2>$null - -# 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结 -# 辅助函数: clone 后缓存凭证到 Windows Credential Manager -function Cache-GitCredentials($credObj) { - if (-not $credObj) { return } - try { - $approveInput = "protocol=https`nhost=code.letcareme.com`nusername=$($credObj.User)`npassword=$($credObj.Pass)`n`n" - $approveInput | & git credential approve 2>$null - Bw-Log "OK" "Gitea 凭证已缓存到 Windows Credential Manager" - } catch { Bw-Log "WARN" "凭证缓存失败: $_" } -} - -if (Test-Path (Join-Path $ClaudeDir ".git")) { - Log-Info "配置仓库已存在, 更新中..." - # 设置 git 身份 (auto-resolve commit 需要) - & git -C $ClaudeDir config user.email "bookworm@auto.local" 2>$null - & git -C $ClaudeDir config user.name "Bookworm" 2>$null - try { - # 强制清除冲突状态 (运行时文件不重要, Phase 5 会重新渲染) - & git -C $ClaudeDir reset --hard HEAD 2>&1 | Out-Null - $r = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase", "--autostash") "同步配置仓库" 120000 - if ($r.OK) { - Log-OK "配置仓库已更新" - } else { - # pull 失败可能是认证问题, 尝试重新输入凭证 - Log-Warn "git pull 失败, 尝试重新认证..." - $cred = Show-GiteaCredentialDialog - if ($cred) { - Cache-GitCredentials $cred - $r2 = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase", "--autostash") "重试同步" 120000 - if ($r2.OK) { Log-OK "配置仓库已更新 (重新认证成功)" } - else { Log-Warn "git pull 仍失败, 使用本地版本" } - } else { Log-Warn "用户取消认证, 使用本地版本" } - } - } catch { Log-Warn "git pull 异常: $_, 使用本地版本" } -} -elseif (Test-Path $ClaudeDir) { - Log-Info "备份现有 .claude/ 并克隆..." - if (Test-Path $BackupDir) { Remove-Item $BackupDir -Recurse -Force } - Rename-Item $ClaudeDir $BackupDir - - $cred = Show-GiteaCredentialDialog - $cloneUrl = if ($cred) { $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" } else { $GitUrl } - $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000 - if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { - Log-OK "配置仓库克隆成功 (旧目录已备份)" - Cache-GitCredentials $cred - } else { - Log-Fail "克隆失败" - if (Test-Path $BackupDir) { Rename-Item $BackupDir $ClaudeDir } - Show-MsgBox "配置仓库克隆失败。`n请检查网络和 Gitea 账号密码。" "克隆失败" "OK" "Error" - exit 1 - } -} -else { - Log-Info "首次安装, 克隆配置仓库..." - $cred = Show-GiteaCredentialDialog - $cloneUrl = if ($cred) { $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" } else { $GitUrl } - $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000 - if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { - Log-OK "配置仓库克隆成功" - Cache-GitCredentials $cred - } else { - Log-Fail "克隆失败" - Show-MsgBox "配置仓库克隆失败。`n请检查网络连接和 Gitea 账号。" "克隆失败" "OK" "Error" - exit 1 - } -} - -# 创建本地运行时目录 -$dirs = @("debug","sessions","cache","backups","telemetry","shell-snapshots","projects","memory") -foreach ($d in $dirs) { - $p = Join-Path $ClaudeDir $d - if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } -} - -# ─── 克隆/更新 bookworm-boot (含 crypto-helper.js + secrets-*.enc + install.ps1) ─── -if (Test-Path (Join-Path $BootDir ".git")) { - Log-Info "boot 仓库已存在, 更新中..." - try { - $r = Run-CmdWithUI "git" @("-C", $BootDir, "pull", "--rebase") "同步 boot 仓库" 120000 - if ($r.OK) { Log-OK "boot 仓库已更新" } else { Log-Warn "boot 仓库更新失败, 使用本地版本" } - } catch { Log-Warn "boot 仓库更新失败, 使用本地版本" } -} else { - Log-Info "克隆 boot 仓库 (含解密工具与凭证)..." - # F-09 fix: 始终弹凭证对话框, 不依赖 config 分支的 $cred 残留值 - $cred = Show-GiteaCredentialDialog - $bootCloneUrl = if ($cred) { $BootUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" } else { $BootUrl } - $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库" 180000 - if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) { - Log-Fail "启动工具包下载失败" - Show-MsgBox "Bookworm 启动工具包下载失败。`n`n请检查:`n1. Gitea 账号和密码是否正确`n2. 网络连接是否正常`n3. 代理软件是否已启动`n`n然后重新运行安装器即可。" "下载失败" "OK" "Error" - exit 1 - } - Log-OK "boot 仓库克隆成功 → $BootDir" - Cache-GitCredentials $cred # F-09 fix: 缓存 boot 仓库凭证 -} - -# ======================================================================== -# Phase 4: 凭证解密 (GUI 弹窗) -# ======================================================================== -Log-Phase 4 "凭证解密" - -$secretsDecrypted = $false - -# 优先级 1: User 级环境变量已有 (上次安装已永久写入) -$existingKey = [System.Environment]::GetEnvironmentVariable("ANTHROPIC_API_KEY", "User") -$existingUrl = [System.Environment]::GetEnvironmentVariable("ANTHROPIC_BASE_URL", "User") -if ($existingKey) { - # 注入到当前 Process (User 环境变量新终端才生效, 当前进程需手动加载) - $env:ANTHROPIC_API_KEY = $existingKey - if ($existingUrl) { $env:ANTHROPIC_BASE_URL = $existingUrl } - # 加载其他 Key (如果有) - foreach ($k in $CacheAllowedKeys) { - $v = [System.Environment]::GetEnvironmentVariable($k, "User") - if ($v) { [System.Environment]::SetEnvironmentVariable($k, $v, "Process") } - } - Log-OK "从系统环境变量加载凭证 (已有安装记录, 免输授权码)" - $secretsDecrypted = $true -} - -# 优先级 2: Registry DPAPI 缓存 -if (-not $secretsDecrypted -and (Get-CachedSecrets)) { - Log-OK "从 Registry 缓存加载凭证" - $secretsDecrypted = $true -} -# 优先级 3: 直接输入中转站 API Key (v2.3 新增) -if (-not $secretsDecrypted) { - Show-MsgBox "欢迎使用 Bookworm Portable!`n`n接下来需要配置你的中转站 API Key。`n`n如果还没有, 请先去 https://bww.letcareme.com 注册并充值获取。" "配置 API Key" "OK" "Information" - - $keyAttempts = 0 - $maxKeyAttempts = 3 - while ($keyAttempts -lt $maxKeyAttempts) { - $keyAttempts++ - $apiKey = Show-ApiKeyDialog $keyAttempts $maxKeyAttempts - if (-not $apiKey) { - Log-Warn "用户取消 API Key 输入" - break - } - # 基础格式校验 - if ($apiKey.Length -lt 20) { - Show-MsgBox "API Key 格式错误 (长度过短)。`n请检查后重试。" "格式错误" "OK" "Warning" - continue - } - Log-Info "正在验证..." - # P1-2: 统一调用 change-key.js (stdin 管道, 无 argv 泄露) - $injectJs = Join-Path $ClaudeDir "change-key.js" - $ckOk = $false - if ((Test-Path $injectJs) -and (Test-Cmd "node")) { - try { - $apiKey | & node $injectJs 2>&1 | Out-Null - $ckOk = ($LASTEXITCODE -eq 0) - } catch { $ckOk = $false } - } else { - # 回退: 内置验证 + .NET API (change-key.js 不存在时) - $ckOk = Test-ApiKey $apiKey - if ($ckOk) { - [System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $apiKey, "User") - [System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", "https://bww.letcareme.com", "User") - } - } - if ($ckOk) { - $env:ANTHROPIC_API_KEY = $apiKey - $env:ANTHROPIC_BASE_URL = "https://bww.letcareme.com" - Log-OK "凭证已验证并持久化" - $secretsDecrypted = $true - Save-SecretsToCache - Show-MsgBox "API Key 验证成功!`n`n已写入系统环境变量, 任何终端输入 claude 即可启动。`n`n以后想换 Key, 可以:`n1. 双击桌面 '更换Key.bat'`n2. 或 Claude Code 里输入 /change-key`n3. 或重跑安装器" "验证成功" "OK" "Information" - break - } else { - $remaining = $maxKeyAttempts - $keyAttempts - if ($remaining -gt 0) { - Show-MsgBox "API Key 验证失败 (无法连接或认证错误)。`n剩余重试: $remaining 次`n`n请检查:`n1. Key 是否正确 (sk- 开头)`n2. 中转站是否有余额`n3. 网络和代理是否正常" "验证失败" "OK" "Warning" - } else { - Show-MsgBox "3 次验证均失败。`n`n请检查 Key 和网络, 或联系管理员。" "验证失败" "OK" "Error" - } - } - } -} - -# 优先级 4: 解密授权码 (向后兼容, 旧用户保留) -if (-not $secretsDecrypted) { -$cryptoHelper = Join-Path $BootDir "crypto-helper.js" -if (-not (Test-Cmd "node") -or -not (Test-Path $cryptoHelper)) { - Log-Info "跳过授权码解密" -} -elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) { - # 强制要求授权码 — 不允许跳过 (跳过 = 无法使用) - Show-MsgBox "检测到加密凭证文件,需要输入授权码才能使用 Bookworm。`n`n授权码由管理员提供,格式: BW-YYYYMMDD-XXXX...`n如果没有授权码,请联系管理员获取。" "需要授权码" "OK" "Information" - - $validAttempts = 0 - while ($validAttempts -lt 3) { - $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 - if (-not $rawCode) { - # 不再静默跳过,明确警告 - $skip = Show-MsgBox "未输入授权码。`n`n没有授权码将无法使用 Bookworm(无 API 凭证)。`n`n确定要跳过吗?" "警告" "YesNo" "Warning" - if ($skip -eq "No") { continue } - Log-Warn "用户确认跳过授权码" - break - } - - $token = Parse-AuthCode-GUI $rawCode - if ($token -eq 'EXPIRED') { - Show-MsgBox "授权码已过期。`n请联系管理员获取新授权码。" "授权码过期" "OK" "Warning" - continue - } - if (-not $token) { - Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning" - continue - } - - # B7: 先检查文件存在, 再递增 validAttempts (文件缺失不消耗尝试次数) - $fileId = $token.Substring(0, 8) - $encFile = Join-Path $BootDir "secrets-$fileId.enc" - if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc } - if (-not (Test-Path $encFile)) { - Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。`n`n(此次不计为失败尝试)" "文件未找到" "OK" "Warning" - $token = $null - continue - } - - $validAttempts++ # B7: 只有真正尝试解密才计数 - - try { - $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 - $decExit = $LASTEXITCODE - $token = $null - - if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { - $count = 0 - foreach ($line in $decrypted -split "`n") { - $line = $line.Trim() - if (-not $line -or $line -notmatch '=') { continue } - $key = ($line -split '=', 2)[0].Trim() - $value = ($line -split '=', 2)[1].Trim() - # F-12 fix: 白名单 + 长度校验, 防止 secrets.enc 被污染注入恶意 env - if ($key -and $value -and ($key -in $CacheAllowedKeys) -and ($value.Length -lt 512)) { - [System.Environment]::SetEnvironmentVariable($key, $value, "Process") - [System.Environment]::SetEnvironmentVariable($key, $value, "User") - Log-OK "已注入: $key (永久)" - $count++ - } elseif ($key -and ($key -notin $CacheAllowedKeys)) { - Bw-Log "WARN" "跳过未知 key: $key (不在白名单)" - } - } - $decrypted = $null - $secretsDecrypted = $true - Save-SecretsToCache # F-22 fix: 写入 DPAPI 缓存, 下次免授权码 - - Show-MsgBox "授权码验证成功!`n`n$count 个凭证已写入系统环境变量 (永久生效)。`n任何终端输入 claude 即可启动,无需再次输入授权码。" "验证成功" "OK" "Information" - break - } else { - $token = $null - $remaining = 3 - $validAttempts - if ($remaining -gt 0) { - Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次" "验证失败" "OK" "Warning" - } else { - Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error" - } - } - } catch { - $token = $null - Log-Warn "解密异常: $_" - } - } -} -else { - Log-Warn "未找到任何 secrets*.enc,跳过凭证解密" -} -} # end if (-not $secretsDecrypted) - -# ======================================================================== -# Phase 4.5: License 激活 (v3.0 新增, 可选) -# ======================================================================== -# 检查 ~/.claude/.bw-token 是否存在且未过期; 不存在则引导激活 -$bwTokenFile = Join-Path $ClaudeDir ".bw-token" -$bwActivateJs = Join-Path $ClaudeDir "lib\activate.js" -$needLicenseActivate = $true -if (Test-Path $bwTokenFile) { - try { - $tok = Get-Content $bwTokenFile -Raw | ConvertFrom-Json - if ($tok.expires_at -gt ((New-TimeSpan -Start (Get-Date "1970-01-01") -End (Get-Date).ToUniversalTime()).TotalMilliseconds + 86400000)) { - Log-OK "License token 有效 (UUID: $($tok.license_uuid))" - $needLicenseActivate = $false - } - } catch {} -} -if ($needLicenseActivate -and (Test-Path $bwActivateJs) -and (Test-Cmd "node")) { - $activateChoice = Show-MsgBox "是否激活 Bookworm License (用于加载 Skill 专家)?`n`n如果有 License Key (BW-XXXX-XXXX-...), 点是`n如果没有, 点否 (可后续用 change-key.bat 激活)" "License 激活" "YesNo" "Information" - if ($activateChoice -eq "Yes") { - $licKey = Show-ApiKeyDialog 1 1 "" # 复用现有对话框 - if ($licKey) { - try { - $licKey | & node $bwActivateJs 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Log-OK "License 激活成功" - } else { - Log-Warn "License 激活失败 (可用 change-key.bat 重试)" - } - } catch { Log-Warn "License 激活异常: $_" } - } - } else { - Log-Info "已跳过 License 激活" - } -} - -# ======================================================================== -# Phase 5: 配置渲染 -# ======================================================================== -Log-Phase 5 "配置渲染" - -$templateFile = Join-Path $ClaudeDir "settings.template.json" -$settingsFile = Join-Path $ClaudeDir "settings.json" - -if (Test-Path $templateFile) { - $claudeRoot = $ClaudeDir.Replace('\', '/') - $homeDir = $env:USERPROFILE - # pwsh 路径转正斜杠供 JSON 使用 (C:/Program Files/PowerShell/7/pwsh.exe) - $pwshJsonPath = if ($PwshPath) { $PwshPath.Replace('\', '/') } else { "pwsh" } - - $content = Get-Content $templateFile -Raw - $content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot - # HOME 必须用正斜杠: C:\Users\x 中 \U 是非法 JSON 转义序列 - $homeForward = $homeDir.Replace('\', '/') - $content = $content -replace '\{\{HOME\}\}', $homeForward - $content = $content -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath - - Set-Content $settingsFile -Value $content -Encoding UTF8 - Log-OK "settings.json 已渲染 (ROOT=$claudeRoot, SHELL=$pwshJsonPath)" - - # settings.local.template.json (向后兼容) - $localTpl = Join-Path $ClaudeDir "settings.local.template.json" - $localSet = Join-Path $ClaudeDir "settings.local.json" - if (Test-Path $localTpl) { - $lc = Get-Content $localTpl -Raw - $lc = $lc -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot - $lc = $lc -replace '\{\{HOME\}\}', $homeForward - $lc = $lc -replace '\{\{USERNAME\}\}', $env:USERNAME - $lc = $lc -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath - Set-Content $localSet -Value $lc -Encoding UTF8 - Log-OK "settings.local.json 已渲染" - } - - # ── ~/.claude.json (Claude Code v2.1+ MCP 服务器配置的正确位置) ── - # 优先用 config 仓库的 inject-mcp.js, 如果 git pull 失败则内嵌 fallback - $injectScript = Join-Path $ClaudeDir "inject-mcp.js" - $mcpInjected = $false - # 方案 A: 调用 config 仓库里的 inject-mcp.js - if (Test-Path $injectScript) { - try { - $nodeOut = & node $injectScript 2>&1 - $firstLine = ($nodeOut | Select-Object -First 1).ToString().Trim() - Log-OK $firstLine - $mcpInjected = $true - } catch { Bw-Log "WARN" "inject-mcp.js 执行失败: $_" } - } - # 方案 B: 内嵌 fallback (git pull 失败时 inject-mcp.js 不存在) - if (-not $mcpInjected) { - Log-Info "inject-mcp.js 不可用, 使用内嵌 MCP 注入..." - $fallbackJs = Join-Path $env:TEMP "bw-mcp-fallback.js" - try { - $fbLines = @( - 'var fs=require("fs"),p=require("path");' - 'var H=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.cmd",Y="--yes";' - 'var 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)");' - ) - $fbLines -join "`n" | Set-Content $fallbackJs -Encoding UTF8 - $nodeOut = & node $fallbackJs 2>&1 - $firstLine = ($nodeOut | Select-Object -First 1).ToString().Trim() - Remove-Item $fallbackJs -Force -ErrorAction SilentlyContinue - Log-OK $firstLine - } catch { Bw-Log "WARN" "MCP fallback 注入失败: $_" } - } -} else { - Log-Warn "settings.template.json 不存在, 跳过渲染" -} - -$env:CLAUDE_HOME = $ClaudeDir - -# ======================================================================== -# Phase 6: MCP 验证 + 自动安装 -# ======================================================================== -Log-Phase 6 "MCP 服务验证 + 预安装" - -# ── 6a: Bookworm 完整性检查 ── -$skillCount = 0; $hookCount = 0 -$skillsDir = Join-Path $ClaudeDir "skills" -$hooksDir = Join-Path $ClaudeDir "hooks" -if (Test-Path $skillsDir) { $skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count } -if (Test-Path $hooksDir) { $hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count } - -$claudeMdOK = $false -$claudeMdPath = Join-Path $ClaudeDir "CLAUDE.md" -if (Test-Path $claudeMdPath) { - $cm = Get-Content $claudeMdPath -Raw -ErrorAction SilentlyContinue - $claudeMdOK = $cm -match "Bookworm" -} - -$settingsOK = $false -if (Test-Path $settingsFile) { - $sc = Get-Content $settingsFile -Raw -ErrorAction SilentlyContinue - $settingsOK = $sc -match '"hooks"' -} - -$checks = @( - @{ Name = "CLAUDE.md (Bookworm 指令)"; OK = $claudeMdOK } - @{ Name = "Skills ($skillCount 个)"; OK = ($skillCount -gt 50) } - @{ Name = "Hooks ($hookCount 个)"; OK = ($hookCount -gt 10) } - @{ Name = "Settings hooks 配置"; OK = $settingsOK } -) - -$allOK = $true -foreach ($c in $checks) { - if ($c.OK) { Log-OK $c.Name } else { Log-Fail $c.Name; $allOK = $false } -} - -# ── 6b: API 凭证检查 ── -Log-Info "API 凭证检查..." -if ($env:ANTHROPIC_API_KEY) { Log-OK "ANTHROPIC_API_KEY 已配置" } else { Log-Fail "ANTHROPIC_API_KEY 未配置" } -if ($env:ANTHROPIC_BASE_URL) { Log-OK "ANTHROPIC_BASE_URL 已配置" } else { Log-Warn "ANTHROPIC_BASE_URL 未配置 (将使用默认)" } - -# ── 6c: MCP npx 包预缓存 (非阻塞 UI) ── -Log-Info "MCP 预安装 (npx 包预缓存)..." - -$npxPackages = @( - @{ Name = "context7"; Pkg = "@upstash/context7-mcp@2.1.1" } - @{ Name = "sequential-thinking"; Pkg = "@modelcontextprotocol/server-sequential-thinking@2025.12.18" } - @{ Name = "playwright"; Pkg = "@playwright/mcp@0.0.68" } - @{ Name = "session-continuity"; Pkg = "claude-session-continuity-mcp@1.13.0" } - @{ Name = "notebooklm"; Pkg = "notebooklm-mcp@latest" } - @{ Name = "cloudflare-docs"; Pkg = "mcp-remote" } - @{ Name = "chrome-devtools"; Pkg = "chrome-devtools-mcp@0.18.1" } - @{ Name = "github"; Pkg = "@modelcontextprotocol/server-github" } - @{ Name = "slack"; Pkg = "@modelcontextprotocol/server-slack" } - @{ Name = "firecrawl"; Pkg = "firecrawl-mcp" } - @{ Name = "mcp-image"; Pkg = "mcp-image" } - @{ Name = "google-drive"; Pkg = "@piotr-agier/google-drive-mcp" } -) - -$mcpOK = 0; $mcpFail = 0 -foreach ($mcp in $npxPackages) { - $idx = $mcpOK + $mcpFail + 1 - $label = "[$idx/$($npxPackages.Count)] $($mcp.Name)" - Update-Progress-SubStatus "$label ..." - try { - $outTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name).tmp" - $errTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name)-err.tmp" - $proc = Start-Process npm.cmd -ArgumentList "cache", "add", $mcp.Pkg ` - -NoNewWindow -PassThru ` - -RedirectStandardOutput $outTmp ` - -RedirectStandardError $errTmp - $ok = Wait-ProcessWithUI $proc 60000 $label - if ($ok -and $proc.ExitCode -eq 0) { - Bw-Log "OK" "$label cached" - $mcpOK++ - } else { throw "exit=$($proc.ExitCode)" } - Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue - } catch { - Bw-Log "WARN" "$label failed: $_" - $mcpFail++ - } -} -Log-OK "npx 预缓存: $mcpOK/$($npxPackages.Count) 成功" - -# ── 6d: Playwright 浏览器安装 (非阻塞 UI) ── -Log-Info "Playwright 浏览器安装..." -try { - $pwBrowserPath = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright" - if (Test-Path (Join-Path $pwBrowserPath "chromium-*")) { - Log-OK "Playwright Chromium 已存在" - } else { - $outTmp = Join-Path $env:TEMP "bw-playwright.tmp" - $errTmp = Join-Path $env:TEMP "bw-playwright-err.tmp" - $pwProc = Start-Process npx.cmd -ArgumentList "-y", "playwright", "install", "chromium" ` - -NoNewWindow -PassThru ` - -RedirectStandardOutput $outTmp ` - -RedirectStandardError $errTmp - $pwOk = Wait-ProcessWithUI $pwProc 300000 "下载 Chromium (~150MB)" - Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue - if (-not $pwOk) { Log-Warn "Playwright 下载超时, 跳过" } - elseif (Test-Path (Join-Path $pwBrowserPath "chromium-*")) { - Log-OK "Playwright Chromium 安装成功" - } else { - Log-Warn "Playwright Chromium 安装可能未完成" - } - } -} catch { Log-Warn "Playwright 浏览器安装失败: $_ (不影响核心功能)" } - -# ── 6e: Python MCP (uvx) 验证 (非阻塞 UI) ── -if (Test-Cmd "uvx") { - Log-Info "Python MCP 验证 (uvx)..." - $uvxPackages = @( - # F-17 fix: uv tool install 参数顺序 = 包名在前, --python 在后 - @{ Name = "windows-mcp"; Args = @("tool", "install", "windows-mcp", "--python", "3.13") } - @{ Name = "atlassian"; Args = @("tool", "install", "mcp-atlassian") } - ) - foreach ($pkg in $uvxPackages) { - try { - $outTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name).tmp" - $errTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name)-err.tmp" - $installArgs = $pkg.Args - $proc = Start-Process uv -ArgumentList $installArgs ` - -NoNewWindow -PassThru ` - -RedirectStandardOutput $outTmp ` - -RedirectStandardError $errTmp - $ok = Wait-ProcessWithUI $proc 90000 "uvx $($pkg.Name)" - Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue - if ($ok) { Bw-Log "OK" "uvx $($pkg.Name) ready" } - } catch { - Bw-Log "WARN" "uvx $($pkg.Name): $_" - } - } -} else { - Bw-Log "INFO" "uvx 不可用, 跳过 Python MCP" -} - -# ── 6f: 可选 API Key 提示 ── -$optional = @( - @{ Key = "GITHUB_PERSONAL_ACCESS_TOKEN"; Name = "GitHub MCP" } - @{ Key = "FIRECRAWL_API_KEY"; Name = "Firecrawl MCP" } - @{ Key = "SLACK_BOT_TOKEN"; Name = "Slack MCP" } - @{ Key = "BROWSERBASE_API_KEY"; Name = "Browserbase MCP" } - @{ Key = "GEMINI_API_KEY"; Name = "MCP Image / Browserbase" } - @{ Key = "ATLASSIAN_API_TOKEN"; Name = "Atlassian MCP" } -) -$missingOpt = $optional | Where-Object { -not [System.Environment]::GetEnvironmentVariable($_.Key, "Process") } -if ($missingOpt.Count -gt 0) { - foreach ($m in $missingOpt) { Bw-Log "INFO" "可选 Key 未配置: $($m.Name) ($($m.Key))" } -} - -# ======================================================================== -# Phase 7: 环境加固 + 完成 + 启动 -# ======================================================================== -Log-Phase 7 "环境加固 + 启动" - -# ── 7a: claude 命令默认带 --dangerously-skip-permissions ── -# PS2EXE 下 $PROFILE 可能为 $null, 需先检查 -try { - # 构造 pwsh profile 路径 (不依赖 $PROFILE 自动变量, PS2EXE 下可能为空) - $pwshProfile = if ($PROFILE) { $PROFILE } - elseif ($PwshPath) { Join-Path (Split-Path $PwshPath -Parent) "profile.ps1" } - else { Join-Path "$env:USERPROFILE\Documents\PowerShell" "Microsoft.PowerShell_profile.ps1" } - - if ($pwshProfile) { - $profileDir = Split-Path $pwshProfile -Parent - if ($profileDir -and -not (Test-Path $profileDir)) { - New-Item -ItemType Directory -Path $profileDir -Force | Out-Null - } - $aliasLine = 'function claude { $exe = (Get-Command claude.exe -EA SilentlyContinue).Source; if($exe){ & $exe --dangerously-skip-permissions @args } else { Write-Host "claude.exe not found" } }' - $hasAlias = (Test-Path $pwshProfile) -and (Select-String -Path $pwshProfile -Pattern 'dangerously-skip-permissions' -Quiet -ErrorAction SilentlyContinue) - if (-not $hasAlias) { - Add-Content -Path $pwshProfile -Value "`n# Bookworm: claude 默认免权限确认`n$aliasLine" -Encoding utf8 - Bw-Log "OK" "PowerShell profile 已添加 claude alias" - } - } -} catch { Bw-Log "WARN" "7a claude alias 设置失败: $_" } - -# ── 7b: 清理 OAuth 登录 (防止与 relay key 冲突) ── -try { - $credFile = Join-Path $ClaudeDir ".credentials.json" - if ($env:ANTHROPIC_BASE_URL -and (Test-Path $credFile)) { - $credContent = Get-Content $credFile -Raw -ErrorAction SilentlyContinue - if ($credContent -match '"claudeAiOauth"') { - Remove-Item $credFile -Force -ErrorAction SilentlyContinue - Bw-Log "OK" "已清理 OAuth 登录凭证 (改用中转站 relay key)" - } - } -} catch { Bw-Log "WARN" "7b OAuth 清理失败: $_" } - -# ── 7c: 自动修复 .claude 仓库冲突 ── -try { - $claudeGit = Join-Path $ClaudeDir ".git" - if (Test-Path $claudeGit) { - # F-19 fix: 逐行匹配冲突状态 (Out-String 合并后 ^ 不匹配行内) - $gitLines = & git -C $ClaudeDir status --porcelain 2>&1 - $hasConflict = $gitLines | Where-Object { $_ -match '^U|^.U' } - if ($hasConflict) { - & git -C $ClaudeDir checkout --theirs . 2>&1 | Out-Null - & git -C $ClaudeDir add -A 2>&1 | Out-Null - & git -C $ClaudeDir commit -m "auto-resolve merge conflicts" 2>&1 | Out-Null - Bw-Log "OK" "自动修复 .claude 仓库合并冲突" - } - } -} catch { Bw-Log "WARN" "7c 冲突修复失败: $_" } - -# 关闭进度窗口 -Close-ProgressForm - -# 创建桌面快捷方式 -New-DesktopShortcuts - -if ($allOK -and $env:ANTHROPIC_API_KEY) { - Bw-Log "DONE" "v$BWVersion 安装成功 ($skillCount Skills / $hookCount Hooks)" - - # ═══ 祝贺闪屏 (2.5 秒自动消失) ═══ - $splash = New-Object System.Windows.Forms.Form - $splash.FormBorderStyle = "None" - $splash.StartPosition = "CenterScreen" - $splash.Size = New-Object System.Drawing.Size(480, 300) - $splash.BackColor = [System.Drawing.Color]::FromArgb(24, 25, 38) - $splash.TopMost = $true - $splash.ShowInTaskbar = $false - $splash.Opacity = 0.0 - - # 品牌蓝紫装饰条 - $topBar = New-Object System.Windows.Forms.Panel - $topBar.Location = New-Object System.Drawing.Point(0, 0) - $topBar.Size = New-Object System.Drawing.Size(480, 4) - $topBar.BackColor = [System.Drawing.Color]::FromArgb(88, 101, 242) - $splash.Controls.Add($topBar) - - # 大勾图标 - $checkLabel = New-Object System.Windows.Forms.Label - $checkLabel.Location = New-Object System.Drawing.Point(0, 35) - $checkLabel.Size = New-Object System.Drawing.Size(480, 55) - $checkLabel.Text = [char]0x2714 - $checkLabel.Font = New-Object System.Drawing.Font("Segoe UI", 36) - $checkLabel.ForeColor = [System.Drawing.Color]::FromArgb(46, 160, 67) - $checkLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - $splash.Controls.Add($checkLabel) - - # 主标题 - $mainTitle = New-Object System.Windows.Forms.Label - $mainTitle.Location = New-Object System.Drawing.Point(0, 95) - $mainTitle.Size = New-Object System.Drawing.Size(480, 38) - $mainTitle.Text = "Bookworm v$BWVersion 安装成功" - $mainTitle.Font = New-Object System.Drawing.Font("Segoe UI", 18, [System.Drawing.FontStyle]::Bold) - $mainTitle.ForeColor = [System.Drawing.Color]::White - $mainTitle.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - $splash.Controls.Add($mainTitle) - - # 副标题 - $subTitle = New-Object System.Windows.Forms.Label - $subTitle.Location = New-Object System.Drawing.Point(0, 140) - $subTitle.Size = New-Object System.Drawing.Size(480, 28) - $subTitle.Text = "$skillCount Skills / $hookCount Hooks / 全部就绪" - $subTitle.Font = New-Object System.Drawing.Font("Segoe UI", 11) - $subTitle.ForeColor = [System.Drawing.Color]::FromArgb(160, 170, 200) - $subTitle.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - $splash.Controls.Add($subTitle) - - # 祝福语 - $wish = New-Object System.Windows.Forms.Label - $wish.Location = New-Object System.Drawing.Point(0, 190) - $wish.Size = New-Object System.Drawing.Size(480, 30) - $wish.Text = "善读者,必善造。使用愉快!" - $wish.Font = New-Object System.Drawing.Font("Segoe UI", 12) - $wish.ForeColor = [System.Drawing.Color]::FromArgb(88, 101, 242) - $wish.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - $splash.Controls.Add($wish) - - # 底部提示 - $hint = New-Object System.Windows.Forms.Label - $hint.Location = New-Object System.Drawing.Point(0, 250) - $hint.Size = New-Object System.Drawing.Size(480, 22) - $hint.Text = "双击桌面 Bookworm 图标即可随时启动" - $hint.Font = New-Object System.Drawing.Font("Segoe UI", 9) - $hint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130) - $hint.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - $splash.Controls.Add($hint) - - # 底部装饰条 - $bottomBar = New-Object System.Windows.Forms.Panel - $bottomBar.Location = New-Object System.Drawing.Point(0, 296) - $bottomBar.Size = New-Object System.Drawing.Size(480, 4) - $bottomBar.BackColor = [System.Drawing.Color]::FromArgb(88, 101, 242) - $splash.Controls.Add($bottomBar) - - # 淡入动画 + 定时关闭 - $splash.Show() - for ($i = 0; $i -le 10; $i++) { - $splash.Opacity = $i / 10.0 - $splash.Refresh() - Start-Sleep -Milliseconds 30 - } - # 停留 2.5 秒 - $sw = [System.Diagnostics.Stopwatch]::StartNew() - while ($sw.ElapsedMilliseconds -lt 2500) { - [System.Windows.Forms.Application]::DoEvents() - Start-Sleep -Milliseconds 50 - } - # 淡出 - for ($i = 10; $i -ge 0; $i--) { - $splash.Opacity = $i / 10.0 - $splash.Refresh() - Start-Sleep -Milliseconds 25 - } - $splash.Close() - $splash.Dispose() - - # 启动 Bookworm — 优先 Windows Terminal > pwsh > cmd.exe - if (-not $SkipLaunch) { - $startBat = Join-Path $BootDir "启动Bookworm.bat" - if (Test-Path $startBat) { - Start-Process -FilePath $startBat -WorkingDirectory $BootDir - } else { - $claudeCmd = "claude --dangerously-skip-permissions" - if (Get-Command wt.exe -ErrorAction SilentlyContinue) { - # Windows Terminal: 现代 UI, 支持全屏/标签页 - Start-Process wt.exe -ArgumentList "new-tab", "-d", $BootDir, "--title", "Bookworm v$BWVersion", "cmd", "/k", $claudeCmd - } elseif ($PwshPath -and (Test-Path $PwshPath)) { - Start-Process $PwshPath -ArgumentList "-NoExit", "-Command", "cd '$BootDir'; $claudeCmd" -WorkingDirectory $BootDir - } else { - Start-Process cmd.exe -ArgumentList "/k", "title Bookworm v$BWVersion && cd /d `"$BootDir`" && $claudeCmd" - } - } - } - -} else { - Bw-Log "DONE" "安装完成但部分受限 allOK=$allOK hasKey=$($env:ANTHROPIC_API_KEY -ne $null)" - - $issues = @() - if (-not $allOK) { $issues += "- Bookworm 配置不完整" } - if (-not $env:ANTHROPIC_API_KEY) { $issues += "- API 凭证未解密" } - $issueText = $issues -join "`n" - - $launchResult = Show-MsgBox "安装完成, 但存在以下问题:`n$issueText`n`n是否仍然启动 Claude Code?`n(将以受限模式运行)`n`n日志: $BWLogFile" "安装警告" "YesNo" "Warning" - if ($launchResult -eq "Yes" -and -not $SkipLaunch) { - $claudeCmd = "claude --dangerously-skip-permissions" - if (Get-Command wt.exe -ErrorAction SilentlyContinue) { - Start-Process wt.exe -ArgumentList "new-tab", "--title", "Bookworm", "cmd", "/k", $claudeCmd - } else { - Start-Process cmd.exe -ArgumentList "/k", $claudeCmd - } - } -} +<# +.SYNOPSIS + Bookworm Portable - 全自动一键安装器 +.DESCRIPTION + 全新电脑从零到 Bookworm 完全就绪,最大程度自动化。 + 7 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 启动 + 需要人工输入时弹出 GUI 对话框。 +.USAGE + .\auto-setup.ps1 + .\auto-setup.ps1 -SkipLaunch # 安装但不启动 +#> +param( + [switch]$SkipLaunch, + # DryRun: 只跑指定 Phase 做验证, 不落任何副作用. 空值 = 正常全流程安装 + # 值: env (Phase 1 环境检测) / deps (Phase 2 依赖安装) / net (Phase 3 网络诊断) + # / repo (Phase 3 git clone) / creds (Phase 4 凭证) / license (Phase 4.5) + [string]$DryRun = "" +) + +$ErrorActionPreference = "Stop" + +# ─── 版本号 (每次更新递增, build.ps1 自动读取) ────── +$BWVersion = "3.0.1" # +DryRun 支持 +Test-ApiKey Proxy=new() 直连 +UTF-8 BOM 处理 + +# DryRun 模式日志标记 +if ($DryRun) { $global:BWDryRun = $DryRun } else { $global:BWDryRun = $null } +function Is-DryRun($phase) { return ($global:BWDryRun -eq $phase -or $global:BWDryRun -eq "all") } + +# ─── B4: 单实例保护 (防止双击两次导致竞态) ───────── +$mutexCreated = $false +$global:BWMutex = [System.Threading.Mutex]::new($true, "Global\BookwormPortableSetup", [ref]$mutexCreated) +if (-not $mutexCreated) { + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.MessageBox]::Show("Bookworm 安装器已在运行中。`n请勿重复启动。", "提示", "OK", "Information") | Out-Null + exit 0 +} + +# ─── 路径定义 ──────────────────────────────────────── +# PS2EXE 兼容: $MyInvocation.MyCommand.Path 在 EXE 启动时为空,需用 Process MainModule +$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 +} +$ClaudeDir = Join-Path $env:USERPROFILE ".claude" +$BackupDir = Join-Path $env:USERPROFILE ".claude.bw-backup" +$GitUrl = "https://code.letcareme.com/bookworm/bookworm-portable-config.git" # v3.0: 脱敏分发仓库 +$BootUrl = "https://code.letcareme.com/bookworm/bookworm-boot.git" +$BootDir = Join-Path $ScriptDir "bookworm-boot" +$SecretsEnc = Join-Path $BootDir "secrets.enc" +$TOTAL_PHASES = 7 + +# ─── GUI 初始化 ───────────────────────────────────── +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() + +# ─── 日志 + 进度 (PS2EXE -NoConsole -NoOutput 模式: console 全静默) ── +# 所有 Log-X 输出走文件 + GUI 进度窗口 (避免被 PS2EXE 弹窗化) +$BWLogFile = Join-Path $env:TEMP "bookworm-setup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" +function Bw-Log($level, $msg) { + try { Add-Content -Path $BWLogFile -Value "[$(Get-Date -Format 'HH:mm:ss')] [$level] $msg" -Encoding utf8 } catch {} +} +function Log-OK($msg) { Bw-Log "OK" $msg; Update-Progress-SubStatus "$msg" } +function Log-Info($msg) { Bw-Log "INFO" $msg; Update-Progress-SubStatus "$msg" } +function Log-Warn($msg) { Bw-Log "WARN" $msg } +function Log-Fail($msg) { Bw-Log "FAIL" $msg } +function Log-Phase($n, $title) { + Bw-Log "PHASE" "[$n/$TOTAL_PHASES] $title" + Update-Progress $n $title +} + +# ─── GUI 进度窗口 (常驻顶部, 替代 console 输出) ──── +$global:BWProgressForm = $null +$global:BWPhaseLabel = $null +$global:BWStatusLabel = $null +$global:BWProgressBar = $null + +function Show-ProgressForm { + # 统一品牌色 + $brandBlue = [System.Drawing.Color]::FromArgb(88, 101, 242) # Bookworm 蓝紫 + $brandDark = [System.Drawing.Color]::FromArgb(30, 31, 46) + $uiFont = "Segoe UI" + + $global:BWProgressForm = New-Object System.Windows.Forms.Form + $global:BWProgressForm.Text = "Bookworm Portable Setup v$BWVersion" + $global:BWProgressForm.Size = New-Object System.Drawing.Size(520, 230) + $global:BWProgressForm.StartPosition = "CenterScreen" + $global:BWProgressForm.FormBorderStyle = "FixedDialog" + $global:BWProgressForm.MaximizeBox = $false + $global:BWProgressForm.MinimizeBox = $false + $global:BWProgressForm.TopMost = $false # P2: 不遮挡其他窗口 + $global:BWProgressForm.ControlBox = $true # P0 F1: 允许关闭 (触发确认) + $global:BWProgressForm.BackColor = [System.Drawing.Color]::White + + # X 按钮关闭时弹确认 + $global:BWProgressForm.Add_FormClosing({ + param($s, $e) + if (-not $global:BWInstallDone) { + $r = [System.Windows.Forms.MessageBox]::Show( + "安装尚未完成。`n确定要取消安装吗?", + "取消安装", "YesNo", "Warning") + if ($r -eq "No") { $e.Cancel = $true; return } + Bw-Log "ABORT" "用户手动取消安装" + } + }) + + $titleLabel = New-Object System.Windows.Forms.Label + $titleLabel.Location = New-Object System.Drawing.Point(20, 16) + $titleLabel.Size = New-Object System.Drawing.Size(480, 26) + $titleLabel.Text = "Bookworm 智能助手 — 自动安装中" + $titleLabel.Font = New-Object System.Drawing.Font($uiFont, 12, [System.Drawing.FontStyle]::Bold) + $titleLabel.ForeColor = $brandDark + $global:BWProgressForm.Controls.Add($titleLabel) + + $global:BWPhaseLabel = New-Object System.Windows.Forms.Label + $global:BWPhaseLabel.Location = New-Object System.Drawing.Point(20, 50) + $global:BWPhaseLabel.Size = New-Object System.Drawing.Size(480, 22) + $global:BWPhaseLabel.Text = "[0/$TOTAL_PHASES] 初始化..." + $global:BWPhaseLabel.Font = New-Object System.Drawing.Font($uiFont, 10) + $global:BWPhaseLabel.ForeColor = $brandBlue + $global:BWProgressForm.Controls.Add($global:BWPhaseLabel) + + $global:BWStatusLabel = New-Object System.Windows.Forms.Label + $global:BWStatusLabel.Location = New-Object System.Drawing.Point(20, 78) + $global:BWStatusLabel.Size = New-Object System.Drawing.Size(480, 22) + $global:BWStatusLabel.Text = "" + $global:BWStatusLabel.Font = New-Object System.Drawing.Font($uiFont, 9) + $global:BWStatusLabel.ForeColor = [System.Drawing.Color]::FromArgb(120, 120, 140) + $global:BWProgressForm.Controls.Add($global:BWStatusLabel) + + $global:BWProgressBar = New-Object System.Windows.Forms.ProgressBar + $global:BWProgressBar.Location = New-Object System.Drawing.Point(20, 112) + $global:BWProgressBar.Size = New-Object System.Drawing.Size(480, 20) + $global:BWProgressBar.Minimum = 0 + $global:BWProgressBar.Maximum = $TOTAL_PHASES + $global:BWProgressBar.Value = 0 + $global:BWProgressBar.Style = [System.Windows.Forms.ProgressBarStyle]::Continuous # P3: 平滑 + $global:BWProgressForm.Controls.Add($global:BWProgressBar) + + $global:BWElapsedLabel = New-Object System.Windows.Forms.Label + $global:BWElapsedLabel.Location = New-Object System.Drawing.Point(400, 136) + $global:BWElapsedLabel.Size = New-Object System.Drawing.Size(100, 18) + $global:BWElapsedLabel.Text = "" + $global:BWElapsedLabel.Font = New-Object System.Drawing.Font($uiFont, 8) + $global:BWElapsedLabel.ForeColor = [System.Drawing.Color]::Silver + $global:BWElapsedLabel.TextAlign = [System.Drawing.ContentAlignment]::TopRight + $global:BWProgressForm.Controls.Add($global:BWElapsedLabel) + + $hint = New-Object System.Windows.Forms.Label + $hint.Location = New-Object System.Drawing.Point(20, 136) + $hint.Size = New-Object System.Drawing.Size(380, 32) + $hint.Text = "首次安装约 5-10 分钟 (依赖下载)`n关闭窗口可取消安装" + $hint.Font = New-Object System.Drawing.Font($uiFont, 8) + $hint.ForeColor = [System.Drawing.Color]::Silver + $global:BWProgressForm.Controls.Add($hint) + + $global:BWProgressForm.Show() | Out-Null + $global:BWProgressForm.Refresh() + [System.Windows.Forms.Application]::DoEvents() +} + +# 全局安装完成标记 (Close-ProgressForm 前设为 $true, 避免 X 按钮弹确认) +$global:BWInstallDone = $false +# 全局计时器 +$global:BWStartTime = [System.Diagnostics.Stopwatch]::StartNew() + +function Update-Progress($phase, $title) { + if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) { + try { + $global:BWPhaseLabel.Text = "[$phase/$TOTAL_PHASES] $title" + $global:BWStatusLabel.Text = "" + $global:BWProgressBar.Value = [Math]::Min($phase, $TOTAL_PHASES) + $global:BWProgressForm.Refresh() + [System.Windows.Forms.Application]::DoEvents() + } catch {} + } +} + +function Update-Progress-SubStatus($msg) { + if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed -and $global:BWStatusLabel) { + try { + $shortMsg = if ($msg.Length -gt 70) { $msg.Substring(0, 67) + "..." } else { $msg } + $global:BWStatusLabel.Text = $shortMsg + # 刷新总耗时 + if ($global:BWElapsedLabel -and $global:BWStartTime) { + $sec = [int]$global:BWStartTime.Elapsed.TotalSeconds + $global:BWElapsedLabel.Text = "$([int]($sec / 60))m $($sec % 60)s" + } + $global:BWStatusLabel.Refresh() + [System.Windows.Forms.Application]::DoEvents() + } catch {} + } +} + +function Close-ProgressForm { + $global:BWInstallDone = $true # 关闭时不再弹 "取消安装?" 确认 + if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) { + try { $global:BWProgressForm.Close(); $global:BWProgressForm.Dispose() } catch {} + } +} + +function Test-Cmd($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) } + +# ─── 非阻塞子进程执行 (解决 PS2EXE UI 冻结) ─────────── +# 所有耗时子进程都必须经过这两个函数, 保持 GUI 消息泵活跃 + +function Wait-ProcessWithUI { + <# 替代 System.Diagnostics.Process.WaitForExit(N) + 在等待期间每 200ms 泵一次 DoEvents, 防止 "(未响应)" #> + param( + [System.Diagnostics.Process]$proc, + [int]$timeoutMs = 60000, + [string]$label = "" + ) + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while (-not $proc.HasExited -and $sw.ElapsedMilliseconds -lt $timeoutMs) { + [System.Windows.Forms.Application]::DoEvents() + # 每 5 秒更新一次副状态, 显示等待耗时 + if ($label -and ($sw.ElapsedMilliseconds % 5000) -lt 250) { + $elapsed = [int]($sw.ElapsedMilliseconds / 1000) + Update-Progress-SubStatus "$label ($($elapsed)s)" + } + Start-Sleep -Milliseconds 200 + } + if (-not $proc.HasExited) { + try { $proc.Kill() } catch {} + Bw-Log "WARN" "子进程超时 ($timeoutMs ms): $label" + return $false + } + return $true +} + +function Run-CmdWithUI { + <# 替代 & cmd args 2>&1 | ForEach-Object { Write-Host } + 将阻塞调用转为 Start-Process + Wait-ProcessWithUI #> + param( + [string]$exe, + [string[]]$arguments, + [string]$label = "", + [int]$timeoutMs = 180000, # 默认 3 分钟 + [switch]$captureOutput # 返回 stdout 内容 + ) + # W-01: Windows Start-Process 兼容 — npm/npx 无扩展名是 Unix shell 脚本 + if ($exe -in @("npm", "npx") -and -not $exe.EndsWith(".cmd")) { + $exe = "$exe.cmd" + } + + # B1: 脱敏日志 (去除 URL 内嵌凭证 user:pass@) + $sanitizedArgs = ($arguments -join ' ') -replace '://[^@]+@', '://***@' + Bw-Log "CMD" "$exe $sanitizedArgs" + Update-Progress-SubStatus $label + + # V-04: 用 GetTempFileName (原子创建+加密随机) 替代 Get-Random + $outFile = [System.IO.Path]::GetTempFileName() + $errFile = [System.IO.Path]::GetTempFileName() + try { + $proc = Start-Process -FilePath $exe -ArgumentList $arguments ` + -NoNewWindow -PassThru ` + -RedirectStandardOutput $outFile ` + -RedirectStandardError $errFile + $ok = Wait-ProcessWithUI $proc $timeoutMs $label + $exitCode = if ($proc.HasExited) { $proc.ExitCode } else { -1 } + + # 日志记录 stdout/stderr (不超过 20 行) + if (Test-Path $outFile) { + $out = Get-Content $outFile -TotalCount 20 -ErrorAction SilentlyContinue + if ($out) { $out | ForEach-Object { Bw-Log "OUT" $_ } } + } + if (Test-Path $errFile) { + $err = Get-Content $errFile -TotalCount 10 -ErrorAction SilentlyContinue + if ($err) { $err | ForEach-Object { Bw-Log "ERR" $_ } } + } + + if ($captureOutput -and (Test-Path $outFile)) { + return @{ OK = ($ok -and $exitCode -eq 0); Output = (Get-Content $outFile -Raw -ErrorAction SilentlyContinue); ExitCode = $exitCode } + } + return @{ OK = ($ok -and $exitCode -eq 0); ExitCode = $exitCode } + } finally { + Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue + } +} + +# ─── GUI 对话框 ───────────────────────────────────── + +function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon = "Information") { + [System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon) +} + +function Parse-AuthCode-GUI { + param([string]$code) + $code = $code.Trim() + if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { return $null } + $expiryStr = $Matches[1] + $token = $Matches[2].ToLower() + $today = (Get-Date).ToString("yyyyMMdd") + if ([int]$expiryStr -lt [int]$today) { return 'EXPIRED' } + return $token +} + +function Show-AuthCodeDialog($attempt = 1, $maxAttempts = 3) { + $form = New-Object System.Windows.Forms.Form + $form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)" + $form.Size = New-Object System.Drawing.Size(480, 240) + $form.StartPosition = "CenterScreen" + $form.FormBorderStyle = "FixedDialog" + $form.MaximizeBox = $false + $form.MinimizeBox = $false + $form.TopMost = $true + + $label = New-Object System.Windows.Forms.Label + $label.Location = New-Object System.Drawing.Point(20, 18) + $label.Size = New-Object System.Drawing.Size(440, 36) + $label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX" + $label.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $form.Controls.Add($label) + + # 授权码可见 (用于粘贴验证), 不用 PasswordChar + $codeBox = New-Object System.Windows.Forms.TextBox + $codeBox.Location = New-Object System.Drawing.Point(20, 65) + $codeBox.Size = New-Object System.Drawing.Size(430, 30) + $codeBox.Font = New-Object System.Drawing.Font("Consolas", 11) + $codeBox.CharacterCasing = "Upper" # 自动转大写 + $form.Controls.Add($codeBox) + + $hint = New-Object System.Windows.Forms.Label + $hint.Location = New-Object System.Drawing.Point(20, 100) + $hint.Size = New-Object System.Drawing.Size(440, 20) + $hint.Text = "提示: 直接粘贴管理员发送的授权码即可 (Ctrl+V)" + $hint.Font = New-Object System.Drawing.Font("Segoe UI", 8) + $hint.ForeColor = [System.Drawing.Color]::Gray + $form.Controls.Add($hint) + + $btnOK = New-Object System.Windows.Forms.Button + $btnOK.Location = New-Object System.Drawing.Point(250, 145) + $btnOK.Size = New-Object System.Drawing.Size(90, 35) + $btnOK.Text = "验证" + $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.AcceptButton = $btnOK + $form.Controls.Add($btnOK) + + $btnCancel = New-Object System.Windows.Forms.Button + $btnCancel.Location = New-Object System.Drawing.Point(350, 145) + $btnCancel.Size = New-Object System.Drawing.Size(90, 35) + $btnCancel.Text = "取消安装" + $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + $form.CancelButton = $btnCancel + $form.Controls.Add($btnCancel) + + $form.Add_Shown({ $codeBox.Focus() }) + $result = $form.ShowDialog() + + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + return $codeBox.Text.Trim() + } + return $null +} + +# ─── 中转站 API Key 输入对话框 + 验证 (v2.3 新增) ── +function Show-ApiKeyDialog($attempt = 1, $maxAttempts = 3, $existingKey = "") { + $form = New-Object System.Windows.Forms.Form + $form.Text = "Bookworm - 中转站 API Key ($attempt/$maxAttempts)" + $form.Size = New-Object System.Drawing.Size(520, 280) + $form.StartPosition = "CenterScreen" + $form.FormBorderStyle = "FixedDialog" + $form.MaximizeBox = $false + $form.TopMost = $true + $form.BackColor = [System.Drawing.Color]::White + + $lblInfo = New-Object System.Windows.Forms.Label + $lblInfo.Location = New-Object System.Drawing.Point(20, 15) + $lblInfo.Size = New-Object System.Drawing.Size(470, 50) + $lblInfo.Text = "请粘贴你的中转站 API Key`n(bww.letcareme.com 注册后在后台获取, sk- 开头)" + $lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $form.Controls.Add($lblInfo) + + $keyBox = New-Object System.Windows.Forms.TextBox + $keyBox.Location = New-Object System.Drawing.Point(20, 75) + $keyBox.Size = New-Object System.Drawing.Size(470, 30) + $keyBox.Font = New-Object System.Drawing.Font("Consolas", 10) + $keyBox.Text = $existingKey + $keyBox.PasswordChar = '*' + $form.Controls.Add($keyBox) + + $chkShow = New-Object System.Windows.Forms.CheckBox + $chkShow.Location = New-Object System.Drawing.Point(20, 110) + $chkShow.Size = New-Object System.Drawing.Size(150, 25) + $chkShow.Text = "显示 Key" + $chkShow.Add_CheckedChanged({ if ($chkShow.Checked) { $keyBox.PasswordChar = [char]0 } else { $keyBox.PasswordChar = '*' } }) + $form.Controls.Add($chkShow) + + $lblHint = New-Object System.Windows.Forms.Label + $lblHint.Location = New-Object System.Drawing.Point(20, 140) + $lblHint.Size = New-Object System.Drawing.Size(470, 40) + $lblHint.Text = "首次使用请先注册并充值:`nhttps://bww.letcareme.com" + $lblHint.Font = New-Object System.Drawing.Font("Segoe UI", 8) + $lblHint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130) + $form.Controls.Add($lblHint) + + $btnOK = New-Object System.Windows.Forms.Button + $btnOK.Location = New-Object System.Drawing.Point(280, 195) + $btnOK.Size = New-Object System.Drawing.Size(100, 35) + $btnOK.Text = "验证并保存" + $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.AcceptButton = $btnOK + $form.Controls.Add($btnOK) + + $btnCancel = New-Object System.Windows.Forms.Button + $btnCancel.Location = New-Object System.Drawing.Point(390, 195) + $btnCancel.Size = New-Object System.Drawing.Size(100, 35) + $btnCancel.Text = "取消" + $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + $form.CancelButton = $btnCancel + $form.Controls.Add($btnCancel) + + $form.Add_Shown({ $keyBox.Focus() }) + $result = $form.ShowDialog() + + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + return $keyBox.Text.Trim() + } + return $null +} + +# ─── Bookworm License Key 输入对话框 (v3.0 专用, 格式 BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX) ── +function Show-LicenseKeyDialog($attempt = 1, $maxAttempts = 3) { + $form = New-Object System.Windows.Forms.Form + $form.Text = "Bookworm - License 激活 ($attempt/$maxAttempts)" + $form.Size = New-Object System.Drawing.Size(540, 270) + $form.StartPosition = "CenterScreen" + $form.FormBorderStyle = "FixedDialog" + $form.MaximizeBox = $false + $form.MinimizeBox = $false + $form.TopMost = $true + $form.BackColor = [System.Drawing.Color]::White + + $lblInfo = New-Object System.Windows.Forms.Label + $lblInfo.Location = New-Object System.Drawing.Point(20, 15) + $lblInfo.Size = New-Object System.Drawing.Size(490, 46) + $lblInfo.Text = "请粘贴 Bookworm License Key`n格式: BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (6 段 4 位十六进制)" + $lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $form.Controls.Add($lblInfo) + + $keyBox = New-Object System.Windows.Forms.TextBox + $keyBox.Location = New-Object System.Drawing.Point(20, 70) + $keyBox.Size = New-Object System.Drawing.Size(490, 30) + $keyBox.Font = New-Object System.Drawing.Font("Consolas", 11) + $keyBox.CharacterCasing = "Upper" + $keyBox.MaxLength = 34 + $form.Controls.Add($keyBox) + + $lblHint = New-Object System.Windows.Forms.Label + $lblHint.Location = New-Object System.Drawing.Point(20, 110) + $lblHint.Size = New-Object System.Drawing.Size(490, 60) + $lblHint.Text = "提示: 大写字母 + 数字, 中间 5 个短横线`n常见混淆: O→0 (零), I→1 (一), L→1 (一)`n没 Key? 联系管理员获取" + $lblHint.Font = New-Object System.Drawing.Font("Segoe UI", 8) + $lblHint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130) + $form.Controls.Add($lblHint) + + $btnOK = New-Object System.Windows.Forms.Button + $btnOK.Location = New-Object System.Drawing.Point(300, 185) + $btnOK.Size = New-Object System.Drawing.Size(100, 35) + $btnOK.Text = "激活" + $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.AcceptButton = $btnOK + $form.Controls.Add($btnOK) + + $btnCancel = New-Object System.Windows.Forms.Button + $btnCancel.Location = New-Object System.Drawing.Point(410, 185) + $btnCancel.Size = New-Object System.Drawing.Size(100, 35) + $btnCancel.Text = "跳过" + $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + $form.CancelButton = $btnCancel + $form.Controls.Add($btnCancel) + + $form.Add_Shown({ $keyBox.Focus() }) + $result = $form.ShowDialog() + + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + return $keyBox.Text.Trim() + } + return $null +} + +# 向中转站发一个最小请求验证 key 是否可用 +function Test-ApiKey([string]$apiKey, [string]$baseUrl = "https://bww.letcareme.com") { + if (-not $apiKey -or $apiKey.Length -lt 10) { return $false } + # 中转站/官方的模型命名差异大, 依次试候选列表, 任一 2xx/4xx(非认证) 即认 Key 有效 + # 顺序: 当前主流 (4-6/4-7) → 老版兼容 (4-5/3-5) + # 中转站 (bww.letcareme.com) 目前限定这 5 个模型, 严格匹配防 503 误判 + # 默认主模型: claude-opus-4-7 + $modelCandidates = @( + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-6-thinking", + "claude-sonnet-4-6", + "claude-sonnet-4-6-thinking" + ) + # v3.0.1 错误分类 (red-team-logic P0): + # $true = 认证通过 (200/400) + # $false = 认证失败 (401/403) 明确 Key 无效 + # $null = 网络/中转站故障 (5xx/404/timeout), 非 Key 问题, 外层应放行 + $lastStatus = 0 + $hadNetworkError = $false + foreach ($model in $modelCandidates) { + try { + $body = '{"model":"' + $model + '","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' + $req = [System.Net.WebRequest]::Create("$baseUrl/v1/messages") + $req.Method = "POST" + $req.ContentType = "application/json" + $req.Headers["x-api-key"] = $apiKey + $req.Headers["anthropic-version"] = "2023-06-01" + # 中转站 bww.letcareme.com 对境外 IP 返 503, 强制直连绕代理 + $req.Proxy = [System.Net.WebProxy]::new() + $req.Timeout = 10000 + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream(); $stream.Write($bytes, 0, $bytes.Length); $stream.Close() + $resp = $req.GetResponse(); $resp.Close() + return $true # 200 = Key 有效 + } catch [System.Net.WebException] { + $lastStatus = 0 + try { $lastStatus = [int]$_.Exception.Response.StatusCode } catch {} + # 401/403 = 明确认证失败, 立即返回 false + if ($lastStatus -eq 401 -or $lastStatus -eq 403) { return $false } + # 400 = 请求体问题 (模型名等), Key 本身通过 + if ($lastStatus -eq 400) { return $true } + # 5xx/404/0 = 网络或中转站故障, 不能归咎 Key + $hadNetworkError = $true + continue + } catch { $hadNetworkError = $true; continue } + } + # 全部候选都遇网络故障 → 返回 $null (外层应: 接受 Key, 首次真实请求时再判) + if ($hadNetworkError) { return $null } + return $false +} + +function Show-GiteaCredentialDialog { + $form = New-Object System.Windows.Forms.Form + $form.Text = "Bookworm - Gitea 登录" + $form.Size = New-Object System.Drawing.Size(420, 300) + $form.StartPosition = "CenterScreen" + $form.FormBorderStyle = "FixedDialog" + $form.MaximizeBox = $false + $form.TopMost = $true + $form.BackColor = [System.Drawing.Color]::White + + $lblInfo = New-Object System.Windows.Forms.Label + $lblInfo.Location = New-Object System.Drawing.Point(20, 15) + $lblInfo.Size = New-Object System.Drawing.Size(360, 40) + $lblInfo.Text = "输入 Gitea 账号 (code.letcareme.com)`n用于下载 Bookworm 配置文件,由管理员提供。" + $lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $form.Controls.Add($lblInfo) + + $lblUser = New-Object System.Windows.Forms.Label + $lblUser.Location = New-Object System.Drawing.Point(20, 65) + $lblUser.Size = New-Object System.Drawing.Size(80, 25) + $lblUser.Text = "用户名:" + $form.Controls.Add($lblUser) + + $txtUser = New-Object System.Windows.Forms.TextBox + $txtUser.Location = New-Object System.Drawing.Point(100, 63) + $txtUser.Size = New-Object System.Drawing.Size(280, 25) + $txtUser.Font = New-Object System.Drawing.Font("Consolas", 11) + $form.Controls.Add($txtUser) + + $lblPass = New-Object System.Windows.Forms.Label + $lblPass.Location = New-Object System.Drawing.Point(20, 105) + $lblPass.Size = New-Object System.Drawing.Size(80, 25) + $lblPass.Text = "密码:" + $form.Controls.Add($lblPass) + + $txtPass = New-Object System.Windows.Forms.TextBox + $txtPass.Location = New-Object System.Drawing.Point(100, 103) + $txtPass.Size = New-Object System.Drawing.Size(280, 25) + $txtPass.PasswordChar = '*' + $txtPass.Font = New-Object System.Drawing.Font("Consolas", 11) + $form.Controls.Add($txtPass) + + # P1 F10: 空值验证提示 + $lblError = New-Object System.Windows.Forms.Label + $lblError.Location = New-Object System.Drawing.Point(100, 135) + $lblError.Size = New-Object System.Drawing.Size(280, 20) + $lblError.Text = "" + $lblError.Font = New-Object System.Drawing.Font("Segoe UI", 8) + $lblError.ForeColor = [System.Drawing.Color]::Red + $form.Controls.Add($lblError) + + $btnOK = New-Object System.Windows.Forms.Button + $btnOK.Location = New-Object System.Drawing.Point(200, 165) + $btnOK.Size = New-Object System.Drawing.Size(90, 35) + $btnOK.Text = "登录" + $form.Controls.Add($btnOK) + + $btnCancel = New-Object System.Windows.Forms.Button + $btnCancel.Location = New-Object System.Drawing.Point(300, 165) + $btnCancel.Size = New-Object System.Drawing.Size(80, 35) + $btnCancel.Text = "取消" + $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + $form.CancelButton = $btnCancel + $form.Controls.Add($btnCancel) + + # OK 按钮手动验证 (不用 DialogResult, 防止空值直接关闭) + $btnOK.Add_Click({ + if (-not $txtUser.Text.Trim() -or -not $txtPass.Text) { + $lblError.Text = "用户名和密码不能为空" + return + } + $form.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.Close() + }) + $form.AcceptButton = $btnOK + + $form.Add_Shown({ $txtUser.Focus() }) + $result = $form.ShowDialog() + + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + return @{ User = $txtUser.Text.Trim(); Pass = $txtPass.Text } + } + return $null +} + +# ─── openssl 检测 ──────────────────────────────────── +function Find-OpenSSL { + $cmd = Get-Command openssl -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + $paths = @( + "C:\Program Files\Git\usr\bin\openssl.exe", + "D:\Git\usr\bin\openssl.exe", + "C:\Program Files\Git\mingw64\bin\openssl.exe", + "D:\Git\mingw64\bin\openssl.exe" + ) + return $paths | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +# ─── 凭证缓存 (DPAPI 加密, 绑定当前 Windows 用户) ── +# B2: 不再明文存注册表, 使用 ProtectedData 加密 +# B9: 读取时使用白名单, 不加载任意 KEY +Add-Type -AssemblyName System.Security + +$CacheAllowedKeys = @("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") + +function Protect-String([string]$plain) { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($plain) + $enc = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, "CurrentUser") + return [Convert]::ToBase64String($enc) +} + +function Unprotect-String([string]$b64) { + $enc = [Convert]::FromBase64String($b64) + $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, "CurrentUser") + return [System.Text.Encoding]::UTF8.GetString($bytes) +} + +function Get-CachedSecrets { + try { + $regPath = "HKCU:\Software\Bookworm\CachedEnv" + if (-not (Test-Path $regPath)) { return $false } + $expiry = (Get-ItemProperty $regPath -Name "_expiry" -ErrorAction SilentlyContinue)._expiry + if (-not $expiry -or [datetime]$expiry -le (Get-Date)) { + Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue + return $false + } + $props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue + $loaded = 0 + $needMigrate = $false + foreach ($p in $props.PSObject.Properties) { + if ($CacheAllowedKeys -contains $p.Name) { + $val = $null + # 先尝试 DPAPI 解密 (新格式) + try { $val = Unprotect-String $p.Value } catch {} + # 回退: 旧版明文格式 (非 Base64 / DPAPI 失败) + if (-not $val -and $p.Value -and $p.Value.Length -lt 200) { + $val = $p.Value + $needMigrate = $true + } + if ($val) { + [System.Environment]::SetEnvironmentVariable($p.Name, $val, "Process") + [System.Environment]::SetEnvironmentVariable($p.Name, $val, "User") + $loaded++ + } + } + } + # 旧缓存自动迁移为 DPAPI 格式 + if ($needMigrate -and $loaded -gt 0) { + Save-SecretsToCache + Bw-Log "INFO" "旧版明文缓存已迁移为 DPAPI 加密" + } + return ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY) + } catch { return $false } +} + +function Save-SecretsToCache { + try { + $regPath = "HKCU:\Software\Bookworm\CachedEnv" + if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null } + foreach ($k in $CacheAllowedKeys) { + $v = [System.Environment]::GetEnvironmentVariable($k, "Process") + if ($v) { + $encrypted = Protect-String $v + Set-ItemProperty $regPath -Name $k -Value $encrypted -Force + } + } + Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToUniversalTime().ToString("o") -Force + } catch {} +} + +# ─── 桌面快捷方式 ────────────────────────────────── +function New-DesktopShortcuts { + try { + $shell = New-Object -ComObject WScript.Shell + $desktop = $shell.SpecialFolders("Desktop") + + # 桌面专用图标 (Bookworm 蓝紫渐变 B 圆, 多尺寸 ICO) + $iconPath = Join-Path $BootDir "bookworm-desktop.ico" + if (-not (Test-Path $iconPath)) { + # 回退到 EXE 图标 (bookworm.ico) + $iconPath = Join-Path $BootDir "bookworm.ico" + } + + # 快速启动 (bat 文件位于 bookworm-boot 仓库内) + $shortcut = $shell.CreateShortcut("$desktop\Bookworm.lnk") + $batPath = Join-Path $BootDir "启动Bookworm.bat" + if (-not (Test-Path $batPath)) { $batPath = Join-Path $BootDir "Bookworm-OneClick.bat" } + $shortcut.TargetPath = $batPath + $shortcut.WorkingDirectory = $BootDir + $shortcut.Description = "Bookworm Smart Assistant - 智能助手" + if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" } + $shortcut.Save() + + # 更新启动 + $shortcut2 = $shell.CreateShortcut("$desktop\更新Bookworm.lnk") + $updateBat = Join-Path $BootDir "更新并启动Bookworm.bat" + if (Test-Path $updateBat) { + $shortcut2.TargetPath = $updateBat + $shortcut2.WorkingDirectory = $BootDir + $shortcut2.Description = "更新并启动 Bookworm" + if (Test-Path $iconPath) { $shortcut2.IconLocation = "$iconPath,0" } + $shortcut2.Save() + } + Log-OK "桌面快捷方式已创建 (含 Bookworm 图标)" + } catch { Log-Warn "快捷方式创建失败: $_" } +} + +# ======================================================================== +# 启动: 显示 GUI 进度窗口 (替代 console banner, PS2EXE -NoOutput 兼容) +# ======================================================================== +Bw-Log "INIT" "Bookworm Portable Setup 启动 - 日志: $BWLogFile" +Show-ProgressForm + +# ======================================================================== +# Phase 1: 环境检测 + 依赖自动安装 +# ======================================================================== +Log-Phase 1 "环境检测 + 依赖自动安装" + +# 刷新 PATH +$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + +# DryRun 模式: 只做 Phase 1 检测, 不动任何东西 +if (Is-DryRun "env") { + Log-Info "[DryRun=env] Phase 1 只检测 PATH + 依赖可达性, 跳过后续步骤" +} + +$deps = @( + # 核心依赖 (缺失则尝试自动安装) + @{ Name = "Node.js"; Cmd = "node"; WingetId = "OpenJS.NodeJS.LTS"; NpmPkg = $null; PipPkg = $null; Core = $true } + @{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null; PipPkg = $null; Core = $true } + @{ Name = "PowerShell 7"; Cmd = "pwsh"; WingetId = "Microsoft.PowerShell"; NpmPkg = $null; PipPkg = $null; Core = $false } + @{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code"; PipPkg = $null; Core = $true } + # Python 移到可选依赖 (不在此列表, 由 line 753 单独处理) +) + +$hasWinget = Test-Cmd "winget" +$installed = @() + +foreach ($dep in $deps) { + if (Test-Cmd $dep.Cmd) { + $ver = try { & $dep.Cmd --version 2>$null | Select-Object -First 1 } catch { "installed" } + Log-OK "$($dep.Name) $ver" + } else { + Log-Warn "$($dep.Name) 未安装, 正在自动安装..." + + if ($dep.WingetId -and $hasWinget) { + try { + $r = Run-CmdWithUI "winget" @("install", $dep.WingetId, "--accept-source-agreements", "--accept-package-agreements") "安装 $($dep.Name)" 300000 + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + if (Test-Cmd $dep.Cmd) { + Log-OK "$($dep.Name) 安装成功" + $installed += $dep.Name + } else { + Log-Fail "$($dep.Name) 安装后仍无法找到, 可能需要重启终端" + } + } catch { + Log-Fail "$($dep.Name) 安装失败: $_" + } + } + elseif ($dep.NpmPkg -and (Test-Cmd "npm")) { + try { + $r = Run-CmdWithUI "npm" @("i", "-g", $dep.NpmPkg) "npm 安装 $($dep.Name)" 120000 + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + if (Test-Cmd $dep.Cmd) { + Log-OK "$($dep.Name) 安装成功" + $installed += $dep.Name + } + } catch { Log-Fail "$($dep.Name) npm 安装失败: $_" } + } + elseif (-not $hasWinget) { + if ($dep.Core) { + Log-Fail "$($dep.Name) 需要手动安装 (winget 不可用)" + Show-MsgBox "$($dep.Name) 未安装且 winget 不可用。`n请手动安装后重新运行。`n`nNode.js: https://nodejs.org`nGit: https://git-scm.com" "缺少依赖" "OK" "Error" + } else { + Log-Info "$($dep.Name) 未安装 (可选, 不影响核心功能)" + } + } + } +} + +# ── bash PATH 自动修复 (Claude Code 的核心工具依赖 bash) ── +# Git 默认只把 cmd\ 加 PATH (有 git.exe), 但 bash.exe 在 bin\ 目录 +if ((Test-Cmd "git") -and -not (Test-Cmd "bash")) { + $gitBinPaths = @( + "$env:ProgramFiles\Git\bin", + "${env:ProgramFiles(x86)}\Git\bin", + "D:\Git\bin", + "$env:LOCALAPPDATA\Programs\Git\bin" + ) + $gitBin = $gitBinPaths | Where-Object { Test-Path (Join-Path $_ "bash.exe") } | Select-Object -First 1 + if ($gitBin) { + # 加入用户 PATH (永久) + $userPath = [System.Environment]::GetEnvironmentVariable("Path", "User") + if ($userPath -notmatch [regex]::Escape($gitBin)) { + [System.Environment]::SetEnvironmentVariable("Path", "$userPath;$gitBin", "User") + $env:Path += ";$gitBin" + Log-OK "bash 已加入 PATH: $gitBin" + } + } else { + Log-Warn "Git 已安装但找不到 bash.exe (Claude Code Bash 工具可能不可用)" + } +} elseif (Test-Cmd "bash") { + Log-OK "bash 已就绪" +} + +# Claude Code 依赖 npm, 需要在 Node.js 安装后再检查 +if (-not (Test-Cmd "claude") -and (Test-Cmd "npm")) { + Log-Info "安装 Claude Code..." + $r = Run-CmdWithUI "npm" @("i", "-g", "@anthropic-ai/claude-code") "安装 Claude Code (首次约 2 分钟)" 180000 + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + if (Test-Cmd "claude") { Log-OK "Claude Code 安装成功" } else { Log-Fail "Claude Code 安装失败" } +} + +# uv (Python 包管理器, 可选依赖) - 完全静默, 失败不阻断不弹窗 +# 安装策略: 1) winget astral-sh.uv 2) Astral 官方脚本 3) pip fallback +$uvLogFile = Join-Path $env:TEMP "bookworm-uv-install.log" +$uvInstalled = $false + +if (Test-Cmd "uv") { + $uvVer = try { (& uv --version 2>$null | Select-Object -First 1) } catch { "installed" } + Log-OK "uv $uvVer (已存在)" + $uvInstalled = $true +} else { + Log-Info "安装 uv (Python 包管理器, 可选)..." + + # B8: try/finally 确保 ErrorActionPreference 恢复 (防止后续 Phase 静默吞错) + $prevErrPref = $ErrorActionPreference + try { + $ErrorActionPreference = "SilentlyContinue" + + # 方案 A: winget (最可靠) + if (Test-Cmd "winget") { + try { + $null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 | + Out-File -FilePath $uvLogFile -Encoding utf8 -Append + } catch { "[winget] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + $uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links" + if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" } + if (Test-Cmd "uv") { $uvInstalled = $true } + } + + # 方案 B: Astral 官方一行脚本 + if (-not $uvInstalled) { + try { + $null = & powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | + Out-File -FilePath $uvLogFile -Encoding utf8 -Append + } catch { "[astral] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } + $localBin = Join-Path $env:USERPROFILE ".local\bin" + if (Test-Path $localBin) { $env:Path += ";$localBin" } + if (Test-Cmd "uv") { $uvInstalled = $true } + } + + # 方案 C: pip fallback + if (-not $uvInstalled -and (Test-Cmd "python")) { + try { + $null = & python -m pip install --quiet uv 2>&1 | + Out-File -FilePath $uvLogFile -Encoding utf8 -Append + } catch { "[pip] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } + try { + $pyScripts = Join-Path (Split-Path (& python -c "import sys; print(sys.executable)") -Parent) "Scripts" + if (Test-Path $pyScripts) { $env:Path += ";$pyScripts" } + } catch {} + if (Test-Cmd "uv") { $uvInstalled = $true } + } + } finally { + $ErrorActionPreference = $prevErrPref + } + + if ($uvInstalled) { + Log-OK "uv 安装成功" + $installed += "uv" + } else { + # 静默 fallback: 仅写日志文件, 不调 Log-Warn 避免 PS2EXE 弹窗 + "[fail] uv 三种安装方式均失败, Python MCP 将不可用. 详见上方日志." | Out-File -FilePath $uvLogFile -Encoding utf8 -Append + Log-Info "uv 未就绪 (可选, 不影响核心功能, 详情: $uvLogFile)" + } +} + +# OpenSSL (随 Git 安装) +$opensslCmd = Find-OpenSSL +if ($opensslCmd) { Log-OK "OpenSSL: $opensslCmd" } else { Log-Warn "OpenSSL 未找到 (凭证解密可能失败)" } + +# 最终检查 (仅 Node.js + Git + Claude Code 为硬性依赖, PowerShell 7 可选) +if (-not (Test-Cmd "node") -or -not (Test-Cmd "git") -or -not (Test-Cmd "claude")) { + $missing = @() + if (-not (Test-Cmd "node")) { $missing += "Node.js" } + if (-not (Test-Cmd "git")) { $missing += "Git" } + if (-not (Test-Cmd "claude")) { $missing += "Claude Code" } + Show-MsgBox "以下核心依赖安装失败: $($missing -join ', ')`n`n请手动安装后重新运行。`n`nNode.js: https://nodejs.org`nGit: https://git-scm.com" "安装中断" "OK" "Error" + exit 1 +} +if (-not (Test-Cmd "pwsh")) { + Log-Info "PowerShell 7 未安装 (可选, 用系统 PowerShell 5.1 替代)" +} + +# 定位 pwsh.exe 完整路径 (供后续 settings.json 配置使用) +$PwshPath = (Get-Command pwsh -ErrorAction SilentlyContinue).Source +if (-not $PwshPath) { + # winget 默认安装路径 + $defaultPaths = @( + "$env:ProgramFiles\PowerShell\7\pwsh.exe", + "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe", + "$env:LOCALAPPDATA\Microsoft\PowerShell\pwsh.exe" + ) + $PwshPath = $defaultPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 +} +if ($PwshPath) { + Log-OK "PowerShell 7 路径: $PwshPath" +} else { + Log-Warn "pwsh 可执行但无法定位完整路径, 使用 'pwsh'" + $PwshPath = "pwsh" +} + +# 可选依赖检查 (不阻断, 用 Log-Info 避免 PS2EXE 弹窗化) +$optionalMissing = @() +if (-not (Test-Cmd "python")) { $optionalMissing += "Python 3.12" } +if (-not (Test-Cmd "uv")) { $optionalMissing += "uv" } +if ($optionalMissing.Count -gt 0) { + Log-Info "可选依赖未就绪: $($optionalMissing -join ', ') — 仅影响 Python 类 MCP, 核心功能正常" +} + +if ($installed.Count -gt 0) { + Log-OK "本次新安装: $($installed -join ', ')" +} + +# DryRun "env" 退出点: Phase 1 完成即退 +if (Is-DryRun "env") { Log-OK "[DryRun=env] 完成, 退出"; exit 0 } + +# ======================================================================== +# Phase 2: 网络诊断 +# ======================================================================== +Log-Phase 2 "网络诊断" +if (Is-DryRun "net") { Log-Info "[DryRun=net] 只跑网络诊断, 跳过 Phase 3+" } + +# 代理检测 +$env:NO_PROXY = "bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" +$env:no_proxy = $env:NO_PROXY + +$proxyFound = $false + +# .NET 系统代理 +if (-not $env:HTTPS_PROXY) { + try { + $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") + if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { + $env:HTTPS_PROXY = "http://$($proxyUri.Authority)" + $env:HTTP_PROXY = $env:HTTPS_PROXY + Log-OK "系统代理: $($env:HTTPS_PROXY)" + $proxyFound = $true + } + } catch {} +} + +# 注册表 IE 代理 +if (-not $proxyFound -and -not $env:HTTPS_PROXY) { + try { + $reg = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -ErrorAction SilentlyContinue + if ($reg.ProxyEnable -eq 1 -and $reg.ProxyServer) { + $proxy = $reg.ProxyServer + if ($proxy -notmatch '^http') { $proxy = "http://$proxy" } + $env:HTTPS_PROXY = $proxy + $env:HTTP_PROXY = $proxy + Log-OK "IE 代理: $proxy" + $proxyFound = $true + } + } catch {} +} + +# 端口扫描 +if (-not $proxyFound -and -not $env:HTTPS_PROXY) { + $ports = @(7890,7891,7893,10792,10793,10808,10809,1080,1087,8080,8118) + foreach ($port in $ports) { + try { + $tcp = New-Object System.Net.Sockets.TcpClient + $ar = $tcp.BeginConnect("127.0.0.1", $port, $null, $null) + $ok = $ar.AsyncWaitHandle.WaitOne(500) + if ($ok) { $tcp.EndConnect($ar); $tcp.Close() + $env:HTTPS_PROXY = "http://127.0.0.1:$port" + $env:HTTP_PROXY = $env:HTTPS_PROXY + Log-OK "本地代理端口: $port" + $proxyFound = $true + break + } + $tcp.Close() + } catch {} + } +} + +if ($env:HTTPS_PROXY) { $proxyFound = $true } + +if (-not $proxyFound) { + Log-Warn "未检测到代理/VPN" + $r = Show-MsgBox "未检测到代理/VPN 软件。`n国内 Claude Code 需要代理才能启动。`n`n请先启动代理软件 (Clash / V2Ray / 快柠檬),`n然后点击 '重试'。`n`n或点击 '忽略' 继续 (可能失败)。" "网络警告" "AbortRetryIgnore" "Warning" + if ($r -eq "Retry") { + # 重试代理检测 + try { + $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") + if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { + $env:HTTPS_PROXY = "http://$($proxyUri.Authority)" + $env:HTTP_PROXY = $env:HTTPS_PROXY + Log-OK "系统代理: $($env:HTTPS_PROXY)" + } + } catch {} + } elseif ($r -eq "Abort") { exit 1 } +} + +Log-OK "NO_PROXY: bww.letcareme.com, code.letcareme.com" + +# 连通性测试 +Log-Info "测试网络连通性..." + +$netTests = @( + @{ Name = "Gitea 代码仓库"; Url = "https://code.letcareme.com"; Direct = $true } + @{ Name = "API 中转站"; Url = "https://bww.letcareme.com"; Direct = $true } + @{ Name = "Claude API"; Url = "https://api.anthropic.com"; Direct = $false } +) + +foreach ($t in $netTests) { + try { + $req = [System.Net.HttpWebRequest]::Create($t.Url) + $req.Timeout = 8000 + $req.Method = "HEAD" + if ($t.Direct) { $req.Proxy = [System.Net.GlobalProxySelection]::GetEmptyWebProxy() } + $resp = $req.GetResponse() + $code = [int]$resp.StatusCode + $resp.Close() + Log-OK "$($t.Name) ($($t.Url)) - HTTP $code" + } catch { + $errMsg = $_.Exception.InnerException.Message + if (-not $errMsg) { $errMsg = $_.Exception.Message } + # 非 200 但能连上也算成功 (如 401, 403) + if ($errMsg -match '40[0-9]|30[0-9]') { + Log-OK "$($t.Name) - 可达 (需认证)" + } else { + Log-Warn "$($t.Name) - 不可达: $($errMsg.Substring(0, [Math]::Min(60, $errMsg.Length)))" + } + } +} + +# DryRun "net" 退出点: Phase 2 完成即退 +if (Is-DryRun "net") { Log-OK "[DryRun=net] 网络诊断完成, 退出"; exit 0 } + +# ======================================================================== +# Phase 3: 仓库克隆 +# ======================================================================== +Log-Phase 3 "同步 Bookworm 配置" +if (Is-DryRun "repo") { Log-Info "[DryRun=repo] 测试 git clone 完成后退出, 不做凭证解密/配置渲染" } + +# B3: 使用 Windows Credential Manager (DPAPI 加密) 替代明文 store +git config --global credential.helper manager 2>$null + +# 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结 +# 辅助函数: clone 后缓存凭证到 Windows Credential Manager +function Cache-GitCredentials($credObj) { + if (-not $credObj) { return } + try { + $approveInput = "protocol=https`nhost=code.letcareme.com`nusername=$($credObj.User)`npassword=$($credObj.Pass)`n`n" + $approveInput | & git credential approve 2>$null + Bw-Log "OK" "Gitea 凭证已缓存到 Windows Credential Manager" + } catch { Bw-Log "WARN" "凭证缓存失败: $_" } +} + +if (Test-Path (Join-Path $ClaudeDir ".git")) { + Log-Info "配置仓库已存在, 更新中..." + # 设置 git 身份 (auto-resolve commit 需要) + & git -C $ClaudeDir config user.email "bookworm@auto.local" 2>$null + & git -C $ClaudeDir config user.name "Bookworm" 2>$null + try { + # 强制清除冲突状态 (运行时文件不重要, Phase 5 会重新渲染) + & git -C $ClaudeDir reset --hard HEAD 2>&1 | Out-Null + $r = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase", "--autostash") "同步配置仓库" 120000 + if ($r.OK) { + Log-OK "配置仓库已更新" + } else { + # pull 失败可能是认证问题, 尝试重新输入凭证 + Log-Warn "git pull 失败, 尝试重新认证..." + $cred = Show-GiteaCredentialDialog + if ($cred) { + Cache-GitCredentials $cred + $r2 = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase", "--autostash") "重试同步" 120000 + if ($r2.OK) { Log-OK "配置仓库已更新 (重新认证成功)" } + else { Log-Warn "git pull 仍失败, 使用本地版本" } + } else { Log-Warn "用户取消认证, 使用本地版本" } + } + } catch { Log-Warn "git pull 异常: $_, 使用本地版本" } +} +elseif (Test-Path $ClaudeDir) { + Log-Info "备份现有 .claude/ 并克隆..." + if (Test-Path $BackupDir) { Remove-Item $BackupDir -Recurse -Force } + Rename-Item $ClaudeDir $BackupDir + + # v3.0.1 先匿名克隆 (repos 已 public + REQUIRE_SIGNIN_VIEW=false), 失败才弹凭证 + $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $GitUrl, $ClaudeDir) "克隆配置仓库 (匿名)" 180000 + if (-not (Test-Path (Join-Path $ClaudeDir "CLAUDE.md"))) { + Log-Warn "匿名克隆失败, 弹凭证对话框重试..." + $cred = Show-GiteaCredentialDialog + if ($cred) { + $cloneUrl = $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" + $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库 (认证)" 180000 + if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { Cache-GitCredentials $cred } + } + } + if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { + Log-OK "配置仓库克隆成功 (旧目录已备份)" + } else { + Log-Fail "克隆失败" + if (Test-Path $BackupDir) { Rename-Item $BackupDir $ClaudeDir } + Show-MsgBox "配置仓库克隆失败。`n请检查网络连接。" "克隆失败" "OK" "Error" + exit 1 + } +} +else { + Log-Info "首次安装, 匿名克隆配置仓库..." + # v3.0.1 先匿名, 失败才弹凭证 + $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $GitUrl, $ClaudeDir) "克隆配置仓库 (匿名)" 180000 + if (-not (Test-Path (Join-Path $ClaudeDir "CLAUDE.md"))) { + Log-Warn "匿名克隆失败, 弹凭证对话框重试..." + $cred = Show-GiteaCredentialDialog + if ($cred) { + $cloneUrl = $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" + $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库 (认证)" 180000 + if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { Cache-GitCredentials $cred } + } + } + if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { + Log-OK "配置仓库克隆成功" + } else { + Log-Fail "克隆失败" + Show-MsgBox "配置仓库克隆失败。`n请检查网络连接。" "克隆失败" "OK" "Error" + exit 1 + } +} + +# 创建本地运行时目录 +$dirs = @("debug","sessions","cache","backups","telemetry","shell-snapshots","projects","memory") +foreach ($d in $dirs) { + $p = Join-Path $ClaudeDir $d + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } +} + +# ─── 克隆/更新 bookworm-boot (含 crypto-helper.js + secrets-*.enc + install.ps1) ─── +if (Test-Path (Join-Path $BootDir ".git")) { + Log-Info "boot 仓库已存在, 更新中..." + try { + $r = Run-CmdWithUI "git" @("-C", $BootDir, "pull", "--rebase") "同步 boot 仓库" 120000 + if ($r.OK) { Log-OK "boot 仓库已更新" } else { Log-Warn "boot 仓库更新失败, 使用本地版本" } + } catch { Log-Warn "boot 仓库更新失败, 使用本地版本" } +} else { + Log-Info "匿名克隆 boot 仓库 (含解密工具)..." + # v3.0.1: 先匿名 (repo 已公开), 失败才弹凭证 + $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $BootUrl, $BootDir) "克隆 boot 仓库 (匿名)" 180000 + if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) { + Log-Warn "匿名克隆失败, 弹凭证对话框重试..." + $cred = Show-GiteaCredentialDialog + if ($cred) { + $bootCloneUrl = $BootUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" + $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库 (认证)" 180000 + if (Test-Path (Join-Path $BootDir "crypto-helper.js")) { Cache-GitCredentials $cred } + } + } + if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) { + Log-Fail "启动工具包下载失败" + Show-MsgBox "Bookworm 启动工具包下载失败。`n`n请检查:`n1. 网络和代理是否正常`n2. Gitea (code.letcareme.com) 是否可达`n`n重新运行安装器即可。" "下载失败" "OK" "Error" + exit 1 + } + Log-OK "boot 仓库克隆成功 → $BootDir" +} + +# DryRun "repo" 退出点: Phase 3 git clone 完成即退 +if (Is-DryRun "repo") { Log-OK "[DryRun=repo] git clone 完成, 退出"; exit 0 } + +# ======================================================================== +# Phase 4: 凭证解密 (GUI 弹窗) +# ======================================================================== +Log-Phase 4 "凭证解密" +if (Is-DryRun "creds") { Log-Info "[DryRun=creds] 只测 sk- Key 验证, 不持久化, 不进入 Phase 5" } + +$secretsDecrypted = $false + +# 优先级 1: User 级环境变量已有 (上次安装已永久写入) +# v3.0.1: 静默使用旧 Key 改为明确询问 (防误用吊销的老 Key) +$existingKey = [System.Environment]::GetEnvironmentVariable("ANTHROPIC_API_KEY", "User") +$existingUrl = [System.Environment]::GetEnvironmentVariable("ANTHROPIC_BASE_URL", "User") +if ($existingKey) { + $keyPreview = if ($existingKey.Length -ge 14) { $existingKey.Substring(0,10) + "..." + $existingKey.Substring($existingKey.Length - 4) } else { $existingKey.Substring(0, [Math]::Min(6, $existingKey.Length)) + "..." } + $urlPreview = if ($existingUrl) { $existingUrl } else { "(默认中转站)" } + $useExistingChoice = Show-MsgBox "检测到系统已保存的 API Key:`n`n Key : $keyPreview`n URL : $urlPreview`n`n是 = 继续使用这把 Key`n否 = 更换为新 Key (弹输入框)" "已有 API Key" "YesNo" "Question" + if ($useExistingChoice -eq "Yes") { + # 复用旧 Key + $env:ANTHROPIC_API_KEY = $existingKey + if ($existingUrl) { $env:ANTHROPIC_BASE_URL = $existingUrl } + foreach ($k in $CacheAllowedKeys) { + $v = [System.Environment]::GetEnvironmentVariable($k, "User") + if ($v) { [System.Environment]::SetEnvironmentVariable($k, $v, "Process") } + } + Log-OK "继续使用已保存 Key ($keyPreview)" + $secretsDecrypted = $true + } else { + # 用户选择换 Key: 彻底清除所有缓存层 (否则 Priority 2 DPAPI 会复活旧 Key) + Log-Info "用户选择更换 Key, 清除所有层缓存, 转到手动输入" + # 层 1: User env var + [System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $null, "User") + $env:ANTHROPIC_API_KEY = $null + # 层 2: DPAPI Registry 缓存 (HKCU:\Software\Bookworm\CachedEnv) + try { Remove-Item "HKCU:\Software\Bookworm\CachedEnv" -Recurse -Force -ErrorAction SilentlyContinue } catch {} + # URL 保留不清, 新 Key 默认还指向同一中转站 + Log-Info "已清 User env + DPAPI cache, 下面将弹输入框让你粘新 Key" + } +} + +# 优先级 2: Registry DPAPI 缓存 +if (-not $secretsDecrypted -and (Get-CachedSecrets)) { + Log-OK "从 Registry 缓存加载凭证" + $secretsDecrypted = $true +} + +# v3.0.1: 强制设默认模型 = claude-opus-4-7 (中转站支持, Claude Code 2.0.1 默认 4-5 会 503) +# 顺序: 已显式设置 > Worker /config 拉取 > fallback 硬编码 +if (-not $env:ANTHROPIC_MODEL) { + $defaultModel = "claude-opus-4-7" + try { + $cfg = Invoke-RestMethod "https://bookworm-router.bookworm-api.workers.dev/config" -TimeoutSec 8 -ErrorAction Stop + if ($cfg.default_model) { $defaultModel = $cfg.default_model } + } catch {} + [System.Environment]::SetEnvironmentVariable("ANTHROPIC_MODEL", $defaultModel, "User") + $env:ANTHROPIC_MODEL = $defaultModel + Log-OK "ANTHROPIC_MODEL=$defaultModel (中转站兼容)" +} +# 优先级 3: 直接输入中转站 API Key (v2.3 新增) +if (-not $secretsDecrypted) { + Show-MsgBox "欢迎使用 Bookworm Portable!`n`n接下来需要配置你的中转站 API Key。`n`n如果还没有, 请先去 https://bww.letcareme.com 注册并充值获取。" "配置 API Key" "OK" "Information" + + $keyAttempts = 0 + $maxKeyAttempts = 3 + while ($keyAttempts -lt $maxKeyAttempts) { + $keyAttempts++ + $apiKey = Show-ApiKeyDialog $keyAttempts $maxKeyAttempts + if (-not $apiKey) { + Log-Warn "用户取消 API Key 输入" + break + } + # 基础格式校验 + if ($apiKey.Length -lt 20) { + Show-MsgBox "API Key 格式错误 (长度过短)。`n请检查后重试。" "格式错误" "OK" "Warning" + continue + } + Log-Info "正在验证..." + # P1-2: 统一调用 change-key.js (stdin 管道, 无 argv 泄露) + $injectJs = Join-Path $ClaudeDir "change-key.js" + $ckOk = $false + if ((Test-Path $injectJs) -and (Test-Cmd "node")) { + try { + $apiKey | & node $injectJs 2>&1 | Out-Null + $ckOk = ($LASTEXITCODE -eq 0) + } catch { $ckOk = $false } + } else { + # v3.0.1: Test-ApiKey 三值返回 (true=有效, false=认证失败, null=网络故障放行) + $checkResult = Test-ApiKey $apiKey + if ($checkResult -eq $true) { + $ckOk = $true + Log-OK "Key 在线验证通过" + } elseif ($null -eq $checkResult) { + # 网络故障, 不能归咎 Key, 先接受 (首次真实请求时再判) + $ckOk = $true + Log-Warn "中转站暂不可达, 暂接受此 Key (首次使用时若失败请重装换 Key)" + } else { + $ckOk = $false # 明确认证失败 + } + if ($ckOk) { + [System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $apiKey, "User") + [System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", "https://bww.letcareme.com", "User") + } + } + if ($ckOk) { + $env:ANTHROPIC_API_KEY = $apiKey + $env:ANTHROPIC_BASE_URL = "https://bww.letcareme.com" + Log-OK "凭证已验证并持久化" + $secretsDecrypted = $true + Save-SecretsToCache + Show-MsgBox "API Key 验证成功!`n`n已写入系统环境变量, 任何终端输入 claude 即可启动。`n`n以后想换 Key, 可以:`n1. 双击桌面 '更换Key.bat'`n2. 或 Claude Code 里输入 /change-key`n3. 或重跑安装器" "验证成功" "OK" "Information" + break + } else { + $remaining = $maxKeyAttempts - $keyAttempts + if ($remaining -gt 0) { + Show-MsgBox "API Key 验证失败 (无法连接或认证错误)。`n剩余重试: $remaining 次`n`n请检查:`n1. Key 是否正确 (sk- 开头)`n2. 中转站是否有余额`n3. 网络和代理是否正常" "验证失败" "OK" "Warning" + } else { + Show-MsgBox "3 次验证均失败。`n`n请检查 Key 和网络, 或联系管理员。" "验证失败" "OK" "Error" + } + } + } +} + +# 优先级 4: 解密授权码 (向后兼容, 旧用户保留) +if (-not $secretsDecrypted) { +$cryptoHelper = Join-Path $BootDir "crypto-helper.js" +if (-not (Test-Cmd "node") -or -not (Test-Path $cryptoHelper)) { + Log-Info "跳过授权码解密" +} +elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) { + # 强制要求授权码 — 不允许跳过 (跳过 = 无法使用) + Show-MsgBox "检测到加密凭证文件,需要输入授权码才能使用 Bookworm。`n`n授权码由管理员提供,格式: BW-YYYYMMDD-XXXX...`n如果没有授权码,请联系管理员获取。" "需要授权码" "OK" "Information" + + $validAttempts = 0 + while ($validAttempts -lt 3) { + $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 + if (-not $rawCode) { + # 不再静默跳过,明确警告 + $skip = Show-MsgBox "未输入授权码。`n`n没有授权码将无法使用 Bookworm(无 API 凭证)。`n`n确定要跳过吗?" "警告" "YesNo" "Warning" + if ($skip -eq "No") { continue } + Log-Warn "用户确认跳过授权码" + break + } + + $token = Parse-AuthCode-GUI $rawCode + if ($token -eq 'EXPIRED') { + Show-MsgBox "授权码已过期。`n请联系管理员获取新授权码。" "授权码过期" "OK" "Warning" + continue + } + if (-not $token) { + Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning" + continue + } + + # B7: 先检查文件存在, 再递增 validAttempts (文件缺失不消耗尝试次数) + $fileId = $token.Substring(0, 8) + $encFile = Join-Path $BootDir "secrets-$fileId.enc" + if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc } + if (-not (Test-Path $encFile)) { + Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。`n`n(此次不计为失败尝试)" "文件未找到" "OK" "Warning" + $token = $null + continue + } + + $validAttempts++ # B7: 只有真正尝试解密才计数 + + try { + $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 + $decExit = $LASTEXITCODE + $token = $null + + if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { + $count = 0 + foreach ($line in $decrypted -split "`n") { + $line = $line.Trim() + if (-not $line -or $line -notmatch '=') { continue } + $key = ($line -split '=', 2)[0].Trim() + $value = ($line -split '=', 2)[1].Trim() + # F-12 fix: 白名单 + 长度校验, 防止 secrets.enc 被污染注入恶意 env + if ($key -and $value -and ($key -in $CacheAllowedKeys) -and ($value.Length -lt 512)) { + [System.Environment]::SetEnvironmentVariable($key, $value, "Process") + [System.Environment]::SetEnvironmentVariable($key, $value, "User") + Log-OK "已注入: $key (永久)" + $count++ + } elseif ($key -and ($key -notin $CacheAllowedKeys)) { + Bw-Log "WARN" "跳过未知 key: $key (不在白名单)" + } + } + $decrypted = $null + $secretsDecrypted = $true + Save-SecretsToCache # F-22 fix: 写入 DPAPI 缓存, 下次免授权码 + + Show-MsgBox "授权码验证成功!`n`n$count 个凭证已写入系统环境变量 (永久生效)。`n任何终端输入 claude 即可启动,无需再次输入授权码。" "验证成功" "OK" "Information" + break + } else { + $token = $null + $remaining = 3 - $validAttempts + if ($remaining -gt 0) { + Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次" "验证失败" "OK" "Warning" + } else { + Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error" + } + } + } catch { + $token = $null + Log-Warn "解密异常: $_" + } + } +} +else { + Log-Warn "未找到任何 secrets*.enc,跳过凭证解密" +} +} # end if (-not $secretsDecrypted) + +# ======================================================================== +# Phase 4.5: License 激活 (v3.0 新增, 可选) +# ======================================================================== +# 检查 ~/.claude/.bw-token 是否存在且未过期; 不存在则引导激活 +$bwTokenFile = Join-Path $ClaudeDir ".bw-token" +$bwActivateJs = Join-Path $ClaudeDir "lib\activate.js" +$needLicenseActivate = $true +if (Test-Path $bwTokenFile) { + try { + $tok = Get-Content $bwTokenFile -Raw | ConvertFrom-Json + if ($tok.expires_at -gt ((New-TimeSpan -Start (Get-Date "1970-01-01") -End (Get-Date).ToUniversalTime()).TotalMilliseconds + 86400000)) { + Log-OK "License token 有效 (UUID: $($tok.license_uuid))" + $needLicenseActivate = $false + } + } catch {} +} +if ($needLicenseActivate -and (Test-Path $bwActivateJs) -and (Test-Cmd "node")) { + # 零输入快路径: $env:BW_LICENSE_KEY 预设时直接激活, 不弹 GUI + # (install.ps1 通过环境变量传递, 格式: BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX) + $envKey = $env:BW_LICENSE_KEY + if ($envKey -and $envKey -match '^BW-[A-F0-9]{4}(-[A-F0-9]{4}){5}$') { + Log-Info "检测到 `$env:BW_LICENSE_KEY, 静默激活..." + try { + $envKey | & node $bwActivateJs 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0 -and (Test-Path $bwTokenFile)) { + Log-OK "License 静默激活成功" + } else { + Log-Warn "静默激活失败 (退出码 $LASTEXITCODE), 回退到 GUI" + $envKey = $null # 失败后走 GUI 补救 + } + } catch { + Log-Warn "静默激活异常: $_, 回退到 GUI" + $envKey = $null + } + # 清掉 env 防止后续进程泄露 + Remove-Item Env:\BW_LICENSE_KEY -ErrorAction SilentlyContinue + } + # 仍未激活 → 走 GUI 交互流 (带三次重试) + if (-not (Test-Path $bwTokenFile)) { + $activateChoice = Show-MsgBox "是否激活 Bookworm License (用于加载 Skill 专家)?`n`n如果有 License Key (BW-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX), 点是`n如果没有, 点否 (可后续用 change-key.bat 激活)" "License 激活" "YesNo" "Information" + if ($activateChoice -eq "Yes") { + $attempt = 0 + while ($attempt -lt 3 -and -not (Test-Path $bwTokenFile)) { + $attempt++ + $licKey = Show-LicenseKeyDialog $attempt 3 + if (-not $licKey) { break } # 用户取消 + if ($licKey -notmatch '^BW-[A-F0-9]{4}(-[A-F0-9]{4}){5}$') { + Show-MsgBox "格式错误! License Key 必须是 6 段 4 位十六进制`n例: BW-1A2B-3C4D-5E6F-7890-ABCD-EF12" "格式错误 ($attempt/3)" "OK" "Warning" + continue + } + try { + $licKey | & node $bwActivateJs 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0 -and (Test-Path $bwTokenFile)) { + Log-OK "License 激活成功" + break + } else { + Show-MsgBox "激活失败 (可能 Key 已吊销/达设备上限/过期, 或代理不稳)`n重试或点取消跳过" "激活失败 ($attempt/3)" "OK" "Warning" + } + } catch { + Show-MsgBox "激活异常: $_" "激活失败 ($attempt/3)" "OK" "Error" + } + } + } else { + Log-Info "已跳过 License 激活" + } + } +} + +# DryRun "creds" / "license" 退出点: Phase 4 / 4.5 完成即退 +if (Is-DryRun "creds") { Log-OK "[DryRun=creds] 凭证验证完成, 退出"; exit 0 } +if (Is-DryRun "license") { Log-OK "[DryRun=license] License 激活完成, 退出"; exit 0 } + +# ======================================================================== +# Phase 5: 配置渲染 +# ======================================================================== +Log-Phase 5 "配置渲染" + +$templateFile = Join-Path $ClaudeDir "settings.template.json" +$settingsFile = Join-Path $ClaudeDir "settings.json" + +if (Test-Path $templateFile) { + $claudeRoot = $ClaudeDir.Replace('\', '/') + $homeDir = $env:USERPROFILE + # pwsh 路径转正斜杠供 JSON 使用 (C:/Program Files/PowerShell/7/pwsh.exe) + $pwshJsonPath = if ($PwshPath) { $PwshPath.Replace('\', '/') } else { "pwsh" } + + $content = Get-Content $templateFile -Raw + $content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot + # HOME 必须用正斜杠: C:\Users\x 中 \U 是非法 JSON 转义序列 + $homeForward = $homeDir.Replace('\', '/') + $content = $content -replace '\{\{HOME\}\}', $homeForward + $content = $content -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath + + Set-Content $settingsFile -Value $content -Encoding UTF8 + Log-OK "settings.json 已渲染 (ROOT=$claudeRoot, SHELL=$pwshJsonPath)" + + # settings.local.template.json (向后兼容) + $localTpl = Join-Path $ClaudeDir "settings.local.template.json" + $localSet = Join-Path $ClaudeDir "settings.local.json" + if (Test-Path $localTpl) { + $lc = Get-Content $localTpl -Raw + $lc = $lc -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot + $lc = $lc -replace '\{\{HOME\}\}', $homeForward + $lc = $lc -replace '\{\{USERNAME\}\}', $env:USERNAME + $lc = $lc -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath + Set-Content $localSet -Value $lc -Encoding UTF8 + Log-OK "settings.local.json 已渲染" + } + + # ── ~/.claude.json (Claude Code v2.1+ MCP 服务器配置的正确位置) ── + # 优先用 config 仓库的 inject-mcp.js, 如果 git pull 失败则内嵌 fallback + $injectScript = Join-Path $ClaudeDir "inject-mcp.js" + $mcpInjected = $false + # 方案 A: 调用 config 仓库里的 inject-mcp.js + if (Test-Path $injectScript) { + try { + $nodeOut = & node $injectScript 2>&1 + $firstLine = ($nodeOut | Select-Object -First 1).ToString().Trim() + Log-OK $firstLine + $mcpInjected = $true + } catch { Bw-Log "WARN" "inject-mcp.js 执行失败: $_" } + } + # 方案 B: 内嵌 fallback (git pull 失败时 inject-mcp.js 不存在) + if (-not $mcpInjected) { + Log-Info "inject-mcp.js 不可用, 使用内嵌 MCP 注入..." + $fallbackJs = Join-Path $env:TEMP "bw-mcp-fallback.js" + try { + $fbLines = @( + 'var fs=require("fs"),p=require("path");' + 'var H=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.cmd",Y="--yes";' + 'var 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)");' + ) + $fbLines -join "`n" | Set-Content $fallbackJs -Encoding UTF8 + $nodeOut = & node $fallbackJs 2>&1 + $firstLine = ($nodeOut | Select-Object -First 1).ToString().Trim() + Remove-Item $fallbackJs -Force -ErrorAction SilentlyContinue + Log-OK $firstLine + } catch { Bw-Log "WARN" "MCP fallback 注入失败: $_" } + } +} else { + Log-Warn "settings.template.json 不存在, 跳过渲染" +} + +$env:CLAUDE_HOME = $ClaudeDir + +# ======================================================================== +# Phase 6: MCP 验证 + 自动安装 +# ======================================================================== +Log-Phase 6 "MCP 服务验证 + 预安装" + +# ── 6a: Bookworm 完整性检查 ── +$skillCount = 0; $hookCount = 0 +$skillsDir = Join-Path $ClaudeDir "skills" +$hooksDir = Join-Path $ClaudeDir "hooks" +if (Test-Path $skillsDir) { $skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count } +if (Test-Path $hooksDir) { $hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count } + +$claudeMdOK = $false +$claudeMdPath = Join-Path $ClaudeDir "CLAUDE.md" +if (Test-Path $claudeMdPath) { + $cm = Get-Content $claudeMdPath -Raw -ErrorAction SilentlyContinue + $claudeMdOK = $cm -match "Bookworm" +} + +$settingsOK = $false +if (Test-Path $settingsFile) { + $sc = Get-Content $settingsFile -Raw -ErrorAction SilentlyContinue + $settingsOK = $sc -match '"hooks"' +} + +# v3.0.1 修复: Skills 门槛从 >50 改为 >=10 +# 原因: 架构是 14 本地 + 79 云端懒加载, 本地只缓存常用 skill +# >50 的旧门槛和架构不匹配, 每次装完都误报 "配置不完整" +$checks = @( + @{ Name = "CLAUDE.md (Bookworm 指令)"; OK = $claudeMdOK } + @{ Name = "Skills ($skillCount 本地 + 79 云端懒加载)"; OK = ($skillCount -ge 10) } + # v3.0.1: hook 阈值从 >10 改为 >=3 (config repo 分发精简版只有 4 个核心 hook) + @{ Name = "Hooks ($hookCount 核心)"; OK = ($hookCount -ge 3) } + @{ Name = "Settings hooks 配置"; OK = $settingsOK } +) + +$allOK = $true +foreach ($c in $checks) { + if ($c.OK) { Log-OK $c.Name } else { Log-Fail $c.Name; $allOK = $false } +} + +# ── 6b: API 凭证检查 ── +Log-Info "API 凭证检查..." +if ($env:ANTHROPIC_API_KEY) { Log-OK "ANTHROPIC_API_KEY 已配置" } else { Log-Fail "ANTHROPIC_API_KEY 未配置" } +if ($env:ANTHROPIC_BASE_URL) { Log-OK "ANTHROPIC_BASE_URL 已配置" } else { Log-Warn "ANTHROPIC_BASE_URL 未配置 (将使用默认)" } + +# ── 6c: MCP npx 包预缓存 (非阻塞 UI) ── +Log-Info "MCP 预安装 (npx 包预缓存)..." + +$npxPackages = @( + @{ Name = "context7"; Pkg = "@upstash/context7-mcp@2.1.1" } + @{ Name = "sequential-thinking"; Pkg = "@modelcontextprotocol/server-sequential-thinking@2025.12.18" } + @{ Name = "playwright"; Pkg = "@playwright/mcp@0.0.68" } + @{ Name = "session-continuity"; Pkg = "claude-session-continuity-mcp@1.13.0" } + @{ Name = "notebooklm"; Pkg = "notebooklm-mcp@latest" } + @{ Name = "cloudflare-docs"; Pkg = "mcp-remote" } + @{ Name = "chrome-devtools"; Pkg = "chrome-devtools-mcp@0.18.1" } + @{ Name = "github"; Pkg = "@modelcontextprotocol/server-github" } + @{ Name = "slack"; Pkg = "@modelcontextprotocol/server-slack" } + @{ Name = "firecrawl"; Pkg = "firecrawl-mcp" } + @{ Name = "mcp-image"; Pkg = "mcp-image" } + @{ Name = "google-drive"; Pkg = "@piotr-agier/google-drive-mcp" } +) + +$mcpOK = 0; $mcpFail = 0 +foreach ($mcp in $npxPackages) { + $idx = $mcpOK + $mcpFail + 1 + $label = "[$idx/$($npxPackages.Count)] $($mcp.Name)" + Update-Progress-SubStatus "$label ..." + try { + $outTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name).tmp" + $errTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name)-err.tmp" + $proc = Start-Process npm.cmd -ArgumentList "cache", "add", $mcp.Pkg ` + -NoNewWindow -PassThru ` + -RedirectStandardOutput $outTmp ` + -RedirectStandardError $errTmp + $ok = Wait-ProcessWithUI $proc 60000 $label + if ($ok -and $proc.ExitCode -eq 0) { + Bw-Log "OK" "$label cached" + $mcpOK++ + } else { throw "exit=$($proc.ExitCode)" } + Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue + } catch { + Bw-Log "WARN" "$label failed: $_" + $mcpFail++ + } +} +Log-OK "npx 预缓存: $mcpOK/$($npxPackages.Count) 成功" + +# ── 6d: Playwright 浏览器安装 (非阻塞 UI) ── +Log-Info "Playwright 浏览器安装..." +try { + $pwBrowserPath = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright" + if (Test-Path (Join-Path $pwBrowserPath "chromium-*")) { + Log-OK "Playwright Chromium 已存在" + } else { + $outTmp = Join-Path $env:TEMP "bw-playwright.tmp" + $errTmp = Join-Path $env:TEMP "bw-playwright-err.tmp" + $pwProc = Start-Process npx.cmd -ArgumentList "-y", "playwright", "install", "chromium" ` + -NoNewWindow -PassThru ` + -RedirectStandardOutput $outTmp ` + -RedirectStandardError $errTmp + $pwOk = Wait-ProcessWithUI $pwProc 300000 "下载 Chromium (~150MB)" + Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue + if (-not $pwOk) { Log-Warn "Playwright 下载超时, 跳过" } + elseif (Test-Path (Join-Path $pwBrowserPath "chromium-*")) { + Log-OK "Playwright Chromium 安装成功" + } else { + Log-Warn "Playwright Chromium 安装可能未完成" + } + } +} catch { Log-Warn "Playwright 浏览器安装失败: $_ (不影响核心功能)" } + +# ── 6e: Python MCP (uvx) 验证 (非阻塞 UI) ── +if (Test-Cmd "uvx") { + Log-Info "Python MCP 验证 (uvx)..." + $uvxPackages = @( + # F-17 fix: uv tool install 参数顺序 = 包名在前, --python 在后 + @{ Name = "windows-mcp"; Args = @("tool", "install", "windows-mcp", "--python", "3.13") } + @{ Name = "atlassian"; Args = @("tool", "install", "mcp-atlassian") } + ) + foreach ($pkg in $uvxPackages) { + try { + $outTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name).tmp" + $errTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name)-err.tmp" + $installArgs = $pkg.Args + $proc = Start-Process uv -ArgumentList $installArgs ` + -NoNewWindow -PassThru ` + -RedirectStandardOutput $outTmp ` + -RedirectStandardError $errTmp + $ok = Wait-ProcessWithUI $proc 90000 "uvx $($pkg.Name)" + Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue + if ($ok) { Bw-Log "OK" "uvx $($pkg.Name) ready" } + } catch { + Bw-Log "WARN" "uvx $($pkg.Name): $_" + } + } +} else { + Bw-Log "INFO" "uvx 不可用, 跳过 Python MCP" +} + +# ── 6f: 可选 API Key 提示 ── +$optional = @( + @{ Key = "GITHUB_PERSONAL_ACCESS_TOKEN"; Name = "GitHub MCP" } + @{ Key = "FIRECRAWL_API_KEY"; Name = "Firecrawl MCP" } + @{ Key = "SLACK_BOT_TOKEN"; Name = "Slack MCP" } + @{ Key = "BROWSERBASE_API_KEY"; Name = "Browserbase MCP" } + @{ Key = "GEMINI_API_KEY"; Name = "MCP Image / Browserbase" } + @{ Key = "ATLASSIAN_API_TOKEN"; Name = "Atlassian MCP" } +) +$missingOpt = $optional | Where-Object { -not [System.Environment]::GetEnvironmentVariable($_.Key, "Process") } +if ($missingOpt.Count -gt 0) { + foreach ($m in $missingOpt) { Bw-Log "INFO" "可选 Key 未配置: $($m.Name) ($($m.Key))" } +} + +# ======================================================================== +# Phase 7: 环境加固 + 完成 + 启动 +# ======================================================================== +Log-Phase 7 "环境加固 + 启动" + +# ── 7a: claude 命令默认带 --dangerously-skip-permissions ── +# PS2EXE 下 $PROFILE 可能为 $null, 需先检查 +try { + # 构造 pwsh profile 路径 (不依赖 $PROFILE 自动变量, PS2EXE 下可能为空) + $pwshProfile = if ($PROFILE) { $PROFILE } + elseif ($PwshPath) { Join-Path (Split-Path $PwshPath -Parent) "profile.ps1" } + else { Join-Path "$env:USERPROFILE\Documents\PowerShell" "Microsoft.PowerShell_profile.ps1" } + + if ($pwshProfile) { + $profileDir = Split-Path $pwshProfile -Parent + if ($profileDir -and -not (Test-Path $profileDir)) { + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + } + $aliasLine = 'function claude { $exe = (Get-Command claude.exe -EA SilentlyContinue).Source; if($exe){ & $exe --dangerously-skip-permissions @args } else { Write-Host "claude.exe not found" } }' + $hasAlias = (Test-Path $pwshProfile) -and (Select-String -Path $pwshProfile -Pattern 'dangerously-skip-permissions' -Quiet -ErrorAction SilentlyContinue) + if (-not $hasAlias) { + Add-Content -Path $pwshProfile -Value "`n# Bookworm: claude 默认免权限确认`n$aliasLine" -Encoding utf8 + Bw-Log "OK" "PowerShell profile 已添加 claude alias" + } + } +} catch { Bw-Log "WARN" "7a claude alias 设置失败: $_" } + +# ── 7b: 清理 OAuth 登录 (防止与 relay key 冲突) ── +try { + $credFile = Join-Path $ClaudeDir ".credentials.json" + if ($env:ANTHROPIC_BASE_URL -and (Test-Path $credFile)) { + $credContent = Get-Content $credFile -Raw -ErrorAction SilentlyContinue + if ($credContent -match '"claudeAiOauth"') { + Remove-Item $credFile -Force -ErrorAction SilentlyContinue + Bw-Log "OK" "已清理 OAuth 登录凭证 (改用中转站 relay key)" + } + } +} catch { Bw-Log "WARN" "7b OAuth 清理失败: $_" } + +# ── 7c: 自动修复 .claude 仓库冲突 ── +try { + $claudeGit = Join-Path $ClaudeDir ".git" + if (Test-Path $claudeGit) { + # F-19 fix: 逐行匹配冲突状态 (Out-String 合并后 ^ 不匹配行内) + $gitLines = & git -C $ClaudeDir status --porcelain 2>&1 + $hasConflict = $gitLines | Where-Object { $_ -match '^U|^.U' } + if ($hasConflict) { + & git -C $ClaudeDir checkout --theirs . 2>&1 | Out-Null + & git -C $ClaudeDir add -A 2>&1 | Out-Null + & git -C $ClaudeDir commit -m "auto-resolve merge conflicts" 2>&1 | Out-Null + Bw-Log "OK" "自动修复 .claude 仓库合并冲突" + } + } +} catch { Bw-Log "WARN" "7c 冲突修复失败: $_" } + +# 关闭进度窗口 +Close-ProgressForm + +# 创建桌面快捷方式 +New-DesktopShortcuts + +if ($allOK -and $env:ANTHROPIC_API_KEY) { + Bw-Log "DONE" "v$BWVersion 安装成功 ($skillCount Skills / $hookCount Hooks)" + + # ═══ 祝贺闪屏 (2.5 秒自动消失) ═══ + $splash = New-Object System.Windows.Forms.Form + $splash.FormBorderStyle = "None" + $splash.StartPosition = "CenterScreen" + $splash.Size = New-Object System.Drawing.Size(480, 300) + $splash.BackColor = [System.Drawing.Color]::FromArgb(24, 25, 38) + $splash.TopMost = $true + $splash.ShowInTaskbar = $false + $splash.Opacity = 0.0 + + # 品牌蓝紫装饰条 + $topBar = New-Object System.Windows.Forms.Panel + $topBar.Location = New-Object System.Drawing.Point(0, 0) + $topBar.Size = New-Object System.Drawing.Size(480, 4) + $topBar.BackColor = [System.Drawing.Color]::FromArgb(88, 101, 242) + $splash.Controls.Add($topBar) + + # 大勾图标 + $checkLabel = New-Object System.Windows.Forms.Label + $checkLabel.Location = New-Object System.Drawing.Point(0, 35) + $checkLabel.Size = New-Object System.Drawing.Size(480, 55) + $checkLabel.Text = [char]0x2714 + $checkLabel.Font = New-Object System.Drawing.Font("Segoe UI", 36) + $checkLabel.ForeColor = [System.Drawing.Color]::FromArgb(46, 160, 67) + $checkLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $splash.Controls.Add($checkLabel) + + # 主标题 + $mainTitle = New-Object System.Windows.Forms.Label + $mainTitle.Location = New-Object System.Drawing.Point(0, 95) + $mainTitle.Size = New-Object System.Drawing.Size(480, 38) + $mainTitle.Text = "Bookworm v$BWVersion 安装成功" + $mainTitle.Font = New-Object System.Drawing.Font("Segoe UI", 18, [System.Drawing.FontStyle]::Bold) + $mainTitle.ForeColor = [System.Drawing.Color]::White + $mainTitle.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $splash.Controls.Add($mainTitle) + + # 副标题 + $subTitle = New-Object System.Windows.Forms.Label + $subTitle.Location = New-Object System.Drawing.Point(0, 140) + $subTitle.Size = New-Object System.Drawing.Size(480, 28) + $subTitle.Text = "$skillCount Skills / $hookCount Hooks / 全部就绪" + $subTitle.Font = New-Object System.Drawing.Font("Segoe UI", 11) + $subTitle.ForeColor = [System.Drawing.Color]::FromArgb(160, 170, 200) + $subTitle.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $splash.Controls.Add($subTitle) + + # 祝福语 + $wish = New-Object System.Windows.Forms.Label + $wish.Location = New-Object System.Drawing.Point(0, 190) + $wish.Size = New-Object System.Drawing.Size(480, 30) + $wish.Text = "善读者,必善造。使用愉快!" + $wish.Font = New-Object System.Drawing.Font("Segoe UI", 12) + $wish.ForeColor = [System.Drawing.Color]::FromArgb(88, 101, 242) + $wish.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $splash.Controls.Add($wish) + + # 底部提示 + $hint = New-Object System.Windows.Forms.Label + $hint.Location = New-Object System.Drawing.Point(0, 250) + $hint.Size = New-Object System.Drawing.Size(480, 22) + $hint.Text = "双击桌面 Bookworm 图标即可随时启动" + $hint.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $hint.ForeColor = [System.Drawing.Color]::FromArgb(100, 110, 130) + $hint.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $splash.Controls.Add($hint) + + # 底部装饰条 + $bottomBar = New-Object System.Windows.Forms.Panel + $bottomBar.Location = New-Object System.Drawing.Point(0, 296) + $bottomBar.Size = New-Object System.Drawing.Size(480, 4) + $bottomBar.BackColor = [System.Drawing.Color]::FromArgb(88, 101, 242) + $splash.Controls.Add($bottomBar) + + # 淡入动画 + 定时关闭 + $splash.Show() + for ($i = 0; $i -le 10; $i++) { + $splash.Opacity = $i / 10.0 + $splash.Refresh() + Start-Sleep -Milliseconds 30 + } + # 停留 2.5 秒 + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while ($sw.ElapsedMilliseconds -lt 2500) { + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 50 + } + # 淡出 + for ($i = 10; $i -ge 0; $i--) { + $splash.Opacity = $i / 10.0 + $splash.Refresh() + Start-Sleep -Milliseconds 25 + } + $splash.Close() + $splash.Dispose() + + # v3.0.1: Bun crash 预检 (Claude Code 2.1.x 内置 Bun, 部分 Win10/CPU 触发 Illegal instruction) + # 先静默跑 `claude --version` 测试 3 秒, 若 crash 自动降到 2.0.1 纯 Node 版 + $claudeVerOK = $false + try { + # 加超时: Bun crash 可能导致 claude --version 挂起不退出, -Wait 会卡死 + $proc = Start-Process -FilePath "claude" -ArgumentList "--version" -NoNewWindow -PassThru -RedirectStandardOutput "$env:TEMP\bw-claude-ver.txt" -RedirectStandardError "$env:TEMP\bw-claude-err.txt" -WindowStyle Hidden + $exited = $proc.WaitForExit(8000) # 8 秒硬超时 + if (-not $exited) { + try { $proc.Kill() } catch {} + Log-Warn "Claude Code --version 超过 8 秒无响应 (疑似 Bun 卡死)" + } elseif ($proc.ExitCode -eq 0) { + $out = Get-Content "$env:TEMP\bw-claude-ver.txt" -Raw -ErrorAction SilentlyContinue + if ($out -match "\d+\.\d+\.\d+") { $claudeVerOK = $true; Log-OK "Claude Code 自检: $($Matches[0])" } + } else { + Log-Warn "Claude Code 自检退出码非 0: $($proc.ExitCode) (可能 Bun Illegal instruction)" + } + Remove-Item "$env:TEMP\bw-claude-ver.txt","$env:TEMP\bw-claude-err.txt" -EA SilentlyContinue + } catch {} + if (-not $claudeVerOK) { + Log-Warn "Claude Code 2.1.x Bun 自检失败 (可能 Illegal instruction), 自动降级到 2.0.1 纯 Node 版..." + Run-CmdWithUI "npm" @("uninstall", "-g", "@anthropic-ai/claude-code") "卸载现版" 120000 | Out-Null + $r = Run-CmdWithUI "npm" @("install", "-g", "@anthropic-ai/claude-code@2.0.1") "安装 2.0.1 纯 Node 版" 180000 + if ($r.OK) { Log-OK "Claude Code 降级到 2.0.1 (Bun crash 规避)" } else { Log-Warn "降级失败, 仍尝试原版启动" } + } + + # v3.0.1: 启动 Bookworm — 彻底绕开 .bat + claude.cmd shim + # 直接调 claude.ps1 (npm 全局装时生成, PS 原生, 不会经 cmd.exe) + # 若没 claude.ps1 (极旧 npm), 回退 node 直接跑 claude.js + if (-not $SkipLaunch) { + $claudeShim = "$env:APPDATA\npm\claude.ps1" + $claudeJsRel = "node_modules\@anthropic-ai\claude-code\cli.js" + $claudeJs = "$env:APPDATA\npm\$claudeJsRel" + # 构造 pwsh 里跑 claude 的命令 (不用 cmd shim) + $launchCmd = if (Test-Path $claudeShim) { + "& '$claudeShim' --dangerously-skip-permissions" + } elseif (Test-Path $claudeJs) { + "& node '$claudeJs' --dangerously-skip-permissions" + } else { + # 最后回退: 让 pwsh 的 Get-Command 找 claude (可能仍走 .cmd) + "claude --dangerously-skip-permissions" + } + # 默认模型双保险 + $launchCmd = "`$env:ANTHROPIC_MODEL='claude-opus-4-7'; $launchCmd" + $shellExe = if ($PwshPath -and (Test-Path $PwshPath)) { $PwshPath } else { "powershell" } + if (Get-Command wt.exe -ErrorAction SilentlyContinue) { + Start-Process wt.exe -ArgumentList "new-tab", "-d", $BootDir, "--title", "Bookworm v$BWVersion", $shellExe, "-NoLogo", "-NoExit", "-Command", $launchCmd + } else { + Start-Process $shellExe -ArgumentList "-NoLogo", "-NoExit", "-Command", "cd '$BootDir'; $launchCmd" -WorkingDirectory $BootDir + } + } + +} else { + Bw-Log "DONE" "安装完成但部分受限 allOK=$allOK hasKey=$($env:ANTHROPIC_API_KEY -ne $null)" + + $issues = @() + if (-not $allOK) { $issues += "- Bookworm 配置不完整" } + if (-not $env:ANTHROPIC_API_KEY) { $issues += "- API 凭证未解密" } + $issueText = $issues -join "`n" + + $launchResult = Show-MsgBox "安装完成, 但存在以下问题:`n$issueText`n`n是否仍然启动 Claude Code?`n(将以受限模式运行)`n`n日志: $BWLogFile" "安装警告" "YesNo" "Warning" + if ($launchResult -eq "Yes" -and -not $SkipLaunch) { + $claudeCmd = "claude --dangerously-skip-permissions" + # v3.0.1: 统一用 pwsh (PS7) 启动, 不再用 cmd.exe + $shellExe = if ($PwshPath -and (Test-Path $PwshPath)) { $PwshPath } else { "powershell" } + if (Get-Command wt.exe -ErrorAction SilentlyContinue) { + Start-Process wt.exe -ArgumentList "new-tab", "--title", "Bookworm", $shellExe, "-NoExit", "-Command", $claudeCmd + } else { + Start-Process $shellExe -ArgumentList "-NoExit", "-Command", $claudeCmd + } + } +}