#Requires -Version 5.1 <# .SYNOPSIS Bookworm Portable OTA 更新检查器 (启动时自动调用) .DESCRIPTION 轻量版同步: 检查远端版本 → 用户确认 → 增量同步 → 验签 → 原子替换. 设计原则: fail-open (任何异常不阻断启动), 24h 冷却, 用户确认制. 与 bookworm-sync.ps1 的区别: - Token 从 DPAPI 加密文件读取 (安装时写入) - Pubkey 内嵌安装目录 - 失败不阻断启动 - 版本相同跳过 .NOTES Author: Bookworm Admin Version: 1.0.0 (2026-04-27) License: Private #> [CmdletBinding()] param( [switch]$Force, [switch]$SkipConfirm, [switch]$DryRun ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $OtaDir = Join-Path $env:USERPROFILE '.claude\.bw-ota' $ClaudeRoot = Join-Path $env:USERPROFILE '.claude' $ConfigFile = Join-Path $OtaDir 'config.json' $CredFile = Join-Path $OtaDir 'pull-cred.dpapi' $PubKeyFile = Join-Path $OtaDir 'signing-pubkey.pem' # ========== 日志 ========== function Write-Ota ($m, $c = 'Cyan') { Write-Host "[Bookworm OTA] $m" -ForegroundColor $c } function Write-OtaOk ($m) { Write-Ota $m 'Green' } function Write-OtaWarn ($m) { Write-Ota $m 'Yellow' } function Write-OtaErr ($m) { Write-Ota $m 'Red' } # ========== OTA 基础设施检查 ========== function Test-OtaReady { if (-not (Test-Path $OtaDir)) { return $false } if (-not (Test-Path $ConfigFile)) { return $false } if (-not (Test-Path $CredFile)) { return $false } if (-not (Test-Path $PubKeyFile)) { return $false } return $true } # ========== 配置读写 ========== function Read-OtaConfig { try { $raw = Get-Content -Raw $ConfigFile if ($raw.Length -gt 0 -and [int][char]$raw[0] -eq 0xFEFF) { $raw = $raw.Substring(1) } return $raw | ConvertFrom-Json } catch { return $null } } function Save-OtaConfig ($cfg) { $json = $cfg | ConvertTo-Json -Depth 4 [IO.File]::WriteAllText($ConfigFile, $json, [Text.UTF8Encoding]::new($false)) } # ========== 冷却检查 (24h) ========== function Test-Cooldown ($cfg) { if ($Force) { return $false } $interval = if ($cfg.checkInterval) { $cfg.checkInterval } else { 86400 } $lastCheck = if ($cfg.lastCheck) { $cfg.lastCheck } else { 0 } $now = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() return ($now - $lastCheck) -lt $interval } # ========== DPAPI 凭证解密 ========== function Read-Credential { try { Add-Type -AssemblyName System.Security $encrypted = [IO.File]::ReadAllBytes($CredFile) $plain = [Security.Cryptography.ProtectedData]::Unprotect( $encrypted, [Text.Encoding]::UTF8.GetBytes('bookworm-ota-salt'), [Security.Cryptography.DataProtectionScope]::CurrentUser ) $json = [Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json return $json } catch { return $null } } # ========== 语义版本比较 (major.minor.patch) ========== function Compare-SemVer ($local, $remote) { $lParts = ($local -replace '^v', '') -split '\.' | ForEach-Object { [int]$_ } $rParts = ($remote -replace '^v', '') -split '\.' | ForEach-Object { [int]$_ } for ($i = 0; $i -lt [Math]::Max($lParts.Count, $rParts.Count); $i++) { $l = if ($i -lt $lParts.Count) { $lParts[$i] } else { 0 } $r = if ($i -lt $rParts.Count) { $rParts[$i] } else { 0 } if ($r -gt $l) { return 1 } if ($r -lt $l) { return -1 } } return 0 } # ========== 本地版本号 ========== function Get-LocalVersion { $versionFile = Join-Path $ClaudeRoot 'VERSION' if (Test-Path $versionFile) { return (Get-Content -Raw $versionFile).Trim() } $statsFile = Join-Path $ClaudeRoot 'stats-compiled.json' if (Test-Path $statsFile) { try { $stats = Get-Content -Raw $statsFile | ConvertFrom-Json return $stats.summary.version } catch { } } return 'unknown' } # ========== Basic Auth 头构造 ========== function Get-AuthHeaders ($cred) { $pair = "$($cred.user):$($cred.pass)" $bytes = [Text.Encoding]::UTF8.GetBytes($pair) $b64 = [Convert]::ToBase64String($bytes) return @{ Authorization = "Basic $b64" } } # ========== 远端版本查询 (Gitea raw, 3s 超时) ========== function Get-RemoteVersion ($cred, $cfg) { $repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' } $apiUrl = "$repoUrl/raw/branch/main/VERSION" try { $headers = Get-AuthHeaders $cred $resp = Invoke-WebRequest -Uri $apiUrl -Headers $headers -UseBasicParsing -TimeoutSec 3 return $resp.Content.Trim() } catch { return $null } } # ========== 远端最新 tag 查询 (备用) ========== function Get-RemoteLatestTag ($cred, $cfg) { $repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' } $host_ = ([Uri]$repoUrl).Host $repoPath = ([Uri]$repoUrl).AbsolutePath.TrimStart('/') $apiUrl = "https://$host_/api/v1/repos/$repoPath/tags?limit=1" try { $headers = Get-AuthHeaders $cred $resp = Invoke-WebRequest -Uri $apiUrl -Headers $headers -UseBasicParsing -TimeoutSec 3 $tags = $resp.Content | ConvertFrom-Json if ($tags.Count -gt 0) { return $tags[0].name } } catch { } return $null } # ========== 同步核心 (精简版 bookworm-sync.ps1) ========== function Invoke-OtaSync ($cred, $cfg, $remoteVersion) { $repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' } $host_ = ([Uri]$repoUrl).Host $repoPath = ([Uri]$repoUrl).AbsolutePath.TrimStart('/') $ref = if ($remoteVersion -and $remoteVersion -match '^v?\d') { $remoteVersion } else { 'main' } if ($ref -notmatch '^v') { $ref = "v$ref" } $stageDir = Join-Path $env:TEMP "bw-ota-$([Guid]::NewGuid().ToString().Substring(0,8))" # 1. Clone (basic auth user:pass) Write-Ota "下载 $ref ..." $escapedUser = [Uri]::EscapeDataString($cred.user) $escapedPass = [Uri]::EscapeDataString($cred.pass) $cloneUrl = "https://${escapedUser}:${escapedPass}@${host_}/${repoPath}.git" $cloneArgs = @('-c', 'core.longpaths=true', 'clone', '--depth', '1', '--branch', $ref, '--single-branch', $cloneUrl, $stageDir) & git @cloneArgs 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "clone 失败 (exit $LASTEXITCODE)" } $gitDir = Join-Path $stageDir '.git' if (Test-Path $gitDir) { Remove-Item -Recurse -Force $gitDir } # 2. 验签 (Ed25519) Write-Ota "验证签名..." $integrityPath = Join-Path $stageDir 'INTEGRITY.sha256' $sigPath = Join-Path $stageDir 'INTEGRITY.sha256.sig' if (-not (Test-Path $integrityPath) -or -not (Test-Path $sigPath)) { throw "包缺失签名文件" } $verifyScript = @' const fs=require('fs'),crypto=require('crypto'); const d=fs.readFileSync(process.argv[2]),s=Buffer.from(fs.readFileSync(process.argv[3],'utf8').trim(),'hex'); const k=crypto.createPublicKey(fs.readFileSync(process.argv[4],'utf8')); process.exit(crypto.verify(null,d,k,s)?0:1); '@ $tmpV = Join-Path $env:TEMP "bw-ota-verify-$([Guid]::NewGuid().ToString().Substring(0,8)).js" [IO.File]::WriteAllText($tmpV, $verifyScript, [Text.UTF8Encoding]::new($false)) node $tmpV $integrityPath $sigPath $PubKeyFile 2>&1 | Out-Null $sigOk = $LASTEXITCODE -eq 0 Remove-Item $tmpV -Force -ErrorAction SilentlyContinue if (-not $sigOk) { throw "Ed25519 验签失败! 包可能被篡改." } Write-OtaOk "签名验证通过" # 3. 逐文件哈希 Write-Ota "校验文件完整性..." $lines = Get-Content $integrityPath $fail = 0 foreach ($line in $lines) { if ($line -notmatch '^([a-f0-9]{64})\s{2}(.+)$') { throw "INTEGRITY 格式错误" } $expected = $Matches[1]; $relPath = $Matches[2] $abs = Join-Path $stageDir $relPath if (-not (Test-Path $abs -PathType Leaf)) { continue } $actual = (Get-FileHash -Path $abs -Algorithm SHA256).Hash.ToLower() if ($actual -ne $expected) { $fail++ } } if ($fail -gt 0) { throw "完整性校验失败: $fail 处哈希不匹配" } Write-OtaOk "文件完整性校验通过 ($($lines.Count) 个文件)" # 4. 渲染 settings.template.json $tplPath = Join-Path $stageDir 'settings.template.json' if (Test-Path $tplPath) { $raw = Get-Content -Raw $tplPath if ($raw.Length -gt 0 -and [int][char]$raw[0] -eq 0xFEFF) { $raw = $raw.Substring(1) } $rootFwd = $ClaudeRoot -replace '\\', '/' $homeFwd = $env:USERPROFILE -replace '\\', '/' $rendered = $raw -replace '\{\{CLAUDE_ROOT\}\}', $rootFwd -replace '\{\{HOME\}\}', $homeFwd $outPath = Join-Path $stageDir 'settings.json' [IO.File]::WriteAllText($outPath, $rendered, [Text.UTF8Encoding]::new($false)) } # 5. 保留本机私有 $preserveList = @( 'memory', 'projects', 'sessions', 'session-env', 'session-state', 'sessions.db', 'tasks', 'teams', 'pinned-sessions.json', 'pinned-sessions.json.tmp', 'history.jsonl', 'evolution-log.jsonl', '.credentials.json', '.bw-token', '.hmac-key', '.skill-cache', 'file-history', 'image-cache', 'paste-cache', 'debug', 'telemetry', 'cache', 'plans', 'plugins', 'shell-snapshots', 'vendor', 'repos', 'backups', 'archives', 'mcp-servers', 'node_modules', 'settings.local.json', 'settings.json.bak.*', 'auto-sync-repos.json', 'scheduled_tasks.lock', '.bw-ota' ) $preserved = 0 if (Test-Path $ClaudeRoot) { foreach ($pat in $preserveList) { $items = @(Get-ChildItem -Path $ClaudeRoot -Filter $pat -Force -ErrorAction SilentlyContinue) foreach ($item in $items) { $target = Join-Path $stageDir $item.Name if (Test-Path $target) { Remove-Item -Recurse -Force $target } Copy-Item -Path $item.FullName -Destination $target -Recurse -Force $preserved++ } } } Write-Ota "已保留 $preserved 项本机数据 (copy)" # DryRun: 验证到此为止, 不做原子替换 if ($DryRun) { $fileCount = (Get-ChildItem -Path $stageDir -Recurse -File).Count Write-OtaOk "[DryRun] 验证通过! staging 含 $fileCount 个文件, 跳过原子替换" Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue return } # 6. 备份 + 原子替换 (先备份再替换, 任一步失败不丢数据) $bakFull = $null if (Test-Path $ClaudeRoot) { $ts = Get-Date -Format 'yyyyMMdd-HHmmss' $bakLeaf = ".claude.bak-$ts" $bakFull = Join-Path (Split-Path -Parent $ClaudeRoot) $bakLeaf try { Rename-Item -Path $ClaudeRoot -NewName $bakLeaf -ErrorAction Stop } catch { throw "无法备份 .claude (可能被占用): $_" } } try { Move-Item -Path $stageDir -Destination $ClaudeRoot -ErrorAction Stop } catch { if ($bakFull -and (Test-Path $bakFull)) { Rename-Item -Path $bakFull -NewName (Split-Path -Leaf $ClaudeRoot) Write-OtaWarn "替换失败, 已回滚到原版本" } throw "替换失败: $_" } # 7. 清理旧备份 (保留最近 3 个) $parent = Split-Path -Parent $ClaudeRoot $baks = @(Get-ChildItem -Path $parent -Directory -Filter ".claude.bak-*" -ErrorAction SilentlyContinue | Sort-Object Name -Descending) if ($baks.Count -gt 3) { $baks | Select-Object -Skip 3 | ForEach-Object { Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue } } # 8. 写入 VERSION 到新 .claude $versionPath = Join-Path $ClaudeRoot 'VERSION' if (-not (Test-Path $versionPath)) { [IO.File]::WriteAllText($versionPath, "$remoteVersion`n", [Text.UTF8Encoding]::new($false)) } Write-OtaOk "更新完成! $ref" } # ========== 主流程 (fail-open 包裹) ========== function Invoke-OtaMain { try { # 基础设施检查 if (-not (Test-OtaReady)) { return } $cfg = Read-OtaConfig if (-not $cfg) { return } # 冷却检查 if (Test-Cooldown $cfg) { return } # 解密凭证 $cred = Read-Credential if (-not $cred -or -not $cred.user -or -not $cred.pass) { Write-OtaWarn "凭证解密失败, 跳过更新检查" return } # 本地 vs 远端版本 $localVer = Get-LocalVersion if ($DryRun) { Write-Ota "[DryRun 模式] 仅验证, 不替换文件" 'Yellow' } Write-Ota "当前版本: $localVer" $remoteVer = Get-RemoteVersion $cred $cfg if (-not $remoteVer) { $remoteVer = Get-RemoteLatestTag $cred $cfg if ($remoteVer) { $remoteVer = $remoteVer -replace '^v', '' } } # 更新 lastCheck $cfg.lastCheck = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() Save-OtaConfig $cfg if (-not $remoteVer) { Write-OtaWarn "无法获取远端版本, 跳过" return } $localClean = ($localVer -replace '^v', '').Trim() $remoteClean = ($remoteVer -replace '^v', '').Trim() if ($localClean -eq $remoteClean) { Write-OtaOk "v$localClean 已是最新" return } $cmp = Compare-SemVer $localClean $remoteClean if ($cmp -le 0) { Write-OtaOk "v$localClean 已是最新 (远端 v$remoteClean 非更高版本)" return } # 发现新版本 (远端 > 本地) Write-Host "" Write-Ota "发现新版本 v$remoteClean (当前 v$localClean)" 'Magenta' if (-not $SkipConfirm) { Write-Host "[Bookworm OTA] 按 Enter 更新 / Ctrl+C 跳过: " -ForegroundColor Yellow -NoNewline try { [void][Console]::ReadLine() } catch { Write-Ota "已跳过更新" return } } Invoke-OtaSync $cred $cfg $remoteClean Write-Host "" } catch { Write-OtaWarn "更新检查异常: $($_.Exception.Message)" Write-OtaWarn "跳过更新, 继续启动..." } } Invoke-OtaMain