bookworm-boot/auto-setup.ps1
bookworm 47079cb8b1 fix(v3.2.0): 移除 Bun crash 降级逻辑 (根因: 安装后 claude 被降到 v2.0.1)
- 删除 claude --version 自检 → 自动降级到 2.0.1 的逻辑
- Claude Code 2.1.x+ 已不使用 Bun, 降级完全不必要
- 此逻辑导致对方机器 claude 被降到 v2.0.1 (远古版本)

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

2785 lines
140 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 完全就绪,最大程度自动化。
8 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 环境加固 → OTA 基础设施 → 启动
需要人工输入时弹出 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"
# ─── v3.0.7: 顶层 trap 防静默闪退 ───────────────────
# 场景: PS2EXE 包装的 .exe 在 ErrorActionPreference=Stop 模式下, 任何未处理
# cmdlet/方法异常直接结束进程. 无 trap 则用户看到"安装到一半窗口消失",
# 无错误信息可报障. 此 trap 把异常写崩溃日志 + 弹 GUI 让用户截图.
$global:BWCrashLog = Join-Path $env:TEMP "bw-crash.log"
trap {
$e = $_
$msg = "[$([DateTime]::Now.ToString('s'))] UNHANDLED EXCEPTION`n"
$msg += "Message : $($e.Exception.Message)`n"
$msg += "Type : $($e.Exception.GetType().FullName)`n"
$msg += "Location : $($e.InvocationInfo.ScriptName):$($e.InvocationInfo.ScriptLineNumber):$($e.InvocationInfo.OffsetInLine)`n"
$msg += "Line : $($e.InvocationInfo.Line.Trim())`n"
$msg += "Stack:`n$($e.ScriptStackTrace)`n"
$msg += "---`n"
try { $msg | Out-File -FilePath $global:BWCrashLog -Append -Encoding UTF8 -EA SilentlyContinue } catch {}
# 尝试弹 GUI (Add-Type 可能失败, try 包裹)
try {
Add-Type -AssemblyName System.Windows.Forms -EA Stop
$shortMsg = "Bookworm 安装遇到未处理异常, 进程将退出.`n`n"
$shortMsg += "错误: $($e.Exception.Message)`n"
$shortMsg += "位置: $(Split-Path $e.InvocationInfo.ScriptName -Leaf):$($e.InvocationInfo.ScriptLineNumber)`n`n"
$shortMsg += "详细日志已写入:`n $global:BWCrashLog`n`n"
$shortMsg += "请把该日志文件截图发给管理员报障."
[System.Windows.Forms.MessageBox]::Show($shortMsg, "Bookworm 安装异常 v$BWVersion", 'OK', 'Error') | Out-Null
} catch {}
exit 2
}
# ─── 版本号 (每次更新递增, build.ps1 自动读取) ──────
$BWVersion = "3.2.0" # feat: Phase 8 OTA 自动更新基础设施 (pubkey/DPAPI凭证/bw-ota.ps1)
# 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-smart-assistant.git" # v3.2: 统一配置仓库 (public)
$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") {
$script:LastValidatedModel = $null
$script:LastAuthFailCodes = @()
if (-not $apiKey -or $apiKey.Length -lt 10) { return $false }
# v3.0.4: 模型候选按中转站兼容性+套餐覆盖面排序
# sonnet-4-6 放首位 - 中转站基础套餐都有, opus-4-7 可能被低档套餐 403
# 去掉 haiku (多数中转站已移除), 新增 sonnet-4-5 兼容老套餐
$modelCandidates = @(
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-6-thinking",
"claude-sonnet-4-6-thinking"
)
# 错误分类:
# $true = 认证通过 (200/400) — $script:LastValidatedModel 记录通过的 model
# $false = 认证失败 (全部候选都返 401/403) 明确 Key 无效或权限不足
# $null = 网络/中转站故障 (5xx/404/timeout), 非 Key 问题, 外层应放行
$authFailModels = @()
$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()
$script:LastValidatedModel = $model
return $true # 200 = Key 有效
} catch [System.Net.WebException] {
$lastStatus = 0
try { $lastStatus = [int]$_.Exception.Response.StatusCode } catch {}
# 401/403 = 这个 model 维度的认证/权限失败, 继续试其他 model (v3.0.4 核心改动)
# 只有全部候选都 401/403 才认 Key 无效; 任一 200/400 就算 Key 有效
if ($lastStatus -eq 401 -or $lastStatus -eq 403) {
$authFailModels += "$model=$lastStatus"
continue
}
# 400 = 请求体问题 (模型名等), Key 本身通过
if ($lastStatus -eq 400) { $script:LastValidatedModel = $model; return $true }
# 5xx/404/0 = 网络或中转站故障, 不能归咎 Key
$hadNetworkError = $true
continue
} catch { $hadNetworkError = $true; continue }
}
$script:LastAuthFailCodes = $authFailModels
# 全部候选都遇网络故障 → 返回 $null (外层应: 接受 Key, 首次真实请求时再判)
if ($hadNetworkError -and $authFailModels.Count -eq 0) { 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
# v3.0.11/v3.1.0: 同步注入 profile.ps1 sentinel 块
# v3.1.0 双 profile 注入 (PS7 + PS5.1), 失败显式弹窗 (闭合 L2/L3)
try { Inject-CredentialLoaderProfile }
catch {
$errMsg = $_.Exception.Message
Bw-Log "WARN" "profile 凭证块注入失败: $errMsg"
try {
$hint = "PowerShell profile 凭证块注入失败 (不阻断 v3.1.0 安装).`n`n"
$hint += "影响: 桌面快捷方式启动 Claude 时, 可能读不到 ANTHROPIC_API_KEY 凭证.`n`n"
$hint += "异常: $errMsg`n`n"
$hint += "修复方案 (任选一):`n"
$hint += " 方案 A (推荐): 重跑 Bookworm-Setup.exe, 凭证缓存有效会自动重试`n"
$hint += " 方案 B: 手动跑命令在 PowerShell 7 里:`n"
$hint += " pwsh -NoProfile -Command `"Inject-CredentialLoaderProfile`"`n"
$hint += " 方案 C: 启动 Claude 前手动 setx ANTHROPIC_API_KEY <你的Key>"
Show-MsgBox $hint "v3.1.0 profile 凭证注入失败 (可继续安装)" "OK" "Warning"
} catch {}
}
} catch {}
}
# v3.0.11/v3.1.0 ADR-002: DPAPI 凭证加载迁移到 PowerShell profile
# 桌面 .lnk 直调 pwsh/wrapper 后, 凭证由 profile 启动 hook 自动加载, 完全脱离 bat/Base64.
# v3.1.0 双 profile 注入 (PS7 + PS5.1), 闭合 L2 局限.
# profile 编辑用 String.Replace 字面替换 (不走 -replace 的 $ backreference 语义,
# 避免 v3.0.5 BW_CLIP 块踩过的同类 bug)
function Inject-CredentialLoaderProfile {
$sentinelStart = "# BW_CRED_START v3.1.0 — 不要手动修改 (Bookworm 启动凭证 DPAPI 自动加载)"
$sentinelEnd = "# BW_CRED_END"
# 注: here-string 内 PS 变量用反引号 ` 转义保留为字面量, 由 profile 加载时再求值
$block = @"
$sentinelStart
# pwsh / PS5.1 HKCU:\Software\Bookworm\CachedEnv
# Process env, claude.ps1 ANTHROPIC_API_KEY
try {
`$bwReg = 'HKCU:\Software\Bookworm\CachedEnv'
if (Test-Path `$bwReg) {
Add-Type -AssemblyName System.Security -ErrorAction Stop
(Get-ItemProperty `$bwReg -ErrorAction Stop).PSObject.Properties |
Where-Object { `$_.Name -match '^[A-Z_]+`$' } |
ForEach-Object {
`$v = `$_.Value
try {
`$b = [Security.Cryptography.ProtectedData]::Unprotect(
[Convert]::FromBase64String(`$v), `$null,
[Security.Cryptography.DataProtectionScope]::CurrentUser)
`$v = [Text.Encoding]::UTF8.GetString(`$b)
} catch {}
[Environment]::SetEnvironmentVariable(`$_.Name, `$v, 'Process')
}
}
} catch {}
$sentinelEnd
"@
# v3.1.0: 双 profile 路径 (PS7 主用, PS5.1 兜底)
$profileTargets = @(
@{ Name = "PS7"; Dir = (Join-Path $env:USERPROFILE "Documents\PowerShell"); File = "profile.ps1" }
@{ Name = "PS5.1"; Dir = (Join-Path $env:USERPROFILE "Documents\WindowsPowerShell"); File = "profile.ps1" }
)
$injected = @()
$failed = @()
foreach ($t in $profileTargets) {
try {
if (-not (Test-Path $t.Dir)) { New-Item -ItemType Directory -Path $t.Dir -Force -EA Stop | Out-Null }
$psProfilePath = Join-Path $t.Dir $t.File
$existing = if (Test-Path $psProfilePath) { Get-Content $psProfilePath -Raw -Encoding UTF8 } else { "" }
# 同时清理 v3.0.11 旧 sentinel (BW_CRED_START v3.0.11 ...)
$oldPattern = "# BW_CRED_START v3\.0\.\d+[\s\S]*?# BW_CRED_END"
$existing = [regex]::Replace($existing, $oldPattern, "")
# 替换/追加 v3.1.0 块
$pattern = [regex]::Escape($sentinelStart) + "[\s\S]*?" + [regex]::Escape($sentinelEnd)
$regex = [regex]::new($pattern)
$match = $regex.Match($existing)
if ($match.Success) {
$updated = $existing.Replace($match.Value, $block)
} else {
$updated = if ($existing.Trim()) { $existing.TrimEnd() + "`n`n" + $block + "`n" } else { $block + "`n" }
}
# v3.1.1 (闭合 L7): FileShare.None 排他锁 + 5 次重试防并发损坏
$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($updated)
$written = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
$fs = [System.IO.File]::Open($psProfilePath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
try {
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
$written = $true
break
} finally {
$fs.Close()
$fs.Dispose()
}
} catch [System.IO.IOException] {
if ($attempt -lt 5) { Start-Sleep -Milliseconds 50 }
}
}
if (-not $written) { throw "profile 文件锁占用 (5 次重试均失败): $psProfilePath" }
$injected += "$($t.Name) ($psProfilePath)"
} catch {
$failed += "$($t.Name): $($_.Exception.Message)"
}
}
if ($injected.Count -gt 0) {
Bw-Log "OK" "BW_CRED v3.1.0 块已注入 $($injected.Count) 个 profile: $($injected -join '; ')"
}
if ($failed.Count -gt 0) {
# 双 profile 至少一个成功不算失败 (PS7 默认场景写到一个就够)
Bw-Log "WARN" "部分 profile 注入失败: $($failed -join '; ')"
if ($injected.Count -eq 0) {
# 全失败才 throw → 由调用方 catch 弹窗
throw "所有 profile 注入失败: $($failed -join '; ')"
}
}
}
# ─── 桌面快捷方式 ──────────────────────────────────
function New-DesktopShortcuts {
# v3.0.11 架构重构: 桌面 .lnk 直调 pwsh + claude.ps1 绝对路径
# 不再走 bat → wt → Base64 → DPAPI 7 跳链路, 消除 F1-F8 大部分失败模式
# 启动 lnk: Target=pwsh.exe, Args=-NoLogo -NoExit -File "<claude.ps1 绝对路径>" --dangerously-skip-permissions
# 更新 lnk: Target=cmd.exe, Args=/c "git pull..." (失败也不影响启动 lnk)
try {
$shell = New-Object -ComObject WScript.Shell
$desktop = $shell.SpecialFolders("Desktop")
# ── 1. 定位 pwsh.exe 绝对路径 ─────────────────────────
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
if (-not $pwshExe -or -not (Test-Path $pwshExe)) {
# 兜底候选 (winget 默认 / MSI 默认)
foreach ($p in @("$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe", "$env:LOCALAPPDATA\Microsoft\PowerShell\pwsh.exe")) {
if (Test-Path $p) { $pwshExe = $p; break }
}
}
if (-not $pwshExe -or -not (Test-Path $pwshExe)) {
Log-Fail "找不到 pwsh.exe 绝对路径, 拒绝创建桌面快捷方式 (v3.0.11 架构强依赖 pwsh)"
Show-MsgBox "无法定位 pwsh.exe 绝对路径.`n`nPowerShell 7 可能未正确安装. 请先解决 PS7 安装问题再重跑安装器." "v3.0.11 桌面快捷方式创建失败" "OK" "Error"
return
}
# ── 2. 定位 bw-launch.ps1 wrapper (v3.1.0 ADR) ───────
# v3.1.0 改为 wrapper 模式: .lnk 调 bw-launch.ps1, wrapper 启动时动态查 claude.ps1
# 闭合 L4 (claude.ps1 路径迁移破坏 .lnk).
# bw-launch.ps1 由本 EXE 自带 (Phase 7 复制到 $BootDir), 路径稳定.
$bwLaunchPs1 = Join-Path $BootDir "bw-launch.ps1"
if (-not (Test-Path $bwLaunchPs1)) {
# 兜底 1: 从本进程同目录拿 (开发场景)
$devCandidate = Join-Path (Split-Path -Parent $PSCommandPath) "bw-launch.ps1"
if ($devCandidate -and (Test-Path $devCandidate)) {
Copy-Item $devCandidate $bwLaunchPs1 -Force -EA SilentlyContinue
}
}
# 兜底 2: 从 bookworm-boot git 仓库 (clone 时已自带)
if (-not (Test-Path $bwLaunchPs1)) {
Log-Fail "bw-launch.ps1 wrapper 缺失, 拒绝创建桌面快捷方式"
Show-MsgBox "Bookworm 启动 wrapper (bw-launch.ps1) 未找到.`n`n这表示 bookworm-boot git 仓库内容不完整, 请重跑 Bookworm-Setup.exe 让 Phase 3 重新克隆仓库." "v3.1.0 wrapper 缺失" "OK" "Error"
return
}
# 兼容性自验证: 也确认 claude.ps1 当前可达 (装机时校验, 运行时由 wrapper 兜底)
$claudePs1Check = $null
try {
$cc = Get-Command claude -ErrorAction SilentlyContinue
if ($cc -and $cc.Source -and $cc.Source.EndsWith('.ps1') -and (Test-Path $cc.Source)) { $claudePs1Check = $cc.Source }
if (-not $claudePs1Check) {
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
$cand = Join-Path $npmPrefix "claude.ps1"
if (Test-Path $cand) { $claudePs1Check = $cand }
}
} catch {}
if (-not $claudePs1Check) {
Log-Fail "claude.ps1 装机时不可达, 拒绝创建桌面快捷方式 (即便 wrapper 也无法启动)"
Show-MsgBox "Claude Code 安装可能失败 (claude.ps1 不可达).`n`n请先 npm i -g @anthropic-ai/claude-code 验证安装, 再重跑 Bookworm-Setup.exe" "v3.1.0 Claude 安装异常" "OK" "Error"
return
}
Log-OK "v3.1.0 启动路径已锁定: pwsh=$pwshExe; wrapper=$bwLaunchPs1; claude.ps1=$claudePs1Check"
# ── 3. 桌面图标 ───────────────────────────────────────
$iconPath = Join-Path $BootDir "bookworm-desktop.ico"
if (-not (Test-Path $iconPath)) { $iconPath = Join-Path $BootDir "bookworm.ico" }
# ── 4. 启动 .lnk: 直调 pwsh + claude.ps1 (1 跳链路) ──
$newLnk = "$desktop\启动Bookworm.lnk"
$shortcut = $shell.CreateShortcut($newLnk)
$shortcut.TargetPath = $pwshExe
# v3.1.0: .lnk 调 wrapper, wrapper 内动态查 claude.ps1, 闭合 L4 stale 路径问题
# -ExecutionPolicy Bypass 防 LTSC/组策略 AllSigned 拒绝未签名脚本
$shortcut.Arguments = "-NoLogo -NoExit -ExecutionPolicy Bypass -File `"$bwLaunchPs1`" --dangerously-skip-permissions"
$shortcut.WorkingDirectory = $env:USERPROFILE
$shortcut.Description = "Bookworm Smart Assistant (v3.1.0 wrapper 架构)"
if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" }
$shortcut.Save()
# ── 5. 自验证: 读回 .lnk 确认 4 项均写入完整 ──
$verify = $shell.CreateShortcut($newLnk)
$checks = @{
"TargetPath = pwshExe" = ($verify.TargetPath -eq $pwshExe)
"Arguments 含 bw-launch.ps1 字面路径" = ($verify.Arguments -match [regex]::Escape("`"$bwLaunchPs1`""))
"Arguments 含 --dangerously-skip-perms" = ($verify.Arguments -match "--dangerously-skip-permissions")
"Arguments 含 -ExecutionPolicy Bypass" = ($verify.Arguments -match "-ExecutionPolicy Bypass")
}
$failed = $checks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key }
if ($failed) {
Log-Fail "启动.lnk 自验证失败 (项: $($failed -join ', '))"
Log-Fail " 实际 Target: $($verify.TargetPath)"
Log-Fail " 实际 Args : $($verify.Arguments)"
Remove-Item $newLnk -Force -EA SilentlyContinue
Show-MsgBox "桌面快捷方式自验证失败:`n$(($failed | ForEach-Object { ' - ' + $_ }) -join "`n")`n`n已删除坏 lnk. 请重跑安装器." "v3.0.11 自验证失败" "OK" "Error"
return
}
Log-OK "启动Bookworm.lnk 创建并通过 4 项自验证"
# ── 6. 迁移清理老 lnk (v3.0.3 及以前的 Bookworm.lnk) ──
$oldLnk = "$desktop\Bookworm.lnk"
if (Test-Path $oldLnk) {
try { Remove-Item -LiteralPath $oldLnk -Force -ErrorAction Stop; Log-Info "已清理旧 Bookworm.lnk (迁移到 v3.0.11 架构)" } catch {}
}
# ── 7. 更新 .lnk: 仅 git pull, 失败不影响启动 lnk ────
$updateLnk = "$desktop\更新Bookworm.lnk"
$updateBat = Join-Path $BootDir "更新Bookworm.bat"
# 兼容旧名 (gen-launcher-bats 之前生成的)
if (-not (Test-Path $updateBat)) {
$oldUpdateBat = Join-Path $BootDir "更新并启动Bookworm.bat"
if (Test-Path $oldUpdateBat) { $updateBat = $oldUpdateBat }
}
if (Test-Path $updateBat) {
$u = $shell.CreateShortcut($updateLnk)
$u.TargetPath = $updateBat
$u.WorkingDirectory = $BootDir
$u.Description = "更新 Bookworm 配置 (git pull)"
if (Test-Path $iconPath) { $u.IconLocation = "$iconPath,0" }
$u.Save()
Log-OK "更新Bookworm.lnk 创建"
} else {
Log-Info "更新.bat 未找到, 跳过更新 lnk (启动 lnk 已就绪, 不影响使用)"
}
# ── 8. 体检 .lnk: 调 pwsh + bw-doctor.ps1 (v3.1.2 新增) ──
$doctorPs1 = Join-Path $BootDir "bw-doctor.ps1"
if (Test-Path $doctorPs1) {
$doctorLnk = "$desktop\体检Bookworm.lnk"
$d = $shell.CreateShortcut($doctorLnk)
$d.TargetPath = $pwshExe
$d.Arguments = "-NoLogo -NoExit -ExecutionPolicy Bypass -File `"$doctorPs1`""
$d.WorkingDirectory = $BootDir
$d.Description = "Bookworm 13 维度健康体检"
if (Test-Path $iconPath) { $d.IconLocation = "$iconPath,0" }
$d.Save()
Log-OK "体检Bookworm.lnk 创建"
} else {
Log-Info "bw-doctor.ps1 未找到, 跳过体检 lnk"
}
# ── 9. 卸载 .lnk: 调 cmd + 卸载Bookworm.bat (v3.1.2 新增) ──
$uninstBat = Join-Path $BootDir "卸载Bookworm.bat"
if (Test-Path $uninstBat) {
$uninstLnk = "$desktop\卸载Bookworm.lnk"
$ui = $shell.CreateShortcut($uninstLnk)
$ui.TargetPath = $uninstBat
$ui.WorkingDirectory = $BootDir
$ui.Description = "卸载 Bookworm (二次确认后执行)"
if (Test-Path $iconPath) { $ui.IconLocation = "$iconPath,0" }
$ui.Save()
Log-OK "卸载Bookworm.lnk 创建"
} else {
Log-Info "卸载Bookworm.bat 未找到, 跳过卸载 lnk"
}
Log-OK "v3.1.2 桌面快捷方式 (启动/更新/体检/卸载) 创建完成"
} 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")
# v3.0.2 (三台机踩坑修复): 放开 PS ExecutionPolicy, 否则管理员 PS 下 claude.ps1 会被拦
try {
$curPolicy = Get-ExecutionPolicy -Scope CurrentUser
if ($curPolicy -eq "Restricted" -or $curPolicy -eq "Undefined") {
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -EA Stop
Log-OK "PS ExecutionPolicy 已放开: CurrentUser = RemoteSigned"
}
} catch { Log-Warn "ExecutionPolicy 设置失败 (可能组策略锁死): $_" }
# DryRun 模式: 只做 Phase 1 检测, 不动任何东西
if (Is-DryRun "env") {
Log-Info "[DryRun=env] Phase 1 只检测 PATH + 依赖可达性, 跳过后续步骤"
}
$deps = @(
# 核心依赖 (三层 fallback: winget → MSI/EXE 直链 → 手动指引)
# v3.0.5: 所有核心依赖都补齐直链兜底,防 winget 不可用时 Phase 1 直接挂
@{ Name = "Node.js"; Cmd = "node"; WingetId = "OpenJS.NodeJS.LTS"; NpmPkg = $null; PipPkg = $null; Core = $true
MsiUrl = "https://nodejs.org/dist/v22.11.0/node-v22.11.0-x64.msi"
ManualUrl = "https://nodejs.org/zh-cn/download" }
@{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null; PipPkg = $null; Core = $true
# Git for Windows 是 Inno Setup (非 MSI), 用 /VERYSILENT /NORESTART 静默
ExeUrl = "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1.2-64-bit.exe"
ExeArgs = @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/SUPPRESSMSGBOXES")
ManualUrl = "https://git-scm.com/download/win" }
@{ Name = "PowerShell 7"; Cmd = "pwsh"; WingetId = "Microsoft.PowerShell"; NpmPkg = $null; PipPkg = $null; Core = $true
MsiUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi"
ManualUrl = "https://github.com/PowerShell/PowerShell/releases/latest" }
@{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code"; PipPkg = $null; Core = $true
ManualUrl = "https://docs.anthropic.com/en/docs/claude-code/overview" }
# Python 移到可选依赖 (不在此列表, 由 line 753 单独处理)
)
$hasWinget = Test-Cmd "winget"
$installed = @()
# v3.0.5: winget 不可用时醒目提示 (但不阻断, MSI/EXE 直链兜底可以继续)
if (-not $hasWinget) {
$wingetHint = @"
winget (Windows )
Bookworm MSI/EXE Node.js Git winget
winget
1. "" (App Installer)
2. GitHub https://aka.ms/getwinget
Windows 10 1809+ / Windows 11
winget? Win10 1803
MSI/EXE
"@
Show-MsgBox $wingetHint "winget 未就绪 (不阻断)" "OK" "Information"
Log-Warn "winget 不可用, 将走 MSI/EXE 直链兜底"
}
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) 安装失败: $_"
}
}
# v3.0.3/v3.0.5: winget 缺席/失败时, 用 MSI/EXE 直链兜底
# v3.0.5 扩展: Git 是 Inno Setup (非 MSI), 分 MsiUrl 和 ExeUrl 两条分派
if (-not (Test-Cmd $dep.Cmd) -and ($dep.MsiUrl -or $dep.ExeUrl)) {
$isExe = [bool]$dep.ExeUrl
$installerUrl = if ($isExe) { $dep.ExeUrl } else { $dep.MsiUrl }
$ext = if ($isExe) { "exe" } else { "msi" }
$installerPath = Join-Path $env:TEMP "bw-$($dep.Cmd)-installer.$ext"
Log-Warn "$($dep.Name) winget 路径失败, 改走 $($ext.ToUpper()) 直链..."
try {
# TLS 1.2 强制 (Win10 默认 TLS 1.0/1.1 访问 GitHub 会断)
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Log-Info " 下载 $($installerUrl.Substring($installerUrl.LastIndexOf('/')+1))"
Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing -TimeoutSec 300 -EA Stop
if ((Get-Item $installerPath).Length -lt 1MB) { throw "$($ext.ToUpper()) 下载不完整: $((Get-Item $installerPath).Length) bytes" }
Log-Info " 安装 $($ext.ToUpper()) (静默, 需 1-3 分钟)..."
if ($isExe) {
# Inno Setup (Git for Windows): /VERYSILENT /NORESTART /SP- /SUPPRESSMSGBOXES
$exeArgs = if ($dep.ExeArgs) { $dep.ExeArgs } else { @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/SUPPRESSMSGBOXES") }
$p = Start-Process $installerPath -ArgumentList $exeArgs -Wait -PassThru -NoNewWindow
} else {
# MSI (Node.js / PS7): msiexec /i ... /quiet /qn /norestart ADD_PATH=1
$p = Start-Process "msiexec.exe" -ArgumentList "/i", "`"$installerPath`"", "/quiet", "/qn", "/norestart", "ADD_PATH=1" -Wait -PassThru -NoNewWindow
}
Remove-Item $installerPath -Force -EA SilentlyContinue
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
# 常见安装位置强制补 PATH (避免 User env 延迟刷新)
$commonDirs = @(
"$env:ProgramFiles\PowerShell\7",
"$env:ProgramFiles\Git\cmd",
"$env:ProgramFiles\Git\bin",
"$env:ProgramFiles\nodejs",
"$env:APPDATA\npm"
)
foreach ($d in $commonDirs) {
if ((Test-Path $d) -and ($env:Path -notlike "*$d*")) { $env:Path = "$d;$env:Path" }
}
if (Test-Cmd $dep.Cmd) {
Log-OK "$($dep.Name) $($ext.ToUpper()) 安装成功 (exit $($p.ExitCode))"
$installed += $dep.Name
} else {
Log-Fail "$($dep.Name) $($ext.ToUpper()) 安装后仍找不到 $($dep.Cmd) (exit $($p.ExitCode))"
}
} catch { Log-Fail "$($dep.Name) $($ext.ToUpper()) 直链失败: $_" }
}
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 不可用)"
# 失败专属弹窗由下面统一的 per-dep 失败检查处理
} else {
Log-Info "$($dep.Name) 未安装 (可选, 不影响核心功能)"
}
}
# v3.0.8: per-dep 失败立即弹专属 Warning (不阻断继续下一个 dep, Phase 1 末尾统一判断 exit)
# 场景: 3 种安装路径全挂 + Core=true → 给用户**具体**该 dep 的手动方案, 而非等到末尾总弹窗
if ($dep.Core -and -not (Test-Cmd $dep.Cmd)) {
$depFailMsg = "[$($dep.Name)] 自动安装失败`n`n"
$depFailMsg += "请手动安装 (任选一种):`n"
if ($dep.WingetId) {
$depFailMsg += "`n方式 A (winget): `n winget install --id $($dep.WingetId) --accept-source-agreements --accept-package-agreements`n"
}
if ($dep.MsiUrl) {
$depFailMsg += "`n方式 B (MSI 直链):`n 浏览器打开: $($dep.MsiUrl)`n 下载后双击安装, 全部选默认`n"
}
if ($dep.ExeUrl) {
$depFailMsg += "`n方式 B (EXE 直链):`n 浏览器打开: $($dep.ExeUrl)`n 下载后双击安装, 全部选默认`n"
}
if ($dep.NpmPkg) {
$depFailMsg += "`n方式 A (需 Node.js 就绪):`n 命令行: npm i -g $($dep.NpmPkg)`n"
}
if ($dep.ManualUrl) {
$depFailMsg += "`n参考下载页: $($dep.ManualUrl)`n"
}
$depFailMsg += "`n【装完后】重新双击 Bookworm-Setup.exe 即可继续 (已装的依赖会自动跳过)"
Show-MsgBox $depFailMsg "[$($dep.Name)] 需手动安装" "OK" "Warning"
}
}
}
# ── 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 安装后再检查
# v3.0.8: 失败时写诊断到 bw-crash.log (node -v / npm -v / npm prefix / PATH 片段)
# v3.0.9: 装完立即 `npm config get prefix` 拿真实路径 → 强制写入 User PATH (永久生效)
# 根治: 用户机器 Node.js MSI 安装时 User PATH 写入异常 → 快捷方式永远找不到 claude
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")
# v3.0.9: 动态查询 npm 真实 prefix (兼容 nvm/fnm/Program Files/APPDATA 任意位置)
$npmPrefix = $null
try {
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
} catch {}
# 如果 npm prefix 查询失败, 回退常见候选位置
if (-not $npmPrefix -or -not (Test-Path $npmPrefix)) {
foreach ($p in @("$env:APPDATA\npm", "$env:ProgramFiles\nodejs", "$env:LOCALAPPDATA\npm")) {
if (Test-Path $p) { $npmPrefix = $p; break }
}
}
# 进程级立即生效
if ($npmPrefix -and ($env:Path -notlike "*$npmPrefix*")) { $env:Path = "$npmPrefix;$env:Path" }
# User 级永久写入 (根治未来任何新 shell 找不到 claude)
if ($npmPrefix) {
try {
$userPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -and $userPath -notlike "*$npmPrefix*") {
[System.Environment]::SetEnvironmentVariable("Path", "$userPath;$npmPrefix", "User")
Log-OK "npm 全局路径已永久写入 User PATH: $npmPrefix"
} elseif (-not $userPath) {
[System.Environment]::SetEnvironmentVariable("Path", $npmPrefix, "User")
Log-OK "创建 User PATH 含 npm 全局路径: $npmPrefix"
} else {
Log-Info "npm 全局路径已在 User PATH 中"
}
} catch { Log-Warn "写入 User PATH 失败 (不阻断, 进程级 PATH 已更新): $_" }
}
if (Test-Cmd "claude") {
Log-OK "Claude Code 安装成功"
} else {
Log-Fail "Claude Code 安装失败"
# v3.0.8: 写具体诊断到 crash log, 便于用户报障 (不走 Show-MsgBox 避免双重打扰, 由 Phase 1 末尾统一弹)
try {
$nodeV = try { (& node --version 2>$null) } catch { "(node 不可用)" }
$npmV = try { (& npm --version 2>$null) } catch { "(npm 不可用)" }
$npmPrefix = try { (& npm config get prefix 2>$null) } catch { "(获取失败)" }
$pathNpm = ($env:Path -split ';') | Where-Object { $_ -match 'npm|nodejs' } | Out-String
$diag = "[$([DateTime]::Now.ToString('s'))] CLAUDE_INSTALL_FAIL`n"
$diag += " node -v : $nodeV`n"
$diag += " npm -v : $npmV`n"
$diag += " npm prefix : $npmPrefix`n"
$diag += " PATH (npm 相关):`n$pathNpm`n"
$diag | Out-File -FilePath $global:BWCrashLog -Append -Encoding UTF8 -EA SilentlyContinue
Bw-Log "INFO" "Claude Code 诊断已写入: $global:BWCrashLog"
} catch {}
}
}
# 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 未找到 (凭证解密可能失败)" }
# v3.0.8: Phase 1 总结弹窗 — 让用户一眼看懂全局, 避免 GUI 进度条滚动中遗漏关键信息
$coreList = @("Node.js", "Git", "PowerShell 7", "Claude Code")
$coreCmds = @{ "Node.js" = "node"; "Git" = "git"; "PowerShell 7" = "pwsh"; "Claude Code" = "claude" }
$summaryReady = @() # 已就绪 (本次未重装)
$summaryNew = @() # 本次自动安装成功
$summaryFail = @() # 本次自动安装失败
foreach ($name in $coreList) {
$cmd = $coreCmds[$name]
if (Test-Cmd $cmd) {
if ($installed -contains $name) { $summaryNew += $name }
else { $summaryReady += $name }
} else {
$summaryFail += $name
}
}
# v3.1.1 (闭合 L5): 始终弹总结弹窗 (无论是否有动作), 让用户明确确认 Phase 1 通过
$sum = "[Phase 1] 依赖环境检查完成`n`n"
if ($summaryReady.Count -gt 0) { $sum += "[OK] 已就绪: $($summaryReady -join ', ')`n" }
if ($summaryNew.Count -gt 0) { $sum += "[INSTALLED] 本次自动安装: $($summaryNew -join ', ')`n" }
if ($summaryFail.Count -gt 0) {
$sum += "[FAIL] 需手动处理: $($summaryFail -join ', ')`n"
$sum += "`n失败项已在前面弹窗给出手动方案; 装完重跑 EXE 即可继续."
} elseif ($summaryNew.Count -eq 0) {
$sum += "`n所有核心依赖之前已装好 (本次零新装).`n即将进入 Phase 2 网络诊断."
} else {
$sum += "`n所有核心依赖就绪, 即将进入 Phase 2 网络诊断."
}
$icon = if ($summaryFail.Count -gt 0) { "Warning" } else { "Information" }
Show-MsgBox $sum "Phase 1 总结 — v$BWVersion" "OK" $icon
# v3.0.8: PS7 升为硬核依赖 (启动器 bat 强依赖 pwsh 的 -EncodedCommand + STA runspace 正确性,
# PS5.1 降级路径体验差: 粘贴多行命令被拆分 / Base64 可能被截断 / WinForms 兼容不一致)
# 最终检查 (Node.js + Git + Claude Code + PowerShell 7 全部为硬性依赖)
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 "claude")) { $missing += "Claude Code" }
if (-not (Test-Cmd "pwsh")) { $missing += "PowerShell 7" }
# v3.0.5: 清除桌面僵尸快捷方式, 防用户点击空快捷方式触发 'claude.exe not found'
try {
$desktop = [System.Environment]::GetFolderPath("Desktop")
foreach ($lnk in @("启动Bookworm.lnk", "更新Bookworm.lnk", "Bookworm.lnk")) {
$lnkPath = Join-Path $desktop $lnk
if (Test-Path $lnkPath) {
Remove-Item -LiteralPath $lnkPath -Force -EA SilentlyContinue
Log-Warn "已移除僵尸快捷方式: $lnk (Phase 1 未完成)"
}
}
} catch {}
# v3.0.5: 手动安装清单 (winget 失败 + 直链失败时最后一根稻草)
$manualGuide = "以下核心依赖自动安装失败:`n " + ($missing -join ", ") + "`n`n"
$manualGuide += "【手动安装清单】请按顺序操作:`n`n"
if ($missing -contains "Node.js") {
$manualGuide += "1. Node.js`n"
$manualGuide += " 下载: https://nodejs.org/zh-cn/download`n"
$manualGuide += " 选 LTS → Windows Installer (.msi) → 双击安装`n`n"
}
if ($missing -contains "Git") {
$manualGuide += "2. Git for Windows`n"
$manualGuide += " 下载: https://git-scm.com/download/win`n"
$manualGuide += " 选 64-bit Git → 双击安装 (默认选项即可)`n`n"
}
if ($missing -contains "Claude Code") {
$manualGuide += "3. Claude Code (需先装好 Node.js)`n"
$manualGuide += " 命令行执行: npm i -g @anthropic-ai/claude-code`n`n"
}
if ($missing -contains "PowerShell 7") {
$manualGuide += "4. PowerShell 7 (启动器 bat + Base64 启动链路必需)`n"
$manualGuide += " 方式 A: winget install --id Microsoft.PowerShell`n"
$manualGuide += " 方式 B: 下载 MSI https://github.com/PowerShell/PowerShell/releases/latest`n"
$manualGuide += " 选 PowerShell-7.x.x-win-x64.msi → 双击安装`n`n"
}
$manualGuide += "【完成后】`n"
$manualGuide += " 重新双击 Bookworm-Setup.exe 即可继续安装`n"
$manualGuide += " (已装成功的依赖会被自动跳过)`n`n"
$manualGuide += "【仍有问题?】`n"
$manualGuide += " 1. 检查是否以管理员身份运行`n"
$manualGuide += " 2. 检查网络能否访问上述下载地址`n"
$manualGuide += " 3. 联系 Bookworm 管理员`n"
Show-MsgBox $manualGuide "安装中断 — 手动安装指引" "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 ', ')"
}
# 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.4: 默认模型优先级重排(修复: opus-4-7 硬编码导致 sonnet-only 套餐用户启动后全 403
# 顺序: 已显式设置 > Test-ApiKey 实际通过的 model (延后设) > Worker /config > 兜底 sonnet-4-6
# 注: 实际验证通过后会在 Phase 4 末尾用 $script:LastValidatedModel 覆盖为"真·可用模型"
if (-not $env:ANTHROPIC_MODEL) {
$defaultModel = "claude-sonnet-4-6" # v3.0.4: 兜底改 sonnet-4-6 (基础套餐都有, opus 常被低档 403)
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 (暂设, Phase 4 验证通过后会根据实际可用 model 覆盖)"
}
# 优先级 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 "正在验证..."
# v3.0.4: 统一走 Test-ApiKey (移除 change-key.js 优先分支)
# 删除原因: change-key.js 硬编码 claude-3-haiku-20240307 做验证,
# sonnet-only 套餐用户的 Key 在 haiku 必返 403 → 误判 Key 无效,
# 而 Test-ApiKey 本来就支持多模型 fallback + 三值错误分类, 覆盖更广.
# 同步修复: stderr 不再 2>&1 | Out-Null, 改为记录到 phase4-validate.log 便于用户报障
# 残留清理: 如 .claude/change-key.js 存在, 保留但不再调用 (未来版本会彻底移除)
$ckOk = $false
$checkResult = Test-ApiKey $apiKey
$phase4Log = Join-Path $env:TEMP "bw-phase4-validate.log"
if ($checkResult -eq $true) {
$ckOk = $true
$validatedModel = if ($script:LastValidatedModel) { $script:LastValidatedModel } else { "(unknown)" }
Log-OK "Key 在线验证通过 (model=$validatedModel)"
try { "[$([DateTime]::Now.ToString('s'))] PASS model=$validatedModel" | Out-File -FilePath $phase4Log -Append -Encoding UTF8 } catch {}
} elseif ($null -eq $checkResult) {
# 全部候选网络故障, 不能归咎 Key, 先接受 (首次真实请求时再判)
$ckOk = $true
Log-Warn "中转站暂不可达, 暂接受此 Key (首次使用时若失败请重装换 Key)"
try { "[$([DateTime]::Now.ToString('s'))] NETWORK_ACCEPT baseUrl=https://bww.letcareme.com" | Out-File -FilePath $phase4Log -Append -Encoding UTF8 } catch {}
} else {
# 全部候选都 401/403 = 明确认证失败
$ckOk = $false
$failDetail = if ($script:LastAuthFailCodes) { $script:LastAuthFailCodes -join ',' } else { 'all_models_auth_fail' }
Log-Warn "Key 被拒绝 ($failDetail)"
try { "[$([DateTime]::Now.ToString('s'))] AUTH_FAIL $failDetail" | Out-File -FilePath $phase4Log -Append -Encoding UTF8 } catch {}
}
if ($ckOk) {
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $apiKey, "User")
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", "https://bww.letcareme.com", "User")
# v3.0.4: 如果 Test-ApiKey 返回了真实通过的模型, 用它覆盖先前设的兜底默认
if ($script:LastValidatedModel) {
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_MODEL", $script:LastValidatedModel, "User")
$env:ANTHROPIC_MODEL = $script:LastValidatedModel
Log-OK "ANTHROPIC_MODEL 锁定为实际可用: $($script:LastValidatedModel)"
}
}
if ($ckOk) {
$env:ANTHROPIC_API_KEY = $apiKey
$env:ANTHROPIC_BASE_URL = "https://bww.letcareme.com"
# v3.0.2: 广播 WM_SETTINGCHANGE, 让 explorer.exe + 已打开窗口刷新 env
try {
$sig = '[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'
$api = Add-Type -MemberDefinition $sig -Name Win32SendMsg -Namespace BWBroadcast -PassThru -EA Stop
$HWND_BROADCAST = [IntPtr]0xffff; $WM_SETTINGCHANGE = 0x001A; $result = [UIntPtr]::Zero
[void]$api::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result)
} catch { Log-Warn "env 广播失败 (不影响功能): $_" }
# v3.0.2: 预填 .claude.json 跳过 Claude Code 2.0.1 的登录选择页 (两选项都走 anthropic.com OAuth, 国内不通)
try {
$claudeJsonPath = Join-Path $env:USERPROFILE ".claude.json"
$keyPrefix = $apiKey.Substring(0, [Math]::Min(20, $apiKey.Length))
$bypassConfig = '{"hasCompletedOnboarding":true,"hasSeenWelcome":true,"bypassPermissionsModeAccepted":true,"customApiKeyResponses":{"approved":["' + $keyPrefix + '"],"rejected":[]},"numStartups":5,"projects":{}}'
# 无 BOM UTF-8 写入, Set-Content 会加 BOM 导致 Claude Code JSON parser 拒收
[IO.File]::WriteAllText($claudeJsonPath, $bypassConfig, [System.Text.UTF8Encoding]::new($false))
Log-OK "Claude Code onboarding 已预填 (跳过 v2.0.1 登录选择页)"
} catch { Log-Warn ".claude.json 预填失败 (首次启动需手工过登录画面): $_" }
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 5.5: 截图粘贴助手部署 (v3.0.5 新增)
# ========================================================================
# 目标: Shift+Win+S 截图后, 在 Claude Code CLI Ctrl+V 直接粘贴为 [Image #N] 附件
# 分发文件: $ClaudeDir\scripts\ClipImageWatcher.ps1 (由 bookworm-portable-config 仓库提供)
# 注入点:
# 1. $HOME\Documents\PowerShell\profile.ps1 (sentinel 追加式, Q1=A)
# 2. HKCU:\...Microsoft.ScreenSketch\...\Enabled=0 (备份原值到 HKCU:\Software\Bookworm\ToastBackup, 卸载时还原, Q2=B)
# ========================================================================
Log-Info "[5.5/$TOTAL_PHASES] 截图粘贴助手部署"
Update-Progress-SubStatus "配置截图粘贴助手..."
# v3.0.7: 整段 Phase 5.5 包 try-catch, 任何失败都降级到 Log-Warn 不阻断主流程
# 原因: Phase 5.5 是可选体验增强, 失败不应导致主安装流程闪退 (Phase 6/7 还没跑)
try {
$clipWatcherSrc = Join-Path $ClaudeDir "scripts\ClipImageWatcher.ps1"
if (Test-Path $clipWatcherSrc) {
# ── 5.5a: profile sentinel 注入 (幂等, 精准替换 BW_CLIP_START..END 中间块) ──
$psProfileDir = Join-Path $env:USERPROFILE "Documents\PowerShell"
$psProfilePath = Join-Path $psProfileDir "profile.ps1"
if (-not (Test-Path $psProfileDir)) { New-Item -ItemType Directory -Path $psProfileDir -Force | Out-Null }
$sentinelStart = "# BW_CLIP_START v3.0.5 — 不要手动修改此块 (Bookworm 自动生成)"
$sentinelEnd = "# BW_CLIP_END"
$bwClipBlock = @"
$sentinelStart
`$script:ClipWatcherPath = "`$HOME\.claude\scripts\ClipImageWatcher.ps1"
function Start-ClipWatch {
[CmdletBinding()]
param([int]`$IntervalMs = 400, [switch]`$Foreground)
if (-not (Test-Path `$script:ClipWatcherPath)) {
Write-Error ": `$script:ClipWatcherPath"
return
}
if (`$Foreground) {
& pwsh -NoProfile -ExecutionPolicy Bypass -File `$script:ClipWatcherPath -IntervalMs `$IntervalMs
return
}
`$logDir = Join-Path `$env:TEMP 'claude-clip'
if (-not (Test-Path `$logDir)) { New-Item -ItemType Directory -Path `$logDir -Force | Out-Null }
`$logPath = Join-Path `$logDir 'watcher.log'
`$psi = New-Object System.Diagnostics.ProcessStartInfo
`$psi.FileName = 'pwsh'
`$psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File ```"`$(`$script:ClipWatcherPath)```" -IntervalMs `$IntervalMs"
`$psi.UseShellExecute = `$false
`$psi.CreateNoWindow = `$true
`$psi.WindowStyle = 'Hidden'
`$psi.RedirectStandardOutput = `$true
`$psi.RedirectStandardError = `$true
`$p = [System.Diagnostics.Process]::Start(`$psi)
`$global:ClipWatchPid = `$p.Id
Register-ObjectEvent -InputObject `$p -EventName OutputDataReceived -Action {
if (`$EventArgs.Data) { Add-Content -Path `$using:logPath -Value `$EventArgs.Data -Encoding UTF8 }
} | Out-Null
Register-ObjectEvent -InputObject `$p -EventName ErrorDataReceived -Action {
if (`$EventArgs.Data) { Add-Content -Path `$using:logPath -Value "[ERR] `$(`$EventArgs.Data)" -Encoding UTF8 }
} | Out-Null
`$p.BeginOutputReadLine()
`$p.BeginErrorReadLine()
Write-Host "[ClipWatch] (PID=`$(`$p.Id)): `$logPath" -ForegroundColor Cyan
}
function Stop-ClipWatch {
if (`$global:ClipWatchPid) {
try { Stop-Process -Id `$global:ClipWatchPid -Force -ErrorAction Stop; Write-Host "[ClipWatch] PID=`$global:ClipWatchPid" -ForegroundColor Yellow }
catch { Write-Warning ": `$(`$_.Exception.Message)" }
`$global:ClipWatchPid = `$null
} else { Write-Host "[ClipWatch] " -ForegroundColor DarkGray }
}
function Get-ClipWatchDir {
`$d = Join-Path `$env:TEMP 'claude-clip'
if (Test-Path `$d) { Get-ChildItem `$d | Sort-Object LastWriteTime -Descending | Select-Object -First 10 }
else { Write-Host "" }
}
# (, CommandLine )
try {
`$watcherName = Split-Path `$script:ClipWatcherPath -Leaf
`$running = Get-CimInstance Win32_Process -Filter "Name='pwsh.exe'" -ErrorAction SilentlyContinue |
Where-Object { `$_.CommandLine -like "*`$watcherName*" }
if (-not `$running -and (Test-Path `$script:ClipWatcherPath)) { Start-ClipWatch }
} catch {}
$sentinelEnd
"@
# 读现有 profile, 精准替换 sentinel 中间块 (不动其他内容)
# v3.0.7 修复: 原 -replace 运算符把 replacement 里的 $script:/`$HOME/$1 等当作 backreference,
# 导致生成的 profile.ps1 损坏. 改用 [regex]::Match + String.Replace 字面替换绕过 $ 语义.
$existing = if (Test-Path $psProfilePath) { Get-Content $psProfilePath -Raw -Encoding UTF8 } else { "" }
$pattern = [regex]::Escape($sentinelStart) + "[\s\S]*?" + [regex]::Escape($sentinelEnd)
$regex = [regex]::new($pattern)
$match = $regex.Match($existing)
if ($match.Success) {
# 字面替换: 把匹配到的旧块整段 String.Replace 成新块, 不走正则 replacement 语义
$updated = $existing.Replace($match.Value, $bwClipBlock)
} else {
$updated = if ($existing) { $existing.TrimEnd() + "`n`n" + $bwClipBlock + "`n" } else { $bwClipBlock + "`n" }
}
# 无 BOM UTF-8 写入 (PS7 能正确识别, PS5.1 也 OK)
[System.IO.File]::WriteAllText($psProfilePath, $updated, [System.Text.UTF8Encoding]::new($false))
Log-OK "PowerShell profile 已注入 BW_CLIP 块: $psProfilePath"
# ── 5.5b: 截图 Toast 关闭 + 备份原值用于卸载还原 ──
$toastReg = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\Microsoft.ScreenSketch_8wekyb3d8bbwe!App"
$bwBackup = "HKCU:\Software\Bookworm\ToastBackup"
try {
$origVal = $null
if (Test-Path $toastReg) {
$origVal = (Get-ItemProperty -Path $toastReg -Name "Enabled" -EA SilentlyContinue).Enabled
}
# 备份原值到 HKCU:\Software\Bookworm\ToastBackup (卸载时用于还原)
if (-not (Test-Path $bwBackup)) { New-Item -Path $bwBackup -Force | Out-Null }
$bwBackupProps = Get-ItemProperty -Path $bwBackup -EA SilentlyContinue
if (-not $bwBackupProps -or -not $bwBackupProps.ScreenSketchToast_Backed) {
# 只在首次备份 (防重装覆盖用户手动改过的值)
$bakValue = if ($null -eq $origVal) { "__ABSENT__" } else { $origVal.ToString() }
New-ItemProperty -Path $bwBackup -Name "ScreenSketchToast_Original" -Value $bakValue -PropertyType String -Force | Out-Null
New-ItemProperty -Path $bwBackup -Name "ScreenSketchToast_Backed" -Value 1 -PropertyType DWord -Force | Out-Null
Log-Info "已备份 Toast 原值到 $bwBackup (卸载时自动还原)"
}
# 关 Toast
if (-not (Test-Path $toastReg)) { New-Item -Path $toastReg -Force | Out-Null }
New-ItemProperty -Path $toastReg -Name "Enabled" -Value 0 -PropertyType DWord -Force | Out-Null
Log-OK "截图工具 Toast 已关闭 (原值已备份)"
} catch {
Log-Warn "截图 Toast 关闭失败 (不影响截图本身): $_"
}
} else {
Log-Warn "ClipImageWatcher.ps1 未分发到 $clipWatcherSrc, 跳过截图助手部署"
}
} catch {
# v3.0.7: 截图助手是可选功能, 失败降级不阻断主流程
Log-Warn "Phase 5.5 截图助手部署异常 (不影响主功能): $($_.Exception.Message)"
try { "[$([DateTime]::Now.ToString('s'))] PHASE_5_5_FAIL $($_.Exception.Message) @ $($_.InvocationInfo.ScriptLineNumber)" | Out-File -FilePath $global:BWCrashLog -Append -Encoding UTF8 -EA SilentlyContinue } catch {}
}
# ========================================================================
# 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" }
)
# v3.1.2 L11: 国内镜像 fallback (npmjs 失败 → npmmirror 重试)
$npmMirrorRegistry = "https://registry.npmmirror.com"
$mcpOK = 0; $mcpFail = 0
foreach ($mcp in $npxPackages) {
$idx = $mcpOK + $mcpFail + 1
$label = "[$idx/$($npxPackages.Count)] $($mcp.Name)"
Update-Progress-SubStatus "$label ..."
$cached = $false
foreach ($registry in @($null, $npmMirrorRegistry)) {
try {
$outTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name).tmp"
$errTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name)-err.tmp"
$npmArgs = @("cache", "add", $mcp.Pkg)
if ($registry) {
$npmArgs += "--registry=$registry"
Update-Progress-SubStatus "$label (镜像重试)..."
}
$proc = Start-Process npm.cmd -ArgumentList $npmArgs `
-NoNewWindow -PassThru `
-RedirectStandardOutput $outTmp `
-RedirectStandardError $errTmp
$ok = Wait-ProcessWithUI $proc 60000 $label
if ($ok -and $proc.ExitCode -eq 0) {
$src = if ($registry) { "mirror" } else { "npmjs" }
Bw-Log "OK" "$label cached ($src)"
$mcpOK++; $cached = $true
} else { throw "exit=$($proc.ExitCode)" }
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
break
} catch {
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
if ($registry) {
Bw-Log "WARN" "$label failed (mirror): $_"
} else {
Bw-Log "INFO" "$label npmjs failed, trying mirror..."
}
}
}
if (-not $cached) { $mcpFail++ }
}
Log-OK "npx 预缓存: $mcpOK/$($npxPackages.Count) 成功$(if ($mcpFail -gt 0) { " ($mcpFail 失败)" })"
# ── 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) ──
# v3.1.2 L11: PyPI 失败 → tuna 镜像重试
$pypiMirrorIndex = "https://pypi.tuna.tsinghua.edu.cn/simple"
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) {
$installed = $false
foreach ($indexUrl in @($null, $pypiMirrorIndex)) {
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
if ($indexUrl) {
$installArgs += @("--index-url", $indexUrl)
Update-Progress-SubStatus "uvx $($pkg.Name) (镜像重试)..."
}
$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 -and $proc.ExitCode -eq 0) {
$src = if ($indexUrl) { "tuna" } else { "pypi" }
Bw-Log "OK" "uvx $($pkg.Name) ready ($src)"
$installed = $true; break
} else { throw "exit=$($proc.ExitCode)" }
} catch {
Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue
if ($indexUrl) {
Bw-Log "WARN" "uvx $($pkg.Name) failed (mirror): $_"
} else {
Bw-Log "INFO" "uvx $($pkg.Name) pypi failed, trying tuna..."
}
}
}
if (-not $installed) { Bw-Log "WARN" "uvx $($pkg.Name): all sources failed" }
}
} 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
# v3.0.9: 创建桌面快捷方式前强制自验证 claude 可达 (防止交付半成品快捷方式)
# 已 Phase 1 + 补装 Claude 到这里仍 Test-Cmd false → 真的有问题, 立即弹窗让用户修,
# 而不是创建"点了没反应"的坏快捷方式误导用户
if (-not (Test-Cmd "claude")) {
# 尝试最后一次 PATH 重载 (可能 Phase 1 写 User PATH 后本进程还没 pickup)
try {
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
if ($npmPrefix -and (Test-Path $npmPrefix) -and ($env:Path -notlike "*$npmPrefix*")) {
$env:Path = "$npmPrefix;$env:Path"
}
} catch {}
}
if (-not (Test-Cmd "claude")) {
$msg = "[CRITICAL] Claude Code 安装成功但命令不可达`n`n"
$msg += "这通常是 npm 全局路径未进 PATH 导致. 尝试修复:`n`n"
$msg += "1. 关闭本安装器 + 所有终端窗口`n"
$msg += "2. 打开新 PowerShell 跑:`n"
$msg += " `$p=[Environment]::GetEnvironmentVariable('Path','User')`n"
$msg += " [Environment]::SetEnvironmentVariable('Path',`$p+';'+(npm config get prefix),'User')`n"
$msg += "3. 再次关闭所有终端, 重新双击 Bookworm-Setup.exe`n`n"
$msg += "为避免交付无法启动的桌面快捷方式, 本次不创建快捷方式."
Show-MsgBox $msg "快捷方式创建已拒绝 — Claude 不可达" "OK" "Error"
Bw-Log "FAIL" "Claude 命令不可达, 跳过桌面快捷方式创建"
} else {
New-DesktopShortcuts
}
# ========================================================================
# Phase 8: OTA 自动更新基础设施
# ========================================================================
Log-Phase 8 "OTA 自动更新基础设施"
try {
$otaDir = Join-Path $ClaudeDir '.bw-ota'
if (-not (Test-Path $otaDir)) { New-Item -ItemType Directory -Path $otaDir -Force | Out-Null }
# 8a: 写入 Ed25519 签名公钥 (从主机 admin-private 导出, base64 硬编码)
$pubKeyB64 = 'MCowBQYDK2VwAyEAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXo='
$pubKeyPemPath = Join-Path $otaDir 'signing-pubkey.pem'
$pubKeyContent = "-----BEGIN PUBLIC KEY-----`n$pubKeyB64`n-----END PUBLIC KEY-----`n"
# 检查是否需要从 admin-private 读取真实公钥
$adminPub = Join-Path $env:USERPROFILE 'bookworm-admin-private\ed25519-sync.pub.pem'
if (Test-Path $adminPub) {
$pubKeyContent = Get-Content -Raw $adminPub
Log-OK "8a 签名公钥已从 admin-private 读取"
} else {
# 从 .claude/tools/ 导出包中的公钥读取 (Portable config 仓库已含)
$repoPub = Join-Path $ClaudeDir 'bw-signing-pubkey.pem'
if (Test-Path $repoPub) {
$pubKeyContent = Get-Content -Raw $repoPub
Log-OK "8a 签名公钥已从配置仓库读取"
} else {
Log-Warn "8a 签名公钥未找到, OTA 验签将不可用"
}
}
[IO.File]::WriteAllText($pubKeyPemPath, $pubKeyContent, [Text.UTF8Encoding]::new($false))
# 8b: DPAPI 加密 Gitea 凭证 (复用 Phase 3 已缓存的 credential manager)
$credFilePath = Join-Path $otaDir 'pull-cred.dpapi'
$giteaUser = $null; $giteaPass = $null
try {
$fillInput = "protocol=https`nhost=code.letcareme.com`n`n"
$fillOutput = $fillInput | git credential fill 2>$null
if ($fillOutput) {
foreach ($line in ($fillOutput -split "`n")) {
if ($line -match '^username=(.+)') { $giteaUser = $Matches[1].Trim() }
if ($line -match '^password=(.+)') { $giteaPass = $Matches[1].Trim() }
}
}
} catch {}
if ($giteaUser -and $giteaPass) {
Add-Type -AssemblyName System.Security
$credJson = @{ user = $giteaUser; pass = $giteaPass } | ConvertTo-Json -Compress
$plainBytes = [Text.Encoding]::UTF8.GetBytes($credJson)
$encrypted = [Security.Cryptography.ProtectedData]::Protect(
$plainBytes,
[Text.Encoding]::UTF8.GetBytes('bookworm-ota-salt'),
[Security.Cryptography.DataProtectionScope]::CurrentUser
)
[IO.File]::WriteAllBytes($credFilePath, $encrypted)
Log-OK "8b Gitea 凭证已 DPAPI 加密存储"
} else {
Log-Warn "8b Gitea 凭证未找到, OTA 更新将不可用"
}
# 8c: OTA 配置文件
$otaConfig = @{
repoUrl = 'https://code.letcareme.com/bookworm/bookworm-smart-assistant'
checkInterval = 86400
lastCheck = 0
autoUpdate = $false
channel = 'stable'
} | ConvertTo-Json -Depth 4
$otaConfigPath = Join-Path $otaDir 'config.json'
[IO.File]::WriteAllText($otaConfigPath, $otaConfig, [Text.UTF8Encoding]::new($false))
Log-OK "8c OTA 配置已写入"
# 8d: 复制 bw-ota.ps1 (从安装器同目录或 BootDir)
$otaScript = Join-Path $otaDir 'bw-ota.ps1'
$otaSrc = $null
foreach ($candidate in @(
(Join-Path $ScriptDir 'bw-ota.ps1'),
(Join-Path $BootDir 'bw-ota.ps1')
)) {
if (Test-Path $candidate) { $otaSrc = $candidate; break }
}
if ($otaSrc) {
Copy-Item -Path $otaSrc -Destination $otaScript -Force
Log-OK "8d bw-ota.ps1 已部署到 $otaDir"
} else {
Log-Warn "8d bw-ota.ps1 未找到, OTA 功能不可用"
}
# 8e: 写入 VERSION 文件 (首次安装基准)
$versionFile = Join-Path $ClaudeDir 'VERSION'
if (-not (Test-Path $versionFile)) {
$statsFile = Join-Path $ClaudeDir 'stats-compiled.json'
$ver = 'unknown'
if (Test-Path $statsFile) {
try { $ver = (Get-Content -Raw $statsFile | ConvertFrom-Json).version -replace '^v', '' } catch {}
}
[IO.File]::WriteAllText($versionFile, "$ver`n", [Text.UTF8Encoding]::new($false))
Log-OK "8e VERSION 文件已写入: $ver"
}
Log-OK "Phase 8 OTA 基础设施就绪"
} catch {
Bw-Log "WARN" "Phase 8 OTA 设置失败 (不影响主安装): $_"
}
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.2.0: Bun crash 降级逻辑已移除 (Claude Code 2.1.x+ 不再使用 Bun, 无需降级到 2.0.1)
# 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"
}
# v3.2.0: 安装完毕不自动启动终端 (用户通过桌面快捷方式启动)
Bw-Log "INFO" "安装完毕, 用户可通过桌面快捷方式启动 Bookworm"
}
} 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"
Show-MsgBox "安装完成, 但存在以下问题:`n$issueText`n`n请通过桌面快捷方式启动 Bookworm。`n`n日志: $BWLogFile" "安装警告" "OK" "Warning"
}