bookworm-smart-assistant/tools/bookworm-sync.ps1

359 lines
13 KiB
PowerShell

#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-<ts>
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 = '<your-readonly-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