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 } |