bookworm-boot/auto-setup.ps1
bookworm 7d60911f65 perf(installer): 消除全部 UI 冻结 + Write-Host 清零
## P0: 子进程 UI 阻塞 → 非阻塞轮询
- 新增 Wait-ProcessWithUI: 替代所有 WaitForExit(), 每 200ms
  泵 DoEvents(), 进度窗口保持响应, 显示等待计时
- 新增 Run-CmdWithUI: 替代 & cmd 2>&1 | ForEach-Object 模式,
  所有子进程输出走临时文件→日志, 不阻塞 UI

## P1: Phase 3 git clone/pull 无超时 → 带超时 + UI 泵
- 9 处 git 调用全部改为 Run-CmdWithUI (120s/180s 超时)
- 消除 Push-Location/Pop-Location, 改用 git -C <dir>

## P1: Phase 1 winget/npm 阻塞 → 带超时 + UI 泵
- winget install: 5 分钟超时 + 进度状态显示
- npm i -g: 2-3 分钟超时 + 进度状态显示
- Claude Code npm install: 3 分钟超时

## P2: Write-Host 归零
- 27 处非注释 Write-Host 全部替换为 Bw-Log + Update-Progress-SubStatus
- EXE 在 PS2EXE -NoOutput 下不再有任何静默丢失的输出

验证: 7/7 补丁字符串确认编译; Write-Host 仅剩 1 处 (注释内)

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

