Created
July 15, 2025 07:55
-
-
Save sycomix/f4eba3259bc751f1a1d9ee1f87228a69 to your computer and use it in GitHub Desktop.
WSL2 Customization
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| A PowerShell script to create a custom WSL2 distribution tar file. | |
| .DESCRIPTION | |
| This script provides an interactive menu to select a base Linux distribution | |
| and a variety of packages to build a personalized WSL2 environment. It uses | |
| Docker to assemble the distro and then exports the filesystem as a .tar file. | |
| .NOTES | |
| Author: Gemini | |
| Version: 2.1 | |
| Requires: Docker Desktop installed and running. | |
| Changes: | |
| - Fixed `Malformed entry` apt error by using a more robust `grep`/`cut` method to get distro info for Docker repo setup. | |
| - Fixed `exec /install.sh: no such file or directory` error by converting script line endings from CRLF (Windows) to LF (Linux). | |
| - Fixed NullArrayIndex error by explicitly creating a sorted array of distribution options. | |
| - Implemented robust input validation for distribution selection. | |
| - Implemented strict error checking for all Docker commands. | |
| .EXAMPLE | |
| .\Create-CustomWSLDistro.ps1 | |
| This will start the interactive menu to build your custom WSL distribution. | |
| #> | |
| #region Global Variables & Initial Checks | |
| # Function to check if Docker is running | |
| function Test-DockerConnection { | |
| try { | |
| docker ps -q > $null | |
| return $true | |
| } catch { | |
| return $false | |
| } | |
| } | |
| if (-not (Test-DockerConnection)) { | |
| Write-Host "❌ Docker is not running or not found." -ForegroundColor Red | |
| Write-Host "Please start Docker Desktop and ensure it's accessible from your terminal." -ForegroundColor Yellow | |
| exit | |
| } | |
| # Define available distributions and their package managers/commands | |
| $Distributions = @{ | |
| "Debian" = @{ | |
| Image = "debian:stable-slim" | |
| PackageManager = "apt-get" | |
| UpdateCmd = "apt-get update" | |
| InstallCmd = "apt-get install -y --no-install-recommends" | |
| CleanCmd = "apt-get clean && rm -rf /var/lib/apt/lists/*" | |
| } | |
| "Ubuntu" = @{ | |
| Image = "ubuntu:latest" | |
| PackageManager = "apt-get" | |
| UpdateCmd = "apt-get update" | |
| InstallCmd = "apt-get install -y --no-install-recommends" | |
| CleanCmd = "apt-get clean && rm -rf /var/lib/apt/lists/*" | |
| } | |
| "Fedora" = @{ | |
| Image = "fedora:latest" | |
| PackageManager = "dnf" | |
| UpdateCmd = "dnf makecache" | |
| InstallCmd = "dnf install -y" | |
| CleanCmd = "dnf clean all" | |
| } | |
| "Arch Linux" = @{ | |
| Image = "archlinux:latest" | |
| PackageManager = "pacman" | |
| UpdateCmd = "pacman -Sy" | |
| InstallCmd = "pacman -S --noconfirm" | |
| CleanCmd = "pacman -Scc --noconfirm" | |
| } | |
| } | |
| # Define available packages categorized for selection | |
| $PackageCategories = @{ | |
| "Essential Tools" = @( | |
| "build-essential", "git", "curl", "wget", "unzip", "ca-certificates", "gnupg", "sudo" # 'build-essential' is for Debian/Ubuntu, will be adapted | |
| ) | |
| "Shells & Terminals" = @( | |
| "zsh", "fish", "tmux" | |
| ) | |
| "Development Runtimes" = @( | |
| "python3", "python3-pip", "nodejs", "ruby-full", "golang" | |
| ) | |
| "System & Monitoring" = @( | |
| "htop", "neofetch", "ncdu", "man-db", "command-not-found" # 'command-not-found' is distro-specific | |
| ) | |
| "Common Editors" = @( | |
| "neovim", "nano", "micro" | |
| ) | |
| "Container Tools" = @( | |
| "docker-ce-cli" # Special handling for Docker CLI | |
| ) | |
| "AI & Specialized Runtimes" = @( | |
| "rust", "nvidia-container-toolkit", "rocm", "openvino" # Complex, multi-step installations | |
| ) | |
| } | |
| #endregion | |
| #region Menu Functions | |
| # Function to display the main menu and get user selection | |
| function Show-Menu { | |
| param ( | |
| [string]$Title, | |
| [array]$Options | |
| ) | |
| Clear-Host | |
| Write-Host "================ $Title ================" -ForegroundColor Cyan | |
| for ($i = 0; $i -lt $Options.Count; $i++) { | |
| Write-Host (" " + ($i + 1) + ". " + $Options[$i]) | |
| } | |
| Write-Host "===============================================" -ForegroundColor Cyan | |
| } | |
| # Function to handle multi-select package menu | |
| function Show-PackageMenu { | |
| $SelectedPackages = [System.Collections.Generic.List[string]]::new() | |
| $AllPackages = $PackageCategories.Keys | ForEach-Object { $Category = $_; $PackageCategories[$Category] | ForEach-Object { "$_ ($Category)" } } | |
| while ($true) { | |
| Clear-Host | |
| Write-Host "========= Select Packages (Press 'd' when done) =========" -ForegroundColor Cyan | |
| for ($i = 0; $i -lt $AllPackages.Count; $i++) { | |
| $PackageDisplayName = $AllPackages[$i] | |
| $PackageName = $PackageDisplayName.Split(' ')[0] | |
| $IsSelected = $SelectedPackages.Contains($PackageName) | |
| $DisplayColor = if ($IsSelected) { "Green" } else { "White" } | |
| Write-Host (" [{0}] {1}. {2}" -f ($i + 1), $(if ($IsSelected) { "x" } else { " " }), $PackageDisplayName) -ForegroundColor $DisplayColor | |
| } | |
| Write-Host "==========================================================" -ForegroundColor Cyan | |
| Write-Host "Enter number to toggle, 'd' to finish, 'a' to select all, 'n' for none." | |
| $input = Read-Host "Your choice" | |
| if ($input -eq 'd') { break } | |
| if ($input -eq 'a') { | |
| $SelectedPackages.Clear() | |
| $PackageCategories.Values.ForEach({$_.ForEach({$SelectedPackages.Add($_)})}) | |
| continue | |
| } | |
| if ($input -eq 'n') { | |
| $SelectedPackages.Clear() | |
| continue | |
| } | |
| if ($input -match "^\d+$" -and [int]$input -ge 1 -and [int]$input -le $AllPackages.Count) { | |
| $Index = [int]$input - 1 | |
| $PackageName = $AllPackages[$Index].Split(' ')[0] | |
| if ($SelectedPackages.Contains($PackageName)) { | |
| $SelectedPackages.Remove($PackageName) | |
| } else { | |
| $SelectedPackages.Add($PackageName) | |
| } | |
| } else { | |
| Write-Host "Invalid input. Please try again." -ForegroundColor Yellow | |
| Start-Sleep -Seconds 1 | |
| } | |
| } | |
| return $SelectedPackages | |
| } | |
| #endregion | |
| #region Core Logic | |
| # Function to adapt package names for the selected distro | |
| function Adapt-PackageNames { | |
| param( | |
| [string]$DistroName, | |
| [array]$Packages | |
| ) | |
| $AdaptedPackages = @() | |
| # These packages have complex installs handled separately in the script. | |
| $SpecialPackages = @("rust", "nvidia-container-toolkit", "rocm", "openvino", "docker-ce-cli") | |
| foreach ($pkg in $Packages) { | |
| if ($SpecialPackages -contains $pkg) { | |
| # Add the original name to be checked later in the install script | |
| $AdaptedPackages += $pkg | |
| continue | |
| } | |
| switch ($DistroName) { | |
| "Fedora" { | |
| switch ($pkg) { | |
| "build-essential" { $AdaptedPackages += "@development-tools" } | |
| "python3-pip" { $AdaptedPackages += "python3-pip" } | |
| "ruby-full" { $AdaptedPackages += "ruby" } | |
| "command-not-found" { $AdaptedPackages += "PackageKit-command-not-found" } | |
| default { $AdaptedPackages += $pkg } | |
| } | |
| } | |
| "Arch Linux" { | |
| switch ($pkg) { | |
| "build-essential" { $AdaptedPackages += "base-devel" } | |
| "python3-pip" { $AdaptedPackages += "python-pip" } | |
| "ruby-full" { $AdaptedPackages += "ruby" } | |
| "sudo" { continue } # Already part of base image, do nothing. | |
| "command-not-found" { $AdaptedPackages += "pkgfile" } | |
| default { $AdaptedPackages += $pkg } | |
| } | |
| } | |
| default { # Debian/Ubuntu | |
| switch ($pkg) { | |
| "golang" { $AdaptedPackages += "golang-go" } | |
| default { $AdaptedPackages += $pkg } | |
| } | |
| } | |
| } | |
| } | |
| return $AdaptedPackages | |
| } | |
| # Main script execution | |
| function Start-DistroCreation { | |
| # 1. Select Distribution | |
| $DistroOptions = @($Distributions.Keys) | Sort-Object | |
| Show-Menu -Title "Select a Base Distribution" -Options $DistroOptions | |
| [int]$DistroChoice = 0 | |
| $RawInput = Read-Host "Enter the number for your choice" | |
| if (-not ([int]::TryParse($RawInput, [ref]$DistroChoice)) -or $DistroChoice -lt 1 -or $DistroChoice -gt $DistroOptions.Count) { | |
| Write-Host "❌ Invalid selection. Please enter a number between 1 and $($DistroOptions.Count). Exiting." -ForegroundColor Red | |
| exit | |
| } | |
| $SelectedDistroName = $DistroOptions[$DistroChoice - 1] | |
| $DistroInfo = $Distributions[$SelectedDistroName] | |
| Write-Host "You selected: $SelectedDistroName" -ForegroundColor Green | |
| Start-Sleep -Seconds 1 | |
| # 2. Select Packages | |
| $SelectedPackages = Show-PackageMenu | |
| if ($SelectedPackages.Count -eq 0) { | |
| Write-Host "No packages selected. The distro will be minimal." -ForegroundColor Yellow | |
| Start-Sleep -Seconds 2 | |
| } else { | |
| Write-Host "You selected the following packages:" -ForegroundColor Green | |
| $SelectedPackages | ForEach-Object { Write-Host "- $_" } | |
| Start-Sleep -Seconds 2 | |
| } | |
| # 3. Get Distro Name and Custom Identifiers | |
| $DistroName = Read-Host "Enter a name for your new WSL distro (e.g., 'my-custom-debian')" | |
| if (-not $DistroName) { | |
| $DistroName = "custom-wsl-distro-$(Get-Date -Format 'yyyyMMddHHmm')" | |
| Write-Host "No name entered. Using default: $DistroName" -ForegroundColor Yellow | |
| } | |
| Write-Host "`nYou can optionally set a custom OS name and version that will appear inside the distro." -ForegroundColor Cyan | |
| $CustomPrettyName = Read-Host "Enter a custom OS Pretty Name (e.g., MyDevOS) [Optional]" | |
| $CustomVersion = Read-Host "Enter a custom OS Version (e.g., 2025.1) [Optional]" | |
| # 4. Build the Docker image | |
| Write-Host "`nBuilding Docker container... This may take a while." -ForegroundColor Cyan | |
| $ContainerName = "wsl-builder-$(Get-Random)" | |
| $AllAdaptedPackages = Adapt-PackageNames -DistroName $SelectedDistroName -Packages $SelectedPackages | |
| # Separate simple packages from complex ones | |
| $SpecialPackageNames = @("rust", "nvidia-container-toolkit", "rocm", "openvino", "docker-ce-cli") | |
| $SimplePackages = $AllAdaptedPackages | Where-Object { $SpecialPackageNames -notcontains $_ } | |
| $SpecialPackages = $AllAdaptedPackages | Where-Object { $SpecialPackageNames -contains $_ } | |
| # Validate that we have an image to run | |
| if ([string]::IsNullOrEmpty($DistroInfo.Image)) { | |
| Write-Host "❌ Critical error: Could not determine the Docker image for the selected distribution '$SelectedDistroName'." -ForegroundColor Red | |
| exit | |
| } | |
| $InstallScript = @" | |
| #!/bin/sh | |
| set -e | |
| export DEBIAN_FRONTEND=noninteractive | |
| echo "Updating package lists..." | |
| $($DistroInfo.UpdateCmd) | |
| # Install simple packages first | |
| if [ -n "$($SimplePackages -join ' ')" ]; then | |
| echo "Installing base packages: $($SimplePackages -join ' ')" | |
| $($DistroInfo.InstallCmd) $($SimplePackages -join ' ') | |
| fi | |
| # Add a default user 'wsluser' early so we can run commands as them | |
| echo "Adding user 'wsluser'..." | |
| useradd -m -s /bin/bash wsluser | |
| echo "wsluser:wsluser" | chpasswd | |
| echo "wsluser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers | |
| # --- Handle Complex Installations --- | |
| # Note: These are primarily configured for Debian/Ubuntu. | |
| # Install Rust | |
| if echo "$($SpecialPackages -join ' ')" | grep -q "rust"; then | |
| echo "Installing Rust via rustup..." | |
| su - wsluser -c "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y" | |
| fi | |
| # Install Docker CLI | |
| if echo "$($SpecialPackages -join ' ')" | grep -q "docker-ce-cli"; then | |
| if [ "$($DistroInfo.PackageManager)" = "apt-get" ]; then | |
| echo "Setting up Docker repository for docker-ce-cli..." | |
| install -m 0755 -d /etc/apt/keyrings | |
| curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc | |
| chmod a+r /etc/apt/keyrings/docker.asc | |
| # FIXED: Using robust grep/cut to get distro info instead of sourcing /etc/os-release | |
| echo "deb [arch=`$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/`$(grep '^ID=' /etc/os-release | cut -d'=' -f2) `$(grep 'VERSION_CODENAME=' /etc/os-release | cut -d'=' -f2) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null | |
| $($DistroInfo.UpdateCmd) | |
| $($DistroInfo.InstallCmd) docker-ce-cli | |
| fi | |
| fi | |
| # Install NVIDIA Container Toolkit | |
| if echo "$($SpecialPackages -join ' ')" | grep -q "nvidia-container-toolkit"; then | |
| if [ "$($DistroInfo.PackageManager)" = "apt-get" ]; then | |
| echo "Setting up NVIDIA Container Toolkit repository..." | |
| curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg | |
| curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ | |
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ | |
| tee /etc/apt/sources.list.d/nvidia-container-toolkit.list | |
| $($DistroInfo.UpdateCmd) | |
| $($DistroInfo.InstallCmd) nvidia-container-toolkit | |
| fi | |
| fi | |
| # Install ROCm (user-space tools) | |
| if echo "$($SpecialPackages -join ' ')" | grep -q "rocm"; then | |
| if [ "$($DistroInfo.PackageManager)" = "apt-get" ]; then | |
| echo "Setting up AMD ROCm repository..." | |
| mkdir -p /etc/apt/keyrings | |
| curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor -o /etc/apt/keyrings/rocm.gpg | |
| echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/6.0 ubuntu main" | tee /etc/apt/sources.list.d/rocm.list | |
| $($DistroInfo.UpdateCmd) | |
| $($DistroInfo.InstallCmd) rocm-dev | |
| fi | |
| fi | |
| # Install OpenVINO | |
| if echo "$($SpecialPackages -join ' ')" | grep -q "openvino"; then | |
| if [ "$($DistroInfo.PackageManager)" = "apt-get" ]; then | |
| echo "Setting up Intel OpenVINO repository..." | |
| curl -fsSL https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor -o /usr/share/keyrings/intel-openvino-keyring.gpg | |
| echo "deb [signed-by=/usr/share/keyrings/intel-openvino-keyring.gpg] https://apt.repos.intel.com/openvino/2023 ubuntu22 main" | tee /etc/apt/sources.list.d/intel-openvino-2023.list | |
| $($DistroInfo.UpdateCmd) | |
| $($DistroInfo.InstallCmd) openvino-dev | |
| fi | |
| fi | |
| # --- Customize OS Release Info --- | |
| CUSTOM_PRETTY_NAME="$($CustomPrettyName)" | |
| CUSTOM_VERSION="$($CustomVersion)" | |
| if [ -n "$CUSTOM_PRETTY_NAME" ]; then | |
| echo "Customizing OS release information..." | |
| CUSTOM_ID=`$(echo "$CUSTOM_PRETTY_NAME" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]._-') | |
| VERSION_ID_VAL=${CUSTOM_VERSION:-"1.0"} | |
| CODENAME_VAL=`$(echo "$VERSION_ID_VAL" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]') | |
| # Overwrite /etc/os-release with new and some old info | |
| if [ -f /etc/os-release ]; then | |
| . /etc/os-release | |
| fi | |
| echo "PRETTY_NAME=\"$CUSTOM_PRETTY_NAME\"" > /etc/os-release | |
| echo "NAME=\"$CUSTOM_PRETTY_NAME\"" >> /etc/os-release | |
| echo "ID=$CUSTOM_ID" >> /etc/os-release | |
| echo "VERSION_ID=\"$VERSION_ID_VAL\"" >> /etc/os-release | |
| echo "VERSION=\"$VERSION_ID_VAL ($CUSTOM_PRETTY_NAME)\"" >> /etc/os-release | |
| if [ -n "$ID_LIKE" ]; then echo "ID_LIKE=$ID_LIKE" >> /etc/os-release; fi | |
| if [ -n "$HOME_URL" ]; then echo "HOME_URL=\"$HOME_URL\"" >> /etc/os-release; fi | |
| if [ -n "$SUPPORT_URL" ]; then echo "SUPPORT_URL=\"$SUPPORT_URL\"" >> /etc/os-release; fi | |
| if [ -n "$BUG_REPORT_URL" ]; then echo "BUG_REPORT_URL=\"$BUG_REPORT_URL\"" >> /etc/os-release; fi | |
| # Overwrite /etc/lsb-release | |
| echo "DISTRIB_ID=$CUSTOM_ID" > /etc/lsb-release | |
| echo "DISTRIB_RELEASE=$VERSION_ID_VAL" >> /etc/lsb-release | |
| echo "DISTRIB_CODENAME=$CODENAME_VAL" >> /etc/lsb-release | |
| echo "DISTRIB_DESCRIPTION=\"$CUSTOM_PRETTY_NAME\"" >> /etc/lsb-release | |
| fi | |
| echo "Cleaning up..." | |
| $($DistroInfo.CleanCmd) | |
| rm -f /install.sh | |
| "@ | |
| # Create a temporary script file | |
| $TempScriptPath = Join-Path $env:TEMP "install.sh" | |
| $LfInstallScript = $InstallScript.Replace("`r`n", "`n") | |
| $LfInstallScript | Set-Content -Path $TempScriptPath -Encoding Ascii -NoNewline | |
| try { | |
| # Start the container | |
| docker run -d --name $ContainerName $($DistroInfo.Image) tail -f /dev/null | |
| if ($LASTEXITCODE -ne 0) { throw "Failed to create Docker container. Is Docker running and is the image '$($DistroInfo.Image)' valid?" } | |
| # Copy the install script to the container | |
| docker cp $TempScriptPath "$($ContainerName):/install.sh" | |
| if ($LASTEXITCODE -ne 0) { throw "Failed to copy install script to container." } | |
| # Make the script executable and run it | |
| docker exec $ContainerName chmod +x /install.sh | |
| if ($LASTEXITCODE -ne 0) { throw "Failed to make install script executable." } | |
| docker exec --interactive --tty $ContainerName /install.sh | |
| if ($LASTEXITCODE -ne 0) { throw "Failed during package installation inside the container." } | |
| Write-Host "✅ Container build complete." -ForegroundColor Green | |
| } catch { | |
| Write-Host "❌ An error occurred during the Docker build process." -ForegroundColor Red | |
| Write-Host "This could be a Docker connection issue. Please ensure Docker Desktop is running." -ForegroundColor Yellow | |
| Write-Host "Error details: $($_.Exception.Message)" | |
| docker rm -f $ContainerName | |
| Remove-Item $TempScriptPath -ErrorAction SilentlyContinue | |
| exit | |
| } finally { | |
| Remove-Item $TempScriptPath -ErrorAction SilentlyContinue | |
| } | |
| # 5. Export the tar file | |
| $ExportPath = "$pwd\$($DistroName).tar" | |
| Write-Host "Exporting filesystem to '$ExportPath'..." -ForegroundColor Cyan | |
| try { | |
| docker export $ContainerName -o $ExportPath | |
| if ($LASTEXITCODE -ne 0) { throw "Failed to export container filesystem." } | |
| Write-Host "✅ Successfully exported '$($DistroName).tar'" -ForegroundColor Green | |
| } catch { | |
| Write-Host "❌ Failed to export the tar file." -ForegroundColor Red | |
| Write-Host $_.Exception.Message | |
| exit | |
| } finally { | |
| # Clean up the container | |
| Write-Host "Cleaning up build container..." | |
| docker rm -f $ContainerName | |
| } | |
| # 6. Final Instructions | |
| $InstallLocation = "$env:LOCALAPPDATA\$DistroName" | |
| Write-Host "`n🎉 Your custom WSL distro is ready! 🎉" -ForegroundColor Magenta | |
| Write-Host "To install it, run the following commands in PowerShell:" | |
| Write-Host "--------------------------------------------------------" | |
| Write-Host "wsl --import $DistroName '$InstallLocation' '$ExportPath' --version 2" -ForegroundColor Yellow | |
| Write-Host "wsl -d $DistroName" -ForegroundColor Yellow | |
| Write-Host "--------------------------------------------------------" | |
| Write-Host "NOTE: The default user is 'wsluser' with password 'wsluser'." | |
| Write-Host "This user has passwordless sudo access." | |
| if ($SpecialPackages -match "nvidia|rocm") { | |
| Write-Host "IMPORTANT: For NVIDIA or ROCm tools to work, you MUST have the corresponding" -ForegroundColor Yellow | |
| Write-Host "drivers installed on your Windows host machine." -ForegroundColor Yellow | |
| } | |
| } | |
| # Run the main function | |
| Start-DistroCreation |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment