#Requires -Version 5.1 <# dotfiles installer (windows) Discovers the dots published in dirs.json, checks each folder's metadata against your machine, and lets you pick what to install. Existing files are never overwritten: you get a dry-run plan and a confirmation, and colliding files are skipped. A dir may also ship an install.ps1, which is run after its files are copied (like a dependency install step). It gets DOTFILES_DEST, DOTFILES_DIR, DOTFILES_NAME, DOTFILES_SYSTEM and DOTFILES_ASSUME_YES in its environment. usage (from a clone): .\install.ps1 [-List] [-Yes] [-Dirs "1 3"] [-Dest $HOME] standalone: $env:DOTFILES_DIRS = '1 3'; irm https://win.rplawski.pl | iex options: -List list available dots and exit -Yes skip confirmation prompts -Dirs "..." install dots by name or number, space/comma separated -Dest PATH destination directory (default: $HOME) environment: DOTFILES_BASE_URL forgejo base url (default: https://git.rplawski.pl) DOTFILES_REPO owner/repo (default: robertplawski/dotfiles) DOTFILES_REF branch or tag (default: main) DOTFILES_TOKEN access token, for private repos DOTFILES_DEST destination directory DOTFILES_DIRS same as -Dirs DOTFILES_SYSTEM override detected system (e.g. microsoft/windows-11) NO_COLOR disable colors #> [CmdletBinding()] param( [switch]$List, [switch]$Yes, [switch]$Help, [string]$Dirs = $env:DOTFILES_DIRS, [string]$Dest, [string]$BaseUrl = $(if ($env:DOTFILES_BASE_URL) { $env:DOTFILES_BASE_URL } else { 'https://git.rplawski.pl' }), [string]$Repo = $(if ($env:DOTFILES_REPO) { $env:DOTFILES_REPO } else { 'robertplawski/dotfiles' }), [string]$Ref = $(if ($env:DOTFILES_REF) { $env:DOTFILES_REF } else { 'main' }), [string]$System = $env:DOTFILES_SYSTEM ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 $script:UseColor = -not $env:NO_COLOR function Write-C { param( [Parameter(Position = 0)][string]$Text = '', [string]$Color, [switch]$NoNewline ) $params = @{ Object = $Text } if ($script:UseColor -and $Color) { $params.ForegroundColor = $Color } if ($NoNewline) { $params.NoNewline = $true } Write-Host @params } function Fail { param([Parameter(Position = 0)][string]$Message) Write-Host '' Write-C "error: $Message" -Color Red exit 1 } function Warn { param([Parameter(Position = 0)][string]$Message) Write-Host " warning: $Message" -ForegroundColor Yellow } function Show-Usage { Write-Host @' dotfiles installer - discovers dots via dirs.json, lets you pick, installs them. usage: .\install.ps1 [options] options: -List list available dots and exit -Yes skip confirmation prompts -Dirs "..." install dots by name or number, space/comma separated -Dest PATH destination directory (default: $HOME) -Help show this help environment: DOTFILES_BASE_URL / DOTFILES_REPO / DOTFILES_REF / DOTFILES_TOKEN DOTFILES_DEST / DOTFILES_DIRS / DOTFILES_SYSTEM / NO_COLOR '@ } function Get-DetectedSystem { try { $key = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' $build = [int]($key.CurrentBuildNumber) if ($build -ge 22000) { return 'microsoft/windows-11' } return 'microsoft/windows-10' } catch { return 'microsoft/windows' } } function Test-Compatible { param([string[]]$Systems) if (-not $script:DetectedSystem) { return $false } foreach ($s in $Systems) { if (-not $s) { continue } if ($s -eq $script:DetectedSystem) { return $true } if ($s.EndsWith('*') -and $script:DetectedSystem.StartsWith($s.TrimEnd('*'))) { return $true } } return $false } function Resolve-Selection { param([string]$Raw) $result = New-Object System.Collections.Generic.List[int] foreach ($tok in ($Raw -split '[,\s]+' | Where-Object { $_ })) { $idx = 0 if ($tok -match '^\d+$') { $idx = [int]$tok } else { for ($i = 0; $i -lt $script:Dots.Count; $i++) { if ($script:Dots[$i].Name -eq $tok) { $idx = $i + 1; break } } if ($idx -eq 0) { return $null } } if ($idx -lt 1 -or $idx -gt $script:Dots.Count) { return $null } if (-not $result.Contains($idx)) { $result.Add($idx) } } if ($result.Count -eq 0) { return $null } return , $result.ToArray() } function Get-SelectionNames { param([int[]]$Indexes) $names = @() foreach ($i in $Indexes) { $names += $script:Dots[$i - 1].Name } return ($names -join ', ') } function Show-Menu { Write-Host '' Write-C ' dotfiles' -Color White -NoNewline Write-C " $Repo @ $Ref" -Color DarkGray Write-C ' yoursystem: ' -Color DarkGray -NoNewline Write-C $script:DetectedSystem -Color White Write-Host '' for ($i = 0; $i -lt $script:Dots.Count; $i++) { $dot = $script:Dots[$i] $pretty = if ($dot.Systems.Count -gt 0) { $dot.Systems -join ' ' } else { 'no metadata' } Write-Host (' {0,2}) ' -f ($i + 1)) -NoNewline Write-Host ($dot.Name.PadRight(24)) -NoNewline Write-Host " $pretty" -NoNewline if (Test-Compatible $dot.Systems) { Write-C ' [match]' -Color Green } else { Write-Host '' } } } function Read-Selection { while ($true) { Write-Host '' Write-C ' select dots (numbers or names, e.g. "1 3", a all, q quit, enter = default)' -Color White Write-C " default: $($script:DefaultSelDisplay)" -Color DarkGray Write-C ' > ' -Color Cyan -NoNewline try { $ans = Read-Host } catch { return $null } switch -Regex ($ans) { '^\s*$' { if ($script:DefaultSel.Count -gt 0) { return @($script:DefaultSel) } Warn 'nothing to preselect, pick at least one dot' continue } '^(q|quit|exit)$' { Write-Host '' Write-C ' cancelled' -Color DarkGray exit 0 } '^(a|all)$' { $all = 1..$script:Dots.Count return @($all) } default { $sel = Resolve-Selection $ans if ($sel) { return @($sel) } Warn 'invalid selection, try again (e.g. 1 3)' } } } } function Expand-Dest { param([string]$Path) if ($Path -eq '~') { return $HOME } if ($Path.StartsWith('~/') -or $Path.StartsWith('~\')) { $rest = $Path.Substring(2) return (Join-Path $HOME $rest) } return $Path } if ($Help) { Show-Usage exit 0 } if ($PSVersionTable.PSVersion.Major -lt 6) { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } } $script:Headers = @{} if ($env:DOTFILES_TOKEN) { $script:Headers['Authorization'] = "token $($env:DOTFILES_TOKEN)" } $BaseUrl = $BaseUrl.TrimEnd('/') $script:DetectedSystem = if ($System) { $System } else { Get-DetectedSystem } Write-Host '' Write-C ' dotfiles installer' -Color White Write-C " checking $BaseUrl/$Repo @ $Ref ..." -Color DarkGray $manifestUrl = "$BaseUrl/$Repo/raw/branch/$Ref/dirs.json" try { $manifest = Invoke-RestMethod -Uri $manifestUrl -Headers $script:Headers -UseBasicParsing } catch { Fail "could not download dirs.json from $manifestUrl ($($_.Exception.Message))" } $script:Dots = New-Object System.Collections.Generic.List[object] if ($null -eq $manifest -or -not $manifest.PSObject.Properties['dirs'] -or -not $manifest.dirs) { Fail 'dirs.json has no "dirs" section' } foreach ($p in $manifest.dirs.PSObject.Properties) { $systems = @() if ($p.Value -and $p.Value.PSObject.Properties['systems'] -and $p.Value.systems) { $systems = @($p.Value.systems) } $script:Dots.Add([pscustomobject]@{ Name = $p.Name; Systems = $systems }) } if ($script:Dots.Count -eq 0) { Fail 'no dots published in dirs.json yet' } $script:DefaultSel = New-Object System.Collections.Generic.List[int] for ($i = 0; $i -lt $script:Dots.Count; $i++) { if (Test-Compatible $script:Dots[$i].Systems) { $script:DefaultSel.Add($i + 1) } } $script:DefaultSelDisplay = if ($script:DefaultSel.Count -gt 0) { $script:DefaultSel -join ' ' } else { 'none' } Show-Menu if ($List) { Write-Host '' Write-C " default selection would be: $($script:DefaultSelDisplay)" -Color DarkGray exit 0 } if ($Dirs) { $selection = Resolve-Selection $Dirs if (-not $selection) { Fail "invalid -Dirs selection: $Dirs" } $Interactive = $false } else { if ($Host.Name -eq 'ConsoleHost' -and [Environment]::UserInteractive) { $Interactive = $true } else { Fail 'no interactive console to ask for a selection - use -Dirs "..." or DOTFILES_DIRS' } $selection = Read-Selection if (-not $selection) { Fail 'no interactive console to ask for a selection - use -Dirs "..." or DOTFILES_DIRS' } } if (-not $Dest) { if ($env:DOTFILES_DEST) { $Dest = $env:DOTFILES_DEST } else { $Dest = $HOME } } $Dest = Expand-Dest $Dest if ($Interactive -and -not $Yes) { Write-Host '' Write-C ' destination: ' -Color White -NoNewline Write-C $Dest Write-C ' press enter to accept, or type another path: ' -Color DarkGray -NoNewline $ans = Read-Host if ($ans) { $Dest = Expand-Dest $ans } } $selection = @($selection) Write-Host '' Write-C ' install: ' -Color White -NoNewline Write-C (Get-SelectionNames $selection) Write-C ' into: ' -Color White -NoNewline Write-C $Dest if ($Interactive -and -not $Yes) { Write-C ' proceed? [Y/n] ' -Color Cyan -NoNewline $ans = Read-Host if ($ans -match '^(n|no)$') { Write-Host '' Write-C ' cancelled' -Color DarkGray exit 0 } } Write-Host '' Write-C ' downloading archive...' -Color DarkGray $tmp = Join-Path ([IO.Path]::GetTempPath()) ('dotfiles-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $tmp -Force | Out-Null try { $zip = Join-Path $tmp 'archive.zip' try { Invoke-WebRequest -Uri "$BaseUrl/$Repo/archive/$Ref.zip" -Headers $script:Headers -OutFile $zip -UseBasicParsing } catch { Fail "could not download archive ($($_.Exception.Message))" } $extract = Join-Path $tmp 'x' try { Expand-Archive -LiteralPath $zip -DestinationPath $extract -Force } catch { Fail 'could not extract archive' } $root = Get-ChildItem -LiteralPath $extract -Directory | Select-Object -First 1 if (-not $root) { Fail 'unexpected archive layout (no top-level directory)' } # dry run: classify every file as new or already existing $plan = New-Object System.Collections.Generic.List[object] $skipped = New-Object System.Collections.Generic.List[object] $scripts = New-Object System.Collections.Generic.List[object] foreach ($i in $selection) { $name = $script:Dots[$i - 1].Name $src = Join-Path $root.FullName $name if (-not (Test-Path -LiteralPath $src)) { Warn "skipping $name, not present in the archive" continue } $scriptPath = Join-Path $src 'install.ps1' if (Test-Path -LiteralPath $scriptPath) { $scripts.Add([pscustomobject]@{ Name = $name; Path = $scriptPath }) } Get-ChildItem -LiteralPath $src -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'metadata.json' } | ForEach-Object { $rel = $_.FullName.Substring($src.Length).TrimStart('\', '/') if ($rel -in @('install.sh', 'install.ps1')) { return } $entry = [pscustomobject]@{ Name = $name Source = $_.FullName Relative = $rel Target = (Join-Path $Dest $rel) } if (Test-Path -LiteralPath $entry.Target) { $skipped.Add($entry) } else { $plan.Add($entry) } } } Write-Host '' Write-C ' plan:' -Color White $newPlural = if ($plan.Count -eq 1) { '' } else { 's' } Write-Host ' ' -NoNewline Write-C "$($plan.Count) new file$newPlural to install" -Color Green if ($skipped.Count -gt 0) { $skipPlural = if ($skipped.Count -eq 1) { '' } else { 's' } Write-Host ' ' -NoNewline Write-C "$($skipped.Count) existing file$skipPlural to skip (kept as-is):" -Color Yellow $skipped | Sort-Object Relative | Select-Object -First 15 | ForEach-Object { Write-Host " $($_.Relative)" } if ($skipped.Count -gt 15) { Write-C " ... and $($skipped.Count - 15) more" -Color DarkGray } } if ($scripts.Count -gt 0) { $scriptPlural = if ($scripts.Count -eq 1) { '' } else { 's' } Write-Host ' ' -NoNewline Write-C "$($scripts.Count) install script$scriptPlural to run after copying:" -Color Cyan $scripts | ForEach-Object { Write-Host " $($_.Name) (install.ps1)" } } if ($plan.Count -eq 0 -and $scripts.Count -eq 0) { Write-Host '' Write-C ' everything already exists, nothing to do' -Color DarkGray Write-Host '' } else { if ($Interactive -and -not $Yes) { $what = "install $($plan.Count) new file$newPlural" if ($scripts.Count -gt 0) { $scriptPlural = if ($scripts.Count -eq 1) { '' } else { 's' } $what += " and run $($scripts.Count) install script$scriptPlural" } Write-C " $what? [Y/n] " -Color Cyan -NoNewline $ans = Read-Host if ($ans -match '^(n|no)$') { Write-Host '' Write-C ' cancelled' -Color DarkGray exit 0 } } if (-not (Test-Path -LiteralPath $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } foreach ($f in $plan) { $parent = Split-Path -Parent $f.Target if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } Copy-Item -LiteralPath $f.Source -Destination $f.Target -Force } $ran = 0 foreach ($entry in $scripts) { Write-Host ' ' -NoNewline Write-C 'run ' -Color Cyan -NoNewline Write-Host ($entry.Name.PadRight(24)) -NoNewline Write-C ' (install.ps1)' -Color DarkGray $env:DOTFILES_DEST = $Dest $env:DOTFILES_DIR = (Split-Path -Parent $entry.Path) $env:DOTFILES_NAME = $entry.Name $env:DOTFILES_SYSTEM = $script:DetectedSystem $env:DOTFILES_ASSUME_YES = if ($Yes) { '1' } else { '0' } $psExe = (Get-Process -Id $PID).Path $psArgs = @('-NoProfile', '-File', $entry.Path) if ($env:OS -eq 'Windows_NT') { $psArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $entry.Path) } Push-Location (Split-Path -Parent $entry.Path) try { & $psExe @psArgs } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { Fail "install.ps1 for $($entry.Name) failed (exit $LASTEXITCODE)" } $ran++ } foreach ($i in $selection) { $name = $script:Dots[$i - 1].Name $c = @($plan | Where-Object { $_.Name -eq $name }).Count $s = @($skipped | Where-Object { $_.Name -eq $name }).Count $scriptNote = '' if ($scripts | Where-Object { $_.Name -eq $name }) { $scriptNote = ', ran install.ps1' } if ($c -gt 0) { $info = "$c file$(if ($c -eq 1) { '' } else { 's' })" if ($s -gt 0) { $info += ", $s skipped" } $info += $scriptNote Write-Host ' ' -NoNewline Write-C 'ok ' -Color Green -NoNewline Write-Host ($name.PadRight(24)) -NoNewline Write-C " ($info)" -Color DarkGray } elseif ($s -gt 0) { Write-Host ' -- ' -NoNewline Write-Host ($name.PadRight(24)) -NoNewline Write-C " (all $s file$(if ($s -eq 1) { '' } else { 's' }) already exist$scriptNote)" -Color DarkGray } elseif ($scriptNote) { Write-Host ' ok ' -NoNewline Write-Host ($name.PadRight(24)) -NoNewline Write-C " (nothing to copy$scriptNote)" -Color DarkGray } else { Write-Host ' -- ' -NoNewline Write-Host ($name.PadRight(24)) -NoNewline Write-C ' (no files yet, only metadata.json)' -Color DarkGray } } Write-Host '' $total = $plan.Count $plural = if ($total -eq 1) { '' } else { 's' } $skipNote = if ($skipped.Count -gt 0) { ", $($skipped.Count) existing skipped" } else { '' } $ranNote = if ($ran -gt 0) { ", $ran install script$(if ($ran -eq 1) { '' } else { 's' }) run" } else { '' } Write-Host ' ' -NoNewline Write-C 'done' -Color Green -NoNewline Write-Host " $($selection.Count) dot(s), $total file$plural into $Dest$skipNote$ranNote" Write-Host '' } } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue }