Initialize-DeveloperMachine.ps1

#Requires -Version 5.1
#Requires -PSEdition Desktop, Core

<#
.SYNOPSIS
    Bootstrap a developer machine: relocate caches, mount junctions, and
    (eventually) install tools, configure dotfiles, and set up the rest of
    a working development environment.

.DESCRIPTION
    This is the entry point. The actual work lives in two places:

      lib/ - reusable helpers (status output, spinner, preflights,
                env broadcast, junction + cache helpers, menu).
      tasks/ - one file per task. Each file returns a hashtable with
                Id, DisplayName, Description, and an Action scriptblock.
                Files are loaded in filename order (numeric prefix is the
                convention - e.g. 10-NuGet.ps1).

    To add a task, drop a new file in tasks/ that returns the same shape.

    Before running tasks the script verifies that every drive referenced in
    $Paths is mounted and warns about applications that may lock cache files.
    Each task is isolated in a try/catch so a single failure does not abort
    the rest of the run; a summary is printed at the end.

.PARAMETER Task
    Specific task ID(s) to run, or 'All'. When omitted, an interactive menu
    lets you pick. Tab-completion lists the discovered task IDs.

.PARAMETER WhatIf
    Shows every state-changing action that would be taken without performing
    any of them.

.PARAMETER Confirm
    Prompts before each state-changing action.

.PARAMETER DryRunDelayMs
    Milliseconds to hold the spinner per task during a -WhatIf run. Without
    this delay the dry-run completes too quickly to see the spinner animate.
    Defaults to 500. Set to 0 to disable. Ignored unless -WhatIf is set.

.PARAMETER Force
    Skips the interactive prompt that fires when blocking applications
    (Docker Desktop, Claude, IDEs) are detected. The warning is still
    shown. Intended for non-interactive runs.

.EXAMPLE
    .\Initialize-DeveloperMachine.ps1
    Shows the interactive menu so you can pick which tasks to run.

.EXAMPLE
    .\Initialize-DeveloperMachine.ps1 -Task All
    Runs every task without showing the menu.

.EXAMPLE
    .\Initialize-DeveloperMachine.ps1 -Task NuGet,NPM
    Runs only the NuGet and NPM tasks.

.EXAMPLE
    .\Initialize-DeveloperMachine.ps1 -Task All -WhatIf
    Dry-runs every task. No env vars, files, or WSL distros are touched.

.NOTES
    Close Docker Desktop, Claude, and any IDEs/terminals before running.
    Restart your terminal and IDE afterwards so they pick up the new env vars.

    Configuration: paths can be customised via Initialize-DeveloperMachine.config.json
    in the script's directory. When that file exists its values override the
    $Paths defaults embedded in this script; missing keys fall through to the
    defaults so a partial config still works.
#>


[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
    [Parameter()]
    [ArgumentCompleter({
        param($command, $param, $wordToComplete, $commandAst, $fakeBoundParameters)
        $scriptDir = if ($commandAst.Extent.File) {
            Split-Path -Parent $commandAst.Extent.File
        } else { $null }
        if (-not $scriptDir) { return @('All') }
        $taskDir = Join-Path $scriptDir 'tasks'
        if (-not (Test-Path $taskDir)) { return @('All') }
        $ids = Get-ChildItem -Path $taskDir -Filter '*.ps1' -ErrorAction SilentlyContinue |
            Sort-Object Name |
            ForEach-Object {
                try { (& $_.FullName).Id } catch {}
            }
        @(@($ids) + 'All') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
            if ($_ -match '\W') { "'$_'" } else { $_ }
        }
    })]
    [string[]] $Task,

    [Parameter()]
    [ValidateRange(0, 60000)]
    [int] $DryRunDelayMs = 500,

    [Parameter()]
    [switch] $Force,

    [Parameter()]
    [switch] $NoSpinner,

    [Parameter()]
    [string] $ConfigFile
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$InformationPreference = 'Continue'