1138 lines
50 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"
# ─── 路径定义 ────────────────────────────────────────
# 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-config.git"
$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 {
$global:BWProgressForm = New-Object System.Windows.Forms.Form
$global:BWProgressForm.Text = "Bookworm Portable Setup"
$global:BWProgressForm.Size = New-Object System.Drawing.Size(520, 200)
$global:BWProgressForm.StartPosition = "CenterScreen"
$global:BWProgressForm.FormBorderStyle = "FixedDialog"
$global:BWProgressForm.MaximizeBox = $false
$global:BWProgressForm.MinimizeBox = $false
$global:BWProgressForm.TopMost = $true
$global:BWProgressForm.ControlBox = $false
$titleLabel = New-Object System.Windows.Forms.Label
$titleLabel.Location = New-Object System.Drawing.Point(20, 18)
$titleLabel.Size = New-Object System.Drawing.Size(480, 24)
$titleLabel.Text = "Bookworm 智能助手 — 自动安装中, 请稍候..."
$titleLabel.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Bold)
$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("Segoe UI", 10)
$global:BWPhaseLabel.ForeColor = [System.Drawing.Color]::DarkBlue
$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("Segoe UI", 9)
$global:BWStatusLabel.ForeColor = [System.Drawing.Color]::DimGray
$global:BWProgressForm.Controls.Add($global:BWStatusLabel)
$global:BWProgressBar = New-Object System.Windows.Forms.ProgressBar
$global:BWProgressBar.Location = New-Object System.Drawing.Point(20, 110)
$global:BWProgressBar.Size = New-Object System.Drawing.Size(480, 22)
$global:BWProgressBar.Minimum = 0
$global:BWProgressBar.Maximum = $TOTAL_PHASES
$global:BWProgressBar.Value = 0
$global:BWProgressForm.Controls.Add($global:BWProgressBar)
$hint = New-Object System.Windows.Forms.Label
$hint.Location = New-Object System.Drawing.Point(20, 140)
$hint.Size = New-Object System.Drawing.Size(480, 18)
$hint.Text = "首次安装约 5-10 分钟 (依赖下载) | 日志: $BWLogFile"
$hint.Font = New-Object System.Drawing.Font("Segoe UI", 8)
$hint.ForeColor = [System.Drawing.Color]::Gray
$global:BWProgressForm.Controls.Add($hint)
$global:BWProgressForm.Show() | Out-Null
$global:BWProgressForm.Refresh()
[System.Windows.Forms.Application]::DoEvents()
}
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
$global:BWStatusLabel.Refresh()
[System.Windows.Forms.Application]::DoEvents()
} catch {}
}
}
function Close-ProgressForm {
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 内容
)
Bw-Log "CMD" "$exe $($arguments -join ' ')"
Update-Progress-SubStatus $label
$outFile = Join-Path $env:TEMP "bw-cmd-out-$(Get-Random).tmp"
$errFile = Join-Path $env:TEMP "bw-cmd-err-$(Get-Random).tmp"
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(80, 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
}
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")
# 桌面专用图标 (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 }
@{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null; PipPkg = $null }
@{ Name = "PowerShell 7"; Cmd = "pwsh"; WingetId = "Microsoft.PowerShell"; NpmPkg = $null; PipPkg = $null }
@{ Name = "Python 3.12"; Cmd = "python"; WingetId = "Python.Python.3.12"; NpmPkg = $null; PipPkg = $null }
@{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code"; PipPkg = $null }
)
$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) {
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..."
$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 包管理器, 可选)..."
# 静默执行子进程, 错误全部捕获到日志文件, 绝不冒泡到 PS2EXE
$prevErrPref = $ErrorActionPreference
$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")
# winget 装到 %LOCALAPPDATA%\Microsoft\WinGet\Packages\astral-sh.uv_*
$uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links"
if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" }
if (Test-Cmd "uv") { $uvInstalled = $true }
}
# 方案 B: Astral 官方一行脚本 (走 https://astral.sh/uv/install.ps1)
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 }
# Astral 脚本默认装到 %USERPROFILE%\.local\bin
$localBin = Join-Path $env:USERPROFILE ".local\bin"
if (Test-Path $localBin) { $env:Path += ";$localBin" }
if (Test-Cmd "uv") { $uvInstalled = $true }
}
# 方案 C: pip fallback (Python 已装且前两个都失败)
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 }
}
$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 未找到 (凭证解密可能失败)" }
# 最终检查 (核心四件套必须)
if (-not (Test-Cmd "node") -or -not (Test-Cmd "git") -or -not (Test-Cmd "claude") -or -not (Test-Cmd "pwsh")) {
$missing = @()
if (-not (Test-Cmd "node")) { $missing += "Node.js" }
if (-not (Test-Cmd "git")) { $missing += "Git" }
if (-not (Test-Cmd "pwsh")) { $missing += "PowerShell 7" }
if (-not (Test-Cmd "claude")) { $missing += "Claude Code" }
Show-MsgBox "以下核心依赖安装失败: $($missing -join ', ')`n`n请手动安装后重新运行。" "安装中断" "OK" "Error"
exit 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 配置"
# 配置 git credential helper
git config --global credential.helper store 2>$null
# 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结
if (Test-Path (Join-Path $ClaudeDir ".git")) {
Log-Info "配置仓库已存在, 更新中..."
try {
Run-CmdWithUI "git" @("-C", $ClaudeDir, "stash") "git stash" 15000 | Out-Null
$r = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase") "同步配置仓库" 120000
if ($r.OK) { Log-OK "配置仓库已更新" } else { Log-Warn "git pull 失败, 使用本地版本" }
Run-CmdWithUI "git" @("-C", $ClaudeDir, "stash", "pop") "git stash pop" 15000 | Out-Null
} 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 '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000
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
$cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000
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 }
}
# ─── 克隆/更新 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 仓库 (含解密工具与凭证)..."
if (-not $cred) { $cred = Show-GiteaCredentialDialog }
$bootCloneUrl = if ($cred) { $BootUrl -replace '://', "://$($cred.User):$($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 "boot 仓库克隆失败 (crypto-helper.js 缺失)"
Show-MsgBox "boot 仓库克隆失败。`n请检查网络和 Gitea 凭证。" "克隆失败" "OK" "Error"
exit 1
}
Log-OK "boot 仓库克隆成功 → $BootDir"
}
# ========================================================================
# Phase 4: 凭证解密 (GUI 弹窗)
# ========================================================================
Log-Phase 4 "凭证解密"
$secretsDecrypted = $false
# 先查缓存
if (Get-CachedSecrets) {
Log-OK "从本日缓存加载凭证 (免密)"
$secretsDecrypted = $true
}
# 再解密 (缓存命中则跳过)
if (-not $secretsDecrypted) {
$cryptoHelper = Join-Path $BootDir "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) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) {
$validAttempts = 0
while ($validAttempts -lt 3) {
$rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3
if (-not $rawCode) {
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
}
$validAttempts++
# 按 token 前8位定位 .enc 文件 (多用户独立 Key),回退 secrets.enc
$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并重新运行安装器(会自动拉取)。" "文件未找到" "OK" "Warning"
$token = $null
continue
}
try {
if ($useNode) {
$decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1
$decExit = $LASTEXITCODE
} else {
$decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $encFile -pass "pass:$token" 2>$null
$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()
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 {
$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 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
$content = $content -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\')
$content = $content -replace '\{\{PWSH_PATH\}\}', ($pwshJsonPath -replace '\\', '\\')
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\}\}', ($homeDir -replace '\\', '\\')
$lc = $lc -replace '\{\{USERNAME\}\}', $env:USERNAME
$lc = $lc -replace '\{\{PWSH_PATH\}\}', ($pwshJsonPath -replace '\\', '\\')
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 服务验证 + 预安装"
# ── 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 = @(
@{ Name = "windows-mcp"; Args = @("--python", "3.13", "windows-mcp") }
@{ Name = "atlassian"; Args = @("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 = @("tool", "install") + $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 "安装完成"
# 关闭 GUI 进度窗口 (后续由 Show-MsgBox 显示成功/失败)
Close-ProgressForm
# 创建桌面快捷方式
New-DesktopShortcuts
if ($allOK -and $env:ANTHROPIC_API_KEY) {
Bw-Log "DONE" "安装成功 ($skillCount Skills / $hookCount Hooks)"
Show-MsgBox "Bookworm Portable 安装成功!`n`n$skillCount Skills / $hookCount Hooks 全部就绪`n`n点击确定后将自动启动 Claude Code。`n`n日志: $BWLogFile" "安装成功" "OK" "Information" | Out-Null
if (-not $SkipLaunch) {
# 启动 Claude Code: 用户的 Claude Code 由 cmd 窗口启动 (PS2EXE -NoConsole 下会创建新窗口)
Start-Process -FilePath "cmd.exe" -ArgumentList "/k", "claude --dangerously-skip-permissions"
}
} 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) {
Start-Process -FilePath "cmd.exe" -ArgumentList "/k", "claude --dangerously-skip-permissions"
}
}