feat(v3.2.0): Phase 8 OTA 自动更新基础设施
- auto-setup.ps1 v3.2.0: Phase 8 pubkey/DPAPI凭证/bw-ota.ps1 部署 - bw-ota.ps1: OTA 升级脚本 (Ed25519签名验证+SHA256+原子替换+DryRun) - 启动Bookworm.bat: 启动时 fail-open 调用 OTA 检查 - Bookworm-Setup.exe: 重建含以上变更 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b4402d417
commit
aa662d8744
Binary file not shown.
107
auto-setup.ps1
107
auto-setup.ps1
@ -3,7 +3,7 @@
|
|||||||
Bookworm Portable - 全自动一键安装器
|
Bookworm Portable - 全自动一键安装器
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
全新电脑从零到 Bookworm 完全就绪,最大程度自动化。
|
全新电脑从零到 Bookworm 完全就绪,最大程度自动化。
|
||||||
7 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 启动
|
8 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 环境加固 → OTA 基础设施 → 启动
|
||||||
需要人工输入时弹出 GUI 对话框。
|
需要人工输入时弹出 GUI 对话框。
|
||||||
.USAGE
|
.USAGE
|
||||||
.\auto-setup.ps1
|
.\auto-setup.ps1
|
||||||
@ -48,7 +48,7 @@ trap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ─── 版本号 (每次更新递增, build.ps1 自动读取) ──────
|
# ─── 版本号 (每次更新递增, build.ps1 自动读取) ──────
|
||||||
$BWVersion = "3.1.3" # hotfix: 卸载精准删除 (不删用户自有 ~/.claude) + 体检凭证链路检测修正
|
$BWVersion = "3.2.0" # feat: Phase 8 OTA 自动更新基础设施 (pubkey/DPAPI凭证/bw-ota.ps1)
|
||||||
|
|
||||||
# DryRun 模式日志标记
|
# DryRun 模式日志标记
|
||||||
if ($DryRun) { $global:BWDryRun = $DryRun } else { $global:BWDryRun = $null }
|
if ($DryRun) { $global:BWDryRun = $DryRun } else { $global:BWDryRun = $null }
|
||||||
@ -2548,6 +2548,109 @@ if (-not (Test-Cmd "claude")) {
|
|||||||
New-DesktopShortcuts
|
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) {
|
if ($allOK -and $env:ANTHROPIC_API_KEY) {
|
||||||
Bw-Log "DONE" "v$BWVersion 安装成功 ($skillCount Skills / $hookCount Hooks)"
|
Bw-Log "DONE" "v$BWVersion 安装成功 ($skillCount Skills / $hookCount Hooks)"
|
||||||
|
|
||||||
|
|||||||
385
bw-ota.ps1
Normal file
385
bw-ota.ps1
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
#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
|
||||||
@ -31,7 +31,13 @@ if not defined CLAUDE_PS1 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
:: 3. 直调 pwsh + claude.ps1 (无 wt / 无 Base64 / 无 DPAPI in-bat)
|
:: 3. OTA 自动更新检查 (fail-open: 脚本不存在或报错均不阻断启动)
|
||||||
|
set "OTA_SCRIPT=%USERPROFILE%\.claude\.bw-ota\bw-ota.ps1"
|
||||||
|
if exist "%OTA_SCRIPT%" (
|
||||||
|
"%PWSH_EXE%" -NoLogo -ExecutionPolicy Bypass -File "%OTA_SCRIPT%"
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 4. 直调 pwsh + claude.ps1 (无 wt / 无 Base64 / 无 DPAPI in-bat)
|
||||||
:: 凭证由 pwsh profile.ps1 BW_CRED_START..END 块自动加载
|
:: 凭证由 pwsh profile.ps1 BW_CRED_START..END 块自动加载
|
||||||
"%PWSH_EXE%" -NoLogo -NoExit -File "%CLAUDE_PS1%" --dangerously-skip-permissions
|
"%PWSH_EXE%" -NoLogo -NoExit -File "%CLAUDE_PS1%" --dangerously-skip-permissions
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user