# --- TASK SHAPE ---
# Every entry in tasks/*.ps1 is converted to a Task instance during
# discovery. Using a strongly-typed class instead of a hashtable / PSCustomObject
# avoids parser quirks with multi-line `[PSCustomObject]@{...}` casts inside a
# CmdletBinding script and gives downstream code a real property contract.
class Task {
    [string]      $Id
    [string]      $DisplayName
    [string]      $Description
    [string[]]    $WingetPackages = @()
    [scriptblock] $Action
}

# --- HOST CAPABILITIES ---
# ANSI/VT support: PS 5.1+ exposes $Host.UI.SupportsVirtualTerminal on hosts
# that interpret VT escape codes. When false (legacy console, redirected
# output) lib/Status and lib/Spinner emit plain text.
$script:UseColor = $false
if ($Host.UI.PSObject.Properties['SupportsVirtualTerminal']) {
    $script:UseColor = [bool]$Host.UI.SupportsVirtualTerminal
}

# --- CONFIGURATION: EDIT THESE PATHS FOR YOUR DRIVE SETUP ---
# These values are the fallback used when Initialize-DeveloperMachine.config.json
# (next to this script) is missing. When the JSON file exists, it takes
# precedence and the values below act as defaults for any keys it doesn't set.
$Paths = [ordered]@{
    NuGetPackages      = 'D:\.nuget\packages'
    NuGetHttpCache     = 'D:\.nuget\http-cache'
    NuGetPluginsCache  = 'D:\.nuget\plugins-cache'
    NuGetScratch       = 'D:\.nuget\temp'
    NpmCache           = 'D:\.npm'
    VcpkgBinaryCache   = 'D:\.vcpkg'
    WslHome            = 'D:\.wsl'
    DotnetHome         = 'D:\.dotnet'
    ClaudeHome         = 'D:\.claude'
    DockerHome         = 'D:\.docker'
}

# --- LOAD HELPERS ---
$libDir = Join-Path $PSScriptRoot 'lib'
foreach ($lib in Get-ChildItem -Path $libDir -Filter '*.ps1' | Sort-Object Name) {
    . $lib.FullName
}

# --- DISCOVER TASKS ---
# Each tasks/*.ps1 returns a hashtable: @{ Id; DisplayName; Description; Action }.
# We dot-source the file rather than invoking it with `&` so the Action
# scriptblock is defined in the entry point's session state and can see
# the lib helpers (Set-CacheLocation, etc.) at invocation time.
#
# Each discovered hashtable is converted to a [Task] instance below so the
# rest of the script works against a strongly-typed property contract.
#
# $tasks is a hashtable for keyed lookup; $taskIds preserves the discovery
# order (filename-sorted) for the menu and the default "All".
$tasks   = @{}
$taskIds = [System.Collections.Generic.List[string]]::new()
$taskDir = Join-Path $PSScriptRoot 'tasks'
foreach ($f in Get-ChildItem -Path $taskDir -Filter '*.ps1' | Sort-Object Name) {
    # Dot-source can yield more than just the hashtable expression at the
    # end of the file; pick the first dictionary out of whatever it produced.
    $allOutputs = @(. $f.FullName)
    $taskDef    = $allOutputs |
        Where-Object { $_ -is [System.Collections.IDictionary] } |
        Select-Object -First 1

    if ($null -eq $taskDef -or
        -not $taskDef.Contains('Id') -or
        -not $taskDef.Contains('Action')) {
        $types = if ($allOutputs) {
            ($allOutputs | ForEach-Object {
                if ($null -eq $_) { '<null>' } else { $_.GetType().FullName }
            }) -join ', '
        } else { '<no output>' }
        Write-Warning "Skipping $($f.Name): expected hashtable with Id and Action; outputs were: $types"
        continue
    }

    # Build a Task instance property-by-property so downstream code has a
    # strongly-typed object to work with. Note: the local can't be named
    # $task because PowerShell variable names are case-insensitive and the
    # script-level $Task parameter ([string[]]) would coerce the assignment.
    $entry                = [Task]::new()
    $entry.Id             = [string]$taskDef['Id']
    $entry.DisplayName    = if ($taskDef.Contains('DisplayName'))    { [string]$taskDef['DisplayName'] }    else { '' }
    $entry.Description    = if ($taskDef.Contains('Description'))    { [string]$taskDef['Description'] }    else { '' }
    $entry.WingetPackages = if ($taskDef.Contains('WingetPackages')) { @([string[]]$taskDef['WingetPackages']) } else { @() }
    $entry.Action         = [scriptblock]$taskDef['Action']

    $tasks[$entry.Id] = $entry
    [void]$taskIds.Add($entry.Id)
}

