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)
    }
}