Created
March 21, 2026 17:43
-
-
Save nsankar/0fddaa485aa21565ab7c954c3020d4d6 to your computer and use it in GitHub Desktop.
OpenClaw Bootstrap: One-Click Windows, WSL2, Docker, SSH and Gateway Automation
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
| [CmdletBinding()] | |
| param( | |
| [string]$DistroName = "Ubuntu", | |
| [ValidateSet("winget", "choco", "local")] | |
| [string]$DockerInstallMethod = "winget", | |
| [ValidateSet("auto", "winget", "choco")] | |
| [string]$GitInstallMethod = "auto", | |
| [ValidateSet("windows-capability", "github-msi")] | |
| [string]$OpenSshInstallMethod = "windows-capability", | |
| [string]$DockerInstallerPath = "", | |
| [string]$OpenSshInstallerPath = "", | |
| [switch]$InstallChocolatey, | |
| [switch]$InstallGit, | |
| [switch]$InstallDockerDesktop, | |
| [switch]$InstallOpenClawDocker, | |
| [switch]$ConfigureAuthorizedKeyFromFile, | |
| [string]$L1PublicKeyPath = "", | |
| [string]$OpenClawRepoUrl = "https://github.com/openclaw/openclaw.git", | |
| [string]$OpenClawRepoPath = "~/openclaw", | |
| [int]$OpenClawGatewayPort = 18789, | |
| [int]$OpenClawBridgePort = 18790, | |
| [ValidateSet("loopback", "lan", "tailnet")] | |
| [string]$OpenClawGatewayBind = "lan", | |
| [switch]$ExposeOpenClawOnLan, | |
| [string]$OpenClawImage = "", | |
| [string]$OpenClawDockerAptPackages = "", | |
| [string]$OpenClawExtensions = "", | |
| [string]$OpenClawExtraMounts = "", | |
| [string]$OpenClawHomeVolume = "", | |
| [switch]$OpenClawSandbox, | |
| [switch]$RebootIfNeeded | |
| ) | |
| <# | |
| .SYNOPSIS | |
| Bootstrap L2 (Windows 10) for remote admin from L1 using OpenSSH, WSL2, and Docker Desktop. | |
| .DESCRIPTION | |
| This script is designed to be run ON L2 from an elevated PowerShell session. | |
| The assumed working folder is: | |
| the folder that contains this script | |
| It automates or semi-automates the following: | |
| - Prepares a working folder structure and logs. | |
| - Optionally installs Chocolatey. | |
| - Installs OpenSSH Client + Server and configures sshd. | |
| - Creates/imports SSH authorized_keys for inbound access from L1. | |
| - Installs or verifies WSL2 and a Linux distro. | |
| - Optionally installs Docker Desktop using winget, Chocolatey, or a local installer. | |
| - Starts Docker Desktop and validates Docker from Windows. | |
| - Verifies whether Docker works from the WSL distro. | |
| - Generates helper files with connection details and next steps. | |
| IMPORTANT: | |
| 1. WSL installation can require a reboot. | |
| 2. The first launch of the Linux distro normally requires you to complete the distro's user setup. | |
| 3. Docker Desktop WSL integration is enabled on the default WSL distro by default in common setups, | |
| but if Docker is in Windows containers mode or the integration isn't enabled for the target distro, | |
| you may need one manual confirmation in Docker Desktop settings. | |
| 4. This script never exposes the Docker daemon over insecure TCP on your LAN. | |
| .NOTES | |
| Suggested usage examples: | |
| # Minimal preflight + OpenSSH + WSL | |
| .\l2.ps1 -Verbose | |
| # Install everything with Chocolatey + Docker Desktop | |
| .\l2.ps1 -InstallChocolatey -InstallGit -InstallDockerDesktop -DockerInstallMethod choco -Verbose | |
| # Install Docker Desktop with winget and import L1's public key from a file | |
| .\l2.ps1 -InstallDockerDesktop -DockerInstallMethod winget -ConfigureAuthorizedKeyFromFile -L1PublicKeyPath "$PSScriptRoot\l1-public-key.txt" -Verbose | |
| AUTHOR: | |
| SANKAR NAGARAJAN | |
| https://www.linkedin.com/in/nsk007/ | |
| #> | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| # ------------------------------- | |
| # Path setup | |
| # ------------------------------- | |
| $ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Join-Path $HOME 'openclaw-setup' } | |
| $StateDir = Join-Path $ScriptRoot 'state' | |
| $LogDir = Join-Path $ScriptRoot 'logs' | |
| $GeneratedDir = Join-Path $ScriptRoot 'generated' | |
| $HelpersDir = Join-Path $ScriptRoot 'helpers' | |
| $ReportsDir = Join-Path $ScriptRoot 'reports' | |
| $DownloadsDir = Join-Path $ScriptRoot 'downloads' | |
| $StateFile = Join-Path $StateDir 'bootstrap-state.json' | |
| $ConnectionInfoPath = Join-Path $ReportsDir 'connection-info.txt' | |
| $SummaryPath = Join-Path $ReportsDir 'summary.txt' | |
| $TranscriptPath = Join-Path $LogDir ("bootstrap-" + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') | |
| foreach ($dir in @($ScriptRoot, $StateDir, $LogDir, $GeneratedDir, $HelpersDir, $ReportsDir, $DownloadsDir)) { | |
| if (-not (Test-Path -LiteralPath $dir)) { | |
| New-Item -ItemType Directory -Path $dir -Force | Out-Null | |
| } | |
| } | |
| Start-Transcript -Path $TranscriptPath -Append | Out-Null | |
| # ------------------------------- | |
| # Utility functions | |
| # ------------------------------- | |
| function Write-Section { | |
| param([string]$Message) | |
| Write-Host "`n=== $Message ===" -ForegroundColor Cyan | |
| } | |
| function Write-Step { | |
| param([string]$Message) | |
| Write-Host "[+] $Message" -ForegroundColor Green | |
| } | |
| function Write-WarnMsg { | |
| param([string]$Message) | |
| Write-Warning $Message | |
| } | |
| function Write-Info { | |
| param([string]$Message) | |
| Write-Host "[i] $Message" -ForegroundColor Yellow | |
| } | |
| function Assert-Admin { | |
| $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() | |
| $principal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) | |
| if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { | |
| throw "Please run this script from an elevated PowerShell session (Run as Administrator)." | |
| } | |
| } | |
| function Save-State { | |
| param([hashtable]$State) | |
| $json = $State | ConvertTo-Json -Depth 8 | |
| Set-Content -Path $StateFile -Value $json -Encoding UTF8 | |
| } | |
| function ConvertTo-HashtableRecursive { | |
| param([Parameter(Mandatory)]$InputObject) | |
| if ($null -eq $InputObject) { | |
| return $null | |
| } | |
| if ($InputObject -is [System.Collections.IDictionary]) { | |
| $result = @{} | |
| foreach ($key in $InputObject.Keys) { | |
| $result[$key] = ConvertTo-HashtableRecursive -InputObject $InputObject[$key] | |
| } | |
| return $result | |
| } | |
| if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) { | |
| $items = @() | |
| foreach ($item in $InputObject) { | |
| $items += ,(ConvertTo-HashtableRecursive -InputObject $item) | |
| } | |
| return $items | |
| } | |
| if ($InputObject -is [pscustomobject] -or $InputObject -is [System.Management.Automation.PSObject]) { | |
| $result = @{} | |
| foreach ($property in $InputObject.PSObject.Properties) { | |
| $result[$property.Name] = ConvertTo-HashtableRecursive -InputObject $property.Value | |
| } | |
| return $result | |
| } | |
| return $InputObject | |
| } | |
| function Load-State { | |
| if (Test-Path -LiteralPath $StateFile) { | |
| try { | |
| $rawState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json | |
| return ConvertTo-HashtableRecursive -InputObject $rawState | |
| } | |
| catch { | |
| Write-WarnMsg "State file exists but could not be parsed. Starting with a fresh state. Error: $($_.Exception.Message)" | |
| } | |
| } | |
| return @{} | |
| } | |
| function Invoke-LoggedCommand { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$FilePath, | |
| [string[]]$ArgumentList = @(), | |
| [switch]$IgnoreExitCode | |
| ) | |
| Write-Host ("> " + $FilePath + " " + ($ArgumentList -join ' ')) -ForegroundColor DarkGray | |
| $outputFile = Join-Path $LogDir ("cmd-" + [Guid]::NewGuid().ToString('N') + '.txt') | |
| $errorFile = Join-Path $LogDir ("cmd-" + [Guid]::NewGuid().ToString('N') + '.err.txt') | |
| $stdout = '' | |
| $stderr = '' | |
| $exitCode = 0 | |
| try { | |
| $escapedArgs = foreach ($arg in $ArgumentList) { | |
| if ($null -eq $arg) { | |
| '""' | |
| continue | |
| } | |
| if ($arg -notmatch '[\s"]') { | |
| $arg | |
| continue | |
| } | |
| $escaped = $arg -replace '(\\*)"', '$1$1\"' | |
| $escaped = $escaped -replace '(\\+)$', '$1$1' | |
| '"' + $escaped + '"' | |
| } | |
| $startInfo = New-Object System.Diagnostics.ProcessStartInfo | |
| $startInfo.FileName = $FilePath | |
| $startInfo.Arguments = ($escapedArgs -join ' ') | |
| $startInfo.UseShellExecute = $false | |
| $startInfo.RedirectStandardOutput = $true | |
| $startInfo.RedirectStandardError = $true | |
| $startInfo.CreateNoWindow = $true | |
| $process = New-Object System.Diagnostics.Process | |
| $process.StartInfo = $startInfo | |
| $null = $process.Start() | |
| $stdout = ($process.StandardOutput.ReadToEnd() -replace "`0", '').TrimEnd() | |
| $stderr = ($process.StandardError.ReadToEnd() -replace "`0", '').TrimEnd() | |
| $process.WaitForExit() | |
| $exitCode = [int]$process.ExitCode | |
| } | |
| catch { | |
| $stderr = ($_.Exception.Message -replace "`0", '') | |
| $exitCode = 1 | |
| } | |
| Set-Content -Path $outputFile -Value $stdout -Encoding UTF8 | |
| Set-Content -Path $errorFile -Value $stderr -Encoding UTF8 | |
| if (-not $IgnoreExitCode -and $exitCode -ne 0) { | |
| throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')`nSTDOUT:`n$stdout`nSTDERR:`n$stderr" | |
| } | |
| return [pscustomobject]@{ | |
| ExitCode = $exitCode | |
| StdOut = $stdout | |
| StdErr = $stderr | |
| } | |
| } | |
| function Test-CommandExists { | |
| param([Parameter(Mandatory)][string]$Name) | |
| return [bool](Get-Command $Name -ErrorAction SilentlyContinue) | |
| } | |
| function Refresh-ProcessPath { | |
| $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') | |
| $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') | |
| $pathParts = @($machinePath, $userPath) | Where-Object { $_ } | |
| if ($pathParts.Count -gt 0) { | |
| $env:Path = ($pathParts -join ';') | |
| } | |
| } | |
| function ConvertTo-BashLiteral { | |
| param([Parameter(Mandatory)][string]$Value) | |
| $escaped = $Value.Replace("'", "'""'""'") | |
| return "'" + $escaped + "'" | |
| } | |
| function Get-WslPathBootstrap { | |
| param([Parameter(Mandatory)][string]$Path) | |
| $pathLiteral = ConvertTo-BashLiteral -Value $Path | |
| return @" | |
| path_input=$pathLiteral | |
| if [ "`$path_input" = "~" ]; then | |
| resolved_path="`$HOME" | |
| elif [[ "`$path_input" == "~/"* ]]; then | |
| resolved_path="`$HOME/`${path_input:2}" | |
| elif [[ "`$path_input" = /* ]]; then | |
| resolved_path="`$path_input" | |
| else | |
| resolved_path="`$HOME/`$path_input" | |
| fi | |
| "@ | |
| } | |
| function Get-WslBashLauncherCommand { | |
| param([Parameter(Mandatory)][string]$Command) | |
| $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Command)) | |
| return "printf '%s' " + (ConvertTo-BashLiteral -Value $encoded) + " | base64 -d | bash" | |
| } | |
| function Invoke-WslBashCommand { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][string]$TargetDistro, | |
| [Parameter(Mandatory)][string]$Command, | |
| [string]$User = "", | |
| [switch]$IgnoreExitCode | |
| ) | |
| Refresh-ProcessPath | |
| $args = @('-d', $TargetDistro) | |
| if ($User) { | |
| $args += @('-u', $User) | |
| } | |
| $launcherCommand = Get-WslBashLauncherCommand -Command $Command | |
| $args += @('--', 'bash', '-lc', $launcherCommand) | |
| if ($IgnoreExitCode) { | |
| return Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList $args -IgnoreExitCode | |
| } | |
| return Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList $args | |
| } | |
| function Invoke-WslBashInteractive { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][string]$TargetDistro, | |
| [Parameter(Mandatory)][string]$Command, | |
| [string]$User = "" | |
| ) | |
| Refresh-ProcessPath | |
| $args = @('-d', $TargetDistro) | |
| if ($User) { | |
| $args += @('-u', $User) | |
| } | |
| $launcherCommand = Get-WslBashLauncherCommand -Command $Command | |
| $args += @('--', 'bash', '-lc', $launcherCommand) | |
| Write-Host ("> wsl.exe " + ($args -join ' ')) -ForegroundColor DarkGray | |
| & wsl.exe @args | |
| $exitCode = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 } | |
| if ($exitCode -ne 0) { | |
| throw "Interactive WSL command failed with exit code ${exitCode}: wsl.exe $($args -join ' ')" | |
| } | |
| } | |
| function Get-CurrentUserIdentityInfo { | |
| $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() | |
| $principal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) | |
| [pscustomobject]@{ | |
| Name = $currentIdentity.Name | |
| Sid = $currentIdentity.User.Value | |
| IsAdministrator = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) | |
| } | |
| } | |
| function Ensure-ParentDirectory { | |
| param([Parameter(Mandatory)][string]$Path) | |
| $parent = Split-Path -Parent $Path | |
| if ($parent -and -not (Test-Path -LiteralPath $parent)) { | |
| New-Item -ItemType Directory -Path $parent -Force | Out-Null | |
| } | |
| } | |
| function Ensure-FileExists { | |
| param([Parameter(Mandatory)][string]$Path) | |
| Ensure-ParentDirectory -Path $Path | |
| if (-not (Test-Path -LiteralPath $Path)) { | |
| New-Item -ItemType File -Path $Path -Force | Out-Null | |
| } | |
| } | |
| function Add-UniqueContentLine { | |
| param( | |
| [Parameter(Mandatory)][string]$Path, | |
| [Parameter(Mandatory)][string]$Line | |
| ) | |
| $existingLines = @() | |
| if (Test-Path -LiteralPath $Path) { | |
| $existingLines = @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue) | |
| } | |
| if ($existingLines -notcontains $Line) { | |
| Add-Content -LiteralPath $Path -Value $Line | |
| return $true | |
| } | |
| return $false | |
| } | |
| function Set-SshProfilePathPermissions { | |
| param( | |
| [Parameter(Mandatory)][string]$SshDir, | |
| [Parameter(Mandatory)][string]$AuthorizedKeysPath | |
| ) | |
| $identity = Get-CurrentUserIdentityInfo | |
| & icacls.exe $SshDir /inheritance:r | Out-Null | |
| & icacls.exe $SshDir /grant:r "*$($identity.Sid):(F)" /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)' | Out-Null | |
| & icacls.exe $AuthorizedKeysPath /inheritance:r | Out-Null | |
| & icacls.exe $AuthorizedKeysPath /grant:r "*$($identity.Sid):(F)" /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)' | Out-Null | |
| } | |
| function Set-AdministratorsAuthorizedKeysPermissions { | |
| param([Parameter(Mandatory)][string]$Path) | |
| & icacls.exe $Path /inheritance:r | Out-Null | |
| & icacls.exe $Path /grant:r '*S-1-5-32-544:(F)' /grant:r '*S-1-5-18:(F)' | Out-Null | |
| } | |
| function Get-AuthorizedKeyTargets { | |
| $sshDir = Join-Path $HOME '.ssh' | |
| $targets = @( | |
| [pscustomobject]@{ | |
| Path = Join-Path $sshDir 'authorized_keys' | |
| Description = 'per-user authorized_keys' | |
| PermissionProfile = 'UserProfile' | |
| } | |
| ) | |
| $identity = Get-CurrentUserIdentityInfo | |
| if ($identity.IsAdministrator) { | |
| $targets += [pscustomobject]@{ | |
| Path = Join-Path $env:ProgramData 'ssh\administrators_authorized_keys' | |
| Description = 'administrators_authorized_keys' | |
| PermissionProfile = 'AdministratorsFile' | |
| } | |
| } | |
| return [pscustomobject]@{ | |
| SshDir = $sshDir | |
| Targets = $targets | |
| Identity = $identity | |
| } | |
| } | |
| function Get-WslDistributionList { | |
| $attempts = @( | |
| @('--list', '--verbose'), | |
| @('-l', '-v'), | |
| @('--list'), | |
| @('-l') | |
| ) | |
| $lastResult = $null | |
| foreach ($args in $attempts) { | |
| try { | |
| $result = Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList $args -IgnoreExitCode | |
| $combinedOutput = (($result.StdOut + "`n" + $result.StdErr).Trim()) | |
| $lastResult = [pscustomobject]@{ | |
| ExitCode = $result.ExitCode | |
| Output = $combinedOutput | |
| Arguments = ($args -join ' ') | |
| } | |
| if ($result.ExitCode -eq 0) { | |
| return [pscustomobject]@{ | |
| Success = $true | |
| NoInstalledDistributions = ($combinedOutput -match 'There are no installed distributions' -or $combinedOutput -match 'Windows Subsystem for Linux has no installed distributions') | |
| Output = $combinedOutput | |
| Arguments = ($args -join ' ') | |
| } | |
| } | |
| if ($combinedOutput -match 'There are no installed distributions' -or $combinedOutput -match 'Windows Subsystem for Linux has no installed distributions') { | |
| return [pscustomobject]@{ | |
| Success = $true | |
| NoInstalledDistributions = $true | |
| Output = $combinedOutput | |
| Arguments = ($args -join ' ') | |
| } | |
| } | |
| if ($combinedOutput -match 'Usage:\s*wsl(\.exe)?') { | |
| continue | |
| } | |
| } | |
| catch { | |
| $lastResult = [pscustomobject]@{ | |
| ExitCode = -1 | |
| Output = $_.Exception.Message | |
| Arguments = ($args -join ' ') | |
| } | |
| } | |
| } | |
| return [pscustomobject]@{ | |
| Success = $false | |
| NoInstalledDistributions = $false | |
| Output = if ($lastResult) { $lastResult.Output } else { 'WSL status could not be determined.' } | |
| Arguments = if ($lastResult) { $lastResult.Arguments } else { '' } | |
| } | |
| } | |
| function Try-InstallWslDistro { | |
| param([Parameter(Mandatory)][string]$TargetDistro) | |
| $attempts = @( | |
| @('--install', '--distribution', $TargetDistro, '--web-download'), | |
| @('--install', '-d', $TargetDistro), | |
| @('--install', '--distribution', $TargetDistro) | |
| ) | |
| foreach ($args in $attempts) { | |
| try { | |
| Write-Step "Attempting WSL distro installation with: wsl.exe $($args -join ' ')" | |
| Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList $args | Out-Null | |
| $script:NeedsReboot = $true | |
| return $true | |
| } | |
| catch { | |
| Write-WarnMsg "WSL install attempt failed for arguments '$($args -join ' ')': $($_.Exception.Message)" | |
| } | |
| } | |
| return $false | |
| } | |
| function Ensure-WslWindowsFeatures { | |
| $featureNames = @( | |
| 'Microsoft-Windows-Subsystem-Linux', | |
| 'VirtualMachinePlatform' | |
| ) | |
| $enabledAny = $false | |
| foreach ($featureName in $featureNames) { | |
| $feature = Get-WindowsOptionalFeature -Online -FeatureName $featureName -ErrorAction SilentlyContinue | |
| if (-not $feature -or $feature.State -ne 'Enabled') { | |
| Write-Step "Enabling Windows feature '$featureName'." | |
| dism.exe /online /enable-feature /featurename:$featureName /all /norestart | Out-Null | |
| $enabledAny = $true | |
| } else { | |
| Write-Step "Windows feature '$featureName' is already enabled." | |
| } | |
| } | |
| if ($enabledAny) { | |
| $script:NeedsReboot = $true | |
| } | |
| return $enabledAny | |
| } | |
| function Get-DockerCliPath { | |
| $dockerDesktopCli = Join-Path ${env:ProgramFiles} 'Docker\Docker\resources\bin\docker.exe' | |
| if (Test-Path -LiteralPath $dockerDesktopCli) { | |
| return $dockerDesktopCli | |
| } | |
| $dockerCommand = Get-Command docker -ErrorAction SilentlyContinue | |
| if ($dockerCommand) { | |
| return $dockerCommand.Source | |
| } | |
| return $null | |
| } | |
| function Get-OpenSshInstallState { | |
| $inboxDir = Join-Path $env:WINDIR 'System32\OpenSSH' | |
| $programFilesCandidates = @( | |
| (Join-Path $env:ProgramFiles 'OpenSSH'), | |
| (Join-Path $env:ProgramFiles 'OpenSSH-Win64'), | |
| (Join-Path $env:ProgramFiles 'OpenSSH-Win32') | |
| ) | |
| $service = Get-Service -Name sshd -ErrorAction SilentlyContinue | |
| $githubDir = $programFilesCandidates | Where-Object { Test-Path -LiteralPath (Join-Path $_ 'sshd.exe') } | Select-Object -First 1 | |
| [pscustomobject]@{ | |
| ClientInstalled = (Test-Path -LiteralPath (Join-Path $inboxDir 'ssh.exe')) -or [bool]($programFilesCandidates | Where-Object { Test-Path -LiteralPath (Join-Path $_ 'ssh.exe') } | Select-Object -First 1) | |
| ServerInstalled = (Test-Path -LiteralPath (Join-Path $inboxDir 'sshd.exe')) -or [bool]$service -or [bool]$githubDir | |
| InboxDir = $inboxDir | |
| GitHubDir = $githubDir | |
| Service = $service | |
| } | |
| } | |
| function Resolve-OpenSshGitHubMsi { | |
| param([string]$InstallerPath) | |
| if ($InstallerPath) { | |
| if (-not (Test-Path -LiteralPath $InstallerPath)) { | |
| throw "OpenSSH installer was not found at: $InstallerPath" | |
| } | |
| return [pscustomobject]@{ | |
| InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path | |
| Downloaded = $false | |
| } | |
| } | |
| Write-Step "Downloading the latest official Win32-OpenSSH MSI from GitHub." | |
| [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 | |
| $releases = Invoke-RestMethod -Uri 'https://api.github.com/repos/PowerShell/Win32-OpenSSH/releases' | |
| $release = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1 | |
| if (-not $release) { | |
| $release = $releases | Where-Object { -not $_.draft } | Select-Object -First 1 | |
| } | |
| if (-not $release) { | |
| throw "Could not determine an OpenSSH release from the GitHub API." | |
| } | |
| $assetPattern = if ([Environment]::Is64BitOperatingSystem) { '^OpenSSH-Win64-.*\.msi$' } else { '^OpenSSH-Win32-.*\.msi$' } | |
| $asset = $release.assets | Where-Object { $_.name -match $assetPattern } | Select-Object -First 1 | |
| if (-not $asset) { | |
| throw "Could not find a matching OpenSSH MSI asset in the latest GitHub release." | |
| } | |
| $downloadPath = Join-Path $DownloadsDir $asset.name | |
| Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $downloadPath | |
| return [pscustomobject]@{ | |
| InstallerPath = $downloadPath | |
| Downloaded = $true | |
| } | |
| } | |
| function Ensure-OpenSSHViaGitHubMsi { | |
| param([string]$InstallerPath) | |
| $msi = Resolve-OpenSshGitHubMsi -InstallerPath $InstallerPath | |
| Write-Step "Installing OpenSSH using the official GitHub MSI: $($msi.InstallerPath)" | |
| Invoke-LoggedCommand -FilePath 'msiexec.exe' -ArgumentList @('/i', $msi.InstallerPath, '/qn', '/norestart') | Out-Null | |
| $installState = Get-OpenSshInstallState | |
| $service = $installState.Service | |
| if (-not $service) { | |
| $installScript = $null | |
| if ($installState.GitHubDir) { | |
| $candidate = Join-Path $installState.GitHubDir 'install-sshd.ps1' | |
| if (Test-Path -LiteralPath $candidate) { | |
| $installScript = $candidate | |
| } | |
| } | |
| if ($installScript) { | |
| Write-Step "Registering the OpenSSH Server service." | |
| Invoke-LoggedCommand -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $installScript) | Out-Null | |
| $service = Get-Service -Name sshd -ErrorAction SilentlyContinue | |
| } | |
| } | |
| if (-not $service) { | |
| throw "OpenSSH Server service 'sshd' was not found after the GitHub MSI installation." | |
| } | |
| } | |
| function Install-ChocolateyIfNeeded { | |
| Refresh-ProcessPath | |
| if (Test-CommandExists -Name 'choco') { | |
| Write-Step "Chocolatey is already installed." | |
| return | |
| } | |
| Write-Step "Installing Chocolatey using the official bootstrap command." | |
| Set-ExecutionPolicy Bypass -Scope Process -Force | |
| [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 | |
| $installExpr = "Set-ExecutionPolicy Bypass -Scope Process -Force; " + | |
| "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; " + | |
| "iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))" | |
| Invoke-Expression $installExpr | |
| Refresh-ProcessPath | |
| if (-not (Test-CommandExists -Name 'choco')) { | |
| throw "Chocolatey installation appears to have failed." | |
| } | |
| Write-Step "Chocolatey installed successfully." | |
| } | |
| function Install-PackageViaChoco { | |
| param( | |
| [Parameter(Mandatory)][string]$PackageName, | |
| [string]$AdditionalArgs = '' | |
| ) | |
| if (-not (Test-CommandExists -Name 'choco')) { | |
| throw "Chocolatey is not installed. Run with -InstallChocolatey first." | |
| } | |
| $args = @('install', $PackageName, '-y', '--no-progress') | |
| if ($AdditionalArgs) { | |
| $args += $AdditionalArgs | |
| } | |
| Invoke-LoggedCommand -FilePath 'choco' -ArgumentList $args | Out-Null | |
| } | |
| function Ensure-OpenSSH { | |
| Write-Section "OpenSSH setup" | |
| $installState = Get-OpenSshInstallState | |
| switch ($OpenSshInstallMethod) { | |
| 'windows-capability' { | |
| if (-not $installState.ClientInstalled) { | |
| Write-Step "Installing OpenSSH Client." | |
| Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 | Out-Null | |
| } else { | |
| Write-Step "OpenSSH Client is already installed." | |
| } | |
| if (-not $installState.ServerInstalled) { | |
| Write-Step "Installing OpenSSH Server." | |
| Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Out-Null | |
| } else { | |
| Write-Step "OpenSSH Server is already installed." | |
| } | |
| } | |
| 'github-msi' { | |
| if ($installState.ClientInstalled) { | |
| Write-Step "OpenSSH Client is already installed." | |
| } else { | |
| Write-Info "The GitHub MSI will install the OpenSSH client alongside the server." | |
| } | |
| if (-not $installState.ServerInstalled) { | |
| Ensure-OpenSSHViaGitHubMsi -InstallerPath $OpenSshInstallerPath | |
| } else { | |
| Write-Step "OpenSSH Server is already installed." | |
| } | |
| } | |
| } | |
| $sshServerService = Get-Service -Name sshd -ErrorAction SilentlyContinue | |
| if (-not $sshServerService) { | |
| throw "OpenSSH Server service 'sshd' was not found after the installation step." | |
| } | |
| Write-Step "Ensuring sshd service is running and set to Automatic startup." | |
| Set-Service -Name sshd -StartupType Automatic | |
| Start-Service sshd | |
| $fwRule = Get-NetFirewallRule -DisplayName 'OpenSSH Server (sshd)' -ErrorAction SilentlyContinue | |
| if (-not $fwRule) { | |
| Write-Step "Creating inbound firewall rule for sshd on TCP/22." | |
| New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null | |
| } else { | |
| Write-Step "OpenSSH firewall rule already exists." | |
| } | |
| } | |
| function Configure-AuthorizedKeys { | |
| param([string]$PublicKeyPath) | |
| Write-Section "SSH key-based authentication setup" | |
| $script:AuthorizedKeyImportRequested = [bool]$ConfigureAuthorizedKeyFromFile | |
| $script:AuthorizedKeyImportStatus = 'Skipped' | |
| $script:AuthorizedKeyImportTargets = @() | |
| $authorizedKeyTargets = Get-AuthorizedKeyTargets | |
| $sshDir = $authorizedKeyTargets.SshDir | |
| if (-not (Test-Path -LiteralPath $sshDir)) { | |
| New-Item -ItemType Directory -Path $sshDir -Force | Out-Null | |
| } | |
| foreach ($target in $authorizedKeyTargets.Targets) { | |
| Ensure-FileExists -Path $target.Path | |
| } | |
| if ($authorizedKeyTargets.Identity.IsAdministrator) { | |
| Write-Info "The current account is an administrator. Windows OpenSSH uses $($env:ProgramData)\ssh\administrators_authorized_keys by default for administrator accounts." | |
| } | |
| if ($ConfigureAuthorizedKeyFromFile) { | |
| if (-not $PublicKeyPath) { | |
| $PublicKeyPath = Join-Path $ScriptRoot 'l1-public-key.txt' | |
| } | |
| if (-not (Test-Path -LiteralPath $PublicKeyPath)) { | |
| $placeholder = @" | |
| # Paste the full contents of L1's id_ed25519.pub into this file and rerun the script with: | |
| # -ConfigureAuthorizedKeyFromFile -L1PublicKeyPath `"$PublicKeyPath`" | |
| "@ | |
| Set-Content -LiteralPath $PublicKeyPath -Value $placeholder -Encoding UTF8 | |
| $script:AuthorizedKeyImportStatus = 'PlaceholderCreated' | |
| Write-WarnMsg "Public key file was not found. A placeholder file has been created at: $PublicKeyPath" | |
| return | |
| } | |
| $pubKey = (Get-Content -LiteralPath $PublicKeyPath -Raw).Trim() | |
| if (-not $pubKey -or $pubKey.StartsWith('#')) { | |
| $script:AuthorizedKeyImportStatus = 'InvalidPublicKeyFile' | |
| Write-WarnMsg "The public key file exists but does not contain a usable SSH public key yet: $PublicKeyPath" | |
| return | |
| } | |
| $importedTargets = @() | |
| $existingTargets = @() | |
| foreach ($target in $authorizedKeyTargets.Targets) { | |
| if (Add-UniqueContentLine -Path $target.Path -Line $pubKey) { | |
| Write-Step "Imported L1 public key into $($target.Description): $($target.Path)" | |
| $importedTargets += $target.Path | |
| } | |
| else { | |
| Write-Step "L1 public key is already present in $($target.Description): $($target.Path)" | |
| $existingTargets += $target.Path | |
| } | |
| } | |
| $script:AuthorizedKeyImportTargets = @($importedTargets + $existingTargets) | |
| if ($importedTargets.Count -gt 0) { | |
| $script:AuthorizedKeyImportStatus = 'Imported' | |
| } | |
| elseif ($existingTargets.Count -gt 0) { | |
| $script:AuthorizedKeyImportStatus = 'AlreadyPresent' | |
| } | |
| } else { | |
| Write-Info "Skipping authorized_keys import because -ConfigureAuthorizedKeyFromFile was not specified." | |
| } | |
| Write-Step "Applying restrictive permissions to SSH key files." | |
| Set-SshProfilePathPermissions -SshDir $sshDir -AuthorizedKeysPath (Join-Path $sshDir 'authorized_keys') | |
| foreach ($target in $authorizedKeyTargets.Targets | Where-Object PermissionProfile -eq 'AdministratorsFile') { | |
| Set-AdministratorsAuthorizedKeysPermissions -Path $target.Path | |
| } | |
| } | |
| function Get-OSBuildInfo { | |
| $cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' | |
| [pscustomobject]@{ | |
| ProductName = $cv.ProductName | |
| DisplayVersion = $cv.DisplayVersion | |
| CurrentBuild = $cv.CurrentBuild | |
| UBR = $cv.UBR | |
| ReleaseId = $cv.ReleaseId | |
| } | |
| } | |
| function Get-VirtualizationSupportStatus { | |
| $processor = $null | |
| $computerSystem = $null | |
| try { | |
| $processor = Get-CimInstance Win32_Processor -ErrorAction Stop | | |
| Select-Object -First 1 Name,SecondLevelAddressTranslationExtensions,VMMonitorModeExtensions,VirtualizationFirmwareEnabled | |
| } | |
| catch { | |
| Write-WarnMsg "Unable to query Win32_Processor for virtualization details: $($_.Exception.Message)" | |
| } | |
| try { | |
| $computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop | | |
| Select-Object Manufacturer,Model,HypervisorPresent | |
| } | |
| catch { | |
| Write-WarnMsg "Unable to query Win32_ComputerSystem for virtualization details: $($_.Exception.Message)" | |
| } | |
| $systemInfoOutput = '' | |
| $systemInfoHypervisorDetected = $false | |
| $systemInfoFirmwareEnabled = $false | |
| try { | |
| $systemInfoResult = Invoke-LoggedCommand -FilePath 'systeminfo.exe' -IgnoreExitCode | |
| $systemInfoOutput = ($systemInfoResult.StdOut + "`n" + $systemInfoResult.StdErr).Trim() | |
| $systemInfoHypervisorDetected = $systemInfoOutput -match 'A hypervisor has been detected' | |
| $systemInfoFirmwareEnabled = $systemInfoOutput -match 'Virtualization Enabled In Firmware:\s+Yes' | |
| } | |
| catch { | |
| Write-WarnMsg "Unable to query systeminfo.exe for virtualization details: $($_.Exception.Message)" | |
| } | |
| $hypervisorPresent = [bool]($computerSystem -and $computerSystem.HypervisorPresent) | |
| $firmwareEnabled = [bool]($processor -and $processor.VirtualizationFirmwareEnabled) | |
| $effectiveVirtualizationAvailable = $hypervisorPresent -or $systemInfoHypervisorDetected -or $firmwareEnabled -or $systemInfoFirmwareEnabled | |
| [pscustomobject]@{ | |
| ProcessorName = if ($processor) { $processor.Name } else { '' } | |
| SecondLevelAddressTranslationExtensions = [bool]($processor -and $processor.SecondLevelAddressTranslationExtensions) | |
| VMMonitorModeExtensions = [bool]($processor -and $processor.VMMonitorModeExtensions) | |
| VirtualizationEnabledInFirmware = $firmwareEnabled | |
| HypervisorPresent = $hypervisorPresent | |
| EffectiveVirtualizationAvailable = $effectiveVirtualizationAvailable | |
| SystemInfoHypervisorDetected = $systemInfoHypervisorDetected | |
| SystemInfoVirtualizationEnabledInFirmware = $systemInfoFirmwareEnabled | |
| Manufacturer = if ($computerSystem) { $computerSystem.Manufacturer } else { '' } | |
| Model = if ($computerSystem) { $computerSystem.Model } else { '' } | |
| } | |
| } | |
| function Ensure-WSL { | |
| param([string]$TargetDistro) | |
| Write-Section "WSL2 setup" | |
| $featuresChanged = Ensure-WslWindowsFeatures | |
| if ($featuresChanged) { | |
| Write-WarnMsg "WSL platform features were enabled. A reboot is required before distro installation and WSL2 validation can complete." | |
| return | |
| } | |
| $wslExists = Test-CommandExists -Name 'wsl' | |
| if (-not $wslExists) { | |
| Write-Step "WSL command not found. Attempting WSL installation." | |
| try { | |
| Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList @('--install', '-d', $TargetDistro) | Out-Null | |
| } | |
| catch { | |
| Write-WarnMsg "Automatic 'wsl --install' failed. Falling back to enabling required Windows features." | |
| dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart | Out-Null | |
| dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart | Out-Null | |
| $script:NeedsReboot = $true | |
| return | |
| } | |
| $script:NeedsReboot = $true | |
| return | |
| } | |
| Write-Step "Ensuring WSL default version is 2." | |
| try { | |
| Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList @('--set-default-version', '2') | Out-Null | |
| } | |
| catch { | |
| Write-WarnMsg "Could not set WSL default version to 2 yet. This can happen before a required reboot or before the WSL kernel is fully ready." | |
| } | |
| $distroList = Get-WslDistributionList | |
| $stdout = $distroList.Output | |
| if (-not $distroList.Success) { | |
| Write-WarnMsg "Could not query the WSL distro list using supported commands. Last attempt: $($distroList.Arguments). Output: $stdout" | |
| if (-not (Try-InstallWslDistro -TargetDistro $TargetDistro)) { | |
| $script:NeedsManualDistroInit = $true | |
| } | |
| return | |
| } | |
| if ($distroList.NoInstalledDistributions) { | |
| Write-Step "No WSL distro found. Attempting to install distro '$TargetDistro'." | |
| if (-not (Try-InstallWslDistro -TargetDistro $TargetDistro)) { | |
| Write-WarnMsg "Could not install distro automatically. You may need to install it manually from the Microsoft Store or rerun after reboot." | |
| $script:NeedsManualDistroInit = $true | |
| } | |
| return | |
| } | |
| # If the target distro exists in WSL1, upgrade it. | |
| $distroLines = $stdout -split "`r?`n" | Where-Object { $_.Trim() } | |
| $targetLine = $distroLines | Where-Object { $_ -match "^\*?\s*$([regex]::Escape($TargetDistro))\s+" } | |
| if ($targetLine) { | |
| if ($targetLine -notmatch '\s2\s*$') { | |
| Write-Step "Target distro '$TargetDistro' is not in WSL2 mode. Attempting upgrade to WSL2." | |
| try { | |
| Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList @('--set-version', $TargetDistro, '2') | Out-Null | |
| } | |
| catch { | |
| Write-WarnMsg "Failed to upgrade distro '$TargetDistro' to WSL2. This may require a reboot or kernel update." | |
| } | |
| } | |
| Write-Step "Setting '$TargetDistro' as the default WSL distribution." | |
| try { | |
| Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList @('--set-default', $TargetDistro) | Out-Null | |
| } | |
| catch { | |
| Write-WarnMsg "Could not set default distro to '$TargetDistro'." | |
| } | |
| } else { | |
| Write-WarnMsg "Target distro '$TargetDistro' was not found in the WSL distro list." | |
| $script:NeedsManualDistroInit = $true | |
| } | |
| } | |
| function Ensure-GitIfRequested { | |
| if (-not $InstallGit) { | |
| Write-Info "Skipping Git installation because -InstallGit was not specified." | |
| return | |
| } | |
| if (Test-CommandExists -Name 'git') { | |
| Write-Step "Git is already installed." | |
| return | |
| } | |
| Write-Section "Git installation" | |
| $effectiveGitInstallMethod = $GitInstallMethod | |
| if ($effectiveGitInstallMethod -eq 'auto') { | |
| if (Test-CommandExists -Name 'choco') { | |
| $effectiveGitInstallMethod = 'choco' | |
| } | |
| elseif (Test-CommandExists -Name 'winget') { | |
| $effectiveGitInstallMethod = 'winget' | |
| } | |
| else { | |
| $effectiveGitInstallMethod = '' | |
| } | |
| } | |
| if ($effectiveGitInstallMethod -eq 'winget') { | |
| Write-Step "Installing Git using winget." | |
| Invoke-LoggedCommand -FilePath 'winget' -ArgumentList @('install', '--exact', '--id', 'Git.Git', '--silent', '--accept-package-agreements', '--accept-source-agreements') | Out-Null | |
| } | |
| elseif ($effectiveGitInstallMethod -eq 'choco') { | |
| if (-not (Test-CommandExists -Name 'choco')) { | |
| throw "Chocolatey is not installed. Use -InstallChocolatey or choose -GitInstallMethod winget." | |
| } | |
| Write-Step "Installing Git using Chocolatey." | |
| Install-PackageViaChoco -PackageName 'git' | |
| } | |
| else { | |
| Write-WarnMsg "Neither winget nor Chocolatey is available. Git was not installed automatically." | |
| } | |
| } | |
| function Ensure-DockerDesktop { | |
| if (-not $InstallDockerDesktop) { | |
| Write-Info "Skipping Docker Desktop installation because -InstallDockerDesktop was not specified." | |
| return | |
| } | |
| Write-Section "Docker Desktop installation" | |
| $dockerDesktopExe = Join-Path ${env:ProgramFiles} 'Docker\Docker\Docker Desktop.exe' | |
| if (Test-Path -LiteralPath $dockerDesktopExe) { | |
| Write-Step "Docker Desktop appears to already be installed." | |
| return | |
| } | |
| switch ($DockerInstallMethod) { | |
| 'winget' { | |
| if (-not (Test-CommandExists -Name 'winget')) { | |
| throw "winget is not available on this system. Choose -DockerInstallMethod choco or local instead." | |
| } | |
| Write-Step "Installing Docker Desktop using winget." | |
| Invoke-LoggedCommand -FilePath 'winget' -ArgumentList @('install', '--exact', '--id', 'Docker.DockerDesktop', '--silent', '--accept-package-agreements', '--accept-source-agreements') | Out-Null | |
| } | |
| 'choco' { | |
| if (-not (Test-CommandExists -Name 'choco')) { | |
| throw "Chocolatey is not installed. Use -InstallChocolatey or choose a different Docker install method." | |
| } | |
| Write-Step "Installing Docker Desktop using Chocolatey." | |
| Install-PackageViaChoco -PackageName 'docker-desktop' | |
| } | |
| 'local' { | |
| if (-not $DockerInstallerPath) { | |
| throw "-DockerInstallerPath is required when -DockerInstallMethod local is used." | |
| } | |
| if (-not (Test-Path -LiteralPath $DockerInstallerPath)) { | |
| throw "Docker installer was not found at: $DockerInstallerPath" | |
| } | |
| Write-Step "Installing Docker Desktop using the local installer in silent mode." | |
| Invoke-LoggedCommand -FilePath $DockerInstallerPath -ArgumentList @('install', '--quiet') | Out-Null | |
| } | |
| } | |
| Write-Step "Docker Desktop installation command completed." | |
| } | |
| function Start-DockerDesktopAndValidate { | |
| Write-Section "Docker Desktop validation" | |
| $dockerDesktopExe = Join-Path ${env:ProgramFiles} 'Docker\Docker\Docker Desktop.exe' | |
| if (-not (Test-Path -LiteralPath $dockerDesktopExe)) { | |
| Write-WarnMsg "Docker Desktop executable not found. Skipping Docker validation." | |
| return | |
| } | |
| $dockerCli = Get-DockerCliPath | |
| if (-not $dockerCli) { | |
| $dockerCli = 'docker' | |
| } | |
| Write-Step "Starting Docker Desktop." | |
| Start-Process -FilePath $dockerDesktopExe | Out-Null | |
| Write-Step "Waiting for Docker Desktop to become responsive." | |
| $maxAttempts = 24 | |
| $sleepSeconds = 10 | |
| $dockerReady = $false | |
| for ($i = 1; $i -le $maxAttempts; $i++) { | |
| Start-Sleep -Seconds $sleepSeconds | |
| try { | |
| $result = Invoke-LoggedCommand -FilePath $dockerCli -ArgumentList @('version') -IgnoreExitCode | |
| if ($result.ExitCode -eq 0) { | |
| $dockerReady = $true | |
| break | |
| } | |
| } | |
| catch { | |
| # Keep waiting. | |
| } | |
| Write-Info "Docker not ready yet (attempt $i of $maxAttempts)." | |
| } | |
| if (-not $dockerReady) { | |
| Write-WarnMsg "Docker Desktop did not become ready in time. You may need to sign in, reboot, or complete first-run steps." | |
| return | |
| } | |
| Write-Step "Docker Desktop is responding from Windows." | |
| try { | |
| $composeCheck = Invoke-LoggedCommand -FilePath $dockerCli -ArgumentList @('compose', 'version') -IgnoreExitCode | |
| if ($composeCheck.ExitCode -eq 0) { | |
| Write-Step "docker compose is available from Windows." | |
| } else { | |
| Write-WarnMsg "docker compose did not return success from Windows yet." | |
| } | |
| } | |
| catch { | |
| Write-WarnMsg "docker compose validation from Windows failed: $($_.Exception.Message)" | |
| } | |
| } | |
| function Test-DockerFromWSL { | |
| param([string]$TargetDistro) | |
| Write-Section "WSL + Docker integration validation" | |
| if (-not (Test-CommandExists -Name 'wsl')) { | |
| Write-WarnMsg "WSL is not available yet. Skipping WSL validation." | |
| return | |
| } | |
| $wslList = Get-WslDistributionList | |
| if (-not $wslList.Success) { | |
| Write-WarnMsg "Could not query WSL distributions yet. Last attempt: $($wslList.Arguments). Skipping WSL validation." | |
| return | |
| } | |
| if ($wslList.Output -notmatch [regex]::Escape($TargetDistro)) { | |
| Write-WarnMsg "Target distro '$TargetDistro' is not available yet. Skipping WSL Docker validation." | |
| return | |
| } | |
| try { | |
| Invoke-LoggedCommand -FilePath 'wsl.exe' -ArgumentList @('-d', $TargetDistro, '--', 'bash', '-lc', 'uname -a && docker version && docker compose version') | Out-Null | |
| Write-Step "Docker is usable from the WSL distro '$TargetDistro'." | |
| } | |
| catch { | |
| Write-WarnMsg @" | |
| Docker is not usable from WSL yet. | |
| This usually means one of the following: | |
| - Docker Desktop is not fully started. | |
| - Docker Desktop is in Windows containers mode. | |
| - Docker Desktop WSL integration is not enabled for the target distro. | |
| - The distro still needs first-run initialization. | |
| Open Docker Desktop and verify: | |
| Settings > General > Use WSL 2 based engine | |
| Settings > Resources > WSL Integration > enable '$TargetDistro' | |
| Also ensure Docker Desktop is in Linux containers mode. | |
| "@ | |
| } | |
| } | |
| function Get-PrimaryLanIpv4Address { | |
| $ipCandidates = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | | |
| Where-Object { | |
| $_.IPAddress -notlike '169.254*' -and | |
| $_.IPAddress -ne '127.0.0.1' -and | |
| $_.PrefixOrigin -ne 'WellKnown' -and | |
| $_.InterfaceAlias -notlike 'vEthernet*' -and | |
| $_.InterfaceAlias -notlike 'Loopback*' | |
| } | | |
| Sort-Object @{ Expression = { if ($_.InterfaceAlias -match 'Wi-?Fi') { 0 } elseif ($_.InterfaceAlias -match 'Ethernet') { 1 } else { 2 } } }, InterfaceAlias, IPAddress | | |
| Select-Object -ExpandProperty IPAddress -Unique | |
| return @($ipCandidates) | |
| } | |
| function Get-OpenClawRepoShellPrefix { | |
| param([Parameter(Mandatory)][string]$RepoPath) | |
| $pathBootstrap = Get-WslPathBootstrap -Path $RepoPath | |
| return @" | |
| set -e | |
| $pathBootstrap | |
| cd -- "`$resolved_path" | |
| if [ ! -f docker-compose.yml ]; then | |
| echo "OpenClaw docker-compose.yml was not found in `$resolved_path" >&2 | |
| exit 2 | |
| fi | |
| compose_args=(-f docker-compose.yml) | |
| if [ -f docker-compose.extra.yml ]; then | |
| compose_args+=(-f docker-compose.extra.yml) | |
| fi | |
| "@ | |
| } | |
| function Get-OpenClawPublishedHostPortValue { | |
| param([Parameter(Mandatory)][int]$Port) | |
| if ($ExposeOpenClawOnLan) { | |
| return [string]$Port | |
| } | |
| return "127.0.0.1:$Port" | |
| } | |
| function Get-OpenClawRepoEnvMap { | |
| $envMap = [ordered]@{} | |
| $envMap['OPENCLAW_GATEWAY_PORT'] = Get-OpenClawPublishedHostPortValue -Port $OpenClawGatewayPort | |
| $envMap['OPENCLAW_BRIDGE_PORT'] = Get-OpenClawPublishedHostPortValue -Port $OpenClawBridgePort | |
| if ($OpenClawGatewayBind) { | |
| $envMap['OPENCLAW_GATEWAY_BIND'] = $OpenClawGatewayBind | |
| } | |
| if ($OpenClawImage) { | |
| $envMap['OPENCLAW_IMAGE'] = $OpenClawImage | |
| } | |
| if ($OpenClawDockerAptPackages) { | |
| $envMap['OPENCLAW_DOCKER_APT_PACKAGES'] = $OpenClawDockerAptPackages | |
| } | |
| if ($OpenClawExtensions) { | |
| $envMap['OPENCLAW_EXTENSIONS'] = $OpenClawExtensions | |
| } | |
| if ($OpenClawExtraMounts) { | |
| $envMap['OPENCLAW_EXTRA_MOUNTS'] = $OpenClawExtraMounts | |
| } | |
| if ($OpenClawHomeVolume) { | |
| $envMap['OPENCLAW_HOME_VOLUME'] = $OpenClawHomeVolume | |
| } | |
| if ($OpenClawSandbox) { | |
| $envMap['OPENCLAW_SANDBOX'] = '1' | |
| } | |
| return $envMap | |
| } | |
| function Get-OpenClawInstallEnvExports { | |
| $exports = @() | |
| foreach ($entry in (Get-OpenClawRepoEnvMap).GetEnumerator()) { | |
| $exports += 'export ' + $entry.Key + '=' + (ConvertTo-BashLiteral -Value ([string]$entry.Value)) | |
| } | |
| if ($exports.Count -eq 0) { | |
| return '' | |
| } | |
| return ($exports -join "`n") + "`n" | |
| } | |
| function Sync-OpenClawRepoEnv { | |
| param([Parameter(Mandatory)][string]$TargetDistro) | |
| $updateLines = foreach ($entry in (Get-OpenClawRepoEnvMap).GetEnumerator()) { | |
| 'set_env_value ' + (ConvertTo-BashLiteral -Value $entry.Key) + ' ' + (ConvertTo-BashLiteral -Value ([string]$entry.Value)) | |
| } | |
| $command = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| env_path="$resolved_path/.env" | |
| set_env_value() { | |
| key="$1" | |
| value="$2" | |
| tmp_file="${env_path}.tmp.$$" | |
| if [ -f "$env_path" ]; then | |
| awk -v key="$key" -v value="$value" ' | |
| BEGIN { done=0 } | |
| $0 ~ ("^" key "=") { print key "=" value; done=1; next } | |
| { print } | |
| END { if (!done) print key "=" value } | |
| ' "$env_path" > "$tmp_file" | |
| else | |
| printf '%s=%s\n' "$key" "$value" > "$tmp_file" | |
| fi | |
| mv "$tmp_file" "$env_path" | |
| } | |
| '@ + (($updateLines -join "`n") + "`n") | |
| Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $command | Out-Null | |
| } | |
| function Start-OpenClawGatewayFromRepo { | |
| param([Parameter(Mandatory)][string]$TargetDistro) | |
| $command = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| docker compose "${compose_args[@]}" up -d openclaw-gateway | |
| '@ | |
| Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $command | Out-Null | |
| } | |
| function Get-OpenClawSetupScriptBody { | |
| return @' | |
| if [ -x ./docker-setup.sh ]; then | |
| setup_script=./docker-setup.sh | |
| elif [ -x ./scripts/docker/setup.sh ]; then | |
| setup_script=./scripts/docker/setup.sh | |
| else | |
| echo "OpenClaw setup script was not found in $resolved_path" >&2 | |
| exit 2 | |
| fi | |
| '@ | |
| } | |
| function Resolve-OpenClawRepoPathInWSL { | |
| param( | |
| [Parameter(Mandatory)][string]$TargetDistro, | |
| [Parameter(Mandatory)][string]$RepoPath | |
| ) | |
| $command = (Get-WslPathBootstrap -Path $RepoPath) + @' | |
| printf '%s' "$resolved_path" | |
| '@ | |
| return (Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $command).StdOut.Trim() | |
| } | |
| function Ensure-WslGit { | |
| param([Parameter(Mandatory)][string]$TargetDistro) | |
| $gitCheck = Invoke-WslBashCommand -TargetDistro $TargetDistro -Command 'command -v git >/dev/null 2>&1' -IgnoreExitCode | |
| if ($gitCheck.ExitCode -eq 0) { | |
| Write-Step "Git is available inside WSL." | |
| return | |
| } | |
| Write-Step "Installing Git inside WSL Ubuntu." | |
| Invoke-WslBashCommand -TargetDistro $TargetDistro -User 'root' -Command 'apt-get update && apt-get install -y git ca-certificates' | Out-Null | |
| } | |
| function Ensure-OpenClawRepo { | |
| param( | |
| [Parameter(Mandatory)][string]$TargetDistro, | |
| [Parameter(Mandatory)][string]$RepoUrl, | |
| [Parameter(Mandatory)][string]$RepoPath | |
| ) | |
| $repoUrlLiteral = ConvertTo-BashLiteral -Value $RepoUrl | |
| $command = (Get-WslPathBootstrap -Path $RepoPath) + @" | |
| if [ -d "`$resolved_path/.git" ]; then | |
| git -C "`$resolved_path" fetch --all --tags --prune | |
| git -C "`$resolved_path" pull --ff-only | |
| elif [ -e "`$resolved_path" ]; then | |
| echo "OpenClaw repo path exists but is not a git repository: `$resolved_path" >&2 | |
| exit 3 | |
| else | |
| mkdir -p "`$(dirname "`$resolved_path")" | |
| git clone $repoUrlLiteral "`$resolved_path" | |
| fi | |
| "@ | |
| Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $command | Out-Null | |
| } | |
| function Get-OpenClawConfigStatus { | |
| param([Parameter(Mandatory)][string]$TargetDistro) | |
| $command = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| config_dir="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" | |
| config_path="$config_dir/openclaw.json" | |
| if [ -f "$config_path" ]; then | |
| printf 'configured' | |
| else | |
| printf 'missing-config' | |
| fi | |
| '@ | |
| return (Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $command).StdOut.Trim() | |
| } | |
| function Wait-OpenClawGatewayReady { | |
| param( | |
| [Parameter(Mandatory)][string]$TargetDistro, | |
| [int]$Attempts = 20, | |
| [int]$DelaySeconds = 3 | |
| ) | |
| $statusCommand = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| docker compose "${compose_args[@]}" ps --status running --services openclaw-gateway | |
| '@ | |
| for ($attempt = 1; $attempt -le $Attempts; $attempt++) { | |
| $result = Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $statusCommand -IgnoreExitCode | |
| if ($result.ExitCode -eq 0 -and ($result.StdOut -split "`r?`n" | Where-Object { $_.Trim() -eq 'openclaw-gateway' })) { | |
| return $true | |
| } | |
| Start-Sleep -Seconds $DelaySeconds | |
| } | |
| return $false | |
| } | |
| function Ensure-OpenClawDockerDeployment { | |
| param([Parameter(Mandatory)][string]$TargetDistro) | |
| if (-not $InstallOpenClawDocker) { | |
| Write-Info "Skipping OpenClaw Docker deployment because -InstallOpenClawDocker was not specified." | |
| return | |
| } | |
| Write-Section "OpenClaw Docker deployment" | |
| $defaultUser = (Invoke-WslBashCommand -TargetDistro $TargetDistro -Command 'whoami').StdOut.Trim() | |
| $script:OpenClawDefaultUser = $defaultUser | |
| if (-not $defaultUser) { | |
| throw "Could not determine the default Ubuntu user for '$TargetDistro'." | |
| } | |
| if ($defaultUser -eq 'root') { | |
| throw "OpenClaw Docker deployment requires a normal Ubuntu user. The default WSL user for '$TargetDistro' is currently 'root'. Change the default user first, then rerun l2.ps1." | |
| } | |
| Write-Step "Validating Docker from the WSL distro before OpenClaw setup." | |
| Invoke-WslBashCommand -TargetDistro $TargetDistro -Command 'docker version >/dev/null && docker compose version >/dev/null' | Out-Null | |
| Ensure-WslGit -TargetDistro $TargetDistro | |
| Ensure-OpenClawRepo -TargetDistro $TargetDistro -RepoUrl $OpenClawRepoUrl -RepoPath $OpenClawRepoPath | |
| $script:OpenClawRepoResolvedPath = Resolve-OpenClawRepoPathInWSL -TargetDistro $TargetDistro -RepoPath $OpenClawRepoPath | |
| Write-Step "OpenClaw repo path inside WSL: $($script:OpenClawRepoResolvedPath)" | |
| $layoutCheckCommand = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + | |
| (Get-OpenClawSetupScriptBody) + @' | |
| printf '%s' "$setup_script" >/dev/null | |
| '@ | |
| Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $layoutCheckCommand | Out-Null | |
| $installState = Get-OpenClawConfigStatus -TargetDistro $TargetDistro | |
| if ($installState -ne 'configured') { | |
| Write-WarnMsg "OpenClaw is not fully configured in '$($script:OpenClawRepoResolvedPath)' yet. The documented Docker onboarding will now start interactively in Ubuntu." | |
| Write-Info "Complete the OpenClaw setup flow in the interactive Ubuntu session. When the setup script exits successfully, l2.ps1 will resume validation." | |
| $interactiveCommand = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + | |
| (Get-OpenClawSetupScriptBody) + | |
| (Get-OpenClawInstallEnvExports) + @' | |
| "$setup_script" | |
| '@ | |
| Invoke-WslBashInteractive -TargetDistro $TargetDistro -Command $interactiveCommand | |
| $installState = Get-OpenClawConfigStatus -TargetDistro $TargetDistro | |
| if ($installState -ne 'configured') { | |
| throw "OpenClaw onboarding did not create the expected config under ~/.openclaw. Rerun the setup and complete the interactive onboarding before validation can continue." | |
| } | |
| } else { | |
| Write-Step "Existing OpenClaw Docker configuration detected. Skipping the interactive setup script." | |
| } | |
| Sync-OpenClawRepoEnv -TargetDistro $TargetDistro | |
| Start-OpenClawGatewayFromRepo -TargetDistro $TargetDistro | |
| $composePsCommand = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| docker compose "${compose_args[@]}" ps | |
| '@ | |
| $composePs = Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $composePsCommand | |
| $script:OpenClawComposeHealthy = $composePs.ExitCode -eq 0 | |
| $script:OpenClawComposePsOutput = $composePs.StdOut | |
| if (-not (Wait-OpenClawGatewayReady -TargetDistro $TargetDistro)) { | |
| $gatewayLogsCommand = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| docker compose "${compose_args[@]}" logs --tail=80 openclaw-gateway | |
| '@ | |
| $gatewayLogs = Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $gatewayLogsCommand -IgnoreExitCode | |
| throw "OpenClaw gateway did not reach a running state after onboarding. Recent gateway logs:`n$($gatewayLogs.StdOut)`n$($gatewayLogs.StdErr)" | |
| } | |
| $dashboardCommand = (Get-OpenClawRepoShellPrefix -RepoPath $OpenClawRepoPath) + @' | |
| docker compose "${compose_args[@]}" run --rm openclaw-cli dashboard --no-open | |
| '@ | |
| $dashboardResult = Invoke-WslBashCommand -TargetDistro $TargetDistro -Command $dashboardCommand | |
| $script:OpenClawDashboardHint = $dashboardResult.StdOut.Trim() | |
| $gatewayProbe = Invoke-LoggedCommand -FilePath 'curl.exe' -ArgumentList @('-fsS', "http://127.0.0.1:$OpenClawGatewayPort/") -IgnoreExitCode | |
| $script:OpenClawGatewayHttpReachable = $gatewayProbe.ExitCode -eq 0 | |
| $script:OpenClawLocalUrl = "http://127.0.0.1:$OpenClawGatewayPort/" | |
| $script:OpenClawOnboardingCompleted = $true | |
| } | |
| function Write-HelperFiles { | |
| param([string]$TargetDistro) | |
| Write-Section "Writing helper files" | |
| $hostnameValue = $env:COMPUTERNAME | |
| $ipCandidates = @(Get-PrimaryLanIpv4Address) | |
| $sshUser = $env:USERNAME | |
| $primaryHost = if ($ipCandidates.Count -gt 0) { $ipCandidates[0] } else { $hostnameValue } | |
| $sshExample = "ssh $sshUser@$primaryHost" | |
| $remoteWslExample = "ssh $sshUser@$primaryHost `"wsl -d $TargetDistro -- bash -lc 'docker version && docker compose version'`"" | |
| $openClawTunnelExample = "ssh -L $OpenClawGatewayPort`:127.0.0.1`:$OpenClawGatewayPort $sshUser@$primaryHost -N" | |
| $openClawLocalUiUrl = "http://127.0.0.1:$OpenClawGatewayPort/" | |
| $openClawExposureMode = if ($ExposeOpenClawOnLan) { 'LANPublished' } else { 'LoopbackOnly' } | |
| $repoPathForReports = if ($script:OpenClawRepoResolvedPath) { $script:OpenClawRepoResolvedPath } else { $OpenClawRepoPath } | |
| $repoPathForHelpers = $repoPathForReports | |
| $connectionInfo = @" | |
| L2 connection info | |
| ================== | |
| Hostname: $hostnameValue | |
| Windows user: $sshUser | |
| IPv4 addresses: $($ipCandidates -join ', ') | |
| Target WSL distro: $TargetDistro | |
| OpenClaw repo path: $repoPathForReports | |
| OpenClaw local UI: $openClawLocalUiUrl | |
| OpenClaw host publish mode: $openClawExposureMode | |
| Authorized key import status: $($script:AuthorizedKeyImportStatus) | |
| Access model: | |
| - Use L2 locally in Chrome on $openClawLocalUiUrl | |
| - Use L1 remotely through the SSH tunnel below, not by browsing to http://${primaryHost}:$OpenClawGatewayPort/ | |
| - The SSH keypair is created on L1 by openclaw-setup-l1-bootstrap.ps1 | |
| - Copy only L1's exported public key file (l1-public-key.txt) to this workspace before importing it | |
| Example SSH command from L1: | |
| $sshExample | |
| Example remote WSL command from L1: | |
| $remoteWslExample | |
| Example SSH tunnel for the OpenClaw Control UI from L1: | |
| $openClawTunnelExample | |
| Files created by this bootstrap: | |
| - Logs: $LogDir | |
| - Generated files: $GeneratedDir | |
| - Reports: $ReportsDir | |
| - State file: $StateFile | |
| "@ | |
| Set-Content -LiteralPath $ConnectionInfoPath -Value $connectionInfo -Encoding UTF8 | |
| $sshHelper = @" | |
| param( | |
| [string]`$HostOrIp = '$primaryHost', | |
| [string]`$User = '$sshUser' | |
| ) | |
| ssh "`$User@`$HostOrIp" | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'ssh-to-l2.ps1') -Value $sshHelper -Encoding UTF8 | |
| $remoteWslHelper = @" | |
| param( | |
| [string]`$HostOrIp = '$primaryHost', | |
| [string]`$User = '$sshUser', | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$Command = 'pwd && docker version && docker compose version' | |
| ) | |
| ssh "`$User@`$HostOrIp" "wsl -d `"`$Distro`" -- bash -lc '`$Command'" | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'invoke-wsl-on-l2.ps1') -Value $remoteWslHelper -Encoding UTF8 | |
| $openClawHelperCommon = @' | |
| function ConvertTo-BashLiteral { | |
| param([Parameter(Mandatory)][string]$Value) | |
| $escaped = $Value.Replace("'", "'""'""'") | |
| return "'" + $escaped + "'" | |
| } | |
| function Get-OpenClawBashPrefix { | |
| param([Parameter(Mandatory)][string]$RepoPath) | |
| $repoLiteral = ConvertTo-BashLiteral -Value $RepoPath | |
| return @" | |
| set -e | |
| repo_path_input=$repoLiteral | |
| case "$repo_path_input" in | |
| "~") resolved_path="$HOME" ;; | |
| "~/"*) resolved_path="$HOME/${repo_path_input#~/}" ;; | |
| /*) resolved_path="$repo_path_input" ;; | |
| *) resolved_path="$HOME/$repo_path_input" ;; | |
| esac | |
| cd -- "$resolved_path" | |
| compose_args=(-f docker-compose.yml) | |
| if [ -f docker-compose.extra.yml ]; then | |
| compose_args+=(-f docker-compose.extra.yml) | |
| fi | |
| setup_script="" | |
| if [ -x ./docker-setup.sh ]; then | |
| setup_script=./docker-setup.sh | |
| elif [ -x ./scripts/docker/setup.sh ]; then | |
| setup_script=./scripts/docker/setup.sh | |
| fi | |
| "@ | |
| } | |
| function Get-OpenClawWslLauncherCommand { | |
| param([Parameter(Mandatory)][string]$Command) | |
| $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Command)) | |
| return "printf '%s' " + (ConvertTo-BashLiteral -Value $encoded) + " | base64 -d | bash" | |
| } | |
| function Invoke-OpenClawWslCommand { | |
| param( | |
| [Parameter(Mandatory)][string]$Distro, | |
| [Parameter(Mandatory)][string]$Command | |
| ) | |
| $launcherCommand = Get-OpenClawWslLauncherCommand -Command $Command | |
| & wsl.exe -d $Distro -- bash -lc $launcherCommand | |
| } | |
| '@ | |
| $openClawSetupHelper = @" | |
| param( | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$RepoPath = '$repoPathForHelpers' | |
| ) | |
| $openClawHelperCommon | |
| `$bashCommand = (Get-OpenClawBashPrefix -RepoPath `$RepoPath) + @' | |
| [ -n "`$setup_script" ] || { echo "OpenClaw setup script was not found for `$RepoPath" >&2; exit 2; } | |
| "`$setup_script" | |
| '@ | |
| Invoke-OpenClawWslCommand -Distro `$Distro -Command `$bashCommand | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'openclaw-docker-setup.ps1') -Value $openClawSetupHelper -Encoding UTF8 | |
| $openClawDashboardHelper = @" | |
| param( | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$RepoPath = '$repoPathForHelpers' | |
| ) | |
| $openClawHelperCommon | |
| `$bashCommand = (Get-OpenClawBashPrefix -RepoPath `$RepoPath) + @' | |
| docker compose "`${compose_args[@]}" run --rm openclaw-cli dashboard --no-open | |
| '@ | |
| Invoke-OpenClawWslCommand -Distro `$Distro -Command `$bashCommand | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'openclaw-dashboard.ps1') -Value $openClawDashboardHelper -Encoding UTF8 | |
| $openClawDevicesListHelper = @" | |
| param( | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$RepoPath = '$repoPathForHelpers' | |
| ) | |
| $openClawHelperCommon | |
| `$bashCommand = (Get-OpenClawBashPrefix -RepoPath `$RepoPath) + @' | |
| docker compose "`${compose_args[@]}" run --rm openclaw-cli devices list | |
| '@ | |
| Invoke-OpenClawWslCommand -Distro `$Distro -Command `$bashCommand | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'openclaw-devices-list.ps1') -Value $openClawDevicesListHelper -Encoding UTF8 | |
| $openClawDevicesApproveHelper = @" | |
| param( | |
| [string]`$RequestId = '', | |
| [switch]`$Latest, | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$RepoPath = '$repoPathForHelpers' | |
| ) | |
| $openClawHelperCommon | |
| if (-not `$Latest -and -not `$RequestId) { | |
| throw 'Specify -RequestId or use -Latest.' | |
| } | |
| `$approveSuffix = if (`$Latest) { '--latest' } else { ConvertTo-BashLiteral -Value `$RequestId } | |
| `$bashCommand = (Get-OpenClawBashPrefix -RepoPath `$RepoPath) + 'docker compose "`${compose_args[@]}" run --rm openclaw-cli devices approve ' + `$approveSuffix | |
| Invoke-OpenClawWslCommand -Distro `$Distro -Command `$bashCommand | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'openclaw-devices-approve.ps1') -Value $openClawDevicesApproveHelper -Encoding UTF8 | |
| $openClawLogsHelper = @" | |
| param( | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$RepoPath = '$repoPathForHelpers' | |
| ) | |
| $openClawHelperCommon | |
| `$bashCommand = (Get-OpenClawBashPrefix -RepoPath `$RepoPath) + @' | |
| docker compose "`${compose_args[@]}" logs -f openclaw-gateway | |
| '@ | |
| Invoke-OpenClawWslCommand -Distro `$Distro -Command `$bashCommand | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'openclaw-logs.ps1') -Value $openClawLogsHelper -Encoding UTF8 | |
| $openClawRestartHelper = @" | |
| param( | |
| [string]`$Distro = '$TargetDistro', | |
| [string]`$RepoPath = '$repoPathForHelpers' | |
| ) | |
| $openClawHelperCommon | |
| `$bashCommand = (Get-OpenClawBashPrefix -RepoPath `$RepoPath) + @' | |
| docker compose "`${compose_args[@]}" up -d openclaw-gateway | |
| '@ | |
| Invoke-OpenClawWslCommand -Distro `$Distro -Command `$bashCommand | |
| "@ | |
| Set-Content -LiteralPath (Join-Path $HelpersDir 'openclaw-restart.ps1') -Value $openClawRestartHelper -Encoding UTF8 | |
| } | |
| function Write-Summary { | |
| param([hashtable]$State) | |
| Write-Section "Writing summary report" | |
| $osInfo = Get-OSBuildInfo | |
| $virtualizationStatus = Get-VirtualizationSupportStatus | |
| $sshdService = Get-Service sshd -ErrorAction SilentlyContinue | |
| $sshdServiceStatus = if ($sshdService) { $sshdService.Status } else { 'NotInstalled' } | |
| $wslOutput = '' | |
| $dockerVersionOutput = '' | |
| $sshPortCheck = Invoke-LoggedCommand -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-Command', 'Test-NetConnection -ComputerName localhost -Port 22 | Select-Object -ExpandProperty TcpTestSucceeded') -IgnoreExitCode | |
| $sshPortStatus = if ($sshPortCheck.ExitCode -eq 0 -and $sshPortCheck.StdOut.Trim()) { $sshPortCheck.StdOut.Trim() } else { 'Unknown' } | |
| if (Test-CommandExists -Name 'wsl') { | |
| $wslStatus = Get-WslDistributionList | |
| $wslOutput = if ($wslStatus.Success) { $wslStatus.Output } else { "Unable to read WSL status. Last attempt: $($wslStatus.Arguments). Output: $($wslStatus.Output)" } | |
| } | |
| $dockerCli = Get-DockerCliPath | |
| if ($dockerCli) { | |
| try { | |
| $dockerVersionOutput = (Invoke-LoggedCommand -FilePath $dockerCli -ArgumentList @('version') -IgnoreExitCode).StdOut | |
| } | |
| catch { | |
| $dockerVersionOutput = "Unable to read docker version: $($_.Exception.Message)" | |
| } | |
| } else { | |
| $dockerVersionOutput = 'Docker CLI was not found on this system.' | |
| } | |
| $summary = @" | |
| Bootstrap summary | |
| ================= | |
| Completed at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz') | |
| Windows: | |
| - Product: $($osInfo.ProductName) | |
| - DisplayVersion: $($osInfo.DisplayVersion) | |
| - Build: $($osInfo.CurrentBuild).$($osInfo.UBR) | |
| OpenSSH: | |
| - sshd service status: $sshdServiceStatus | |
| - sshd startup type should be Automatic | |
| - localhost:22 reachable: $sshPortStatus | |
| - authorized_keys import requested: $($script:AuthorizedKeyImportRequested) | |
| - authorized_keys import status: $($script:AuthorizedKeyImportStatus) | |
| - authorized_keys targets: $($script:AuthorizedKeyImportTargets -join ', ') | |
| Virtualization: | |
| - Processor: $($virtualizationStatus.ProcessorName) | |
| - VirtualizationEnabledInFirmware: $($virtualizationStatus.VirtualizationEnabledInFirmware) | |
| - SystemInfoVirtualizationEnabledInFirmware: $($virtualizationStatus.SystemInfoVirtualizationEnabledInFirmware) | |
| - VMMonitorModeExtensions: $($virtualizationStatus.VMMonitorModeExtensions) | |
| - SecondLevelAddressTranslationExtensions: $($virtualizationStatus.SecondLevelAddressTranslationExtensions) | |
| - HypervisorPresent: $($virtualizationStatus.HypervisorPresent) | |
| - SystemInfoHypervisorDetected: $($virtualizationStatus.SystemInfoHypervisorDetected) | |
| - EffectiveVirtualizationAvailable: $($virtualizationStatus.EffectiveVirtualizationAvailable) | |
| - System: $($virtualizationStatus.Manufacturer) / $($virtualizationStatus.Model) | |
| WSL status: | |
| $wslOutput | |
| Docker status from Windows: | |
| $dockerVersionOutput | |
| OpenClaw: | |
| - Requested in this run: $($State.InstallOpenClawDocker) | |
| - Repo URL: $($State.OpenClawRepoUrl) | |
| - Repo path: $($State.OpenClawRepoPath) | |
| - Resolved repo path: $($State.OpenClawRepoResolvedPath) | |
| - Gateway port: $($State.OpenClawGatewayPort) | |
| - Bridge port: $($State.OpenClawBridgePort) | |
| - Host publish mode: $($State.OpenClawHostPublishMode) | |
| - Default Ubuntu user: $($State.OpenClawDefaultUser) | |
| - Compose validation passed: $($State.OpenClawComposeHealthy) | |
| - Local gateway URL: $($State.OpenClawLocalUrl) | |
| - Local gateway HTTP reachable: $($State.OpenClawGatewayHttpReachable) | |
| OpenClaw dashboard hint: | |
| $($State.OpenClawDashboardHint) | |
| Flags: | |
| - NeedsReboot: $($State.NeedsReboot) | |
| - NeedsManualDistroInit: $($State.NeedsManualDistroInit) | |
| - NeedsFirmwareVirtualization: $($State.NeedsFirmwareVirtualization) | |
| Recommended next checks: | |
| 1. Reboot if WSL or optional features were just installed. | |
| 2. Launch the Linux distro once and complete its first-run user setup if prompted. | |
| 3. Start Docker Desktop and wait for it to fully initialize. | |
| 4. In Docker Desktop, verify Linux containers mode and WSL integration for the target distro. | |
| 5. From L1, test SSH and then test a remote WSL command. | |
| See also: | |
| - $ConnectionInfoPath | |
| - $TranscriptPath | |
| "@ | |
| Set-Content -LiteralPath $SummaryPath -Value $summary -Encoding UTF8 | |
| } | |
| # ------------------------------- | |
| # Main flow | |
| # ------------------------------- | |
| $State = Load-State | |
| $NeedsReboot = $false | |
| $NeedsManualDistroInit = $false | |
| $NeedsFirmwareVirtualization = $false | |
| $AuthorizedKeyImportRequested = $false | |
| $AuthorizedKeyImportStatus = 'NotRequested' | |
| $AuthorizedKeyImportTargets = @() | |
| $OpenClawComposeHealthy = $false | |
| $OpenClawComposePsOutput = '' | |
| $OpenClawDashboardHint = '' | |
| $OpenClawRepoResolvedPath = '' | |
| $OpenClawOnboardingCompleted = $false | |
| $OpenClawGatewayHttpReachable = $false | |
| $OpenClawLocalUrl = "http://127.0.0.1:$OpenClawGatewayPort/" | |
| $OpenClawDefaultUser = '' | |
| try { | |
| Assert-Admin | |
| Write-Section "Bootstrap start" | |
| Write-Step "Working directory: $ScriptRoot" | |
| Write-Step "Transcript log: $TranscriptPath" | |
| if ($InstallChocolatey) { | |
| Install-ChocolateyIfNeeded | |
| } | |
| Ensure-OpenSSH | |
| Configure-AuthorizedKeys -PublicKeyPath $L1PublicKeyPath | |
| $virtualizationStatus = Get-VirtualizationSupportStatus | |
| if (-not $virtualizationStatus.EffectiveVirtualizationAvailable) { | |
| $NeedsFirmwareVirtualization = $true | |
| Write-WarnMsg "Hardware virtualization is disabled in BIOS/UEFI. WSL2 and Docker Desktop cannot start until virtualization is enabled in firmware." | |
| if ($virtualizationStatus.Manufacturer -match 'HP') { | |
| Write-WarnMsg "On this HP laptop, reboot, press F10 for BIOS Setup, then look under System Configuration or Configuration for 'Virtualization Technology' or 'SVM Mode', enable it, save changes, and boot back into Windows." | |
| } | |
| } else { | |
| Ensure-WSL -TargetDistro $DistroName | |
| } | |
| Ensure-GitIfRequested | |
| if (-not $NeedsFirmwareVirtualization) { | |
| Ensure-DockerDesktop | |
| Start-DockerDesktopAndValidate | |
| Test-DockerFromWSL -TargetDistro $DistroName | |
| Ensure-OpenClawDockerDeployment -TargetDistro $DistroName | |
| } else { | |
| Write-WarnMsg "Skipping Docker installation and validation because firmware virtualization is disabled." | |
| } | |
| Write-HelperFiles -TargetDistro $DistroName | |
| $State = @{ | |
| LastRunAt = (Get-Date).ToString('o') | |
| ScriptRoot = $ScriptRoot | |
| DistroName = $DistroName | |
| DockerInstallMethod = $DockerInstallMethod | |
| InstallOpenClawDocker = [bool]$InstallOpenClawDocker | |
| OpenClawRepoUrl = $OpenClawRepoUrl | |
| OpenClawRepoPath = $OpenClawRepoPath | |
| OpenClawRepoResolvedPath = $OpenClawRepoResolvedPath | |
| OpenClawGatewayPort = $OpenClawGatewayPort | |
| OpenClawBridgePort = $OpenClawBridgePort | |
| OpenClawHostPublishMode = $(if ($ExposeOpenClawOnLan) { 'LANPublished' } else { 'LoopbackOnly' }) | |
| OpenClawComposeHealthy = $OpenClawComposeHealthy | |
| OpenClawDashboardHint = $OpenClawDashboardHint | |
| OpenClawDefaultUser = $OpenClawDefaultUser | |
| OpenClawLocalUrl = $OpenClawLocalUrl | |
| OpenClawGatewayHttpReachable = $OpenClawGatewayHttpReachable | |
| AuthorizedKeyImportRequested = $AuthorizedKeyImportRequested | |
| AuthorizedKeyImportStatus = $AuthorizedKeyImportStatus | |
| AuthorizedKeyImportTargets = @($AuthorizedKeyImportTargets) | |
| NeedsReboot = $NeedsReboot | |
| NeedsManualDistroInit = $NeedsManualDistroInit | |
| NeedsFirmwareVirtualization = $NeedsFirmwareVirtualization | |
| TranscriptPath = $TranscriptPath | |
| ConnectionInfoPath = $ConnectionInfoPath | |
| SummaryPath = $SummaryPath | |
| } | |
| Save-State -State $State | |
| Write-Summary -State $State | |
| Write-Section "Completed" | |
| Write-Step "Summary report written to: $SummaryPath" | |
| Write-Step "Connection info written to: $ConnectionInfoPath" | |
| if ($NeedsReboot) { | |
| Write-WarnMsg "A reboot is recommended or required before all steps can complete successfully." | |
| if ($RebootIfNeeded) { | |
| Write-WarnMsg "The system will reboot in 20 seconds because -RebootIfNeeded was specified." | |
| shutdown.exe /r /t 20 /c "Reboot requested by openclaw bootstrap script" | |
| } | |
| } | |
| if ($NeedsManualDistroInit) { | |
| Write-WarnMsg "The Linux distro may still need first-run initialization. Launch it once with 'wsl' or from the Start menu, complete setup, then rerun this script." | |
| } | |
| if ($NeedsFirmwareVirtualization) { | |
| Write-WarnMsg "Enable virtualization in BIOS/UEFI before rerunning WSL2 and Docker steps." | |
| } | |
| Write-Host "`nNext useful commands:" -ForegroundColor Cyan | |
| Write-Host " Get-Content '$SummaryPath'" -ForegroundColor Gray | |
| Write-Host " Get-Content '$ConnectionInfoPath'" -ForegroundColor Gray | |
| Write-Host " ssh $env:USERNAME@$env:COMPUTERNAME" -ForegroundColor Gray | |
| Write-Host " wsl -d $DistroName -- bash -lc 'docker version && docker compose version'" -ForegroundColor Gray | |
| Write-Host " Start-Process 'http://127.0.0.1:$OpenClawGatewayPort/'" -ForegroundColor Gray | |
| Write-Host " Get-Content '$HelpersDir\openclaw-dashboard.ps1'" -ForegroundColor Gray | |
| } | |
| catch { | |
| Write-Error $_ | |
| throw | |
| } | |
| finally { | |
| Stop-Transcript | Out-Null | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
You can find the workflow diagram here.(or use your AI agent to generate this from the script provided)