# Validate any -Task arguments now that we know the discovered IDs.
if ($Task) {
    $unknown = $Task | Where-Object { $_ -ne 'All' -and -not $tasks.ContainsKey($_) }
    if ($unknown) {
        throw "Unknown task(s): $($unknown -join ', '). Available: $($taskIds -join ', '), All"
    }
}

# --- MAIN EXECUTION ---
# Acquire a single-instance lock first thing so two simultaneous runs
# can't fight over env vars, junctions, the rollback log, etc.
$lock = Open-SingleInstanceLock
if ($null -eq $lock) {
    Write-Status -Level Warn -Message 'Another instance of Initialize-DeveloperMachine is already running. Exiting.'
    return
}

# Defensive Ctrl+C cleanup. The try/finally below already releases
# the lock on normal flow / exceptions / Ctrl+C-during-cmdlet (which
# raises PipelineStoppedException through finally). This handler
# covers the edge case where Ctrl+C is pressed while the user is
# sitting at an interactive prompt (Read-Host, ShouldContinue, ...)
# and the host eats the cancel without unwinding the script's
# finally cleanly. Idempotent with the finally - File.Delete on an
# already-deleted file is caught.
$script:LockPathForCancel = $lock.Path
$script:LockStreamForCancel = $lock.Stream
$cancelHandler = [System.ConsoleCancelEventHandler]{
    param($s, $e)
    try {
        if ($script:LockStreamForCancel) { $script:LockStreamForCancel.Dispose() }
    } catch {}
    try {
        if ($script:LockPathForCancel) { [System.IO.File]::Delete($script:LockPathForCancel) }
    } catch {}
}
[Console]::add_CancelKeyPress($cancelHandler)

