lib/Docker.ps1

function Get-DockerDesktopWslHomeConfig {
    <#
    .SYNOPSIS
        Reads DockerDesktop.WslHome from the script's config file.
        Returns $null when the key is absent.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string] $ConfigFile = $script:ConfigFilePath
    )

    if (-not $ConfigFile -or -not (Test-Path -Path $ConfigFile -PathType Leaf)) {
        return $null
    }

    try {
        $raw    = Get-Content -Path $ConfigFile -Raw -Encoding UTF8
        $parsed = $raw | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "Failed to parse '$ConfigFile': $($_.Exception.Message)"
    }

    if ($null -eq $parsed) { return $null }
    if (-not $parsed.PSObject.Properties['DockerDesktop']) { return $null }
    if (-not $parsed.DockerDesktop.PSObject.Properties['WslHome']) { return $null }

    $value = [string]$parsed.DockerDesktop.WslHome
    if ([string]::IsNullOrWhiteSpace($value)) { return $null }
    return $value
}

function Get-DockerSettingsStorePath {
    <#
    .SYNOPSIS
        Resolves Docker Desktop's settings file. Modern versions
        (~4.30+) use settings-store.json; older ones used settings.json.
        Returns $null when neither exists - typically because Docker
        Desktop isn't installed yet.
    #>

    [CmdletBinding()]
    param()

    if (-not $env:APPDATA) { return $null }
    $base = Join-Path $env:APPDATA 'Docker'
    foreach ($name in 'settings-store.json', 'settings.json') {
        $p = Join-Path $base $name
        if (Test-Path -Path $p -PathType Leaf) { return $p }
    }
    return $null
}

function Get-DockerCustomWslDistroDir {
    <#
    .SYNOPSIS
        Returns the value of the CustomWslDistroDir property from Docker
        Desktop's settings file, or $null when the key is absent.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string] $SettingsPath
    )

    if (-not $SettingsPath) { $SettingsPath = Get-DockerSettingsStorePath }
    if (-not $SettingsPath -or -not (Test-Path -Path $SettingsPath -PathType Leaf)) {
        return $null
    }

    try {
        $raw    = Get-Content -Path $SettingsPath -Raw -Encoding UTF8
        $parsed = $raw | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "Failed to parse '$SettingsPath': $($_.Exception.Message)"
    }

    if ($null -eq $parsed) { return $null }
    if (-not $parsed.PSObject.Properties['CustomWslDistroDir']) { return $null }
    return [string]$parsed.CustomWslDistroDir
}

