<# The Lyriks bootstrap for Windows, served at https://get.lyriks.io/windows (aliases /ps and /install.ps1). Run it from PowerShell: irm https://get.lyriks.io/windows | iex Parameters survive the pipe as environment variables: $env:LYRIKS_ADMIN_EMAIL = 'you@example.com'; $env:LYRIKS_LICENSE_KEY = 'lyk_...'; irm https://get.lyriks.io/windows | iex or use the scriptblock form to pass them explicitly: & ([scriptblock]::Create((irm https://get.lyriks.io/windows))) -LicenseKey lyk_... Why this script exists: Lyriks runs as linux/amd64 containers, and Windows runs those inside WSL2 (Docker Desktop's own engine lives there too). There is no WSL-free path; what CAN go away is the user ever touching WSL. This script gets WSL2 and Docker Desktop into place from PowerShell, then hands over to the Linux bootstrap inside the default WSL distribution. The appliance ends up in the distro at ~/lyriks; the app is used from the Windows browser at http://localhost:. Contract, same as the sh side: everything is CHECKED silently, nothing is INSTALLED silently. Every missing piece is proposed and needs a yes (-Yes consents to all). Re-running is safe: every step reconciles. #> param( [ValidateSet('community', 'enterprise')] [string]$Edition = $(if ($env:LYRIKS_EDITION) { $env:LYRIKS_EDITION } else { 'community' }), # Accepted and ignored, so commands people already wrote down keep running: # no account is created at install any more. [string]$AdminEmail = $env:LYRIKS_ADMIN_EMAIL, [string]$LicenseKey = $env:LYRIKS_LICENSE_KEY, [int]$Port = $(if ($env:LYRIKS_PORT) { [int]$env:LYRIKS_PORT } else { 3000 }), # LYRIKS_YES=1 is the piped-form equivalent of -Yes: `irm | iex` executes # text and text takes no parameters, so the environment is the only channel # that reaches this script through the pipe. Every parameter has an env twin # for exactly that reason. [switch]$Yes = ($env:LYRIKS_YES -eq '1'), # Destroy an existing install in the distro first (stack AND data), then # install fresh. Passed through to the Linux bootstrap as --reinstall. [switch]$Reinstall = ($env:LYRIKS_REINSTALL -eq '1') ) $ErrorActionPreference = 'Stop' function Say([string]$m) { Write-Host "> $m" -ForegroundColor Cyan } function Ok([string]$m) { Write-Host "OK $m" -ForegroundColor Green } # throw, never exit: the documented way to run this script is `irm | iex`, # where the script executes INSIDE the user's console session, so `exit` # closes their terminal window and takes the one error message worth reading # with it (seen in the field, 2026-08). A throw ends the run and leaves the # session alive; the catch closing this script turns it into a non-zero exit # wherever exiting is safe. # # The throw carries a type of its own so that catch can tell "Fail already # explained this" from a genuine crash. Left uncaught, PowerShell 5.1 renders # the record on top of the line below: the message again, a source excerpt, # then the message a third time as the error id. A five-line fix became a # wall of red that read as a crash (seen in the field, 2026-09). function Fail([string]$m) { Write-Host "ERROR: $m" -ForegroundColor Red; throw (New-Object System.OperationCanceledException $m) } # True when this console was started to run one command and end (-Command, # -File or -EncodedCommand, without -NoExit): `exit` closes nothing the user # is reading there, and automation needs the non-zero code. Anything else is # treated as the user's own window. Only the stock console qualifies: editor # hosts are launched with -Command and stay interactive. function Test-OneShotHost { if ($Host.Name -ne 'ConsoleHost') { return $false } $noExit = $false foreach ($a in ([Environment]::GetCommandLineArgs() | Select-Object -Skip 1)) { if ($a -notmatch '^[-/](.+)$') { continue } $k = $Matches[1].ToLowerInvariant() if ($k.Length -ge 3 -and 'noexit'.StartsWith($k)) { $noExit = $true; continue } # Everything after one of these is the payload, not a switch. if ('command'.StartsWith($k) -or 'file'.StartsWith($k) -or 'encodedcommand'.StartsWith($k) -or $k -eq 'ec') { return (-not $noExit) } } return $false } function Confirm-Step([string]$question, [string]$refused) { if ($Yes) { Say "$question (consented via -Yes)"; return } $answer = Read-Host "$question [Y/n]" if ($answer -match '^[nN]') { Fail $refused } } # Windows PowerShell 5.1 quirk: when a native command's stderr is redirected # while $ErrorActionPreference is 'Stop', each stderr line is wrapped in a # TERMINATING NativeCommandError, so `docker info *> $null` crashes the script # on the very condition it is probing for instead of setting $LASTEXITCODE. # Every silent probe must go through here, where the preference is relaxed. function Probe([scriptblock]$cmd) { $eap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { & $cmd *> $null } finally { $ErrorActionPreference = $eap } return $LASTEXITCODE } # One try around the whole run, closed at the bottom of the file, so a Fail # ends it without PowerShell's own rendering. The body is deliberately NOT # re-indented: this file is served as is, and its diff against production # should show what changed. try { # ── 1 · machine ───────────────────────────────────────────────────────────── if ($env:PROCESSOR_ARCHITECTURE -ne 'AMD64') { Fail "Lyriks ships linux/amd64 images and this machine is $($env:PROCESSOR_ARCHITECTURE); Windows on ARM is not supported" } Ok 'Machine: x86-64 on Windows' # The WSL VM and its localhost plumbing belong to the session that starts # them, and the browser this install is used from runs non-elevated. An # elevated install has produced stacks that answered in the admin window and # refused the user's browser, and "run as administrator" under ANOTHER account # is worse: the distro and the install land in that account's WSL, invisible # to the user's own session. Nothing below needs elevation, so an elevated # window is a mistake worth stopping for (seen in the field, 2026-08). $sessionPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if ($sessionPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Confirm-Step 'This PowerShell window is elevated (run as administrator). Lyriks should be installed from a NORMAL window so it lands in your own session, next to your browser. Continue here anyway?' 'Re-run this command from a normal, non-administrator PowerShell window' } if ($LicenseKey -and $LicenseKey -notlike 'lyk_*') { Fail 'The licence key should start with lyk_ (it is in your install pack)' } # ── 2 · WSL2 ──────────────────────────────────────────────────────────────── if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { Confirm-Step 'Lyriks needs WSL, a Windows feature that runs Linux apps. Install it now? (needs administrator rights and one restart)' 'WSL is required' $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Fail 'Installing WSL needs an administrator PowerShell: reopen PowerShell as administrator, run "wsl --install", reboot, then re-run this command' } wsl.exe --install Say 'WSL is installing. Reboot when asked, let Ubuntu finish its first-run user setup, then re-run this command.' # return, never exit: under `irm | iex` an exit would close the user's window. return } # A present wsl.exe does not mean a usable distribution: `wsl -e true` proves # there is a default distro that actually starts. if ((Probe { wsl.exe -e true }) -ne 0) { Confirm-Step 'WSL needs a Linux system to run Lyriks in. Install Ubuntu now? (free, official)' 'A WSL distribution is required' wsl.exe --install -d Ubuntu Say 'Let Ubuntu finish its first-run user setup (it asks for a username and password), then re-run this command.' return } # Idempotent and silent when already set: new distros must land on WSL2, # because WSL1 has no real kernel and can never run Docker. $null = Probe { wsl.exe --set-default-version 2 } Ok 'WSL2 distribution available' # ── 3 · Docker Desktop ────────────────────────────────────────────────────── if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { if (Get-Command winget -ErrorAction SilentlyContinue) { Confirm-Step 'Lyriks runs inside a free app called Docker Desktop, which is not installed yet. Install it now?' 'Docker Desktop is required' winget install -e --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements Say 'Docker Desktop is installed. Start it from the Start menu, accept its first-run dialog, wait for the whale icon to settle, then re-run this command.' } else { Say 'Docker Desktop is missing and winget is not available.' Say 'Install it from https://docs.docker.com/desktop/ then re-run this command.' } return } if ((Probe { docker info }) -ne 0) { $desktop = Join-Path $env:ProgramFiles 'Docker\Docker\Docker Desktop.exe' if (Test-Path $desktop) { Confirm-Step 'Docker Desktop, the app Lyriks runs in, is not open. Open it now?' 'Docker Desktop must be running' Start-Process $desktop Say 'Waiting for Docker Desktop (a first launch shows a dialog to accept) ...' $waited = 0 while ($true) { if ((Probe { docker info }) -eq 0) { break } if ($waited -ge 120) { Fail 'Docker Desktop did not come up in 120s: finish its first-run dialog, then re-run this command' } Start-Sleep -Seconds 3; $waited += 3 } } else { Fail 'Docker is on PATH but its daemon is not reachable: start Docker Desktop, then re-run this command' } } Ok 'Docker daemon reachable' # ── 4 · Docker visible from inside WSL ────────────────────────────────────── # The kit runs inside the distro, so the docker CLI must exist THERE. With # Docker Desktop that is one checkbox, and there is no supported CLI switch # for it: pointing the user at the exact setting is the entire fix. if ((Probe { wsl.exe -e docker version }) -ne 0) { Fail 'Docker Desktop is running but not wired into your WSL distribution: Docker Desktop > Settings > Resources > WSL integration > enable your distro > Apply & restart, then re-run this command' } # That check proves A daemon answers inside the distro, not WHICH one. A # Docker Engine installed inside the distro (a previous Linux-path attempt, # say) answers it too, silently hosts the whole install, and its ports then # depend on the WSL localhost relay, which drops loopback-only binds on real # machines: install green, browser dead (seen in the field, 2026-08). Docker # Desktop publishes ports on the Windows side itself, so it is the only # engine this path supports. $engineOs = '' $eap = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { $engineOs = (wsl.exe -e docker info --format '{{.OperatingSystem}}' 2>$null | Out-String).Trim() } finally { $ErrorActionPreference = $eap } if ($engineOs -ne 'Docker Desktop') { $seen = if ($engineOs) { "the one answering there calls itself '$engineOs'" } else { 'the one answering there does not say what it is' } # -u root: no Linux password to remember, the usual place a WSL user is stuck. # Step 3 is not optional: with the inner engine in the way, the integration # is usually off for this distro, and a re-run would stop on it next. Fail (@( "Lyriks found a second Docker inside your Linux system (WSL): $seen, not Docker Desktop. An install on that one is often unreachable from the Windows browser, so this stops here, before changing anything. To fix it:" ' 1. Turn the extra Docker off (containers running on it stop):' ' wsl -u root -e systemctl disable --now docker.service docker.socket' ' 2. Restart WSL:' ' wsl --shutdown' ' 3. Open Docker Desktop, then Settings > Resources > WSL integration: enable your distro, Apply & restart' ' 4. Re-run this command' ) -join "`n") } Ok "Docker inside WSL is Docker Desktop's engine" # ── 5 · hand over to the Linux bootstrap inside WSL ───────────────────────── # No email, no password, no account: the appliance installs with no identity at # all, and the first person to open it claims it with the licence key issued to # their address. Nothing to type here, nothing to print at the end. if (-not $LicenseKey -and -not $Yes) { $LicenseKey = Read-Host 'Your licence key, if you already have one (starts with lyk_; press Enter to skip, you can add it later in the app)' if ($LicenseKey -and $LicenseKey -notlike 'lyk_*') { Fail 'That does not look like a licence key (lyk_...)' } } if ((Probe { wsl.exe -e bash -lc 'command -v curl' }) -ne 0) { Confirm-Step 'A small tool (curl) is missing inside WSL. Install it now? It will ask for your Linux password.' 'curl is required inside WSL' wsl.exe -e bash -lc 'sudo apt-get update && sudo apt-get install -y curl' if ($LASTEXITCODE -ne 0) { Fail 'Could not install curl inside WSL: install it in the distro, then re-run this command' } } # --from-windows: the AI-client step must happen on the OS the AI client runs # on. This script does it below against the WINDOWS Claude; the flag stops the # Linux side from suggesting the same command inside WSL, where it would # configure a WSL Claude the user does not use. $sh = "curl -fsSL https://get.lyriks.io | sh -s -- $Edition --port $Port --yes --from-windows" if ($LicenseKey) { $sh += " --license-key $LicenseKey" } if ($Reinstall) { $sh += ' --reinstall' } Say 'Handing over to the Linux bootstrap inside WSL ...' wsl.exe -e bash -lc $sh if ($LASTEXITCODE -ne 0) { Fail 'The install did not finish; the messages above say where it stopped. Fix that and re-run this command.' } # ── 5b · prove it from the Windows side ───────────────────────────────────── # ./lyriks smoke proved the appliance from INSIDE WSL. The user's browser # lives on the WINDOWS side of the localhost bridge, which has failure modes # of its own (integration dropped, VPN or security agent filtering loopback), # so success is only announced once Windows itself gets a 200. Seen in the # field: a smoke-green install whose browser saw a dead localhost for a day. Say "Checking http://localhost:$Port from the Windows side ..." $winReady = $false for ($i = 0; $i -lt 30; $i++) { $code = '' $eap = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { $code = (curl.exe -s -o NUL -w '%{http_code}' --max-time 2 "http://127.0.0.1:$Port/readyz" 2>$null | Out-String).Trim() } finally { $ErrorActionPreference = $eap } if ($code -eq '200') { $winReady = $true; break } Start-Sleep -Seconds 2 } if ($winReady) { Ok 'Windows can reach the platform (GET /readyz -> 200)' } else { Fail ("Lyriks is healthy inside WSL but Windows cannot reach http://127.0.0.1:$Port, so your browser will not either. Usual causes: Docker Desktop's WSL integration got disabled (Settings > Resources > WSL integration), or a VPN / security agent filters localhost. Fix that, then verify with: curl.exe http://127.0.0.1:$Port/readyz") } # ── 6 · optional: wire the Windows AI clients ─────────────────────────────── # Never a dependency: Lyriks is installed and usable at this point whatever # happens below, and nothing here may change that. Detection targets the # WINDOWS clients on purpose: wiring the one inside the distro would point the # editor at a Lyriks the user's browser cannot see. $mcpUrl = "http://localhost:$Port/mcp" $mcpJson = '{"name":"lyriks","type":"http","url":"' + $mcpUrl + '"}' $mcpClients = @( @{ Bin = 'claude'; Name = 'Claude Code'; Args = @('mcp','add','--scope','user','--transport','http','lyriks',$mcpUrl) }, @{ Bin = 'codex'; Name = 'OpenAI Codex'; Args = @('mcp','add','lyriks','--url',$mcpUrl) }, @{ Bin = 'gemini'; Name = 'Gemini CLI'; Args = @('mcp','add','--transport','http','lyriks',$mcpUrl) }, @{ Bin = 'code'; Name = 'VS Code'; Args = @('--add-mcp',$mcpJson) } ) $found = @($mcpClients | Where-Object { Get-Command $_.Bin -ErrorAction SilentlyContinue }) if ($found.Count -gt 0) { $names = ($found | ForEach-Object { $_.Name }) -join ', ' $configure = $Yes if (-not $configure) { # Read-Host throws without a console (a scheduled task, a CI runner), and # $ErrorActionPreference is 'Stop', so an unguarded prompt would abort an # install that has already succeeded. A question that cannot be asked is a # NO: this step is optional and consent is never assumed. try { $answer = Read-Host "Connect $names to Lyriks, so it can read and write your projects? [Y/n]" $configure = $answer -notmatch '^[nN]' } catch { Say "No console to ask on; skipping. Point any client at $mcpUrl, or re-run with -Yes" $configure = $false } } if ($configure) { foreach ($client in $found) { # Through Probe, never a bare redirect: in Windows PowerShell 5.1 with # $ErrorActionPreference = 'Stop', a native command whose stderr is # redirected turns every stderr line into a TERMINATING error, so the # install would die on a client politely saying "already configured". # That bug cost a field report once already; see the note on Probe above. # Registered for the MACHINE, not the current folder. $cargs = $client.Args if ((Probe { & $client.Bin @cargs }) -eq 0) { Ok "$($client.Name) can now reach Lyriks" } else { Say "$($client.Name) already knows a 'lyriks' server, or refused it" } } } else { Say "Connect one later by pointing it at $mcpUrl" } } else { Say 'No AI client was detected. Point any MCP-capable client at:' Say " $mcpUrl" Say ' Recipes per client: https://get.lyriks.io/docs#next' } # ── 7 · what now ──────────────────────────────────────────────────────────── Write-Host '' Write-Host '🚀 Lyriks is ready' -ForegroundColor Green Write-Host "http://localhost:$Port" Write-Host '' Say "Open the address above and claim this install: your licence key, the address it was issued to, then a password you choose" if (-not $LicenseKey) { Say 'No licence key yet? A free Community key takes a minute: https://get.lyriks.io' Say 'Asked for one and it never arrived? https://get.lyriks.io/docs#nokey' } Say 'Your Lyriks containers are visible in Docker Desktop (they run on its engine)' Say 'After a reboot, start Docker Desktop and the stack comes back by itself (Settings > General > "Start Docker Desktop when you sign in" makes that automatic)' Say 'Manage it from PowerShell without opening WSL, for example:' Say ' wsl -e bash -lc "cd ~/lyriks && ./lyriks status"' Say ' wsl -e bash -lc "cd ~/lyriks && ./lyriks update"' } catch [System.OperationCanceledException] { # Fail has said why, once, and that is the whole message. `exit` only where # it cannot close the user's window (see the note on Fail): a saved script, # or a console started for this one command. Any other exception is a bug # in this script and keeps PowerShell's full rendering. if ($PSCommandPath -or (Test-OneShotHost)) { exit 1 } }