How to Launch WSL VS Code Projects from the Windows Start Menu
Following on from my GNOME and macOS scripts, this Windows PowerShell version does the same job for Windows. It detects your default WSL distro, scans your projects directory inside WSL for .projectname files, and creates Start Menu shortcuts, with optional desktop shortcuts, that open each named WSL project directly in Windows VS Code via the WSL remote URI.
I do not use Windows very much, but it is nice to know that when I do, I can keep the same easy workflow for opening projects. And, having seen a few reviews of the latest Qualcomm Snapdragon-powered laptops, it does not seem impossible that I might end up using a Windows laptop at some point.
Anyway, here is the script. Be sure to change the $ProjectsDirInWsl value before using it. Full disclosure: I vibecoded most of this with Claude, because unlike Linux shell scripting, PowerShell is not something I have much experience with.
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ── Configuration ──────────────────────────────────────────────────────────────
$ProjectsDirInWsl = "/home/username/Projects"
$LauncherFolderName = "VS Code WSL Projects"
$AlsoCreateDesktopShortcuts = $false
# ── Helpers ────────────────────────────────────────────────────────────────────
function Get-DefaultWslDistro {
# wsl --list outputs UTF-16 LE; temporarily switch console encoding so
# PowerShell decodes the bytes correctly.
$prev = [Console]::OutputEncoding
[Console]::OutputEncoding = [System.Text.Encoding]::Unicode
try {
$lines = & wsl.exe --list --verbose 2>&1
} finally {
[Console]::OutputEncoding = $prev
}
if (-not $lines) {
throw "No output from 'wsl --list --verbose'. Is WSL installed and enabled?"
}
foreach ($line in $lines) {
# Strip residual null bytes that can survive the UTF-16 decode, then trim.
$clean = ($line -replace '\x00', '').Trim()
# The default distro line begins with an asterisk.
if ($clean -notmatch '^\*') { continue }
# Remove the "* " marker, then split on the multi-space column separator
# to isolate the NAME column. This correctly handles distro names that
# contain spaces (e.g. "Ubuntu 22.04").
$name = ($clean -replace '^\*\s+', '') -split '\s{2,}' | Select-Object -First 1
$name = $name.Trim()
if ($name) { return $name }
}
throw "No default WSL distro found. Set one with: wsl --set-default <DistroName>"
}
function Invoke-WslScript {
# Writes the bash script to a temp file inside WSL and executes it there,
# avoiding stdin-truncation and CRLF issues that affect PowerShell pipes.
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string]$Script
)
# Normalise to LF so bash doesn't see stray CR characters.
$lfScript = $Script -replace "`r`n", "`n" -replace "`r", "`n"
# Write to a Windows temp file with UTF-8 no-BOM encoding.
$tmpWin = [System.IO.Path]::GetTempFileName()
$output = $null
try {
[System.IO.File]::WriteAllText($tmpWin, $lfScript, (New-Object System.Text.UTF8Encoding $false))
# Convert the Windows path to a WSL path.
$prev = [Console]::OutputEncoding
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
try {
$tmpWslRaw = & wsl.exe -d $Distro -- wslpath -u ($tmpWin -replace '\\', '/')
if ($LASTEXITCODE -ne 0 -or -not $tmpWslRaw) {
throw "wslpath failed (exit $LASTEXITCODE) for: $tmpWin"
}
$tmpWsl = "$tmpWslRaw".Trim()
$output = & wsl.exe -d $Distro -- bash $tmpWsl
} finally {
[Console]::OutputEncoding = $prev
}
} finally {
Remove-Item -LiteralPath $tmpWin -Force -ErrorAction SilentlyContinue
}
if ($LASTEXITCODE -ne 0) {
throw "WSL bash exited with code $LASTEXITCODE."
}
return $output
}
function Get-VsCodeExe {
$candidates = @(
"$env:LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe",
"$env:ProgramFiles\Microsoft VS Code\Code.exe",
"${env:ProgramFiles(x86)}\Microsoft VS Code\Code.exe"
)
foreach ($path in $candidates) {
if ($path -and (Test-Path -LiteralPath $path)) { return $path }
}
$cmd = Get-Command code -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
throw "VS Code not found. Please install VS Code for Windows first."
}
function Get-SafeWindowsFileName {
param([Parameter(Mandatory)][string]$Name)
$safe = ($Name -replace '[<>:"/\\|?*]', '_').Trim()
if (-not $safe) { throw "Project name '$Name' resolves to an empty Windows filename." }
return $safe
}
function New-VsCodeShortcut {
param(
[Parameter(Mandatory)][string]$LnkPath,
[Parameter(Mandatory)][string]$CodeExe,
[Parameter(Mandatory)][string]$FolderUri,
[Parameter(Mandatory)][string]$Description
)
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut($LnkPath)
$sc.TargetPath = $CodeExe
$sc.Arguments = "--folder-uri `"$FolderUri`""
$sc.WorkingDirectory = $env:USERPROFILE
$sc.Description = $Description
$sc.IconLocation = "$CodeExe,0"
$sc.Save()
}
# ── Discover environment ────────────────────────────────────────────────────────
if ($env:OS -ne 'Windows_NT') { throw "This script must be run on Windows." }
$Distro = Get-DefaultWslDistro
$CodeExe = Get-VsCodeExe
$LauncherDir = Join-Path ([Environment]::GetFolderPath('Programs')) $LauncherFolderName
$DesktopDir = [Environment]::GetFolderPath('Desktop')
Write-Host "WSL distro : $Distro"
Write-Host "Projects dir : $ProjectsDirInWsl"
Write-Host "VS Code : $CodeExe"
Write-Host "Launcher dir : $LauncherDir"
Write-Host ""
if ([string]::IsNullOrWhiteSpace($LauncherDir) -or $LauncherDir.Length -lt 5) {
throw "Launcher directory path looks wrong: '$LauncherDir'"
}
# ── Find all projects in a single WSL call ─────────────────────────────────────
# Output format per project: <absolute-wsl-dir> TAB <project-name>
# Using .Replace() (not -replace) to avoid regex interpretation of the path.
$discoveryScript = @'
PROJECTS_DIR="__PROJECTS_DIR__"
if [ ! -d "$PROJECTS_DIR" ]; then
echo "Directory not found in WSL: $PROJECTS_DIR" >&2
exit 1
fi
find "$PROJECTS_DIR" -type f -name '.projectname' 2>/dev/null | sort |
while IFS= read -r f; do
dir="${f%/*}"
name=$(head -n1 "$f" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -n "$name" ] && printf '%s\t%s\n' "$dir" "$name"
done
'@
$discoveryScript = $discoveryScript.Replace('__PROJECTS_DIR__', $ProjectsDirInWsl)
$projectLines = @(Invoke-WslScript -Distro $Distro -Script $discoveryScript |
Where-Object { $_ -match "`t" })
Write-Host "Projects found: $($projectLines.Count)"
Write-Host ""
# ── Prepare the Start Menu folder ──────────────────────────────────────────────
if (Test-Path -LiteralPath $LauncherDir) {
Write-Host "Clearing existing launchers..."
Get-ChildItem -LiteralPath $LauncherDir -Filter '*.lnk' | Remove-Item -Force
} else {
$null = New-Item -ItemType Directory -Path $LauncherDir
}
# ── Create shortcuts ───────────────────────────────────────────────────────────
$count = 0
foreach ($line in $projectLines) {
$tab = $line.IndexOf("`t")
if ($tab -lt 1) { continue }
$projectDir = $line.Substring(0, $tab).Trim()
$projectName = $line.Substring($tab + 1).Trim()
if (-not $projectDir -or -not $projectName) { continue }
$safeName = Get-SafeWindowsFileName $projectName
$folderUri = "vscode-remote://wsl+$Distro$projectDir"
$desc = "Open $projectName in VS Code (WSL: $Distro)"
$lnkPath = Join-Path $LauncherDir "$safeName.lnk"
New-VsCodeShortcut -LnkPath $lnkPath -CodeExe $CodeExe -FolderUri $folderUri -Description $desc
Write-Host " + $safeName.lnk"
$count++
if ($AlsoCreateDesktopShortcuts) {
New-VsCodeShortcut `
-LnkPath (Join-Path $DesktopDir "$safeName.lnk") `
-CodeExe $CodeExe `
-FolderUri $folderUri `
-Description $desc
}
}
Write-Host ""
Write-Host "$count launcher(s) written to: $LauncherDir"
if ($AlsoCreateDesktopShortcuts -and $count -gt 0) {
Write-Host "Desktop shortcuts also written to: $DesktopDir"
}
Bonus: Turn the script into an .exe
If you prefer, you can wrap the script as a standalone executable and drop it somewhere convenient.
Install-Module PS2EXE -Scope CurrentUser
Invoke-PS2EXE .\Generate-VSCodeWSLLaunchers.ps1 .\Generate-VSCodeWSLLaunchers.exe
After that, you can just run the .exe directly.