function Set-DockerCustomWslDistroDir {
    <#
    .SYNOPSIS
        Surgically replaces the CustomWslDistroDir value in Docker's
        settings file via regex. Avoids parse+reserialize so Docker
        Desktop's own JSON formatting (key order, indentation, comments
        if any) is preserved untouched.

        Throws if the key isn't present in the file (we don't want to
        silently add unknown keys to a Docker-managed settings file).
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)]
        [string] $NewValue,

        [Parameter()]
        [string] $SettingsPath
    )

    if (-not $SettingsPath) { $SettingsPath = Get-DockerSettingsStorePath }
    if (-not $SettingsPath -or -not (Test-Path -Path $SettingsPath -PathType Leaf)) {
        throw 'Docker settings file not found. Is Docker Desktop installed?'
    }

    $content = Get-Content -Path $SettingsPath -Raw -Encoding UTF8

    # JSON-escape backslashes for the replacement string.
    $jsonEscaped = $NewValue.Replace('\', '\\')
    $pattern     = '("CustomWslDistroDir"\s*:\s*)"[^"]*"'
    $replacement = "`$1`"$jsonEscaped`""
    $newContent  = [System.Text.RegularExpressions.Regex]::Replace($content, $pattern, $replacement)

    if ($newContent -eq $content) {
        throw "Could not find CustomWslDistroDir key in '$SettingsPath'."
    }

    if ($PSCmdlet.ShouldProcess($SettingsPath, "Set CustomWslDistroDir to '$NewValue'")) {
        # UTF-8 without BOM; write atomically via WriteAllText.
        [System.IO.File]::WriteAllText(
            $SettingsPath,
            $newContent,
            [System.Text.UTF8Encoding]::new($false)
        )
    }
}

function Get-DockerDesktopInstallPath {
    <#
    .SYNOPSIS
        Locates Docker Desktop.exe. Returns the absolute path if found,
        $null otherwise. Searches the standard install location and the
        App Paths registry as a fallback.
    #>

    [CmdletBinding()]
    param()

    $candidates = New-Object System.Collections.Generic.List[string]
    [void]$candidates.Add('C:\Program Files\Docker\Docker\Docker Desktop.exe')
    if ($env:ProgramFiles) {
        [void]$candidates.Add((Join-Path $env:ProgramFiles 'Docker\Docker\Docker Desktop.exe'))
    }
    foreach ($c in $candidates | Select-Object -Unique) {
        if (Test-Path -LiteralPath $c -PathType Leaf) { return $c }
    }

    $appPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Docker Desktop.exe'
    if (Test-Path -Path $appPaths) {
        $val = (Get-ItemProperty -Path $appPaths -ErrorAction SilentlyContinue).'(default)'
        if ($val -and (Test-Path -LiteralPath $val -PathType Leaf)) { return [string]$val }
    }
    return $null
}

function Wait-DockerDesktopReady {
    <#
    .SYNOPSIS
        Polls until Docker Desktop's first-run state is in place: both
        %APPDATA%\Docker\settings-store.json exists AND the docker-desktop
        WSL distro is registered. Returns $true on success, $false if the
        deadline elapses without both artifacts.

        Emits a one-line progress message every -ProgressIntervalSeconds
        so callers (and CI logs) can see what's still missing.
    #>

    [CmdletBinding()]
    param(
        [int] $TimeoutSeconds = 180,
        [int] $PollIntervalSeconds = 5,
        [int] $ProgressIntervalSeconds = 30
    )

    $start         = Get-Date
    $deadline      = $start.AddSeconds($TimeoutSeconds)
    $nextProgress  = $start.AddSeconds($ProgressIntervalSeconds)
    while ((Get-Date) -lt $deadline) {
        $settings = Get-DockerSettingsStorePath
        $base     = Get-WslDistroBasePath -Name 'docker-desktop'
        if ($settings -and $base) { return $true }

        $now = Get-Date
        if ($now -ge $nextProgress) {
            $elapsed = [int]($now - $start).TotalSeconds
            $sFlag   = if ($settings) { 'yes' } else { 'no' }
            $dFlag   = if ($base)     { 'yes' } else { 'no' }
            Write-Status -Level Debug -Message (" [DEBUG] t+{0,4}s settings={1} docker-desktop-distro={2}" -f $elapsed, $sFlag, $dFlag)
            $nextProgress = $now.AddSeconds($ProgressIntervalSeconds)
        }
        Start-Sleep -Seconds $PollIntervalSeconds
    }
    return $false
}

function Start-DockerDesktopAndWait {
    <#
    .SYNOPSIS
        Launches Docker Desktop and waits for it to write its settings
        file and register the docker-desktop WSL distro. Required before
        the WSL data move can proceed - those artifacts only appear after
        Docker Desktop's first run.

        Throws if Docker Desktop isn't installed, or if it fails to
        bootstrap within the timeout.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [int] $TimeoutSeconds = 180
    )

    $exe = Get-DockerDesktopInstallPath
    if (-not $exe) { throw 'Docker Desktop is not installed.' }

    if (-not $PSCmdlet.ShouldProcess('Docker Desktop', 'Start and wait for first-run bootstrap')) {
        return
    }

    # Docker Desktop needs WSL to be installed and the kernel current
    # before it can register its docker-desktop distro on first launch.
    # That setup is owned by the Wsl task (tasks/60-Wsl.ps1), which
    # always runs before DockerDesktop in -Task All. If a caller invokes
    # DockerDesktop on its own without WSL ready, the wait below will
    # time out with a useful diagnostic dump.

    Write-Status -Level Info -Message ' [INFO] Starting Docker Desktop (first-run bootstrap)...'
    # --accept-license avoids the interactive EULA dialog blocking on
    # headless / CI machines. Harmless on a developer machine that's
    # already accepted.
    Start-Process -FilePath $exe -ArgumentList '--accept-license' -ErrorAction Stop

    Write-Status -Level Info -Message " [INFO] Waiting for settings-store.json and docker-desktop distro (timeout ${TimeoutSeconds}s)..."
    if (-not (Wait-DockerDesktopReady -TimeoutSeconds $TimeoutSeconds)) {
        Write-DockerBootstrapDiagnostics
        throw ("Docker Desktop did not finish bootstrapping within {0} seconds. " +
               "Launch it manually once and re-run the script.") -f $TimeoutSeconds
    }
    Write-Status -Level Info -Message ' [INFO] Docker Desktop bootstrapped.'
}

function Write-DockerBootstrapDiagnostics {
    <#
    .SYNOPSIS
        Dumps everything the user / CI log reader needs to figure out
        why Docker Desktop's first-run bootstrap stalled. Called from
        Start-DockerDesktopAndWait when Wait-DockerDesktopReady times
        out. Never throws - diagnostics are best-effort.
    #>

    [CmdletBinding()]
    param()

    Write-Status -Level Warn -Message ' [WARN] Docker Desktop bootstrap timeout - dumping diagnostics:'

    Write-Status -Level Debug -Message ' [DEBUG] settings-store.json:'
    $settingsPath = Join-Path $env:APPDATA 'Docker\settings-store.json'
    if (Test-Path -LiteralPath $settingsPath) {
        Get-Content -LiteralPath $settingsPath -Raw -ErrorAction SilentlyContinue |
            ForEach-Object { ($_ -split "`n") } |
            ForEach-Object { Write-Information " $_" }
    }
    else {
        Write-Information " <not present at $settingsPath>"
    }

    Write-Status -Level Debug -Message ' [DEBUG] wsl --list --verbose:'
    & wsl.exe --list --verbose 2>&1 | ForEach-Object { Write-Information " $_" }

    Write-Status -Level Info -Message ' [INFO] Diagnostic - running Docker processes:'
    $procs = Get-Process -Name 'Docker*','com.docker.*' -ErrorAction SilentlyContinue
    if ($procs) {
        $procs | Select-Object Id, Name, StartTime |
            Format-Table -AutoSize | Out-String -Stream |
            ForEach-Object { Write-Information " $_" }
    }
    else {
        Write-Information ' <none>'
    }

    Write-Status -Level Info -Message ' [INFO] Diagnostic - Docker-related Windows services:'
    Get-Service -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -match 'docker|com\.docker' -or $_.DisplayName -match 'Docker' } |
        Select-Object Name, Status, StartType |
        Format-Table -AutoSize | Out-String -Stream |
        ForEach-Object { Write-Information " $_" }
}

function Stop-DockerDesktopProcesses {
    <#
    .SYNOPSIS
        Stops Docker Desktop and its background helpers, then runs
        `wsl --shutdown` so the docker-desktop distro is in a movable
        state. Idempotent - silently no-ops for processes that aren't
        running.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param()

    $names = @(
        'Docker Desktop',
        'com.docker.backend',
        'com.docker.dev-envs',
        'com.docker.build',
        'com.docker.cli'
    )
    foreach ($n in $names) {
        $procs = Get-Process -Name $n -ErrorAction SilentlyContinue
        if ($procs -and $PSCmdlet.ShouldProcess($n, 'Stop-Process')) {
            Write-Status -Level Info -Message " [INFO] Stopping $n"
            $procs | Stop-Process -Force -ErrorAction SilentlyContinue
        }
    }
    # Give Docker's watchdog a moment to settle before WSL shutdown.
    if (-not $WhatIfPreference) { Start-Sleep -Seconds 2 }

    if ($PSCmdlet.ShouldProcess('WSL', 'wsl --shutdown')) {
        Write-Status -Level Info -Message ' [INFO] Shutting down WSL...'
        wsl --shutdown 2>&1 | Format-ToolOutput
    }
}