Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions Tests/VSCode/Export-VSCodeProfiles.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
233 changes: 170 additions & 63 deletions VSCode/Export-VSCodeProfiles.ps1
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
}