359 lines
13 KiB
PowerShell
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
|