try {
    # Resolve the config file. When run as a script-from-clone the file
    # lives next to the .ps1; when imported as a PowerShell module
    # (Install-Module) the script directory is read-only user storage,
    # so we have to look in user-writable locations instead.
    #
    # Lookup order (first match wins):
    # 1. Explicit -ConfigFile parameter
    # 2. $PWD\Initialize-DeveloperMachine.config.json
    # 3. $env:APPDATA\Initialize-DeveloperMachine\config.json (Roaming)
    # 4. $env:USERPROFILE\.config\Initialize-DeveloperMachine\config.json (XDG-style)
    #
    # We deliberately do NOT include the bundled
    # $PSScriptRoot\Initialize-DeveloperMachine.config.json in this
    # chain - that file ships with the published module purely as a
    # template for the interactive scaffold (option 'C' on first run).
    # If we let it shadow the user-scope locations, every PSGallery
    # install would silently use the maintainer's bundled config and
    # never offer the scaffold prompt.
    $candidates = New-Object System.Collections.Generic.List[string]
    if ($ConfigFile) { [void]$candidates.Add($ConfigFile) }
    [void]$candidates.Add((Join-Path $PWD.Path 'Initialize-DeveloperMachine.config.json'))
    if ($env:APPDATA) {
        [void]$candidates.Add((Join-Path $env:APPDATA 'Initialize-DeveloperMachine\config.json'))
    }
    if ($env:USERPROFILE) {
        [void]$candidates.Add((Join-Path $env:USERPROFILE '.config\Initialize-DeveloperMachine\config.json'))
    }

    $script:ConfigFilePath = $candidates |
        Where-Object { Test-Path -Path $_ -PathType Leaf } |
        Select-Object -First 1

    if ($script:ConfigFilePath) {
        Write-Status -Level Info -Message " [INFO] Config: $script:ConfigFilePath"
    }
    else {
        # No config file found anywhere on the lookup chain. In an
        # interactive session, offer to scaffold a starter at the
        # recommended location and let the user review/edit it before
        # we continue. In a non-interactive session (-Force, CI,
        # redirected stdin) just log and proceed with built-in defaults.
        $interactive = -not $Force `
            -and -not [Console]::IsInputRedirected `
            -and -not $env:CI `
            -and -not $env:GITHUB_ACTIONS

        $defaultTarget = if ($env:APPDATA) {
            Join-Path $env:APPDATA 'Initialize-DeveloperMachine\config.json'
        }
        else {
            Join-Path $PSScriptRoot 'Initialize-DeveloperMachine.config.json'
        }

        if ($interactive) {
            Write-Host ''
            Write-Host 'No config file found.'
            $templatePath = Join-Path $PSScriptRoot 'Initialize-DeveloperMachine.config.json'
            if (Read-YesNo "Create a starter config at '$defaultTarget'?") {
                New-ConfigFromTemplate -Path $defaultTarget -TemplatePath $templatePath
                Write-Status -Level Info -Message " [INFO] Wrote starter config to: $defaultTarget"
                Write-Status -Level Info -Message " (copied from bundled template at $templatePath)"

                # Review / edit loop. On 'E' we don't touch the file
                # ourselves - the user opens it in whichever editor
                # they prefer; we just show a spinner until they save
                # (LastWriteTime changes), then re-show the contents
                # and re-prompt.
                while ($true) {
                    Show-ConfigPreview -Path $defaultTarget
                    $choice = Read-ProceedOrEdit
                    if ($choice -eq 'P') {
                        $script:ConfigFilePath = $defaultTarget
                        break
                    }
                    if (-not (Open-EditorAndWaitForSave -Path $defaultTarget)) {
                        Write-Status -Level Warn -Message ' [WARN] No save detected within the timeout; continuing with the file as-is.'
                    }
                }
            }
            else {
                Write-Status -Level Info -Message ' [INFO] No config file; using built-in defaults.'
            }
        }
        else {
            Write-Status -Level Info -Message ' [INFO] No config file found; using built-in defaults.'
            Write-Status -Level Info -Message " To customise, drop a config at: $defaultTarget"
            Write-Status -Level Info -Message ' Or pass -ConfigFile <path> explicitly.'
        }
    }
    $Paths = Get-PathsConfig -ConfigFile $script:ConfigFilePath -Defaults $Paths

    Test-PathDriveAvailability -Paths $Paths

    if (-not (Test-BlockingProcess -Force:$Force)) {
        Write-Status -Level Skip -Message 'Migration aborted by user.'
        return
    }

    Initialize-Winget

    $selected = if ($Task) {
        if ($Task -contains 'All') { @($taskIds) } else { @($Task) }
    }
    else {
        @(Show-TaskMenu -Tasks $tasks -TaskOrder $taskIds)
    }

    if (-not $selected) {
        Write-Status -Level Skip -Message 'No tasks selected; exiting.'
        return
    }

    # Collect the union of winget packages we need to install before any
    # task action runs:
    # - the global WingetPackages list from the config (general-purpose
    # dev tools that aren't tied to a specific task)
    # - each selected task's declared WingetPackages (task-specific deps)
    # Install once, then refresh PATH so the binaries are visible to
    # every task action that follows.
    $globalPkgs = @(Get-WingetPackagesConfig)
    $taskPkgs   = foreach ($name in $selected) {
        if ($tasks.ContainsKey($name)) { $tasks[$name].WingetPackages }
    }
    $allPkgs = @(@($globalPkgs) + @($taskPkgs) | Where-Object { $_ } | Sort-Object -Unique)
    if ($allPkgs.Count -gt 0) {
        Write-Header -Name 'winget packages'
        Install-WingetPackages -Packages $allPkgs
    }

    Write-Header -Name 'Running tasks'

    $failed      = @()
    $spinner     = New-Spinner -InitialMessage 'Starting...' -Disabled:$NoSpinner
    $interrupted = $false

    try {
        foreach ($name in $selected) {
            if (-not $tasks.ContainsKey($name)) {
                Write-SpinnerLine -Spinner $spinner -Line " [WARN] Unknown task: $name"
                continue
            }

            # NB: local must NOT be named $task - that's the same variable as
            # the script-level $Task parameter (PS variable names are case
            # insensitive) and the [string[]] type would coerce the assignment.
            $entry = $tasks[$name]
            $label = if ($entry.DisplayName) { $entry.DisplayName } else { $name }
            Set-SpinnerMessage -Spinner $spinner -Message "Running $label..."

            # Convert a task output item into a printable string. Returns
            # $null for items that should be skipped.
            $toLine = {
                param($item)
                if ($null -eq $item) { return $null }
                if ($item -is [System.Management.Automation.InformationRecord]) {
                    return [string]$item.MessageData
                }
                if ($item -is [string]) { return $item }
                return ($item | Out-String).TrimEnd()
            }

            try {
                if ($null -eq $spinner) {
                    # No spinner active (CI / redirected output / -NoSpinner).
                    # Stream each line directly so progress is visible during
                    # long-running tasks like Docker Desktop's first-run
                    # bootstrap, instead of buffering until the task ends.
                    & $entry.Action -Paths $Paths *>&1 | ForEach-Object {
                        $line = & $toLine $_
                        if ($null -ne $line) { Write-Information $line }
                    }
                }
                else {
                    # Spinner is drawing; capture output and replay between
                    # ticks so the spinner stays the only animated thing.
                    $captured = [System.Collections.Generic.List[object]]::new()
                    & $entry.Action -Paths $Paths *>&1 | ForEach-Object { [void]$captured.Add($_) }

                    # Under -WhatIf the tasks return almost instantly, leaving no
                    # time to see the spinner animate. Hold the spinner for the
                    # configured delay so the user can see progress.
                    if ($WhatIfPreference -and $DryRunDelayMs -gt 0) {
                        Start-Sleep -Milliseconds $DryRunDelayMs
                    }

                    foreach ($item in $captured) {
                        $line = & $toLine $item
                        if ($null -ne $line) {
                            Write-SpinnerLine -Spinner $spinner -Line $line
                        }
                    }
                }
            }
            catch {
                $failed += $name
                $errLine = " [ERROR] $name task failed: $($_.Exception.Message)"
                if ($null -eq $spinner) { Write-Information $errLine }
                else { Write-SpinnerLine -Spinner $spinner -Line $errLine }
            }
        }
    }
    catch [System.Management.Automation.PipelineStoppedException] {
        # User pressed Ctrl+C. The finally below still runs and cleans up the
        # spinner; we just need to remember not to print "Migration Complete!"
        $interrupted = $true
    }
    finally {
        Stop-Spinner -Spinner $spinner
    }

    if ($interrupted) {
        Write-Status -Level Warn -Message "`nMigration interrupted by user (Ctrl+C). Any changes already applied are still in place."
        return
    }

    # Notify already-running apps so they pick up the new env vars without
    # needing a restart. No-op under -WhatIf since nothing actually changed.
    if (-not $WhatIfPreference) {
        Send-EnvironmentChangeBroadcast
    }

    if ($failed.Count -eq 0) {
        Write-Status -Level Ok -Message "`nMigration Complete! Newly-launched apps will see the new env vars; some long-running apps may still need a restart."
    }
    else {
        Write-Status -Level Warn -Message "`nMigration finished with errors in tasks: $($failed -join ', '). Review the output above."
    }
}
finally {
    try { [Console]::remove_CancelKeyPress($cancelHandler) } catch {}
    Close-SingleInstanceLock -Lock $lock
}