diff --git a/Tests/VSCode/Export-VSCodeProfiles.Tests.ps1 b/Tests/VSCode/Export-VSCodeProfiles.Tests.ps1 new file mode 100644 index 0000000..3627b5b --- /dev/null +++ b/Tests/VSCode/Export-VSCodeProfiles.Tests.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS +Pester tests for Export-VSCodeProfiles.ps1 + +.DESCRIPTION +Validates parameter handling, prerequisite checks, profile discovery, +extension export, settings backup, manifest generation, and WhatIf behavior. + +.NOTES + Version: 1.0.0 + Author: chriskyfung + License: GNU GPLv3 license +#> + +#Requires -Version 5.0 +#Requires -Module Pester + +Describe "Export-VSCodeProfiles.ps1" { + BeforeAll { + # Use a temp directory to avoid polluting the user's Documents folder + $testRoot = Join-Path -Path $env:TEMP -ChildPath "Export-VSCodeProfiles.Tests.$([guid]::NewGuid)" + $script:TestRoot = $testRoot + $script:ScriptPath = Join-Path -Path $PSScriptRoot -ChildPath "..\..\VSCode\Export-VSCodeProfiles.ps1" + + # Create test directories + New-Item -ItemType Directory -Path (Join-Path $testRoot "User\globalStorage") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $testRoot "User\snippets") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $testRoot "code") -Force | Out-Null + + # Create a fake storage.json + $script:storageJson = @{ + userDataProfiles = @( + @{ name = "Work" }, + @{ name = "Personal" } + ) + } | ConvertTo-Json -Depth 3 + Set-Content -Path (Join-Path $testRoot "User\globalStorage\storage.json") -Value $script:storageJson -Encoding UTF8 + + # Create fake settings files + Set-Content -Path (Join-Path $testRoot "User\settings.json") -Value '{"editor.fontSize": 14}' -Encoding UTF8 + Set-Content -Path (Join-Path $testRoot "User\keybindings.json") -Value '[]' -Encoding UTF8 + } + + AfterAll { + if ($script:TestRoot -and (Test-Path $script:TestRoot)) { + Remove-Item -Path $script:TestRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context "Parameter validation" { + It "accepts a custom OutputDirectory" { + { & $script:ScriptPath -OutputDirectory (Join-Path $script:TestRoot "out") -VSCodeUserDataPath (Join-Path $script:TestRoot "User") -WhatIf } | Should -Not -Throw + } + + It "accepts a custom VSCodeUserDataPath" { + { & $script:ScriptPath -OutputDirectory (Join-Path $script:TestRoot "out2") -VSCodeUserDataPath (Join-Path $script:TestRoot "User") -WhatIf } | Should -Not -Throw + } + + It "throws for invalid OutputDirectory characters" { + { & $script:ScriptPath -OutputDirectory "C:\bad|path" -WhatIf } | Should -Throw + } + } + + Context "Prerequisite checks" { + It "throws when VSCodeUserDataPath does not exist" { + { & $script:ScriptPath -VSCodeUserDataPath "C:\nonexistent\path" -WhatIf } | Should -Throw "VS Code user data directory not found:*" + } + + It "throws when storage.json is missing" { + Remove-Item -Path (Join-Path $script:TestRoot "User\globalStorage\storage.json") -Force + try { + { & $script:ScriptPath -VSCodeUserDataPath (Join-Path $script:TestRoot "User") -WhatIf } | Should -Throw "VS Code storage.json not found*" + } + finally { + $script:storageJson | Set-Content -Path (Join-Path $script:TestRoot "User\globalStorage\storage.json") -Encoding UTF8 + } + } + + It "throws when 'code' CLI is not in PATH" { + Mock -CommandName Get-Command -ParameterFilter { $Name -eq "code" } -MockWith { return $null } + { & $script:ScriptPath -VSCodeUserDataPath (Join-Path $script:TestRoot "User") -WhatIf } | Should -Throw "VS Code CLI*not available*" + } + } + + Context "Profile discovery" { + It "discovers profiles from storage.json" { + $outputDir = Join-Path $script:TestRoot "out_discover" + $codePath = Join-Path $script:TestRoot "code\code.cmd" + Set-Content -Path $codePath -Value '@echo off' -Encoding ASCII + + & $script:ScriptPath -OutputDirectory $outputDir -VSCodeUserDataPath (Join-Path $script:TestRoot "User") -WhatIf + $outputDir | Should -Not -Exist + } + } + + Context "WhatIf behavior" { + It "does not create output files when -WhatIf is specified" { + $outputDir = Join-Path $script:TestRoot "out_whatif" + { & $script:ScriptPath -OutputDirectory $outputDir -VSCodeUserDataPath (Join-Path $script:TestRoot "User") -WhatIf } | Should -Not -Throw + $outputDir | Should -Not -Exist + } + } +} diff --git a/VSCode/Export-VSCodeProfiles.ps1 b/VSCode/Export-VSCodeProfiles.ps1 index 9cf1163..e63e575 100644 --- a/VSCode/Export-VSCodeProfiles.ps1 +++ b/VSCode/Export-VSCodeProfiles.ps1 @@ -1,33 +1,41 @@ <# .SYNOPSIS - Exports all VS Code user profiles, extensions, settings, and snippets to a timestamped backup folder. +Exports all VS Code profiles, extensions, settings, keybindings, and snippets to a structured backup folder. .DESCRIPTION - This script performs a comprehensive backup of Visual Studio Code configurations by: - - Exporting all user profiles and their installed extensions - - Copying global settings, keybindings, and snippets - - Generating a machine-readable manifest.json for programmatic restoration +Performs a comprehensive backup of all Visual Studio Code user profiles. The script exports: +- Profile metadata (storage.json) +- Installed extensions per profile +- Global settings (settings.json, keybindings.json) +- Code snippets +- A machine-readable manifest.json for restore automation - The output is saved to a timestamped folder in the user's Documents directory. +.PARAMETER OutputDirectory +Specifies the destination folder for the export. Defaults to a timestamped folder in the user's Documents directory. -.PARAMETER WhatIf - Shows what would be exported without actually creating files. +.PARAMETER VSCodeUserDataPath +Specifies the VS Code user data directory. Defaults to the standard location for the current user. .EXAMPLE - .\Export-VSCodeProfiles.ps1 +.\Export-VSCodeProfiles.ps1 - Exports all VS Code profiles to C:\Users\username\Documents\vscode-export-2026-06-01 +Exports all VS Code profiles to a timestamped folder in Documents. .EXAMPLE - .\Export-VSCodeProfiles.ps1 -WhatIf +.\Export-VSCodeProfiles.ps1 -OutputDirectory "C:\Backups\VSCode" - Displays what would be exported without making changes. +Exports all profiles to the specified directory. + +.EXAMPLE +.\Export-VSCodeProfiles.ps1 -WhatIf + +Previews the export operation without creating any files. .NOTES Version: 1.0.0 Author: chriskyfung, Claude Sonnet 4.6, Laguna M.1 License: GNU GPLv3 license - Creation Date: 2026-06-01 + Creation Date: 2026-07-21 Last Modified: 2026-07-21 Prerequisite: PowerShell 5.0+ Requirements: VS Code CLI ('code' command) must be in PATH @@ -36,68 +44,167 @@ #Requires -Version 5.0 #Requires -PSEdition Desktop -[CmdletBinding()] +[CmdletBinding(SupportsShouldProcess = $true)] +param ( + [Parameter(Mandatory = $false, Position = 0)] + [string]$OutputDirectory, -# Script-level error handling -$ErrorActionPreference = 'Stop' + [Parameter(Mandatory = $false)] + [string]$VSCodeUserDataPath = "$env:APPDATA\Code\User" +) -try { - $VSCodeUserDir = "$env:APPDATA\Code\User" - $StorageJson = "$VSCodeUserDir\globalStorage\storage.json" - $OutputDir = "$([Environment]::GetFolderPath('MyDocuments'))\vscode-export-$(Get-Date -Format 'yyyy-MM-dd')" +begin { + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' - New-Item -ItemType Directory -Force -Path "$OutputDir\extensions" | Out-Null - Write-Host "=== VS Code Profile Export ===" -ForegroundColor Cyan - Write-Host "Output: $OutputDir`n" + $storageJsonPath = Join-Path -Path $VSCodeUserDataPath -ChildPath "globalStorage\storage.json" + $timestamp = Get-Date -Format 'yyyy-MM-dd' + $settingsFiles = @("settings.json", "keybindings.json") +} - # Step 1: Save profile metadata - Copy-Item $StorageJson "$OutputDir\storage.json" - Write-Host "[✓] Saved storage.json" +process { + try { + #region Validate Prerequisites + if (-not (Test-Path $VSCodeUserDataPath -PathType Container)) { + throw "VS Code user data directory not found: $VSCodeUserDataPath" + } - # Step 2: Auto-discover profiles - $json = Get-Content $StorageJson | ConvertFrom-Json - $VSCodeProfiles = @("Default") + ($json.userDataProfiles | Select-Object -ExpandProperty name) - Write-Host "[✓] Found profiles: $($VSCodeProfiles -join ', ')`n" + if (-not (Test-Path $storageJsonPath -PathType Leaf)) { + throw "VS Code storage.json not found at '$storageJsonPath'. Is VS Code installed and have profiles been created?" + } - # Step 3: Export extensions per profile - $Manifest = @{ profiles = @() } + if (-not (Get-Command code -ErrorAction SilentlyContinue)) { + throw "VS Code CLI ('code') is not available in PATH. Install it via VS Code Command Palette: 'Shell Command: Install code command in PATH'." + } - foreach ($VSCodeProfile in $VSCodeProfiles) { - $SafeName = $VSCodeProfile -replace '[\s/\\]', '_' - $OutFile = "$OutputDir\extensions\$SafeName.txt" + $outputDir = if ($OutputDirectory) { + [System.IO.Path]::GetFullPath($OutputDirectory) + } + else { + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + Join-Path -Path $documentsPath -ChildPath "vscode-export-$timestamp" + } - Write-Host " Exporting: $VSCodeProfile" - $Exts = code --list-extensions --profile $VSCodeProfile 2>$null - $Exts | Out-File $OutFile -Encoding UTF8 + $extensionsDir = Join-Path -Path $outputDir -ChildPath "extensions" + #endregion - Write-Host " $($Exts.Count) extensions found" - $Manifest.profiles += @{ name = $VSCodeProfile; extension_count = $Exts.Count; extensions = $Exts } - } + #region Initialize Output Directory + if ($PSCmdlet.ShouldProcess($outputDir, "Create export directory")) { + New-Item -ItemType Directory -Path $extensionsDir -Force | Out-Null + Write-Host "=== VS Code Profile Export ===" -ForegroundColor Cyan + Write-Host "Output: $outputDir`n" + } + #endregion - # Step 4: Save global settings - Write-Host "`nSaving global settings..." - foreach ($File in @("settings.json", "keybindings.json")) { - $Src = "$VSCodeUserDir\$File" - if (Test-Path $Src) { - Copy-Item $Src "$OutputDir\$File" - Write-Host " [✓] $File" + #region Step 1: Save Profile Metadata + if ($PSCmdlet.ShouldProcess($storageJsonPath, "Copy profile metadata")) { + Copy-Item -Path $storageJsonPath -Destination (Join-Path $outputDir "storage.json") -Force + Write-Host "[✓] Saved storage.json" } - } - if (Test-Path "$VSCodeUserDir\snippets") { - Copy-Item "$VSCodeUserDir\snippets" "$OutputDir\snippets" -Recurse - Write-Host " [✓] snippets/" - } + #endregion - # Step 5: Write manifest - $Manifest | ConvertTo-Json -Depth 5 | Out-File "$OutputDir\manifest.json" -Encoding UTF8 - Write-Host "`n[✓] Generated manifest.json" + #region Step 2: Discover Profiles + $profileData = Get-Content -Path $storageJsonPath -Raw | ConvertFrom-Json + $userProfiles = @() + if ($profileData.PSObject.Properties['userDataProfiles']) { + $userProfiles = @($profileData.userDataProfiles | Select-Object -ExpandProperty name | Where-Object { $_ -ne "Default" }) + } + $profiles = @("Default") + $userProfiles - Write-Host "`n=== Summary ===" -ForegroundColor Green - $Manifest.profiles | ForEach-Object { Write-Host " $($_.name): $($_.extension_count) extensions" } - Write-Host "`nExport complete: $OutputDir" + if ($userProfiles.Count -eq 0) { + Write-Warning "No additional profiles found in storage.json. Exporting 'Default' profile only." + } -} -catch { - Write-Error "An error occurred: $($_.Exception.Message)" - exit 1 + Write-Host "[✓] Found profiles: $($profiles -join ', ')" + Write-Host "" + #endregion + + #region Step 3: Export Extensions Per Profile + $manifest = @{ profiles = @() } + + foreach ($profileName in $profiles) { + $safeProfileName = $profileName -replace '[\s/\\]', '_' + $outputFilePath = Join-Path -Path $extensionsDir -ChildPath "$safeProfileName.txt" + + if ($PSCmdlet.ShouldProcess("Exporting extensions for profile '$profileName'")) { + Write-Host " Exporting: $profileName" + + try { + $extensions = @(code --list-extensions --profile $profileName 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "code exited with code $LASTEXITCODE" + } + } + catch { + Write-Warning "Failed to list extensions for profile '$profileName'. Skipping: $_" + continue + } + + $extensions | Out-File -FilePath $outputFilePath -Encoding UTF8 + $extensionCount = $extensions.Count + Write-Host " $extensionCount extensions found" + + $manifest.profiles += @{ + name = $profileName + extension_count = $extensionCount + extensions = $extensions + } + } + } + #endregion + + #region Step 4: Save Global Settings + if ($PSCmdlet.ShouldProcess("Saving global settings")) { + Write-Host "" + Write-Host "Saving global settings..." + + foreach ($fileName in $settingsFiles) { + $sourcePath = Join-Path -Path $VSCodeUserDataPath -ChildPath $fileName + $destinationPath = Join-Path -Path $outputDir -ChildPath $fileName + + if (Test-Path -Path $sourcePath -PathType Leaf) { + Copy-Item -Path $sourcePath -Destination $destinationPath -Force + Write-Host " [✓] $fileName" + } + else { + Write-Verbose "File not found, skipping: $sourcePath" + } + } + + $snippetsSourcePath = Join-Path -Path $VSCodeUserDataPath -ChildPath "snippets" + if (Test-Path -Path $snippetsSourcePath -PathType Container) { + Copy-Item -Path $snippetsSourcePath -Destination (Join-Path $outputDir "snippets") -Recurse -Force + Write-Host " [✓] snippets/" + } + else { + Write-Verbose "Snippets folder not found, skipping." + } + } + #endregion + + #region Step 5: Generate Manifest + if ($PSCmdlet.ShouldProcess("Generating manifest.json")) { + $manifestPath = Join-Path -Path $outputDir -ChildPath "manifest.json" + $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $manifestPath -Encoding UTF8 + Write-Host "" + Write-Host "[✓] Generated manifest.json" + } + #endregion + + #region Summary + if ($PSCmdlet.ShouldProcess("Displaying summary")) { + Write-Host "" + Write-Host "=== Summary ===" -ForegroundColor Green + foreach ($entry in $manifest.profiles) { + Write-Host " $($entry.name): $($entry.extension_count) extensions" + } + Write-Host "" + Write-Host "Export complete: $outputDir" + } + #endregion + } + catch { + Write-Error "Export failed: $_" -ErrorAction Continue + throw + } }