From e39f1b5766938f71f6d34e982f6ef4dc0d911c2c Mon Sep 17 00:00:00 2001 From: "Chris K.Y. FUNG" <8746768+chriskyfung@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:22:00 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20mo?= =?UTF-8?q?dularize=20profile=20export=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert script to functional structure - Add comprehensive error handling and validation - Implement -WhatIf support for dry runs - Improve logging with helper functions - Standardize path handling using Join-Path --- VSCode/Export-VSCodeProfiles.ps1 | 403 +++++++++++++++++++++++++++---- 1 file changed, 356 insertions(+), 47 deletions(-) diff --git a/VSCode/Export-VSCodeProfiles.ps1 b/VSCode/Export-VSCodeProfiles.ps1 index 9cf1163..b701fa6 100644 --- a/VSCode/Export-VSCodeProfiles.ps1 +++ b/VSCode/Export-VSCodeProfiles.ps1 @@ -25,7 +25,7 @@ .NOTES Version: 1.0.0 - Author: chriskyfung, Claude Sonnet 4.6, Laguna M.1 + Author: Chris KY Fung, Claude Sonnet 4.6, Laguna M.1 License: GNU GPLv3 license Creation Date: 2026-06-01 Last Modified: 2026-07-21 @@ -33,71 +33,380 @@ Requirements: VS Code CLI ('code' command) must be in PATH #> -#Requires -Version 5.0 -#Requires -PSEdition Desktop +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [switch]$WhatIf +) -[CmdletBinding()] +#Requires -Version 5.0 # Script-level error handling $ErrorActionPreference = 'Stop' -try { - $VSCodeUserDir = "$env:APPDATA\Code\User" - $StorageJson = "$VSCodeUserDir\globalStorage\storage.json" - $OutputDir = "$([Environment]::GetFolderPath('MyDocuments'))\vscode-export-$(Get-Date -Format 'yyyy-MM-dd')" +#region Constants +$VSCodeUserDir = Join-Path -Path $env:APPDATA -ChildPath 'Code\User' +$StorageJson = Join-Path -Path $VSCodeUserDir -ChildPath 'globalStorage\storage.json' +$Timestamp = Get-Date -Format 'yyyy-MM-dd' +$OutputDir = Join-Path -Path ([Environment]::GetFolderPath('MyDocuments')) -ChildPath "vscode-export-$Timestamp" +$ManifestPath = Join-Path -Path $OutputDir -ChildPath 'manifest.json' +$SettingsFiles = @('settings.json', 'keybindings.json') +#endregion + +#region Helper Functions +function Write-Step { + <# + .SYNOPSIS + Displays formatted status messages for script steps. + #> + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Host " $Message" -ForegroundColor Gray +} + +function Write-Success { + <# + .SYNOPSIS + Displays success messages with checkmark. + #> + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Host "[✓] $Message" -ForegroundColor Green +} + +function Write-Info { + <# + .SYNOPSIS + Displays informational messages. + #> + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet('Cyan', 'Green', 'Yellow', 'Red', 'Gray')] + [string]$Color = 'Cyan' + ) + + Write-Host $Message -ForegroundColor $Color +} +#endregion + +#region Validate Prerequisites +function Test-Prerequisites { + <# + .SYNOPSIS + Validates that required tools and paths are available. + #> + + Write-Info "`n=== Validating Prerequisites ===" -Color Cyan + + # Check VS Code user directory + if (-not (Test-Path $VSCodeUserDir)) { + throw "VS Code user directory not found at: $VSCodeUserDir" + } + + # Check storage.json + if (-not (Test-Path $StorageJson)) { + throw "storage.json not found at: $StorageJson. Ensure VS Code is installed and profiles are configured." + } + + # Check VS Code CLI + try { + $codeVersion = code --version 2>&1 + if (-not $codeVersion) { + throw "VS Code CLI not found in PATH" + } + } + catch { + throw "VS Code CLI ('code' command) is not available. Install it via Command Palette: 'Shell Command: Install `code` command in PATH'" + } + + Write-Success "Prerequisites validated" +} + +function Get-VSCodeProfiles { + <# + .SYNOPSIS + Discovers all VS Code profiles from storage.json. + #> + [OutputType([string[]])] + + param( + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ })] + [string]$StoragePath + ) + + try { + $null = Get-Content -Path $StoragePath -ErrorAction Stop | ConvertFrom-Json + $json = Get-Content -Path $StoragePath -Raw | ConvertFrom-Json + } + catch { + throw "Failed to parse storage.json: $_" + } + + # Get profile names, defaulting to 'Default' if none found + $profileNames = @('Default') + if ($json.userDataProfiles) { + $userProfiles = $json.userDataProfiles | Select-Object -ExpandProperty name -ErrorAction SilentlyContinue + if ($userProfiles) { + $profileNames += $userProfiles + } + } + + return $profileNames +} +#endregion + +#region Export Functions +function Initialize-ExportDirectory { + <# + .SYNOPSIS + Creates the output directory structure. + #> + + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [switch]$WhatIf + ) + + $extensionsDir = Join-Path -Path $Path -ChildPath 'extensions' + + if ($WhatIf) { + Write-Host "[WHATIF] Would create directory: $Path" + Write-Host "[WHATIF] Would create directory: $extensionsDir" + return + } + + try { + New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop | Out-Null + New-Item -ItemType Directory -Path $extensionsDir -Force -ErrorAction Stop | Out-Null + } + catch { + throw "Failed to create output directories: $_" + } +} + +function Export-ProfileExtensions { + <# + .SYNOPSIS + Exports extensions for each VS Code profile. + #> + [OutputType([hashtable[]])] + param( + [Parameter(Mandatory = $true)] + [string[]]$Profiles, + + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, - New-Item -ItemType Directory -Force -Path "$OutputDir\extensions" | Out-Null - Write-Host "=== VS Code Profile Export ===" -ForegroundColor Cyan - Write-Host "Output: $OutputDir`n" + [switch]$WhatIf + ) - # Step 1: Save profile metadata - Copy-Item $StorageJson "$OutputDir\storage.json" - Write-Host "[✓] Saved storage.json" + $manifest = @{ profiles = @() } + $extensionsDir = Join-Path -Path $OutputDirectory -ChildPath 'extensions' - # 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" + foreach ($profileName in $Profiles) { + $safeName = $profileName -replace '[\s/\\]', '_' + $outFile = Join-Path -Path $extensionsDir -ChildPath "$safeName.txt" - # Step 3: Export extensions per profile - $Manifest = @{ profiles = @() } + Write-Info " Exporting: $profileName" -Color Gray - foreach ($VSCodeProfile in $VSCodeProfiles) { - $SafeName = $VSCodeProfile -replace '[\s/\\]', '_' - $OutFile = "$OutputDir\extensions\$SafeName.txt" + if ($WhatIf) { + Write-Host "[WHATIF] Would list extensions for profile: $profileName" + Write-Host "[WHATIF] Would write to: $outFile" + continue + } + + try { + # List extensions for this profile + $extensions = code --list-extensions --profile $profileName 2>$null + + if ($null -eq $extensions) { + $extensions = @() + } + + # Export to file + $extensions | Out-File -FilePath $outFile -Encoding UTF8 -ErrorAction Stop + + Write-Host " $($extensions.Count) extensions found" -ForegroundColor Gray + + # Add to manifest + $manifest.profiles += @{ + name = $profileName + extension_count = $extensions.Count + extensions = $extensions + } + } + catch { + Write-Warning "Failed to export extensions for profile '$profileName': $_" + } + } + + return $manifest.profiles +} + +function Export-Settings { + <# + .SYNOPSIS + Copies global VS Code settings files. + #> + param( + [Parameter(Mandatory = $true)] + [string]$VSCodeUserDir, - Write-Host " Exporting: $VSCodeProfile" - $Exts = code --list-extensions --profile $VSCodeProfile 2>$null - $Exts | Out-File $OutFile -Encoding UTF8 + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, - Write-Host " $($Exts.Count) extensions found" - $Manifest.profiles += @{ name = $VSCodeProfile; extension_count = $Exts.Count; extensions = $Exts } + [switch]$WhatIf + ) + + Write-Info "`nSaving global settings..." -Color Cyan + + foreach ($fileName in $SettingsFiles) { + $sourcePath = Join-Path -Path $VSCodeUserDir -ChildPath $fileName + $destPath = Join-Path -Path $OutputDirectory -ChildPath $fileName + + if ($WhatIf) { + if (Test-Path $sourcePath) { + Write-Host "[WHATIF] Would copy: $fileName" + } + continue + } + + if (Test-Path $sourcePath) { + try { + Copy-Item -Path $sourcePath -Destination $destPath -ErrorAction Stop + Write-Success $fileName + } + catch { + Write-Warning "Failed to copy $fileName : $_" + } + } + else { + Write-Host " [i] $fileName not found (skipped)" -ForegroundColor Yellow + } + } + + # Export snippets folder + $snippetsSource = Join-Path -Path $VSCodeUserDir -ChildPath 'snippets' + $snippetsDest = Join-Path -Path $OutputDirectory -ChildPath 'snippets' + + if ($WhatIf) { + if (Test-Path $snippetsSource) { + Write-Host "[WHATIF] Would copy snippets/ folder" + } + return } - # 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" + if (Test-Path $snippetsSource) { + try { + Copy-Item -Path $snippetsSource -Destination $snippetsDest -Recurse -ErrorAction Stop + Write-Success 'snippets/' + } + catch { + Write-Warning "Failed to copy snippets folder: $_" } } - if (Test-Path "$VSCodeUserDir\snippets") { - Copy-Item "$VSCodeUserDir\snippets" "$OutputDir\snippets" -Recurse - Write-Host " [✓] snippets/" + else { + Write-Host " [i] snippets/ folder not found (skipped)" -ForegroundColor Yellow } +} + +function New-ManifestFile { + <# + .SYNOPSIS + Generates the manifest.json file. + #> + param( + [Parameter(Mandatory = $true)] + [hashtable[]]$ProfileData, - # Step 5: Write manifest - $Manifest | ConvertTo-Json -Depth 5 | Out-File "$OutputDir\manifest.json" -Encoding UTF8 - Write-Host "`n[✓] Generated manifest.json" + [Parameter(Mandatory = $true)] + [string]$OutputPath, - Write-Host "`n=== Summary ===" -ForegroundColor Green - $Manifest.profiles | ForEach-Object { Write-Host " $($_.name): $($_.extension_count) extensions" } - Write-Host "`nExport complete: $OutputDir" + [switch]$WhatIf + ) + if ($WhatIf) { + Write-Host "`n[WHATIF] Would generate manifest.json with $($ProfileData.Count) profile(s)" + return + } + + try { + $manifest = @{ profiles = $ProfileData } + $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $OutputPath -Encoding UTF8 -ErrorAction Stop + Write-Success 'Generated manifest.json' + } + catch { + Write-Warning "Failed to generate manifest.json: $_" + } } -catch { - Write-Error "An error occurred: $($_.Exception.Message)" - exit 1 +#endregion + +#region Main Script +function Main { + <# + .SYNOPSIS + Main script execution flow. + #> + + try { + # Display welcome message + Write-Info "=== VS Code Profile Export ===" -Color Cyan + Write-Info "Output: $OutputDir`n" -Color Cyan + + # Validate prerequisites + Test-Prerequisites + + # Initialize output directory + Initialize-ExportDirectory -Path $OutputDir -WhatIf:$WhatIf + + if (-not $WhatIf) { + # Step 1: Save storage.json + Copy-Item -Path $StorageJson -Destination (Join-Path $OutputDir 'storage.json') -ErrorAction Stop + Write-Success 'Saved storage.json' + } + else { + Write-Host "[WHATIF] Would save storage.json" -ForegroundColor Yellow + } + + # Step 2: Discover profiles + Write-Info '`nDiscovering profiles...' -Color Cyan + $profiles = Get-VSCodeProfiles -StoragePath $StorageJson + Write-Success "Found profiles: $($profiles -join ', ')" + + # Step 3: Export extensions per profile + Write-Info '`nExporting extensions...' -Color Cyan + $manifestData = Export-ProfileExtensions -Profiles $profiles -OutputDirectory $OutputDir -WhatIf:$WhatIf + + # Step 4: Export global settings + Export-Settings -VSCodeUserDir $VSCodeUserDir -OutputDirectory $OutputDir -WhatIf:$WhatIf + + # Step 5: Generate manifest + New-ManifestFile -ProfileData $manifestData -OutputPath $ManifestPath -WhatIf:$WhatIf + + # Display summary + if (-not $WhatIf) { + Write-Info '`n=== Summary ===' -Color Green + foreach ($profile in $manifestData) { + Write-Host " $($profile.name): $($profile.extension_count) extensions" -ForegroundColor Green + } + Write-Host "`nExport complete: $OutputDir" -ForegroundColor Green + } + } + catch { + Write-Error "Script failed: $_" + exit 1 + } } + +# Execute main function +Main From f763a2c059ad9f0f228705bd9199b7ae0677e7b7 Mon Sep 17 00:00:00 2001 From: "Chris K.Y. FUNG" <8746768+chriskyfung@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:55:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20re?= =?UTF-8?q?write=20export=20script=20for=20better=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert function-based flow to begin/process/end - Implement native ShouldProcess for WhatIf support - Add parameter validation for output and data paths - Streamline profile discovery and extension export - Improve error handling and logging consistency --- VSCode/Export-VSCodeProfiles.ps1 | 492 +++++++++---------------------- 1 file changed, 141 insertions(+), 351 deletions(-) diff --git a/VSCode/Export-VSCodeProfiles.ps1 b/VSCode/Export-VSCodeProfiles.ps1 index b701fa6..a1a8aa0 100644 --- a/VSCode/Export-VSCodeProfiles.ps1 +++ b/VSCode/Export-VSCodeProfiles.ps1 @@ -1,412 +1,202 @@ <# .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: Chris KY Fung, Claude Sonnet 4.6, Laguna M.1 + 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 #> [CmdletBinding(SupportsShouldProcess = $true)] -param( - [switch]$WhatIf +param ( + [Parameter(Mandatory = $false, Position = 0)] + [ValidateScript({ if ($_ -match '[\\/:*?"<>|]') { throw "Output directory path contains invalid characters." }; $_ })] + [string]$OutputDirectory, + + [Parameter(Mandatory = $false)] + [ValidateScript({ if (-not (Test-Path $_ -PathType Container)) { throw "VS Code user data directory not found: $_" } })] + [string]$VSCodeUserDataPath = "$env:APPDATA\Code\User" ) -#Requires -Version 5.0 - -# Script-level error handling -$ErrorActionPreference = 'Stop' - -#region Constants -$VSCodeUserDir = Join-Path -Path $env:APPDATA -ChildPath 'Code\User' -$StorageJson = Join-Path -Path $VSCodeUserDir -ChildPath 'globalStorage\storage.json' -$Timestamp = Get-Date -Format 'yyyy-MM-dd' -$OutputDir = Join-Path -Path ([Environment]::GetFolderPath('MyDocuments')) -ChildPath "vscode-export-$Timestamp" -$ManifestPath = Join-Path -Path $OutputDir -ChildPath 'manifest.json' -$SettingsFiles = @('settings.json', 'keybindings.json') -#endregion - -#region Helper Functions -function Write-Step { - <# - .SYNOPSIS - Displays formatted status messages for script steps. - #> - param( - [Parameter(Mandatory = $true)] - [string]$Message - ) - - Write-Host " $Message" -ForegroundColor Gray -} - -function Write-Success { - <# - .SYNOPSIS - Displays success messages with checkmark. - #> - param( - [Parameter(Mandatory = $true)] - [string]$Message - ) - - Write-Host "[✓] $Message" -ForegroundColor Green -} - -function Write-Info { - <# - .SYNOPSIS - Displays informational messages. - #> - param( - [Parameter(Mandatory = $true)] - [string]$Message, - - [ValidateSet('Cyan', 'Green', 'Yellow', 'Red', 'Gray')] - [string]$Color = 'Cyan' - ) +begin { + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' - Write-Host $Message -ForegroundColor $Color + $script:StorageJsonPath = Join-Path -Path $VSCodeUserDataPath -ChildPath "globalStorage\storage.json" + $script:Timestamp = Get-Date -Format 'yyyy-MM-dd' } -#endregion - -#region Validate Prerequisites -function Test-Prerequisites { - <# - .SYNOPSIS - Validates that required tools and paths are available. - #> - - Write-Info "`n=== Validating Prerequisites ===" -Color Cyan - - # Check VS Code user directory - if (-not (Test-Path $VSCodeUserDir)) { - throw "VS Code user directory not found at: $VSCodeUserDir" - } - # Check storage.json - if (-not (Test-Path $StorageJson)) { - throw "storage.json not found at: $StorageJson. Ensure VS Code is installed and profiles are configured." - } - - # Check VS Code CLI +process { try { - $codeVersion = code --version 2>&1 - if (-not $codeVersion) { - throw "VS Code CLI not found in PATH" + #region Validate Prerequisites + if (-not (Test-Path $script:StorageJsonPath -PathType Leaf)) { + throw "VS Code storage.json not found at '$script:StorageJsonPath'. Is VS Code installed and have profiles been created?" } - } - catch { - throw "VS Code CLI ('code' command) is not available. Install it via Command Palette: 'Shell Command: Install `code` command in PATH'" - } - - Write-Success "Prerequisites validated" -} - -function Get-VSCodeProfiles { - <# - .SYNOPSIS - Discovers all VS Code profiles from storage.json. - #> - [OutputType([string[]])] - - param( - [Parameter(Mandatory = $true)] - [ValidateScript({ Test-Path $_ })] - [string]$StoragePath - ) - try { - $null = Get-Content -Path $StoragePath -ErrorAction Stop | ConvertFrom-Json - $json = Get-Content -Path $StoragePath -Raw | ConvertFrom-Json - } - catch { - throw "Failed to parse storage.json: $_" - } - - # Get profile names, defaulting to 'Default' if none found - $profileNames = @('Default') - if ($json.userDataProfiles) { - $userProfiles = $json.userDataProfiles | Select-Object -ExpandProperty name -ErrorAction SilentlyContinue - if ($userProfiles) { - $profileNames += $userProfiles + 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'." } - } - - return $profileNames -} -#endregion - -#region Export Functions -function Initialize-ExportDirectory { - <# - .SYNOPSIS - Creates the output directory structure. - #> - param( - [Parameter(Mandatory = $true)] - [string]$Path, - - [switch]$WhatIf - ) - - $extensionsDir = Join-Path -Path $Path -ChildPath 'extensions' - - if ($WhatIf) { - Write-Host "[WHATIF] Would create directory: $Path" - Write-Host "[WHATIF] Would create directory: $extensionsDir" - return - } - - try { - New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop | Out-Null - New-Item -ItemType Directory -Path $extensionsDir -Force -ErrorAction Stop | Out-Null - } - catch { - throw "Failed to create output directories: $_" - } -} - -function Export-ProfileExtensions { - <# - .SYNOPSIS - Exports extensions for each VS Code profile. - #> - [OutputType([hashtable[]])] - param( - [Parameter(Mandatory = $true)] - [string[]]$Profiles, - - [Parameter(Mandatory = $true)] - [string]$OutputDirectory, - - [switch]$WhatIf - ) - - $manifest = @{ profiles = @() } - $extensionsDir = Join-Path -Path $OutputDirectory -ChildPath 'extensions' + $script:OutputDir = if ($OutputDirectory) { + [System.IO.Path]::GetFullPath($OutputDirectory) + } + else { + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + Join-Path -Path $documentsPath -ChildPath "vscode-export-$($script:Timestamp)" + } - foreach ($profileName in $Profiles) { - $safeName = $profileName -replace '[\s/\\]', '_' - $outFile = Join-Path -Path $extensionsDir -ChildPath "$safeName.txt" + $script:ExtensionsDir = Join-Path -Path $script:OutputDir -ChildPath "extensions" + #endregion - Write-Info " Exporting: $profileName" -Color Gray + #region Initialize Output Directory + if ($PSCmdlet.ShouldProcess($script:OutputDir, "Create export directory")) { + New-Item -ItemType Directory -Path $script:ExtensionsDir -Force | Out-Null + Write-Host "=== VS Code Profile Export ===" -ForegroundColor Cyan + Write-Host "Output: $script:OutputDir`n" + } + #endregion - if ($WhatIf) { - Write-Host "[WHATIF] Would list extensions for profile: $profileName" - Write-Host "[WHATIF] Would write to: $outFile" - continue + #region Step 1: Save Profile Metadata + if ($PSCmdlet.ShouldProcess($script:StorageJsonPath, "Copy profile metadata")) { + Copy-Item -Path $script:StorageJsonPath -Destination (Join-Path $script:OutputDir "storage.json") -Force + Write-Host "[✓] Saved storage.json" } + #endregion - try { - # List extensions for this profile - $extensions = code --list-extensions --profile $profileName 2>$null + #region Step 2: Discover Profiles + if ($PSCmdlet.ShouldProcess("Discovering VS Code profiles")) { + $profileData = Get-Content -Path $script:StorageJsonPath -Raw | ConvertFrom-Json + $script:Profiles = @("Default") + ($profileData.userDataProfiles | Select-Object -ExpandProperty name | Where-Object { $_ -ne "Default" }) - if ($null -eq $extensions) { - $extensions = @() + if (-not $script:Profiles -or $script:Profiles.Count -eq 0) { + Write-Warning "No profiles found in storage.json." + return } - # Export to file - $extensions | Out-File -FilePath $outFile -Encoding UTF8 -ErrorAction Stop - - Write-Host " $($extensions.Count) extensions found" -ForegroundColor Gray - - # Add to manifest - $manifest.profiles += @{ - name = $profileName - extension_count = $extensions.Count - extensions = $extensions - } - } - catch { - Write-Warning "Failed to export extensions for profile '$profileName': $_" + Write-Host "[✓] Found profiles: $($script:Profiles -join ', ')" + Write-Host "" } - } + #endregion - return $manifest.profiles -} + #region Step 3: Export Extensions Per Profile + $script:Manifest = @{ profiles = @() } -function Export-Settings { - <# - .SYNOPSIS - Copies global VS Code settings files. - #> - param( - [Parameter(Mandatory = $true)] - [string]$VSCodeUserDir, + foreach ($profile in $script:Profiles) { + $safeProfileName = $profile -replace '[\s/\\]', '_' + $outputFilePath = Join-Path -Path $script:ExtensionsDir -ChildPath "$safeProfileName.txt" - [Parameter(Mandatory = $true)] - [string]$OutputDirectory, + if ($PSCmdlet.ShouldProcess("Exporting extensions for profile '$profile'")) { + Write-Host " Exporting: $profile" - [switch]$WhatIf - ) + $extensions = @(code --list-extensions --profile $profile 2>$null) - Write-Info "`nSaving global settings..." -Color Cyan + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to list extensions for profile '$profile'. Skipping." + continue + } - foreach ($fileName in $SettingsFiles) { - $sourcePath = Join-Path -Path $VSCodeUserDir -ChildPath $fileName - $destPath = Join-Path -Path $OutputDirectory -ChildPath $fileName + $extensions | Out-File -FilePath $outputFilePath -Encoding UTF8 + $extensionCount = $extensions.Count + Write-Host " $extensionCount extensions found" - if ($WhatIf) { - if (Test-Path $sourcePath) { - Write-Host "[WHATIF] Would copy: $fileName" + $script:Manifest.profiles += @{ + name = $profile + extension_count = $extensionCount + extensions = $extensions + } } - continue } + #endregion + + #region Step 4: Save Global Settings + if ($PSCmdlet.ShouldProcess("Saving global settings")) { + Write-Host "" + Write-Host "Saving global settings..." + + $settingsFiles = @("settings.json", "keybindings.json") + foreach ($fileName in $settingsFiles) { + $sourcePath = Join-Path -Path $VSCodeUserDataPath -ChildPath $fileName + $destinationPath = Join-Path -Path $script: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" + } + } - if (Test-Path $sourcePath) { - try { - Copy-Item -Path $sourcePath -Destination $destPath -ErrorAction Stop - Write-Success $fileName + $snippetsSourcePath = Join-Path -Path $VSCodeUserDataPath -ChildPath "snippets" + if (Test-Path -Path $snippetsSourcePath -PathType Container) { + Copy-Item -Path $snippetsSourcePath -Destination (Join-Path $script:OutputDir "snippets") -Recurse -Force + Write-Host " [✓] snippets/" } - catch { - Write-Warning "Failed to copy $fileName : $_" + else { + Write-Verbose "Snippets folder not found, skipping." } } - else { - Write-Host " [i] $fileName not found (skipped)" -ForegroundColor Yellow - } - } - - # Export snippets folder - $snippetsSource = Join-Path -Path $VSCodeUserDir -ChildPath 'snippets' - $snippetsDest = Join-Path -Path $OutputDirectory -ChildPath 'snippets' - - if ($WhatIf) { - if (Test-Path $snippetsSource) { - Write-Host "[WHATIF] Would copy snippets/ folder" - } - return - } - - if (Test-Path $snippetsSource) { - try { - Copy-Item -Path $snippetsSource -Destination $snippetsDest -Recurse -ErrorAction Stop - Write-Success 'snippets/' - } - catch { - Write-Warning "Failed to copy snippets folder: $_" - } - } - else { - Write-Host " [i] snippets/ folder not found (skipped)" -ForegroundColor Yellow - } -} - -function New-ManifestFile { - <# - .SYNOPSIS - Generates the manifest.json file. - #> - param( - [Parameter(Mandatory = $true)] - [hashtable[]]$ProfileData, - - [Parameter(Mandatory = $true)] - [string]$OutputPath, - - [switch]$WhatIf - ) - - if ($WhatIf) { - Write-Host "`n[WHATIF] Would generate manifest.json with $($ProfileData.Count) profile(s)" - return - } - - try { - $manifest = @{ profiles = $ProfileData } - $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $OutputPath -Encoding UTF8 -ErrorAction Stop - Write-Success 'Generated manifest.json' - } - catch { - Write-Warning "Failed to generate manifest.json: $_" - } -} -#endregion - -#region Main Script -function Main { - <# - .SYNOPSIS - Main script execution flow. - #> - - try { - # Display welcome message - Write-Info "=== VS Code Profile Export ===" -Color Cyan - Write-Info "Output: $OutputDir`n" -Color Cyan - - # Validate prerequisites - Test-Prerequisites - - # Initialize output directory - Initialize-ExportDirectory -Path $OutputDir -WhatIf:$WhatIf - - if (-not $WhatIf) { - # Step 1: Save storage.json - Copy-Item -Path $StorageJson -Destination (Join-Path $OutputDir 'storage.json') -ErrorAction Stop - Write-Success 'Saved storage.json' - } - else { - Write-Host "[WHATIF] Would save storage.json" -ForegroundColor Yellow + #endregion + + #region Step 5: Generate Manifest + if ($PSCmdlet.ShouldProcess("Generating manifest.json")) { + $manifestPath = Join-Path -Path $script:OutputDir -ChildPath "manifest.json" + $script:Manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $manifestPath -Encoding UTF8 + Write-Host "" + Write-Host "[✓] Generated manifest.json" } - - # Step 2: Discover profiles - Write-Info '`nDiscovering profiles...' -Color Cyan - $profiles = Get-VSCodeProfiles -StoragePath $StorageJson - Write-Success "Found profiles: $($profiles -join ', ')" - - # Step 3: Export extensions per profile - Write-Info '`nExporting extensions...' -Color Cyan - $manifestData = Export-ProfileExtensions -Profiles $profiles -OutputDirectory $OutputDir -WhatIf:$WhatIf - - # Step 4: Export global settings - Export-Settings -VSCodeUserDir $VSCodeUserDir -OutputDirectory $OutputDir -WhatIf:$WhatIf - - # Step 5: Generate manifest - New-ManifestFile -ProfileData $manifestData -OutputPath $ManifestPath -WhatIf:$WhatIf - - # Display summary - if (-not $WhatIf) { - Write-Info '`n=== Summary ===' -Color Green - foreach ($profile in $manifestData) { - Write-Host " $($profile.name): $($profile.extension_count) extensions" -ForegroundColor Green + #endregion + + #region Summary + if ($PSCmdlet.ShouldProcess("Displaying summary")) { + Write-Host "" + Write-Host "=== Summary ===" -ForegroundColor Green + foreach ($profile in $script:Manifest.profiles) { + Write-Host " $($profile.name): $($profile.extension_count) extensions" } - Write-Host "`nExport complete: $OutputDir" -ForegroundColor Green + Write-Host "" + Write-Host "Export complete: $script:OutputDir" } + #endregion } catch { - Write-Error "Script failed: $_" + Write-Error "Export failed: $_" exit 1 } } -# Execute main function -Main +end { + # Cleanup if needed +} From 30633627304657a5053fd296713ad696b1169a48 Mon Sep 17 00:00:00 2001 From: "Chris K.Y. FUNG" <8746768+chriskyfung@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:28:22 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20im?= =?UTF-8?q?prove=20export=20script=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace script-scoped variables with locals - Add explicit PowerShell version requirements - Improve profile discovery and error handling - Move validation logic into the process block - Standardize error reporting and flow control --- VSCode/Export-VSCodeProfiles.ps1 | 108 +++++++++++++++++-------------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/VSCode/Export-VSCodeProfiles.ps1 b/VSCode/Export-VSCodeProfiles.ps1 index a1a8aa0..e63e575 100644 --- a/VSCode/Export-VSCodeProfiles.ps1 +++ b/VSCode/Export-VSCodeProfiles.ps1 @@ -37,16 +37,19 @@ Previews the export operation without creating any files. License: GNU GPLv3 license Creation Date: 2026-07-21 Last Modified: 2026-07-21 + Prerequisite: PowerShell 5.0+ + Requirements: VS Code CLI ('code' command) must be in PATH #> +#Requires -Version 5.0 +#Requires -PSEdition Desktop + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $false, Position = 0)] - [ValidateScript({ if ($_ -match '[\\/:*?"<>|]') { throw "Output directory path contains invalid characters." }; $_ })] [string]$OutputDirectory, [Parameter(Mandatory = $false)] - [ValidateScript({ if (-not (Test-Path $_ -PathType Container)) { throw "VS Code user data directory not found: $_" } })] [string]$VSCodeUserDataPath = "$env:APPDATA\Code\User" ) @@ -54,76 +57,86 @@ begin { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - $script:StorageJsonPath = Join-Path -Path $VSCodeUserDataPath -ChildPath "globalStorage\storage.json" - $script:Timestamp = Get-Date -Format 'yyyy-MM-dd' + $storageJsonPath = Join-Path -Path $VSCodeUserDataPath -ChildPath "globalStorage\storage.json" + $timestamp = Get-Date -Format 'yyyy-MM-dd' + $settingsFiles = @("settings.json", "keybindings.json") } process { try { #region Validate Prerequisites - if (-not (Test-Path $script:StorageJsonPath -PathType Leaf)) { - throw "VS Code storage.json not found at '$script:StorageJsonPath'. Is VS Code installed and have profiles been created?" + if (-not (Test-Path $VSCodeUserDataPath -PathType Container)) { + throw "VS Code user data directory not found: $VSCodeUserDataPath" + } + + 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?" } 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'." } - $script:OutputDir = if ($OutputDirectory) { + $outputDir = if ($OutputDirectory) { [System.IO.Path]::GetFullPath($OutputDirectory) } else { $documentsPath = [Environment]::GetFolderPath('MyDocuments') - Join-Path -Path $documentsPath -ChildPath "vscode-export-$($script:Timestamp)" + Join-Path -Path $documentsPath -ChildPath "vscode-export-$timestamp" } - $script:ExtensionsDir = Join-Path -Path $script:OutputDir -ChildPath "extensions" + $extensionsDir = Join-Path -Path $outputDir -ChildPath "extensions" #endregion #region Initialize Output Directory - if ($PSCmdlet.ShouldProcess($script:OutputDir, "Create export directory")) { - New-Item -ItemType Directory -Path $script:ExtensionsDir -Force | Out-Null + 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: $script:OutputDir`n" + Write-Host "Output: $outputDir`n" } #endregion #region Step 1: Save Profile Metadata - if ($PSCmdlet.ShouldProcess($script:StorageJsonPath, "Copy profile metadata")) { - Copy-Item -Path $script:StorageJsonPath -Destination (Join-Path $script:OutputDir "storage.json") -Force + if ($PSCmdlet.ShouldProcess($storageJsonPath, "Copy profile metadata")) { + Copy-Item -Path $storageJsonPath -Destination (Join-Path $outputDir "storage.json") -Force Write-Host "[✓] Saved storage.json" } #endregion #region Step 2: Discover Profiles - if ($PSCmdlet.ShouldProcess("Discovering VS Code profiles")) { - $profileData = Get-Content -Path $script:StorageJsonPath -Raw | ConvertFrom-Json - $script:Profiles = @("Default") + ($profileData.userDataProfiles | Select-Object -ExpandProperty name | Where-Object { $_ -ne "Default" }) - - if (-not $script:Profiles -or $script:Profiles.Count -eq 0) { - Write-Warning "No profiles found in storage.json." - return - } + $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 "[✓] Found profiles: $($script:Profiles -join ', ')" - Write-Host "" + if ($userProfiles.Count -eq 0) { + Write-Warning "No additional profiles found in storage.json. Exporting 'Default' profile only." } + + Write-Host "[✓] Found profiles: $($profiles -join ', ')" + Write-Host "" #endregion #region Step 3: Export Extensions Per Profile - $script:Manifest = @{ profiles = @() } - - foreach ($profile in $script:Profiles) { - $safeProfileName = $profile -replace '[\s/\\]', '_' - $outputFilePath = Join-Path -Path $script:ExtensionsDir -ChildPath "$safeProfileName.txt" + $manifest = @{ profiles = @() } - if ($PSCmdlet.ShouldProcess("Exporting extensions for profile '$profile'")) { - Write-Host " Exporting: $profile" + foreach ($profileName in $profiles) { + $safeProfileName = $profileName -replace '[\s/\\]', '_' + $outputFilePath = Join-Path -Path $extensionsDir -ChildPath "$safeProfileName.txt" - $extensions = @(code --list-extensions --profile $profile 2>$null) + if ($PSCmdlet.ShouldProcess("Exporting extensions for profile '$profileName'")) { + Write-Host " Exporting: $profileName" - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to list extensions for profile '$profile'. Skipping." + 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 } @@ -131,8 +144,8 @@ process { $extensionCount = $extensions.Count Write-Host " $extensionCount extensions found" - $script:Manifest.profiles += @{ - name = $profile + $manifest.profiles += @{ + name = $profileName extension_count = $extensionCount extensions = $extensions } @@ -145,10 +158,9 @@ process { Write-Host "" Write-Host "Saving global settings..." - $settingsFiles = @("settings.json", "keybindings.json") foreach ($fileName in $settingsFiles) { $sourcePath = Join-Path -Path $VSCodeUserDataPath -ChildPath $fileName - $destinationPath = Join-Path -Path $script:OutputDir -ChildPath $fileName + $destinationPath = Join-Path -Path $outputDir -ChildPath $fileName if (Test-Path -Path $sourcePath -PathType Leaf) { Copy-Item -Path $sourcePath -Destination $destinationPath -Force @@ -161,7 +173,7 @@ process { $snippetsSourcePath = Join-Path -Path $VSCodeUserDataPath -ChildPath "snippets" if (Test-Path -Path $snippetsSourcePath -PathType Container) { - Copy-Item -Path $snippetsSourcePath -Destination (Join-Path $script:OutputDir "snippets") -Recurse -Force + Copy-Item -Path $snippetsSourcePath -Destination (Join-Path $outputDir "snippets") -Recurse -Force Write-Host " [✓] snippets/" } else { @@ -172,8 +184,8 @@ process { #region Step 5: Generate Manifest if ($PSCmdlet.ShouldProcess("Generating manifest.json")) { - $manifestPath = Join-Path -Path $script:OutputDir -ChildPath "manifest.json" - $script:Manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $manifestPath -Encoding UTF8 + $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" } @@ -183,20 +195,16 @@ process { if ($PSCmdlet.ShouldProcess("Displaying summary")) { Write-Host "" Write-Host "=== Summary ===" -ForegroundColor Green - foreach ($profile in $script:Manifest.profiles) { - Write-Host " $($profile.name): $($profile.extension_count) extensions" + foreach ($entry in $manifest.profiles) { + Write-Host " $($entry.name): $($entry.extension_count) extensions" } Write-Host "" - Write-Host "Export complete: $script:OutputDir" + Write-Host "Export complete: $outputDir" } #endregion } catch { - Write-Error "Export failed: $_" - exit 1 + Write-Error "Export failed: $_" -ErrorAction Continue + throw } } - -end { - # Cleanup if needed -} From 85ba8668c32a09ab5da042b0d9e83929eb2121a3 Mon Sep 17 00:00:00 2001 From: "Chris K.Y. FUNG" <8746768+chriskyfung@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:28:59 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=85=20test(vscode):=20add=20Pester=20?= =?UTF-8?q?tests=20for=20profile=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/VSCode/Export-VSCodeProfiles.Tests.ps1 | 103 +++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 Tests/VSCode/Export-VSCodeProfiles.Tests.ps1 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 + } + } +}