From 2bb12fee08a3015f8c96b23c70724590acdd11c8 Mon Sep 17 00:00:00 2001 From: Sascha Haase Date: Fri, 29 May 2026 22:08:15 +0200 Subject: [PATCH 1/2] feat: wire Serilog logging, dynamic post-restore checklist, e2e verification script 1. Serilog logging (foundational): - Add Serilog + RollingFile NuGet packages to App project - Configure rolling file sink in Program.Main -> %LOCALAPPDATA%\ClaudePortable\logs\ - Wire UiLogSink.Append() to also write to Serilog for dual output (file + UI Logs tab) - GUI App.cs initializes ForSubLogger() so both CLI and GUI share the same log stream 2. Dynamic post-restore checklist (actionable): - Rewrite PostRestoreChecklistBuilder.Build() with dynamic content: * Version gate warnings (Warn/Block levels with specific guidance) * Plugin reinstall hints from .claude/plugins/ directory scan * Safety backup paths listing * Per-target restore summary (files written, skipped, warnings) - RestoreEngine now generates checklist via builder instead of copying static file - Add PostRestoreChecklistPath property + OpenChecklistCommand to MainViewModel - Add 'Open Checklist' button in Restore tab XAML (visible only after restore) - Add StringToVisibleConverter for conditional button visibility 3. E2E verification script: - Write scripts/e2e-verify.ps1 with 6-step automated validation: * ZIP extraction and SHA-256 integrity check * Manifest schema validation (required fields, version) * Backup content directory verification * Credential exclusion assertions (tokens.dat, Login Data*, Cookies*, config.json) * MCP server key comparison against expected list * Post-restore checklist file existence and section validation - Update docs/e2e-test.md to reference the working script --- docs/e2e-test.md | 20 +- scripts/e2e-verify.ps1 | 253 ++++++++++++++++++ .../ClaudePortable.App.csproj | 2 + src/ClaudePortable.App/Program.cs | 23 +- src/ClaudePortable.App/Ui/App.cs | 5 + .../Ui/Converters/StringToVisibleConverter.cs | 15 ++ .../Ui/Services/UiLogSink.cs | 3 + .../Ui/ViewModels/MainViewModel.cs | 35 +++ .../Ui/Views/MainWindow.xaml | 5 + .../Post/PostRestoreChecklistBuilder.cs | 132 +++++++-- .../Restore/RestoreEngine.cs | 10 +- 11 files changed, 464 insertions(+), 39 deletions(-) create mode 100644 scripts/e2e-verify.ps1 create mode 100644 src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs diff --git a/docs/e2e-test.md b/docs/e2e-test.md index 003c22b..7a0a948 100644 --- a/docs/e2e-test.md +++ b/docs/e2e-test.md @@ -67,10 +67,16 @@ Run this before tagging a release that changes anything in `ClaudePortable.Core` ## Automating the verification (optional) -A PowerShell verification script template lives in `scripts/e2e-verify.ps1` (TODO, not yet written). It should: -1. Check `Test-Path` for each path in `WindowsPathDiscovery`. -2. Read the restored `claude_desktop_config.json`, assert `mcpServers` keys match the pre-backup capture. -3. Read `extensions-installations.json`, diff extensions list against the backup manifest `sourcePaths`. -4. Assert `config.json` was NOT restored (file size should be 0 or absent). - -This script is a follow-up; see the "Automate E2E verification" issue on the repo. +A PowerShell verification script lives in `scripts/e2e-verify.ps1`. It: +1. Extracts the backup ZIP to a temp directory. +2. Validates `manifest.json` schema, required fields, and SHA-256 integrity. +3. Checks that expected backup content directories exist with file counts. +4. Asserts credential-bearing files (`tokens.dat`, `Login Data*`, `Cookies*`, `config.json`) are correctly excluded. +5. Verifies MCP server keys in `claude_desktop_config.json` against an optional expected list. +6. Checks that a post-restore checklist markdown was generated with required sections. + +Usage: +```powershell +.\scripts\e2e-verify.ps1 -BackupZip ".\claude-backup_20260529.zip" +.\scripts\e2e-verify.ps1 -BackupZip ".\backup.zip" -ExpectedMcpServers @("gmail", "slack") +``` diff --git a/scripts/e2e-verify.ps1 b/scripts/e2e-verify.ps1 new file mode 100644 index 0000000..637756e --- /dev/null +++ b/scripts/e2e-verify.ps1 @@ -0,0 +1,253 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Automated E2E verification for ClaudePortable backup/restore roundtrip. +.DESCRIPTION + Validates that a restored backup ZIP contains expected files, correct + exclusions, and consistent manifest data. Designed to run on VM-B + after a restore from VM-A in the OneDrive roundtrip test playbook. + + Usage: + .\e2e-verify.ps1 -BackupZip "C:\OneDrive\ClaudePortable\claude-backup_20260529.zip" + .\e2e-verify.ps1 -BackupZip ".\backup.zip" -RestoreDir "C:\Temp\restore-check" +.PARAMETER BackupZip + Path to the backup ZIP file to verify. +.PARAMETER RestoreDir + Directory where the ZIP will be extracted for inspection. Defaults to + a temp folder under $env:TEMP\ClaudePortable\e2e-verify-. +.PARAMETER ExpectedMcpServers + Optional JSON array of expected MCP server keys (from pre-backup capture). + Used to assert that mcpServers in claude_desktop_config.json match. +.EXAMPLE + .\e2e-verify.ps1 -BackupZip ".\claude-backup_20260529.zip" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BackupZip, + + [string]$RestoreDir, + + [string[]]$ExpectedMcpServers +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --- Helpers --- +$passed = 0 +$failed = 0 +$warnings = 0 + +function Write-Result { + param( + [string]$Name, + [bool]$Ok, + [string]$Detail = '', + [switch]$Warning + ) + $icon = if ($Ok) { '[PASS]' } elseif ($Warning) { '[WARN]' } else { '[FAIL]' } + $color = if ($Ok) { 'Green' } elseif ($Warning) { 'Yellow' } else { 'Red' } + Write-Host "$icon $Name" -ForegroundColor $color + if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray } + if ($Ok) { $script:passed++ } + elseif ($Warning) { $script:warnings++ } + else { $script:failed++ } +} + +function Test-PathExists { + param([string]$Path, [string]$Label) + if (Test-Path $Path -PathType Leaf) { return $true } + Write-Host " Expected file not found: $Path" -ForegroundColor DarkRed + return $false +} + +function Test-DirExists { + param([string]$Path, [string]$Label) + if (Test-Path $Path -PathType Container) { return $true } + Write-Host " Expected directory not found: $Path" -ForegroundColor DarkRed + return $false +} + +# --- Pre-flight --- +Write-Host '' +Write-Host '========================================' -ForegroundColor Cyan +Write-Host ' ClaudePortable E2E Verification' -ForegroundColor Cyan +Write-Host '========================================' -ForegroundColor Cyan +Write-Host '' + +if (-not (Test-Path $BackupZip)) { + Write-Error "Backup ZIP not found: $BackupZip" + exit 1 +} + +$zipSha = (Get-FileHash $BackupZip -Algorithm SHA256).Hash.ToLower() +Write-Host "[INFO] ZIP SHA-256: $zipSha" -ForegroundColor DarkCyan + +if (-not $RestoreDir) { + $RestoreDir = Join-Path $env:TEMP "ClaudePortable\e2e-verify-$((New-Guid).ToString('N'))" +} +New-Item -ItemType Directory -Force -Path $RestoreDir | Out-Null + +# --- 1. Extract ZIP --- +Write-Host '[1/6] Extracting backup ZIP...' -ForegroundColor Yellow +try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($BackupZip, $RestoreDir) + Write-Result 'ZIP extraction' $true +} catch { + Write-Result 'ZIP extraction' $false "Could not extract: $_" + exit 2 +} + +# --- 2. Validate manifest.json --- +Write-Host '[2/6] Validating manifest...' -ForegroundColor Yellow +$manifestPath = Join-Path $RestoreDir 'manifest.json' +if (-not (Test-PathExists $manifestPath 'manifest.json')) { + Write-Result 'Manifest validation' $false 'manifest.json not found in ZIP root' +} else { + try { + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + Write-Result 'Manifest JSON parse' $true + + # Schema version + if ($manifest.schemaVersion -ge 1) { + Write-Result 'Schema version' $true "v$($manifest.schemaVersion)" + } else { + Write-Result 'Schema version' $false "Unexpected schemaVersion: $($manifest.schemaVersion)" + } + + # Required fields + $requiredFields = @('createdAt', 'hostname', 'windowsUser', 'retentionTier', 'sourcePaths', 'archiveTargets', 'sizeBytes', 'fileCount', 'sha256') + foreach ($field in $requiredFields) { + if ($null -ne $manifest.$field) { + Write-Result "Manifest field: $field" $true + } else { + Write-Result "Manifest field: $field" $false 'Field is null or missing' + } + } + + # SHA-256 consistency check + if ($manifest.sha256 -and $manifest.sha256.ToLower() -eq $zipSha) { + Write-Result 'SHA-256 integrity' $true 'ZIP hash matches manifest' + } elseif ($manifest.sha256) { + Write-Result 'SHA-256 integrity' $false "Expected $($manifest.sha256), got $zipSha" + } else { + Write-Result 'SHA-256 integrity' $false 'Manifest has no sha256 field' -Warning + } + + # Source paths sanity + if ($manifest.sourcePaths.Count -gt 0) { + Write-Result 'Source paths populated' $true "$($manifest.sourcePaths.Count) entries" + } else { + Write-Result 'Source paths populated' $false 'No source paths in manifest' + } + + # Archive targets sanity + if ($manifest.archiveTargets.Count -gt 0) { + Write-Result 'Archive targets populated' $true "$($manifest.archiveTargets.Count) entries" + } else { + Write-Result 'Archive targets populated' $false 'No archive targets in manifest' + } + + } catch { + Write-Result 'Manifest validation' $false "Parse error: $_" + } +} + +# --- 3. Check expected backup content --- +Write-Host '[3/6] Checking backup content...' -ForegroundColor Yellow +$expectedDirs = @( + @{ Path = 'claude-desktop/appdata'; Label = 'Claude Desktop appdata' }, + @{ Path = 'claude-code/dotclaude'; Label = 'Claude Code .claude' } +) + +foreach ($item in $expectedDirs) { + $zipPath = Join-Path $RestoreDir "$($item.Path)" + if (Test-DirExists $zipPath $item.Label) { + $fileCount = (Get-ChildItem $zipPath -Recurse -File).Count + Write-Result "Content: $($item.Label)" $true "$fileCount files" + } else { + Write-Result "Content: $($item.Label)" $false 'Directory not found in backup' + } +} + +# --- 4. Check credential exclusions --- +Write-Host '[4/6] Checking credential exclusions...' -ForegroundColor Yellow +$excludedPatterns = @( + @{ Pattern = '**/tokens.dat'; Label = 'OAuth tokens (tokens.dat)' }, + @{ Pattern = '**/Login Data*'; Label = 'Browser login data' }, + @{ Pattern = '**/Cookies*'; Label = 'Browser cookies' }, + @{ Pattern = '**/config.json'; Label = 'Claude config with tokenCache' } +) + +foreach ($item in $excludedPatterns) { + $matches = Get-ChildItem -Path $RestoreDir -Recurse -File -Filter $item.Pattern -ErrorAction SilentlyContinue + if ($matches.Count -eq 0) { + Write-Result "Exclusion: $($item.Label)" $true 'Not found (correctly excluded)' + } else { + Write-Result "Exclusion: $($item.Label)" $false "Found $($matches.Count) file(s): $($matches.FullName -join ', ')" + } +} + +# --- 5. MCP server verification --- +Write-Host '[5/6] Checking MCP servers...' -ForegroundColor Yellow +$configPath = Join-Path $RestoreDir 'claude-desktop/appdata/claude_desktop_config.json' +if (Test-PathExists $configPath 'claude_desktop_config.json') { + try { + $config = Get-Content $configPath -Raw | ConvertFrom-Json + if ($null -ne $config.mcpServers) { + $mcpKeys = @($config.mcpServers.PSObject.Properties.Name) + Write-Result 'MCP servers in config' $true "$($mcpKeys.Count) server(s): $($mcpKeys -join ', ')" + + if ($ExpectedMcpServers.Count -gt 0) { + $missing = $ExpectedMcpServers | Where-Object { $_ -notin $mcpKeys } + if ($missing.Count -eq 0) { + Write-Result 'MCP servers match expected' $true 'All expected servers present' + } else { + Write-Result 'MCP servers match expected' $false "Missing: $($missing -join ', ')" + } + } + } else { + Write-Result 'MCP servers in config' $false 'mcpServers key not found' -Warning + } + } catch { + Write-Result 'MCP servers parse' $false "Parse error: $_" -Warning + } +} else { + Write-Result 'claude_desktop_config.json' $false 'Not found in backup' -Warning +} + +# --- 6. Post-restore checklist --- +Write-Host '[6/6] Checking post-restore checklist...' -ForegroundColor Yellow +$checklistPattern = 'post-restore-checklist-*.md' +$checklistFiles = Get-ChildItem -Path $env:LOCALAPPDATA\ClaudePortable -Filter $checklistPattern -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending + +if ($checklistFiles.Count -gt 0) { + $latestChecklist = $checklistFiles[0] + Write-Result 'Post-restore checklist exists' $true $latestChecklist.Name + + $content = Get-Content $latestChecklist.FullName -Raw + $requiredSections = @('Required Steps', 'Safety Backups', 'Troubleshooting') + foreach ($section in $requiredSections) { + if ($content -match [regex]::Escape($section)) { + Write-Result "Checklist section: $section" $true + } else { + Write-Result "Checklist section: $section" $false 'Section not found' + } + } +} else { + Write-Result 'Post-restore checklist exists' $false 'No checklist file found in %LOCALAPPDATA%\ClaudePortable\' -Warning +} + +# --- Summary --- +Write-Host '' +Write-Host '========================================' -ForegroundColor Cyan +Write-Host " Results: $passed passed, $failed failed, $warnings warnings" -ForegroundColor $(if ($failed -eq 0) { 'Green' } else { 'Red' }) +Write-Host '========================================' -ForegroundColor Cyan +Write-Host '' + +# Cleanup +try { Remove-Item $RestoreDir -Recurse -Force -ErrorAction SilentlyContinue } catch { } + +exit $(if ($failed -gt 0) { 1 } else { 0 }) diff --git a/src/ClaudePortable.App/ClaudePortable.App.csproj b/src/ClaudePortable.App/ClaudePortable.App.csproj index 3ca7892..b3c021c 100644 --- a/src/ClaudePortable.App/ClaudePortable.App.csproj +++ b/src/ClaudePortable.App/ClaudePortable.App.csproj @@ -7,6 +7,8 @@ + + diff --git a/src/ClaudePortable.App/Program.cs b/src/ClaudePortable.App/Program.cs index 82f686c..dce04c1 100644 --- a/src/ClaudePortable.App/Program.cs +++ b/src/ClaudePortable.App/Program.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Runtime.InteropServices; using ClaudePortable.App.Commands; +using Serilog; using UiApp = ClaudePortable.App.Ui.App; namespace ClaudePortable.App; @@ -12,6 +13,8 @@ public static class Program [STAThread] public static int Main(string[] args) { + ConfigureLogging(); + if (args.Length == 0 || args.Contains("--gui")) { return UiApp.RunGui(); @@ -60,4 +63,22 @@ private static void EnsureConsoleForCli() [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool AttachConsole(int processId); -} + + private static void ConfigureLogging() + { + var logDir = Path.Combine( + Environment.ExpandEnvironmentVariables("%LOCALAPPDATA%"), + "ClaudePortable", + "logs"); + Directory.CreateDirectory(logDir); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + Path.Combine(logDir, "claudeportable-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.Console() + .CreateLogger(); + } diff --git a/src/ClaudePortable.App/Ui/App.cs b/src/ClaudePortable.App/Ui/App.cs index 8998185..0528ad2 100644 --- a/src/ClaudePortable.App/Ui/App.cs +++ b/src/ClaudePortable.App/Ui/App.cs @@ -2,6 +2,7 @@ using System.Runtime.Versioning; using System.Windows; using ClaudePortable.App.Ui.Views; +using Serilog; namespace ClaudePortable.App.Ui; @@ -15,6 +16,10 @@ public static int RunGui() ShutdownMode = ShutdownMode.OnExplicitShutdown, }; + // Wire Serilog as a secondary sink for the GUI so log lines + // appear both in the file and in the UiLogSink (Logs tab). + Log.Logger = Log.Logger.ForSubLogger(); + LoadThemeResources(app); var tray = new TrayIcon(); diff --git a/src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs b/src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs new file mode 100644 index 0000000..c69b0f8 --- /dev/null +++ b/src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs @@ -0,0 +1,15 @@ +using System.Globalization; +using System.Windows.Data; + +namespace ClaudePortable.App.Ui.Converters; + +public sealed class StringToVisibleConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return !string.IsNullOrEmpty(value as string) ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudePortable.App/Ui/Services/UiLogSink.cs b/src/ClaudePortable.App/Ui/Services/UiLogSink.cs index e3fd670..3339a7c 100644 --- a/src/ClaudePortable.App/Ui/Services/UiLogSink.cs +++ b/src/ClaudePortable.App/Ui/Services/UiLogSink.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Globalization; +using Serilog; namespace ClaudePortable.App.Ui.Services; @@ -18,5 +19,7 @@ public void Append(string line) { Entries.RemoveAt(0); } + + Log.Information("{Message}", line); } } diff --git a/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs b/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs index 6e153e6..3d299e7 100644 --- a/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs +++ b/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs @@ -115,10 +115,19 @@ public bool ProgressIsIndeterminate public AsyncRelayCommand RestoreCommand { get; } public AsyncRelayCommand RestoreFromFileCommand { get; } public RelayCommand PickTargetProfileCommand { get; } + public RelayCommand OpenChecklistCommand { get; } public TargetEntry? SelectedTarget { get; set; } public BackupEntry? SelectedBackup { get; set; } + private string _postRestoreChecklistPath = string.Empty; + + public string PostRestoreChecklistPath + { + get => _postRestoreChecklistPath; + private set => SetField(ref _postRestoreChecklistPath, value); + } + public MainViewModel() { foreach (var path in _store.Load()) @@ -149,6 +158,7 @@ public MainViewModel() RestoreCommand = new AsyncRelayCommand(RestoreAsync, () => SelectedBackup is not null); RestoreFromFileCommand = new AsyncRelayCommand(RestoreFromFileAsync); PickTargetProfileCommand = new RelayCommand(PickTargetProfile); + OpenChecklistCommand = new RelayCommand(OpenChecklist, () => !string.IsNullOrEmpty(PostRestoreChecklistPath)); _ = RefreshAsync(); } @@ -376,6 +386,8 @@ private async Task RunRestoreAsync(string zipPath, string displayName) var totalWarnings = outcome.PerTargetReports.Sum(r => r.Warnings.Count); UiLogSink.Instance.Append($"restore complete. safety backups: {outcome.SafetyBackups.Count}, warnings: {totalWarnings}"); UiLogSink.Instance.Append($"version gate: {outcome.VersionGate.Level} - {outcome.VersionGate.Message}"); + + PostRestoreChecklistPath = outcome.PostRestoreChecklistPath; Status = totalWarnings == 0 ? $"Restore complete. Checklist: {outcome.PostRestoreChecklistPath}" : $"Restore complete with {totalWarnings} warning(s). See Logs tab."; @@ -467,6 +479,29 @@ private static async Task EnsureClaudeDesktopClosedAsync() return System.Diagnostics.Process.GetProcessesByName("Claude").Length == 0; } + private void OpenChecklist() + { + if (string.IsNullOrEmpty(PostRestoreChecklistPath) || !File.Exists(PostRestoreChecklistPath)) + { + Status = "No checklist available yet. Run a restore first."; + return; + } + + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = PostRestoreChecklistPath, + UseShellExecute = true, + }); + Status = $"Opened checklist: {PostRestoreChecklistPath}"; + } + catch (Exception ex) + { + Status = $"Could not open checklist: {ex.Message}"; + } + } + /// /// For every detected sync client (OneDrive, Dropbox, GDrive Desktop, ...) /// check whether the user already has a folder called "ClaudePortable" diff --git a/src/ClaudePortable.App/Ui/Views/MainWindow.xaml b/src/ClaudePortable.App/Ui/Views/MainWindow.xaml index db6eb36..63060c6 100644 --- a/src/ClaudePortable.App/Ui/Views/MainWindow.xaml +++ b/src/ClaudePortable.App/Ui/Views/MainWindow.xaml @@ -14,6 +14,7 @@ TextOptions.TextRenderingMode="Grayscale"> + @@ -295,6 +296,10 @@ Margin="0,0,8,0" />