payload/install.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS furnizsh — installer for Windows / PowerShell. .DESCRIPTION Installs the same tool stack as the Unix installer (starship, zoxide, fzf, eza, bat, fd, delta, lazygit), the PowerShell profile with matching aliases and helper commands, and the Windows Terminal colour schemes. Ghostty has no Windows build. For the real Ghostty experience, use WSL2 and run ./install.sh inside it — see docs/WINDOWS.md. This script sets up the native-PowerShell half. Safe by design: backs up anything it replaces to ~/.furnizsh-backup/<timestamp>/, and is idempotent. .PARAMETER DryRun Print every action without changing anything. .PARAMETER Yes Don't prompt for confirmation. .PARAMETER Theme Which look to install: neon (default), catppuccin, gruvbox or tokyonight. Sets Starship, lazygit and the Windows Terminal scheme together. Switch later at any time with the `theme` command. .PARAMETER Tools Which tool set to install: core (default) starship fzf zoxide eza bat fd delta lazygit extended + ripgrep jq tldr btop all + atuin yq httpie dust procs gh .PARAMETER NoExtras Skip the network-dependent helper commands (weather, cheat, qr, gitignore, note, timer, sysinfo, dockerclean). .PARAMETER NoFont Skip the JetBrains Mono Nerd Font install. .PARAMETER NoTerminal Skip patching Windows Terminal's settings.json. .EXAMPLE .\install.ps1 -DryRun .EXAMPLE .\install.ps1 -Yes #> [CmdletBinding()] param( [ValidateSet('neon', 'catppuccin', 'gruvbox', 'tokyonight')] [string]$Theme = 'neon', [ValidateSet('core', 'extended', 'all')] [string]$Tools = 'core', [switch]$DryRun, [switch]$Yes, [switch]$NoFont, [switch]$NoExtras, [switch]$NoTerminal ) $ErrorActionPreference = 'Stop' $RepoDir = $PSScriptRoot $BackupDir = Join-Path $HOME ".furnizsh-backup\$(Get-Date -Format 'yyyyMMdd-HHmmss')" $ConfigDir = Join-Path $HOME '.config' # ------------------------------------------------------------ # Output helpers # ------------------------------------------------------------ $C = @{ Orange = "`e[38;2;250;179;135m" Yellow = "`e[38;2;249;226;175m" Green = "`e[38;2;166;227;161m" Blue = "`e[38;2;137;180;250m" Gray = "`e[38;2;108;112;134m" Bold = "`e[1m" Reset = "`e[0m" } function Write-Step { param([string]$Message) Write-Host "`n$($C.Bold)$($C.Blue)==>$($C.Reset) $Message" } function Write-Info { param([string]$Message) Write-Host " $Message" } function Write-Ok { param([string]$Message) Write-Host " $($C.Green)OK$($C.Reset) $Message" } function Write-Skip { param([string]$Message) Write-Host " $($C.Gray)- $Message$($C.Reset)" } function Write-Warn { param([string]$Message) Write-Host " $($C.Orange)! $Message$($C.Reset)" } function Invoke-Step { <# Run a scriptblock, or describe it under -DryRun. #> param([string]$Description, [scriptblock]$Action) if ($DryRun) { Write-Host " $($C.Gray)[dry-run]$($C.Reset) $Description" } else { & $Action } } function Confirm-Action { param([string]$Message) if ($Yes -or $DryRun) { return $true } $reply = Read-Host " $Message [y/N]" return $reply -match '^[yY]' } function Backup-File { param([string]$Path) if (-not (Test-Path $Path)) { return } Invoke-Step "back up $Path" { New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null Copy-Item -Path $Path -Destination (Join-Path $BackupDir (Split-Path $Path -Leaf)) -Force -Recurse } Write-Skip "backed up $(Split-Path $Path -Leaf)" } function Install-ConfigFile { param([string]$Source, [string]$Destination) if ((Test-Path $Destination) -and ((Get-FileHash $Source).Hash -eq (Get-FileHash $Destination).Hash)) { Write-Skip "$Destination already current" return } Backup-File $Destination Invoke-Step "copy $Source -> $Destination" { New-Item -ItemType Directory -Force -Path (Split-Path $Destination -Parent) | Out-Null Copy-Item -Path $Source -Destination $Destination -Force } Write-Ok $Destination } # ------------------------------------------------------------ # Package manager # ------------------------------------------------------------ function Get-PackageManager { if (Get-Command winget -ErrorAction SilentlyContinue) { return 'winget' } if (Get-Command scoop -ErrorAction SilentlyContinue) { return 'scoop' } if (Get-Command choco -ErrorAction SilentlyContinue) { return 'choco' } return $null } function Install-ToolSet { Write-Step "Installing tools" $pkgManager = Get-PackageManager if (-not $pkgManager) { Write-Warn "No package manager found (winget, scoop or choco)." Write-Info "winget ships with Windows 11 and recent Windows 10 builds." Write-Info "Otherwise install scoop: irm get.scoop.sh | iex" throw "no package manager available" } Write-Info "Using $pkgManager" # id-per-manager, because none of them agree on package names. # `Set` marks which tool set first includes each entry. $tools = @( @{ Name = 'starship'; winget = 'Starship.Starship'; scoop = 'starship'; choco = 'starship' }, @{ Name = 'zoxide'; winget = 'ajeetdsouza.zoxide'; scoop = 'zoxide'; choco = 'zoxide' }, @{ Name = 'fzf'; winget = 'junegunn.fzf'; scoop = 'fzf'; choco = 'fzf' }, @{ Name = 'eza'; winget = 'eza-community.eza'; scoop = 'eza'; choco = 'eza' }, @{ Name = 'bat'; winget = 'sharkdp.bat'; scoop = 'bat'; choco = 'bat' }, @{ Name = 'fd'; winget = 'sharkdp.fd'; scoop = 'fd'; choco = 'fd' }, @{ Name = 'delta'; winget = 'dandavison.delta'; scoop = 'delta'; choco = 'delta' }, @{ Name = 'lazygit'; winget = 'JesseDuffield.lazygit'; scoop = 'lazygit'; choco = 'lazygit' }, @{ Name = 'git'; winget = 'Git.Git'; scoop = 'git'; choco = 'git' } ) # tmux and direnv have no meaningful native-Windows equivalent, so the # extended set is smaller here than on the Unix side. docs/WINDOWS.md says so. if ($Tools -in @('extended', 'all')) { $tools += @( @{ Name = 'rg'; winget = 'BurntSushi.ripgrep.MSVC'; scoop = 'ripgrep'; choco = 'ripgrep' }, @{ Name = 'jq'; winget = 'jqlang.jq'; scoop = 'jq'; choco = 'jq' }, @{ Name = 'tldr'; winget = 'tldr-pages.tlrc'; scoop = 'tlrc'; choco = 'tldr' }, @{ Name = 'btop'; winget = 'aristocratos.btop4win'; scoop = 'btop'; choco = 'btop' } ) } if ($Tools -eq 'all') { $tools += @( @{ Name = 'atuin'; winget = 'ellie.atuin'; scoop = 'atuin'; choco = 'atuin' }, @{ Name = 'yq'; winget = 'MikeFarah.yq'; scoop = 'yq'; choco = 'yq' }, @{ Name = 'http'; winget = 'httpie.httpie'; scoop = 'httpie'; choco = 'httpie' }, @{ Name = 'dust'; winget = 'bootandy.dust'; scoop = 'dust'; choco = 'dust' }, @{ Name = 'procs'; winget = 'dalance.procs'; scoop = 'procs'; choco = 'procs' }, @{ Name = 'gh'; winget = 'GitHub.cli'; scoop = 'gh'; choco = 'gh' } ) } Write-Info "$($tools.Count) packages ($Tools set)" foreach ($tool in $tools) { if (Get-Command $tool.Name -ErrorAction SilentlyContinue) { Write-Skip "$($tool.Name) already installed" continue } $packageId = $tool[$pkgManager] Invoke-Step "$pkgManager install $packageId" { switch ($pkgManager) { 'winget' { winget install --id $packageId --silent --accept-package-agreements --accept-source-agreements | Out-Null } 'scoop' { scoop install $packageId | Out-Null } 'choco' { choco install $packageId -y | Out-Null } } } Write-Ok $tool.Name } } function Install-ModuleSet { Write-Step "Installing PowerShell modules" # PSReadLine gives us the equivalent of zsh-autosuggestions + # zsh-syntax-highlighting. Terminal-Icons is the eza-icons equivalent # for native PowerShell listings. PSFzf wires fzf to Ctrl+r / Ctrl+t. $modules = @( @{ Name = 'PSReadLine'; MinVersion = '2.2.0' }, @{ Name = 'Terminal-Icons'; MinVersion = $null }, @{ Name = 'PSFzf'; MinVersion = $null } ) foreach ($module in $modules) { $installed = Get-Module -ListAvailable -Name $module.Name | Sort-Object Version -Descending | Select-Object -First 1 if ($installed -and (-not $module.MinVersion -or $installed.Version -ge [version]$module.MinVersion)) { Write-Skip "$($module.Name) already installed ($($installed.Version))" continue } Invoke-Step "Install-Module $($module.Name)" { Install-Module -Name $module.Name -Scope CurrentUser -Force -AllowClobber -SkipPublisherCheck } Write-Ok $module.Name } } function Install-NerdFont { if ($NoFont) { Write-Skip "skipping font (-NoFont)"; return } Write-Step "JetBrains Mono Nerd Font" $installedFonts = @() try { $installedFonts = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\Windows\Fonts", "$env:WINDIR\Fonts" ` -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name } catch { # Either font directory can be missing or unreadable on a locked-down # machine. That only means we can't prove the font is present, so fall # through and install it — the install is idempotent either way. Write-Verbose "Could not enumerate font directories: $($_.Exception.Message)" } if ($installedFonts -match 'JetBrainsMono.*Nerd') { Write-Skip "already installed" return } $pkgManager = Get-PackageManager if ($pkgManager -eq 'scoop') { Invoke-Step "scoop install nerd-fonts/JetBrainsMono-NF-Mono" { scoop bucket add nerd-fonts 2>$null | Out-Null scoop install nerd-fonts/JetBrainsMono-NF-Mono | Out-Null } Write-Ok "installed via scoop" return } # winget/choco don't reliably carry Nerd Fonts, so download the release zip. Invoke-Step "download + install JetBrainsMono Nerd Font" { $tmp = Join-Path $env:TEMP "furnizsh-font-$(Get-Random)" New-Item -ItemType Directory -Force -Path $tmp | Out-Null $zip = Join-Path $tmp 'JetBrainsMono.zip' Invoke-WebRequest -Uri 'https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip' ` -OutFile $zip -UseBasicParsing Expand-Archive -Path $zip -DestinationPath $tmp -Force # Copying into the per-user font dir and registering it avoids # needing an elevated shell. $userFontDir = "$env:LOCALAPPDATA\Microsoft\Windows\Fonts" New-Item -ItemType Directory -Force -Path $userFontDir | Out-Null Get-ChildItem $tmp -Filter '*.ttf' | ForEach-Object { Copy-Item $_.FullName -Destination $userFontDir -Force New-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Fonts' ` -Name "$($_.BaseName) (TrueType)" ` -Value (Join-Path $userFontDir $_.Name) ` -PropertyType String -Force | Out-Null } Remove-Item $tmp -Recurse -Force } Write-Ok "installed (restart your terminal to pick it up)" } function Install-Profile { Write-Step "Installing the PowerShell profile" $source = Join-Path $RepoDir 'config\powershell\Microsoft.PowerShell_profile.ps1' if (-not (Test-Path $source)) { throw "profile not found at $source" } if (Test-Path $PROFILE) { $existing = Get-Content $PROFILE -Raw -ErrorAction SilentlyContinue if ($existing -and $existing -notmatch 'furnizsh') { Write-Warn "You already have a PowerShell profile with your own content." Write-Info "It will be backed up to $BackupDir before being replaced." if (-not (Confirm-Action "Replace it?")) { Write-Skip "left as-is"; return } } } if ($NoExtras) { # Strip the marked extras block rather than shipping dead code. if ($DryRun) { Write-Host " $($C.Gray)[dry-run]$($C.Reset) install profile without the extras block" } else { Backup-File $PROFILE $lines = Get-Content $source $keep = @(); $inExtras = $false foreach ($line in $lines) { if ($line -match '^# >>> furnizsh extras >>>') { $inExtras = $true; continue } if ($line -match '^# <<< furnizsh extras <<<') { $inExtras = $false; continue } if (-not $inExtras) { $keep += $line } } New-Item -ItemType Directory -Force -Path (Split-Path $PROFILE -Parent) | Out-Null Set-Content -Path $PROFILE -Value $keep -Encoding UTF8 Write-Ok "$PROFILE (without extras)" } } else { Install-ConfigFile -Source $source -Destination $PROFILE } } function Install-Config { Write-Step "Installing config (theme: $Theme)" $agHome = Join-Path $HOME '.config\furnizsh' # Every theme ships, so `theme <name>` can switch later without the repo. New-Item -ItemType Directory -Force -Path (Join-Path $agHome 'themes\lazygit') | Out-Null foreach ($f in Get-ChildItem (Join-Path $RepoDir 'config\themes') -Filter *.theme) { Install-ConfigFile -Source $f.FullName -Destination (Join-Path $agHome "themes\$($f.Name)") } foreach ($f in Get-ChildItem (Join-Path $RepoDir 'config\themes\lazygit') -Filter *.yml) { Install-ConfigFile -Source $f.FullName -Destination (Join-Path $agHome "themes\lazygit\$($f.Name)") } # Read the selected theme's settings out of its .theme file. $themeFile = Join-Path $RepoDir "config\themes\$Theme.theme" $t = @{} foreach ($line in Get-Content $themeFile) { if ($line -match '^\s*([A-Z_]+)="(.*)"\s*$') { $t[$Matches[1]] = $Matches[2] } } # --- Starship: generated from the preset (--force, it won't overwrite otherwise) --- $starshipOut = Join-Path $ConfigDir 'starship.toml' if ($DryRun) { Write-Host " $($C.Gray)[dry-run]$($C.Reset) starship preset $($t.STARSHIP_PRESET) -o $starshipOut" } elseif (Get-Command starship -ErrorAction SilentlyContinue) { Backup-File $starshipOut New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null starship preset $t.STARSHIP_PRESET --force -o $starshipOut 2>$null if ($LASTEXITCODE -eq 0) { Write-Ok "$starshipOut ($($t.STARSHIP_PRESET))" } else { Write-Warn "starship has no preset '$($t.STARSHIP_PRESET)' — using the bundled config" Copy-Item (Join-Path $RepoDir 'config\starship\starship.toml') $starshipOut -Force } } else { Install-ConfigFile -Source (Join-Path $RepoDir 'config\starship\starship.toml') -Destination $starshipOut } # --- lazygit reads from %APPDATA%\lazygit on Windows --- Install-ConfigFile -Source (Join-Path $RepoDir "config\themes\lazygit\$($t.LAZYGIT_PALETTE).yml") ` -Destination (Join-Path $env:APPDATA 'lazygit\config.yml') if (-not $DryRun) { New-Item -ItemType Directory -Force -Path $agHome | Out-Null Set-Content -Path (Join-Path $agHome 'current-theme') -Value $Theme -NoNewline } } function Set-GitDelta { Write-Step "Configuring git-delta" if (-not (Get-Command delta -ErrorAction SilentlyContinue) -and -not $DryRun) { Write-Warn "delta not installed — skipping" return } $current = git config --global core.pager 2>$null if ($current -eq 'delta') { Write-Skip "already configured"; return } if ($current) { Write-Warn "core.pager is currently '$current'" if (-not (Confirm-Action "Replace it with delta?")) { Write-Skip "left as-is"; return } } Invoke-Step "git config --global core.pager delta" { git config --global core.pager 'delta' git config --global interactive.diffFilter 'delta --color-only' git config --global delta.navigate 'true' } Write-Ok "delta set as the git pager" } function Set-WindowsTerminal { if ($NoTerminal) { Write-Skip "skipping Windows Terminal (-NoTerminal)"; return } Write-Step "Windows Terminal colour schemes" $settingsPath = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json" if (-not (Test-Path $settingsPath)) { Write-Skip "Windows Terminal settings not found — add the schemes by hand" Write-Info "See config\windows-terminal\schemes.json" return } $schemesFile = Join-Path $RepoDir 'config\windows-terminal\schemes.json' $ours = (Get-Content $schemesFile -Raw | ConvertFrom-Json).schemes Backup-File $settingsPath Invoke-Step "merge schemes into Windows Terminal settings.json" { $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json if (-not $settings.schemes) { $settings | Add-Member -NotePropertyName schemes -NotePropertyValue @() -Force } $existingNames = @($settings.schemes | ForEach-Object { $_.name }) $added = 0 foreach ($scheme in $ours) { if ($existingNames -notcontains $scheme.name) { $settings.schemes += $scheme $added++ } } $settings | ConvertTo-Json -Depth 32 | Set-Content $settingsPath -Encoding UTF8 Write-Host " $($C.Gray)added $added scheme(s)$($C.Reset)" } Write-Ok "schemes available" Write-Info "Set them in Windows Terminal: ctrl+, -> your PowerShell profile ->" Write-Info " Appearance -> Color scheme: 'Catppuccin Mocha'" Write-Info " Appearance -> Font face: 'JetBrainsMono Nerd Font Mono'" } # ------------------------------------------------------------ # main # ------------------------------------------------------------ Write-Host "`n$($C.Bold)$($C.Blue) furnizsh$($C.Reset) $($C.Gray)a neon terminal, in one command$($C.Reset)" Write-Host " $($C.Gray)https://github.com/Wosmos/furnizsh$($C.Reset)" if ($DryRun) { Write-Host "`n $($C.Bold)$($C.Orange)DRY RUN - nothing will be changed.$($C.Reset)" } Write-Host "`n $($C.Gray)theme$($C.Reset) $Theme" Write-Host " $($C.Gray)tools$($C.Reset) $Tools" Write-Host "`n $($C.Gray)Ghostty has no Windows build. This installs the PowerShell half of$($C.Reset)" Write-Host " $($C.Gray)the setup. For real Ghostty + zsh, see docs\WINDOWS.md (WSL2).$($C.Reset)" try { Install-ToolSet Install-ModuleSet Install-NerdFont Install-Config Install-Profile Set-GitDelta Set-WindowsTerminal Write-Step "Done" if ($DryRun) { Write-Info "That was a dry run. Rerun without -DryRun to apply." } else { if (Test-Path $BackupDir) { Write-Info "Backups: $BackupDir" } Write-Info "Open a new PowerShell window, then run:" Write-Host " $($C.Yellow)cheatsheet$($C.Reset) the reference" Write-Host " $($C.Yellow)agdoctor$($C.Reset) verify every piece of the setup" } Write-Host "" } catch { Write-Host "`n$($C.Bold)$($C.Orange)error:$($C.Reset) $($_.Exception.Message)`n" exit 1 } |