#Requires -Version 5.1 <# .SYNOPSIS Bookworm Smart Assistant 跨机同步安装器 (独立 CLI) .DESCRIPTION 功能: 1. 从 Gitea 私库克隆 bookworm-smart-assistant 到临时目录 2. 用带外 Ed25519 公钥校验 INTEGRITY.sha256.sig 3. 逐文件哈希验证 INTEGRITY.sha256 4. 备份现有 ~/.claude 到 ~/.claude.bak- 5. 保留本机私有目录 (memory/projects/sessions/oauth/credentials/...) 6. 渲染 settings.template.json ({{CLAUDE_ROOT}} / {{HOME}} 占位) → settings.json 7. 原子替换 ~/.claude 先决条件 (各机首次安装时): 1. 安装 Node.js 18+ (node --version) 2. 安装 Git 2.20+ (支持 core.longpaths) 3. 从 admin 带外复制公钥到: ~/.bookworm-trust.pem 例: scp admin@mainhost:~/bookworm-admin-private/ed25519-sync.pub.pem ~/.bookworm-trust.pem .PARAMETER Token Gitea 只读 deploy token. 可通过 BOOKWORM_PULL_TOKEN 环境变量提供. .PARAMETER Ref Git 引用 (tag 或 branch), 默认 main. 生产环境建议用 tag: -Ref v6.5.1 .PARAMETER DryRun 只跑下载+验证, 不替换本地 .claude. .EXAMPLE # 首次安装 (token 从环境变量) $env:BOOKWORM_PULL_TOKEN = '' .\bookworm-sync.ps1 -Ref v6.5.1 .EXAMPLE # 校验不替换 .\bookworm-sync.ps1 -DryRun .NOTES Author: Bookworm Admin Version: 1.0.0 (2026-04-21) License: Private #> [CmdletBinding()] param( [string]$Repo = 'bookworm/bookworm-smart-assistant', [string]$GiteaHost = 'code.letcareme.com', [string]$Ref = 'main', [string]$Token = '', [string]$ClaudeRoot = (Join-Path $env:USERPROFILE '.claude'), [string]$TrustPem = (Join-Path $env:USERPROFILE '.bookworm-trust.pem'), [int]$BackupRetain = 3, [switch]$DryRun, [switch]$SkipSignature, [switch]$VerboseDiag ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # ========== 日志 ========== function Write-Info ($m) { Write-Host "[sync] $m" -ForegroundColor Cyan } function Write-Ok ($m) { Write-Host "[sync] OK $m" -ForegroundColor Green } function Write-Warn ($m) { Write-Host "[sync] !! $m" -ForegroundColor Yellow } function Write-Err ($m) { Write-Host "[sync] XX $m" -ForegroundColor Red } function Die ($code, $msg) { Write-Err $msg exit $code } # ========== 先决条件 ========== function Test-Prereqs { # Node try { $nodeVer = (node --version) 2>$null if ($LASTEXITCODE -ne 0) { throw } Write-Info "Node: $nodeVer" } catch { Die 10 "Node.js 未安装. 请先装 Node 18+: https://nodejs.org/" } # Git try { $gitVer = (git --version) 2>$null if ($LASTEXITCODE -ne 0) { throw } Write-Info "Git: $gitVer" } catch { Die 11 "Git 未安装. 请先装 Git 2.20+: https://git-scm.com/" } # 带外公钥 if (-not (Test-Path $TrustPem)) { Write-Err "带外公钥不存在: $TrustPem" Write-Host "" Write-Host "请让 admin 带外分发公钥到本机, 例如:" -ForegroundColor Yellow Write-Host " scp admin@mainhost:~/bookworm-admin-private/ed25519-sync.pub.pem $TrustPem" -ForegroundColor Yellow Write-Host " # 或复制粘贴 PEM 内容到该文件" -ForegroundColor Yellow Die 12 "fail-close: 无信任锚点, 拒绝继续." } # Token $tok = $Token if (-not $tok) { $tok = $env:BOOKWORM_PULL_TOKEN } if (-not $tok) { Die 13 "缺少 Gitea 只读 token. 传 -Token 或设 `$env:BOOKWORM_PULL_TOKEN" } return $tok } # ========== Clone ========== function Invoke-GiteaClone ($token, $stageDir) { Write-Info "克隆 $Repo @ $Ref -> $stageDir" $url = "https://${token}@${GiteaHost}/${Repo}.git" & git -c core.longpaths=true clone --depth 1 --branch $Ref --single-branch $url $stageDir 2>&1 | ForEach-Object { if ($VerboseDiag) { Write-Host $_ } } if ($LASTEXITCODE -ne 0) { Die 20 "clone 失败 (exit $LASTEXITCODE)" } # 立刻撕掉 .git 避免 origin URL 含 token 残留 $gitDir = Join-Path $stageDir '.git' if (Test-Path $gitDir) { Remove-Item -Recurse -Force $gitDir } $required = @('INTEGRITY.sha256', 'INTEGRITY.sha256.sig', 'bw-signing-pubkey.pem', 'MANIFEST.json', 'settings.template.json') foreach ($f in $required) { $p = Join-Path $stageDir $f if (-not (Test-Path $p)) { Die 21 "包缺失关键文件: $f" } } Write-Ok "克隆完成, 关键文件齐全" } # ========== 验签 (用 Node 调 crypto.verify) ========== function Test-Signature ($stageDir) { if ($SkipSignature) { Write-Warn "--SkipSignature 已启用, 跳过验签 (仅开发用, 生产严禁)" return } $integrityPath = Join-Path $stageDir 'INTEGRITY.sha256' $sigPath = Join-Path $stageDir 'INTEGRITY.sha256.sig' $pkgPub = Join-Path $stageDir 'bw-signing-pubkey.pem' # 指纹对比 (带外 vs 包内), 非阻断 $fpScript = @' const fs = require('fs'); const crypto = require('crypto'); const fp = (pem) => crypto.createHash('sha256').update(pem).digest('hex').slice(0,16); const trust = fs.readFileSync(process.argv[2], 'utf8'); const pkg = fs.readFileSync(process.argv[3], 'utf8'); console.log(JSON.stringify({ trustFp: fp(trust), pkgFp: fp(pkg) })); '@ $tmpFp = New-TemporaryFile Set-Content -Path $tmpFp -Value $fpScript -Encoding UTF8 $fpJson = node $tmpFp.FullName $TrustPem $pkgPub 2>&1 Remove-Item $tmpFp.FullName -Force $fp = $fpJson | ConvertFrom-Json if ($fp.trustFp -ne $fp.pkgFp) { Write-Warn "公钥指纹不一致: 带外=$($fp.trustFp) 包内=$($fp.pkgFp)" Write-Warn "以带外为准继续验签; 若持续不一致, 联系 admin 确认是否发生密钥轮换" } else { Write-Info "公钥指纹匹配: $($fp.trustFp)" } # Ed25519 验签 $verifyScript = @' const fs = require('fs'); const crypto = require('crypto'); const integrity = fs.readFileSync(process.argv[2]); const sigHex = fs.readFileSync(process.argv[3], 'utf8').trim(); const sig = Buffer.from(sigHex, 'hex'); const pub = crypto.createPublicKey(fs.readFileSync(process.argv[4], 'utf8')); const ok = crypto.verify(null, integrity, pub, sig); process.exit(ok ? 0 : 1); '@ $tmpV = New-TemporaryFile Set-Content -Path $tmpV -Value $verifyScript -Encoding UTF8 node $tmpV.FullName $integrityPath $sigPath $TrustPem 2>&1 | Out-Null $ok = $LASTEXITCODE -eq 0 Remove-Item $tmpV.FullName -Force if (-not $ok) { Die 30 "Ed25519 验签失败! 包可能被篡改, 拒绝安装." } Write-Ok "Ed25519 验签 PASS" } # ========== 逐文件哈希验证 ========== function Test-FileHashes ($stageDir) { $integrityPath = Join-Path $stageDir 'INTEGRITY.sha256' $lines = Get-Content $integrityPath $total = $lines.Count $fail = 0 $missing = 0 $i = 0 foreach ($line in $lines) { $i++ if ($line -notmatch '^([a-f0-9]{64})\s{2}(.+)$') { Die 40 "INTEGRITY.sha256 格式错误在第 $i 行" } $expected = $Matches[1] $relPath = $Matches[2] $abs = Join-Path $stageDir $relPath if (-not (Test-Path $abs -PathType Leaf)) { $missing++ if ($VerboseDiag) { Write-Warn "缺失: $relPath" } continue } $actual = (Get-FileHash -Path $abs -Algorithm SHA256).Hash.ToLower() if ($actual -ne $expected) { $fail++ Write-Err "哈希不匹配: $relPath" Write-Err " 期望: $expected" Write-Err " 实际: $actual" } } if ($fail -gt 0 -or $missing -gt 0) { Die 41 "完整性校验失败: $fail 处哈希不匹配, $missing 个文件缺失 (共 $total)" } Write-Ok "文件完整性校验 PASS ($total 个文件)" } # ========== 渲染 settings.template.json ========== function Build-Settings ($stageDir, $targetClaudeRoot) { $tplPath = Join-Path $stageDir 'settings.template.json' $outPath = Join-Path $stageDir 'settings.json' $raw = Get-Content -Raw $tplPath # 去 BOM if ($raw.Length -gt 0 -and [int][char]$raw[0] -eq 0xFEFF) { $raw = $raw.Substring(1) } # 统一用正向斜杠 (settings.json 原本即是) $rootFwd = $targetClaudeRoot -replace '\\', '/' $homeFwd = $env:USERPROFILE -replace '\\', '/' $rendered = $raw -replace '\{\{CLAUDE_ROOT\}\}', $rootFwd -replace '\{\{HOME\}\}', $homeFwd if ($rendered -match '\{\{[A-Z_]+\}\}') { Die 50 "settings.template 渲染后仍有未替换占位: $($Matches[0])" } Set-Content -Path $outPath -Value $rendered -Encoding UTF8 -NoNewline Write-Ok "settings.json 已渲染 ({{CLAUDE_ROOT}} -> $rootFwd)" } # ========== 保留本机私有目录 ========== $script: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' ) function Save-LocalState ($oldClaudeRoot, $stageDir) { if (-not (Test-Path $oldClaudeRoot)) { return } $preserved = 0 foreach ($pat in $script:PreserveList) { $items = @(Get-ChildItem -Path $oldClaudeRoot -Filter $pat -Force -ErrorAction SilentlyContinue) foreach ($item in $items) { $target = Join-Path $stageDir $item.Name if (Test-Path $target) { Remove-Item -Recurse -Force $target } try { Move-Item -Path $item.FullName -Destination $target -ErrorAction Stop } catch { # 跨卷可能失败, fallback Copy Copy-Item -Path $item.FullName -Destination $target -Recurse -Force } $preserved++ } } Write-Ok "已保留 $preserved 项本机私有 (memory/sessions/credentials/...)" } # ========== 备份 & 原子替换 ========== function Backup-OldClaude ($oldClaudeRoot) { if (-not (Test-Path $oldClaudeRoot)) { return $null } $ts = Get-Date -Format 'yyyyMMdd-HHmmss' $bakLeaf = (Split-Path -Leaf $oldClaudeRoot) + ".bak-$ts" $bakFull = Join-Path (Split-Path -Parent $oldClaudeRoot) $bakLeaf Write-Info "备份 $oldClaudeRoot -> $bakFull" Rename-Item -Path $oldClaudeRoot -NewName $bakLeaf return $bakFull } function Clear-OldBackups ($claudeRoot) { $parent = Split-Path -Parent $claudeRoot $leaf = Split-Path -Leaf $claudeRoot $baks = @(Get-ChildItem -Path $parent -Directory -Filter "$leaf.bak-*" -ErrorAction SilentlyContinue | Sort-Object Name -Descending) if ($baks.Count -le $BackupRetain) { return } $toRemove = $baks | Select-Object -Skip $BackupRetain foreach ($b in $toRemove) { Write-Info "清理旧备份: $($b.Name)" Remove-Item -Recurse -Force $b.FullName -ErrorAction SilentlyContinue } } function Install-Staging ($stageDir, $claudeRoot, $bakPath) { try { Move-Item -Path $stageDir -Destination $claudeRoot -ErrorAction Stop } catch { Write-Err "切换失败, 尝试回滚..." if ($bakPath -and (Test-Path $bakPath)) { Rename-Item -Path $bakPath -NewName (Split-Path -Leaf $claudeRoot) Write-Warn "已回滚到备份" } Die 60 "Move-Item 失败: $_" } Write-Ok "新 .claude 已就位: $claudeRoot" } # ========== 主流程 ========== function Invoke-Main { Write-Host "" Write-Host "========================================" -ForegroundColor Magenta Write-Host " Bookworm Smart Assistant 同步安装器" -ForegroundColor Magenta Write-Host " Repo: $Repo @ $Ref" -ForegroundColor Magenta Write-Host " Target: $ClaudeRoot" -ForegroundColor Magenta Write-Host "========================================" -ForegroundColor Magenta Write-Host "" $tok = Test-Prereqs $stageDir = Join-Path $env:TEMP "bw-sync-$([Guid]::NewGuid().ToString())" try { Invoke-GiteaClone $tok $stageDir Test-Signature $stageDir Test-FileHashes $stageDir $manifest = Get-Content -Raw (Join-Path $stageDir 'MANIFEST.json') | ConvertFrom-Json Write-Info "版本: $($manifest.version) | 文件数: $($manifest.fileCount) | 指纹: $($manifest.pubKeyFingerprint) | 导出: $($manifest.exportedAt)" if ($DryRun) { Write-Ok "DryRun 完成, 验证全部通过. 清理 staging." Remove-Item -Recurse -Force $stageDir return } Build-Settings $stageDir $ClaudeRoot Save-LocalState $ClaudeRoot $stageDir $bakPath = Backup-OldClaude $ClaudeRoot Install-Staging $stageDir $ClaudeRoot $bakPath Clear-OldBackups $ClaudeRoot Write-Host "" Write-Ok "安装/更新完成!" Write-Info " 版本: $($manifest.version)" Write-Info " 根目录: $ClaudeRoot" if ($bakPath) { Write-Info " 上一版: $bakPath" } Write-Info " 信任锚点: $TrustPem" Write-Host "" } catch { if (Test-Path $stageDir) { Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue } throw } } Invoke-Main