Public/Invoke-BykaDrBackup.ps1
|
function Invoke-BykaDrBackup { <# .SYNOPSIS Capture a full ByKA disaster-recovery backup to an encrypted-USB-ready directory. .DESCRIPTION Three-tier backup of the operator's workstation state: P1 (critical secrets & auth) - ~/.secrets/bykainsights/ (.env, .env.n8n, PROTOCOL) - <project>/.mcp.json + <project>/.vscode/mcp.json + <project>/.mcp-ByKA.json - <project>/credentials.json (multi-AI MCP) - ~/.claude/.credentials.json + ~/.claude/settings.json - ~/.gitconfig P2 (full functionality) - ~/.cloudflared (Cloudflare tunnel state) - ~/.notebooklm/{storage_state,context}.json (OAuth tokens) - ~/.claude-mcp-servers (multi-AI MCP server install) - ~/.claude/runway-api-mcp-server (Runway MCP server) - ~/.claude/projects/*/memory (per-project Claude Code memory) - ~/.claude/hooks (global Claude Code hooks) P3 (environment snapshot) - %APPDATA%/Code/User/settings.json - code --list-extensions output - pip freeze + npm list -g --depth=0 + tool-version snapshot Writes RESTORE-INSTRUCTIONS.md alongside the backup with operator-runnable restore steps. Auto-cleans backups under -Destination older than -KeepDays. Operator-workstation only -- captures live filesystem + registry + tool state. Not usable in headless CI. .PARAMETER Destination Parent directory under which backup-<timestamp>/ folders are created. Defaults to "$env:USERPROFILE\Desktop\BYKA-EMERGENCY". .PARAMETER KeepDays Backups under -Destination older than this many days are deleted at the end of the run. Default 7. Range 1-365. .PARAMETER ProjectRoot Project root path (looked up for .mcp.json / .vscode/mcp.json / .mcp-ByKA.json / credentials.json). Default: auto-detect via 3-tier search (walk up from caller's location -> known candidates -> current working dir, each checked for a CLAUDE.md marker). .EXAMPLE Invoke-BykaDrBackup .EXAMPLE Invoke-BykaDrBackup -Destination 'E:\BYKA-EMERGENCY' -KeepDays 14 .EXAMPLE Invoke-BykaDrBackup -ProjectRoot 'C:\Dev\byka' #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( # [G4] sec F4 + code-review F2: deny-list system paths so a typo in # -Destination can't spill plaintext credentials into Windows / Program Files. [ValidateNotNullOrEmpty()] [ValidateScript({ $full = [System.IO.Path]::GetFullPath($_) $deny = @($env:WINDIR, $env:ProgramFiles, ${env:ProgramFiles(x86)}, "$env:SystemRoot\System32") | Where-Object { $_ } | ForEach-Object { [System.IO.Path]::GetFullPath($_) } foreach ($bad in $deny) { if ($full.StartsWith($bad, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Destination '$full' is inside a protected system path ('$bad'). Pick a user-owned path under $env:USERPROFILE or a removable drive." } } $true })] [string]$Destination = "$env:USERPROFILE\Desktop\BYKA-EMERGENCY", [ValidateRange(1, 365)] [int]$KeepDays = 7, [ValidateNotNullOrEmpty()] [string]$ProjectRoot ) # [G4] sec F2: $local: confines the Stop preference to this function's scope # so it doesn't leak to the caller (PowerShell's $ErrorActionPreference is # dynamically scoped). $local:ErrorActionPreference = "Stop" # [G4] sec F1 defense in depth: refuse a -Destination that is itself a # reparse point (symlink/junction). The cleanup loop and Copy-Item paths # would otherwise follow the link to the real target. -Destination's parent # may legitimately be a normal directory we have to create. if (Test-Path -LiteralPath $Destination) { $destItem = Get-Item -LiteralPath $Destination -Force if ($destItem.LinkType) { throw "-Destination '$Destination' is a $($destItem.LinkType) (target: $($destItem.Target)). Pass a real directory path." } } # [G4] qa F9: include milliseconds so back-to-back invocations don't collide. $timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss_fff" $backupDir = Join-Path $Destination "backup-$timestamp" Write-Host "`n=== BYKA Emergency Backup ===" -ForegroundColor Cyan Write-Host "Destination: $backupDir" -ForegroundColor Yellow Write-Host "" # Create backup directory. [G4] sec F6: -LiteralPath everywhere we touch a # $Destination-derived path so brackets/wildcards in operator paths don't # cause glob expansion. New-Item -ItemType Directory -Force -Path $backupDir | Out-Null # Local results accumulator (Private/Backup-Item.ps1 returns rows; we # collect them here instead of mutating script-scope state as the original # procedural script did). $results = @() # --- PRIORITY 1: Cannot work without these --- Write-Host "`nPriority 1: Critical secrets & auth" -ForegroundColor Cyan $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.secrets\bykainsights" ` -DestSubPath 'secrets\bykainsights' -Label 'Secrets directory (.env, .env.n8n, PROTOCOL)' -IsDir # Project root: explicit param wins over auto-detect. $resolvedProjectRoot = $null if ($PSBoundParameters.ContainsKey('ProjectRoot')) { if (Test-Path -LiteralPath $ProjectRoot -PathType Container) { $resolvedProjectRoot = $ProjectRoot # [G4] sec F9: advisory warn if -ProjectRoot is outside the operator's # expected workspaces. Don't block (legitimate alternate paths exist), # just surface in case a typo points at the wrong dir. $resolvedFull = [System.IO.Path]::GetFullPath($ProjectRoot) $expectedPrefixes = @($env:USERPROFILE, 'C:\Dev') | Where-Object { $_ } | ForEach-Object { [System.IO.Path]::GetFullPath($_) } $matchesExpected = $false foreach ($pfx in $expectedPrefixes) { if ($resolvedFull.StartsWith($pfx, [System.StringComparison]::OrdinalIgnoreCase)) { $matchesExpected = $true; break } } if (-not $matchesExpected) { Write-Host " [WARN] -ProjectRoot '$resolvedFull' is outside expected prefixes ($($expectedPrefixes -join ', ')). Continuing." -ForegroundColor Yellow } } else { Write-Host " [WARN] -ProjectRoot '$ProjectRoot' does not exist. MCP configs will be skipped." -ForegroundColor Yellow } } else { # Try 1: Walk up from the module path. $modulePath = $PSCmdlet.MyInvocation.MyCommand.Module.Path if ($modulePath) { $candidate = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $modulePath)) if (Test-Path (Join-Path $candidate 'CLAUDE.md')) { $resolvedProjectRoot = $candidate } } # Try 2: Known candidates. if (-not $resolvedProjectRoot) { $knownPaths = @('C:\Dev\byka') foreach ($candidate in $knownPaths) { if (Test-Path (Join-Path $candidate 'CLAUDE.md')) { $resolvedProjectRoot = $candidate break } } } # Try 3: Current working directory. # [G4] code-review F16: Get-Location can return a non-filesystem provider # (Cert:\, HKLM:\, etc.) if the operator Set-Location'd into one before # invoking. Guard accordingly. if (-not $resolvedProjectRoot) { $loc = Get-Location if ($loc.Provider.Name -eq 'FileSystem' -and (Test-Path (Join-Path $loc.Path 'CLAUDE.md'))) { $resolvedProjectRoot = $loc.Path } } if (-not $resolvedProjectRoot) { Write-Host " [WARN] Could not find project root (no CLAUDE.md found). MCP configs will be skipped." -ForegroundColor Yellow $resolvedProjectRoot = (Get-Location).Path } } Write-Host " Project root: $resolvedProjectRoot" -ForegroundColor DarkGray $results += Backup-Item -BackupDir $backupDir -Source "$resolvedProjectRoot\.mcp.json" -DestSubPath 'mcp\mcp-cli.json' -Label 'MCP CLI config' $results += Backup-Item -BackupDir $backupDir -Source "$resolvedProjectRoot\.vscode\mcp.json" -DestSubPath 'mcp\mcp-vscode.json' -Label 'MCP VS Code config' $results += Backup-Item -BackupDir $backupDir -Source "$resolvedProjectRoot\.mcp-ByKA.json" -DestSubPath 'mcp\mcp-reference.json' -Label 'MCP reference copy' $results += Backup-Item -BackupDir $backupDir -Source "$resolvedProjectRoot\credentials.json" -DestSubPath 'mcp\credentials.json' -Label 'Multi-AI credentials' $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.claude\.credentials.json" -DestSubPath 'claude\credentials.json' -Label 'Claude Code auth' $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.claude\settings.json" -DestSubPath 'claude\settings.json' -Label 'Claude Code global settings' $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.gitconfig" -DestSubPath 'git\.gitconfig' -Label 'Git config' # --- PRIORITY 2: Full functionality --- Write-Host "`nPriority 2: MCP servers & OAuth tokens" -ForegroundColor Cyan $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.cloudflared" -DestSubPath 'cloudflared' -Label 'Cloudflare tunnel' -IsDir # NotebookLM: skip browser_profile (locked files); copy only auth files. $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.notebooklm\storage_state.json" -DestSubPath 'notebooklm\storage_state.json' -Label 'NotebookLM OAuth token' $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.notebooklm\context.json" -DestSubPath 'notebooklm\context.json' -Label 'NotebookLM context' $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.claude-mcp-servers" -DestSubPath 'claude-mcp-servers' -Label 'Multi-AI MCP server' -IsDir $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.claude\runway-api-mcp-server" -DestSubPath 'runway-api-mcp-server' -Label 'Runway MCP server' -IsDir # Claude Code persistent memory (project-specific). $claudeProjects = Get-ChildItem "$env:USERPROFILE\.claude\projects" -Directory -ErrorAction SilentlyContinue foreach ($proj in $claudeProjects) { $memDir = Join-Path $proj.FullName 'memory' if (Test-Path $memDir) { $results += Backup-Item -BackupDir $backupDir -Source $memDir -DestSubPath "claude-memory\$($proj.Name)" -Label "Claude memory: $($proj.Name)" -IsDir } } # Claude Code hooks (global). $results += Backup-Item -BackupDir $backupDir -Source "$env:USERPROFILE\.claude\hooks" -DestSubPath 'claude\hooks' -Label 'Claude Code global hooks' -IsDir # --- PRIORITY 3: Environment snapshot --- Write-Host "`nPriority 3: Environment snapshot" -ForegroundColor Cyan # VS Code settings. $results += Backup-Item -BackupDir $backupDir -Source "$env:APPDATA\Code\User\settings.json" -DestSubPath 'vscode\settings.json' -Label 'VS Code settings' # VS Code extensions list. # [G4] qa F5/F11: SKIP path must contribute a result row so the summary # counter ($skipCount) is accurate. F5 also filters out stderr lines that # `code` may emit ("WARNING:", "ERROR:") so the .txt output is clean. try { $extensions = & code --list-extensions 2>&1 | Where-Object { $_ -notmatch '^(WARNING|ERROR|DEPRECATION)' } if ($extensions) { New-Item -ItemType Directory -Force -Path (Join-Path $backupDir 'vscode') | Out-Null $extensions | Out-File -Encoding utf8NoBOM -LiteralPath (Join-Path $backupDir 'vscode\extensions.txt') Write-Host " [OK] VS Code extensions list ($($extensions.Count) extensions)" -ForegroundColor Green $results += [PSCustomObject]@{ Item = 'VS Code extensions list'; Status = 'OK'; Detail = "$($extensions.Count) extensions" } } else { Write-Host " [SKIP] VS Code extensions (no output)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'VS Code extensions list'; Status = 'SKIP'; Detail = 'No output' } } } catch { Write-Host " [SKIP] VS Code extensions (code command not available)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'VS Code extensions list'; Status = 'SKIP'; Detail = 'code command not available' } } # Python packages. try { $pipPath = (Get-Command pip -ErrorAction SilentlyContinue).Source if (-not $pipPath) { $pipPath = (Get-Command pip3 -ErrorAction SilentlyContinue).Source } if ($pipPath) { $pipFreeze = & $pipPath freeze 2>&1 | Where-Object { $_ -notmatch '^(WARNING|ERROR|DEPRECATION)' } if ($pipFreeze -and $pipFreeze.Count -gt 0) { New-Item -ItemType Directory -Force -Path (Join-Path $backupDir 'python') | Out-Null $pipFreeze | Out-File -Encoding utf8NoBOM -LiteralPath (Join-Path $backupDir 'python\requirements.txt') Write-Host " [OK] Python packages ($($pipFreeze.Count) packages)" -ForegroundColor Green $results += [PSCustomObject]@{ Item = 'Python packages'; Status = 'OK'; Detail = "$($pipFreeze.Count) packages" } } else { Write-Host " [SKIP] Python packages (pip freeze empty)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'Python packages'; Status = 'SKIP'; Detail = 'pip freeze empty' } } } else { Write-Host " [SKIP] Python packages (pip not found)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'Python packages'; Status = 'SKIP'; Detail = 'pip not found' } } } catch { Write-Host " [SKIP] Python packages ($_)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'Python packages'; Status = 'SKIP'; Detail = $_.Exception.Message } } # npm global packages. try { $npmPath = (Get-Command npm -ErrorAction SilentlyContinue).Source if ($npmPath) { $npmGlobal = & $npmPath list -g --depth=0 2>&1 | Where-Object { $_ -notmatch '^(WARNING|ERROR|DEPRECATION)' } New-Item -ItemType Directory -Force -Path (Join-Path $backupDir 'npm') | Out-Null $npmGlobal | Out-File -Encoding utf8NoBOM -LiteralPath (Join-Path $backupDir 'npm\global-packages.txt') Write-Host " [OK] npm global packages" -ForegroundColor Green $results += [PSCustomObject]@{ Item = 'npm global packages'; Status = 'OK'; Detail = 'captured' } } else { Write-Host " [SKIP] npm global packages (npm not found)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'npm global packages'; Status = 'SKIP'; Detail = 'npm not found' } } } catch { Write-Host " [SKIP] npm global packages ($_)" -ForegroundColor Yellow $results += [PSCustomObject]@{ Item = 'npm global packages'; Status = 'SKIP'; Detail = $_.Exception.Message } } # Tool versions snapshot. # [G4] qa F10: each tool gets its own try/catch so a single tool's failure # doesn't silently abandon the whole snapshot. Failures surface as inline # "<tool>: ERROR - <message>" lines in environment.txt instead of being # silently absent. $versions = @() foreach ($tool in @( @{ Name = 'node'; Args = '-v' }, @{ Name = 'npm'; Args = '-v' }, @{ Name = 'python'; Args = '--version' }, @{ Name = 'git'; Args = '--version' } )) { try { $out = & $tool.Name $tool.Args 2>&1 $versions += "$($tool.Name): $out" } catch { $versions += "$($tool.Name): ERROR - $($_.Exception.Message)" } } $versions += "date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" $versions += "hostname: $env:COMPUTERNAME" $versions += "username: $env:USERNAME" try { $versions | Out-File -Encoding utf8NoBOM -LiteralPath (Join-Path $backupDir 'environment.txt') Write-Host " [OK] Environment info" -ForegroundColor Green } catch { Write-Host " [FAIL] Environment info ($_)" -ForegroundColor Red } # --- SUMMARY --- Write-Host "`n=== BACKUP SUMMARY ===" -ForegroundColor Cyan $results | Format-Table -AutoSize $okCount = ($results | Where-Object { $_.Status -eq 'OK' }).Count $skipCount = ($results | Where-Object { $_.Status -eq 'SKIP' }).Count $failCount = ($results | Where-Object { $_.Status -eq 'FAIL' }).Count Write-Host "Results: $okCount OK, $skipCount skipped, $failCount failed" -ForegroundColor $(if ($failCount -gt 0) { 'Red' } else { 'Green' }) # Total size. [G4] code-review F17: skip reparse points so a copied # directory containing a symlink doesn't inflate the size walk via traversal # outside the backup tree. $totalSize = (Get-ChildItem -Recurse -LiteralPath $backupDir -File -Attributes !ReparsePoint -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum $totalSizeMB = [math]::Round($totalSize / 1MB, 2) Write-Host "Total backup size: $totalSizeMB MB" -ForegroundColor Yellow Write-Host "Location: $backupDir" -ForegroundColor Yellow # --- RESTORE INSTRUCTIONS --- # [G4] sec F5: defang $-sigil and backtick in $backupDir before # interpolation into the double-quoted HEREDOC, so a crafted path can't # trigger subexpression expansion ($()) or backtick escape sequences. # The Destination ValidateScript above also blocks system paths, but this # is defense-in-depth at the interpolation boundary. $backupDirEsc = $backupDir -replace '`', '``' -replace '\$', '`$' $restoreFile = Join-Path $backupDir 'RESTORE-INSTRUCTIONS.md' @" # BYKA Emergency Restore Instructions Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') Source PC: $env:COMPUTERNAME ($env:USERNAME) ## Prerequisite (one-time, on fresh machine) Install the BykaDrBackup PowerShell module so you can re-run backups after restore: ``````powershell Install-PSResource -Name BykaDrBackup -Repository PSGallery -Confirm:`$false `````` ## Quick Restore (copy this backup to new PC, then run): ### 1. Install tools (Admin PowerShell) ``````powershell winget install OpenJS.NodeJS.LTS Python.Python.3.14 Git.Git Microsoft.VisualStudioCode npm install -g @anthropic-ai/claude-code lockstep-mcp pip install -r "$backupDirEsc\python\requirements.txt" `````` ### 2. Restore secrets ``````powershell mkdir -Force "`$HOME\.secrets\bykainsights" Copy-Item "$backupDirEsc\secrets\bykainsights\*" "`$HOME\.secrets\bykainsights\" -Recurse `````` ### 3. Restore Claude Code auth ``````powershell mkdir -Force "`$HOME\.claude" Copy-Item "$backupDirEsc\claude\credentials.json" "`$HOME\.claude\.credentials.json" Copy-Item "$backupDirEsc\claude\settings.json" "`$HOME\.claude\settings.json" `````` ### 4. Restore Git config ``````powershell Copy-Item "$backupDirEsc\git\.gitconfig" "`$HOME\.gitconfig" `````` ### 5. Get project (OneDrive sync or git clone) ``````powershell git clone https://github.com/KAGDACI/byka-digital-business.git cd byka-digital-business npm install `````` ### 6. Restore MCP configs ``````powershell Copy-Item "$backupDirEsc\mcp\mcp-cli.json" "<project>\.mcp.json" mkdir -Force "<project>\.vscode" Copy-Item "$backupDirEsc\mcp\mcp-vscode.json" "<project>\.vscode\mcp.json" Copy-Item "$backupDirEsc\mcp\credentials.json" "<project>\credentials.json" `````` ### 7. Restore MCP servers ``````powershell Copy-Item -Recurse "$backupDirEsc\claude-mcp-servers" "`$HOME\.claude-mcp-servers" Copy-Item -Recurse "$backupDirEsc\runway-api-mcp-server" "`$HOME\.claude\runway-api-mcp-server" `````` ### 8. Restore Claude memory ``````powershell # Find your project hash: ls `$HOME\.claude\projects\ Copy-Item -Recurse "$backupDirEsc\claude-memory\*" "`$HOME\.claude\projects\<HASH>\memory\" `````` ### 9. Restore optional items ``````powershell Copy-Item -Recurse "$backupDirEsc\cloudflared" "`$HOME\.cloudflared" Copy-Item -Recurse "$backupDirEsc\notebooklm" "`$HOME\.notebooklm" `````` ### 10. Fix paths (if username differs) Search-and-replace old username in .mcp.json and .vscode/mcp.json ### 11. Install VS Code extensions ``````powershell Get-Content "$backupDirEsc\vscode\extensions.txt" | ForEach-Object { code --install-extension `$_ } `````` ### 12. Verify ``````powershell cd <project> npm run export # tests n8n + Baserow connections claude # tests Claude Code auth # In Claude: /status # tests project context `````` ## Cloud services (NO action needed) n8n Cloud, Baserow, Fly.io, Cloudflare Pages, Blotato, PiAPI, ElevenLabs API, Telegram Bot "@ | Out-File -Encoding UTF8 -LiteralPath $restoreFile Write-Host "`nRestore instructions saved to: $restoreFile" -ForegroundColor Green # --- CLEANUP OLD BACKUPS --- # [G4] sec F1+F7: resolve both -Destination AND the current run's $backupDir # to canonical paths before any deletion so a symlinked $Destination or a # UNC-vs-local alias for $backupDir can't bypass the same-run guard. The # `backup-*` filter + ParseExact date guard + age check + resolved-path # equality form a 4-layer defense; never relax any of them in isolation. $allBackups = Get-ChildItem -LiteralPath $Destination -Directory -Filter 'backup-*' -ErrorAction SilentlyContinue | Sort-Object Name $cutoffDate = (Get-Date).AddDays(-$KeepDays) $currentRunFull = [System.IO.Path]::GetFullPath($backupDir) $deleted = 0 # [G4] qa F4 + code-review F11: SupportsShouldProcess on the CmdletBinding # already enables -WhatIf / -Confirm at the function boundary; wire # ShouldProcess at each destructive Remove-Item so dry-run actually does # something useful (originally Remove-Item would still fire ignoring -WhatIf). foreach ($old in $allBackups) { # Parse date from folder name: backup-YYYY-MM-DD_HHmmss[_fff]. $datePart = $old.Name -replace '^backup-', '' -replace '_\d{6}(_\d{3})?$', '' try { $folderDate = [datetime]::ParseExact($datePart, 'yyyy-MM-dd', $null) $oldFull = [System.IO.Path]::GetFullPath($old.FullName) if ($folderDate -lt $cutoffDate -and $oldFull -ne $currentRunFull) { if ($PSCmdlet.ShouldProcess($old.FullName, "Remove old backup (older than $KeepDays days)")) { Remove-Item -Recurse -Force -LiteralPath $old.FullName $deleted++ Write-Host " [CLEANED] $($old.Name) (older than $KeepDays days)" -ForegroundColor DarkGray } } } catch { # Skip folders with unparseable names. Defense in depth: the date # parse + age comparison + resolved-path equality together prevent # any traversal-driven recursion outside -Destination. } } $remaining = (Get-ChildItem $Destination -Directory -Filter 'backup-*' -ErrorAction SilentlyContinue).Count if ($deleted -gt 0) { Write-Host "Cleaned $deleted old backup(s). $remaining backup(s) remaining." -ForegroundColor Yellow } else { Write-Host "$remaining backup(s) in $Destination (auto-cleanup after $KeepDays days)" -ForegroundColor DarkGray } Write-Host "`n=== BACKUP COMPLETE ===" -ForegroundColor Cyan Write-Host "`nIMPORTANT: Copy this folder to an encrypted USB drive." -ForegroundColor Red Write-Host "NEVER store on OneDrive/cloud -- contains API keys.`n" -ForegroundColor Red # [G4] qa F1: surface FAIL count via process exit-code so `npm run backup` # and CI wrappers can distinguish silent-partial success from true success. # Using $host.SetShouldExit instead of `exit` because `exit` terminates the # entire host (acceptable for the function's primary launch context via # `pwsh -Command`), while SetShouldExit cleanly signals to the host. if ($failCount -gt 0) { $host.SetShouldExit(1) } } |