bookworm-boot/auto-setup.ps1
bookworm b4d3f4de24 fix: 凭证解密优先用 node crypto-helper.js (BWENC1 格式)
secrets.enc 由 crypto-helper.js 加密(BWENC1 格式),
openssl 命令行无法解密(报 bad magic number)。
三个脚本统一修复: 优先 node crypto-helper.js, 回退 openssl。

影响: auto-setup.ps1 / Bookworm-Setup.sh / Bookworm-OneClick-Mac.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:04:02 +08:00

755 lines
32 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<#
.SYNOPSIS
Bookworm Portable - 全自动一键安装器
.DESCRIPTION
全新电脑从零到 Bookworm 完全就绪,最大程度自动化。
7 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 启动
需要人工输入时弹出 GUI 对话框。
.USAGE
.\auto-setup.ps1
.\auto-setup.ps1 -SkipLaunch # 安装但不启动
#>
param(
[switch]$SkipLaunch
)
$ErrorActionPreference = "Stop"
# ─── 路径定义 ────────────────────────────────────────
$ScriptDir = if ($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-config.git"
$BootUrl = "https://code.letcareme.com/bookworm/bookworm-boot.git"
$SecretsEnc = Join-Path $ScriptDir "secrets.enc"
$TOTAL_PHASES = 7
# ─── GUI 初始化 ─────────────────────────────────────
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
# ─── 颜色输出 ────────────────────────────────────────
function Log-OK($msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
function Log-Info($msg) { Write-Host " [..] $msg" -ForegroundColor Cyan }
function Log-Warn($msg) { Write-Host " [!] $msg" -ForegroundColor Yellow }
function Log-Fail($msg) { Write-Host " [!!] $msg" -ForegroundColor Red }
function Log-Phase($n, $title) {
Write-Host ""
Write-Host " [$n/$TOTAL_PHASES] $title" -ForegroundColor White -BackgroundColor DarkBlue
Write-Progress -Activity "Bookworm 自动安装" -Status "$title" -PercentComplete ([int]($n / $TOTAL_PHASES * 100))
}
function Test-Cmd($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) }
# ─── GUI 对话框 ─────────────────────────────────────
function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon = "Information") {
[System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon)
}
function Show-PasswordDialog($prompt = "输入主密码解密凭证", $attempt = 1, $maxAttempts = 3) {
$form = New-Object System.Windows.Forms.Form
$form.Text = "Bookworm - 凭证解密 ($attempt/$maxAttempts)"
$form.Size = New-Object System.Drawing.Size(420, 220)
$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, 20)
$label.Size = New-Object System.Drawing.Size(360, 40)
$label.Text = $prompt
$label.Font = New-Object System.Drawing.Font("Segoe UI", 10)
$form.Controls.Add($label)
$passBox = New-Object System.Windows.Forms.TextBox
$passBox.Location = New-Object System.Drawing.Point(20, 70)
$passBox.Size = New-Object System.Drawing.Size(360, 30)
$passBox.PasswordChar = '*'
$passBox.Font = New-Object System.Drawing.Font("Consolas", 12)
$form.Controls.Add($passBox)
$btnOK = New-Object System.Windows.Forms.Button
$btnOK.Location = New-Object System.Drawing.Point(200, 120)
$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(300, 120)
$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)
$form.Add_Shown({ $passBox.Focus() })
$result = $form.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
return $passBox.Text
}
return $null
}
function Show-GiteaCredentialDialog {
$form = New-Object System.Windows.Forms.Form
$form.Text = "Bookworm - Gitea 登录"
$form.Size = New-Object System.Drawing.Size(420, 280)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false
$form.TopMost = $true
$lblInfo = New-Object System.Windows.Forms.Label
$lblInfo.Location = New-Object System.Drawing.Point(20, 15)
$lblInfo.Size = New-Object System.Drawing.Size(360, 25)
$lblInfo.Text = "输入 Gitea 账号 (code.letcareme.com)"
$lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 10)
$form.Controls.Add($lblInfo)
$lblUser = New-Object System.Windows.Forms.Label
$lblUser.Location = New-Object System.Drawing.Point(20, 50)
$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, 48)
$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, 90)
$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, 88)
$txtPass.Size = New-Object System.Drawing.Size(280, 25)
$txtPass.PasswordChar = '*'
$txtPass.Font = New-Object System.Drawing.Font("Consolas", 11)
$form.Controls.Add($txtPass)
$btnOK = New-Object System.Windows.Forms.Button
$btnOK.Location = New-Object System.Drawing.Point(200, 140)
$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(300, 140)
$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)
$form.Add_Shown({ $txtUser.Focus() })
$result = $form.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK -and $txtUser.Text -and $txtPass.Text) {
return @{ User = $txtUser.Text; 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
}
# ─── 凭证缓存 (Windows Credential Manager) ─────────
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
foreach ($p in $props.PSObject.Properties) {
if ($p.Name -match '^[A-Z_]+$') {
[System.Environment]::SetEnvironmentVariable($p.Name, $p.Value, "Process")
$loaded++
}
}
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 }
$keys = @("ANTHROPIC_API_KEY","ANTHROPIC_BASE_URL","GITHUB_PERSONAL_ACCESS_TOKEN",
"SLACK_BOT_TOKEN","ATLASSIAN_API_TOKEN","BROWSERBASE_API_KEY","FIRECRAWL_API_KEY")
foreach ($k in $keys) {
$v = [System.Environment]::GetEnvironmentVariable($k, "Process")
if ($v) { Set-ItemProperty $regPath -Name $k -Value $v -Force }
}
Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToString("o") -Force
} catch {}
}
# ─── 桌面快捷方式 ──────────────────────────────────
function New-DesktopShortcuts {
try {
$shell = New-Object -ComObject WScript.Shell
$desktop = $shell.SpecialFolders("Desktop")
# 快速启动
$shortcut = $shell.CreateShortcut("$desktop\Bookworm.lnk")
$batPath = Join-Path $ScriptDir "启动Bookworm.bat"
if (-not (Test-Path $batPath)) { $batPath = Join-Path $ScriptDir "Bookworm-OneClick.bat" }
$shortcut.TargetPath = $batPath
$shortcut.WorkingDirectory = $ScriptDir
$shortcut.Description = "Bookworm Smart Assistant"
$shortcut.Save()
# 更新启动
$shortcut2 = $shell.CreateShortcut("$desktop\更新Bookworm.lnk")
$updateBat = Join-Path $ScriptDir "更新并启动Bookworm.bat"
if (Test-Path $updateBat) {
$shortcut2.TargetPath = $updateBat
$shortcut2.WorkingDirectory = $ScriptDir
$shortcut2.Description = "更新并启动 Bookworm"
$shortcut2.Save()
}
Log-OK "桌面快捷方式已创建"
} catch { Log-Warn "快捷方式创建失败: $_" }
}
# ========================================================================
# Banner
# ========================================================================
Write-Host ""
Write-Host " +---------------------------------------------------+" -ForegroundColor Cyan
Write-Host " | |" -ForegroundColor Cyan
Write-Host " | Bookworm Portable - 全自动安装器 |" -ForegroundColor Cyan
Write-Host " | 92 Skills / 18 Agents / 34 Hooks |" -ForegroundColor Cyan
Write-Host " | |" -ForegroundColor Cyan
Write-Host " +---------------------------------------------------+" -ForegroundColor Cyan
Write-Host ""
# ========================================================================
# 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 }
@{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null }
@{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code" }
)
$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 {
$output = winget install $dep.WingetId --accept-source-agreements --accept-package-agreements 2>&1
$output | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
# 刷新 PATH
$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 {
Write-Host " npm i -g $($dep.NpmPkg) ..." -ForegroundColor Gray
npm i -g $dep.NpmPkg 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
$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) {
Log-Fail "$($dep.Name) 需要手动安装 (winget 不可用)"
Show-MsgBox "$($dep.Name) 未安装且 winget 不可用。`n请手动安装后重新运行。`n`nNode.js: https://nodejs.org`nGit: https://git-scm.com" "缺少依赖" "OK" "Error"
}
}
}
# Claude Code 依赖 npm, 需要在 Node.js 安装后再检查
if (-not (Test-Cmd "claude") -and (Test-Cmd "npm")) {
Log-Info "安装 Claude Code..."
npm i -g @anthropic-ai/claude-code 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
$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 安装失败" }
}
# OpenSSL (随 Git 安装)
$opensslCmd = Find-OpenSSL
if ($opensslCmd) { Log-OK "OpenSSL: $opensslCmd" } else { Log-Warn "OpenSSL 未找到 (凭证解密可能失败)" }
# 最终检查
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请手动安装后重新运行。" "安装中断" "OK" "Error"
exit 1
}
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"
# 连通性测试
Write-Host ""
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 配置"
# 配置 git credential helper
git config --global credential.helper store 2>$null
# 克隆/更新 config 仓库 (.claude/)
if (Test-Path (Join-Path $ClaudeDir ".git")) {
Log-Info "配置仓库已存在, 更新中..."
Push-Location $ClaudeDir
try {
$stash = git stash 2>&1
git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
if ($stash -notmatch 'No local changes') { git stash pop 2>&1 | Out-Null }
Log-OK "配置仓库已更新"
} catch { Log-Warn "git pull 失败, 使用本地版本" }
finally { Pop-Location }
}
elseif (Test-Path $ClaudeDir) {
# 已有 .claude 但非 git — 备份后克隆
Log-Info "备份现有 .claude/ 并克隆..."
if (Test-Path $BackupDir) { Remove-Item $BackupDir -Recurse -Force }
Rename-Item $ClaudeDir $BackupDir
# 可能需要 Gitea 凭证
$cred = Show-GiteaCredentialDialog
if ($cred) {
$credUrl = $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@"
git clone --depth 1 $credUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
} else {
git clone --depth 1 $GitUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
}
if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) {
Log-OK "配置仓库克隆成功 (旧目录已备份)"
} 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
if ($cred) {
$credUrl = $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@"
git clone --depth 1 $credUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
} else {
git clone --depth 1 $GitUrl $ClaudeDir 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
}
if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) {
Log-OK "配置仓库克隆成功"
} 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 }
}
# ========================================================================
# Phase 4: 凭证解密 (GUI 弹窗)
# ========================================================================
Log-Phase 4 "凭证解密"
$secretsDecrypted = $false
# 先查缓存
if (Get-CachedSecrets) {
Log-OK "从本日缓存加载凭证 (免密)"
$secretsDecrypted = $true
}
# 再解密
$cryptoHelper = Join-Path $ScriptDir "crypto-helper.js"
$useNode = (Test-Cmd "node") -and (Test-Path $cryptoHelper)
if (-not $useNode -and -not $opensslCmd) {
Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)"
}
elseif (Test-Path $SecretsEnc) {
for ($attempt = 1; $attempt -le 3; $attempt++) {
$password = Show-PasswordDialog "输入主密码解密凭证`n(非 Gitea 密码, 区分大小写)" $attempt 3
if (-not $password) {
Log-Warn "用户取消密码输入"
break
}
try {
if ($useNode) {
# Node.js crypto-helper (BWENC1 格式, 跨平台一致)
$decrypted = & node $cryptoHelper decrypt $password $SecretsEnc 2>&1
$decExit = $LASTEXITCODE
} else {
# OpenSSL 回退 (仅支持 Salted__ 格式)
$decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass "pass:$password" 2>$null
$decExit = $LASTEXITCODE
}
$password = $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()
if ($key -and $value) {
[System.Environment]::SetEnvironmentVariable($key, $value, "Process")
Log-OK "已注入: $key"
$count++
}
}
$decrypted = $null # 清零
$secretsDecrypted = $true
# 询问缓存
$cacheResult = Show-MsgBox "凭证解密成功 ($count 个变量)。`n`n是否缓存至今日 23:59`n(下次启动免输密码)" "本日免密" "YesNo" "Question"
if ($cacheResult -eq "Yes") {
Save-SecretsToCache
Log-OK "凭证已缓存至今日 23:59"
}
break
} else {
$password = $null
if ($attempt -lt 3) {
Show-MsgBox "密码错误, 剩余重试: $(3 - $attempt)" "密码错误" "OK" "Warning"
} else {
Show-MsgBox "3 次密码均错误。`n凭证未解密, Claude Code 可能无法启动。`n请联系管理员确认密码。" "解密失败" "OK" "Error"
}
}
} catch {
$password = $null
Log-Warn "解密异常: $_"
}
}
}
elseif (-not (Test-Path $SecretsEnc)) {
Log-Warn "secrets.enc 不存在, 跳过凭证解密"
}
# ========================================================================
# 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
$content = Get-Content $templateFile -Raw
$content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
$content = $content -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\')
Set-Content $settingsFile -Value $content -Encoding UTF8
Log-OK "settings.json 已渲染 (ROOT=$claudeRoot)"
# 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\}\}', ($homeDir -replace '\\', '\\')
$lc = $lc -replace '\{\{USERNAME\}\}', $env:USERNAME
Set-Content $localSet -Value $lc -Encoding UTF8
Log-OK "settings.local.json 已渲染"
}
} else {
Log-Warn "settings.template.json 不存在, 跳过渲染"
}
$env:CLAUDE_HOME = $ClaudeDir
# ========================================================================
# Phase 6: MCP 验证 + 自动安装
# ========================================================================
Log-Phase 6 "MCP 服务验证"
# 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 }
}
# API Key 检查
Write-Host ""
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 未配置 (将使用默认)" }
# MCP 依赖自动安装
Write-Host ""
Log-Info "MCP 依赖检查..."
# Playwright
$hasPlaywright = $false
try {
$pwCheck = npm list -g @anthropic-ai/mcp-playwright 2>$null
if ($pwCheck -and $pwCheck -notmatch 'empty') { $hasPlaywright = $true }
} catch {}
if ($hasPlaywright) {
Log-OK "Playwright MCP 已安装"
} else {
Log-Info "安装 Playwright MCP..."
try {
npm i -g @anthropic-ai/mcp-playwright 2>&1 | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Log-OK "Playwright MCP 安装完成"
} catch { Log-Warn "Playwright MCP 安装失败 (不影响核心功能)" }
}
# 可选 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" }
)
$missingOpt = $optional | Where-Object { -not [System.Environment]::GetEnvironmentVariable($_.Key, "Process") }
if ($missingOpt.Count -gt 0) {
Write-Host ""
Write-Host " 可选 MCP (未配置, 不影响核心功能):" -ForegroundColor DarkGray
foreach ($m in $missingOpt) { Write-Host " [-] $($m.Name)" -ForegroundColor DarkGray }
}
# ========================================================================
# Phase 7: 完成 + 启动
# ========================================================================
Log-Phase 7 "安装完成"
Write-Progress -Activity "Bookworm 自动安装" -Completed
# 创建桌面快捷方式
New-DesktopShortcuts
Write-Host ""
if ($allOK -and $env:ANTHROPIC_API_KEY) {
Write-Host " +---------------------------------------------------+" -ForegroundColor Green
Write-Host " | |" -ForegroundColor Green
Write-Host " | Bookworm Portable 安装成功! |" -ForegroundColor Green
Write-Host " | $skillCount Skills / $hookCount Hooks / 全部就绪 |" -ForegroundColor Green
Write-Host " | |" -ForegroundColor Green
Write-Host " +---------------------------------------------------+" -ForegroundColor Green
if (-not $SkipLaunch) {
Write-Host ""
Log-Info "正在启动 Claude Code..."
Write-Host ""
& claude
}
} else {
Write-Host " +---------------------------------------------------+" -ForegroundColor Yellow
Write-Host " | |" -ForegroundColor Yellow
Write-Host " | 安装完成 (部分功能可能受限) |" -ForegroundColor Yellow
Write-Host " | |" -ForegroundColor Yellow
Write-Host " +---------------------------------------------------+" -ForegroundColor Yellow
$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(将以受限模式运行)" "安装警告" "YesNo" "Warning"
if ($launchResult -eq "Yes" -and -not $SkipLaunch) {
& claude
}
}