-
-
Save thimslugga/3d9f1eed40830789227971549579f65a to your computer and use it in GitHub Desktop.
@jacobpbrugh's dotfiles bootstrap script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "nodes": { | |
| "flake-compat": { | |
| "flake": false, | |
| "locked": { | |
| "lastModified": 1765121682, | |
| "narHash": "sha256-4VBOP18BFeiPkyhy9o4ssBNQEvfvv1kXkasAYd0+rrA=", | |
| "owner": "edolstra", | |
| "repo": "flake-compat", | |
| "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3", | |
| "type": "github" | |
| }, | |
| "original": { | |
| "owner": "edolstra", | |
| "repo": "flake-compat", | |
| "type": "github" | |
| } | |
| }, | |
| "nixos-wsl": { | |
| "inputs": { | |
| "flake-compat": "flake-compat", | |
| "nixpkgs": [ | |
| "nixpkgs" | |
| ] | |
| }, | |
| "locked": { | |
| "lastModified": 1765841014, | |
| "narHash": "sha256-55V0AJ36V5Egh4kMhWtDh117eE3GOjwq5LhwxDn9eHg=", | |
| "owner": "nix-community", | |
| "repo": "NixOS-WSL", | |
| "rev": "be4af8042e7a61fa12fda58fe9a3b3babdefe17b", | |
| "type": "github" | |
| }, | |
| "original": { | |
| "owner": "nix-community", | |
| "ref": "main", | |
| "repo": "NixOS-WSL", | |
| "type": "github" | |
| } | |
| }, | |
| "nixpkgs": { | |
| "locked": { | |
| "lastModified": 1767379071, | |
| "narHash": "sha256-EgE0pxsrW9jp9YFMkHL9JMXxcqi/OoumPJYwf+Okucw=", | |
| "owner": "NixOS", | |
| "repo": "nixpkgs", | |
| "rev": "fb7944c166a3b630f177938e478f0378e64ce108", | |
| "type": "github" | |
| }, | |
| "original": { | |
| "owner": "NixOS", | |
| "ref": "nixos-unstable", | |
| "repo": "nixpkgs", | |
| "type": "github" | |
| } | |
| }, | |
| "root": { | |
| "inputs": { | |
| "nixos-wsl": "nixos-wsl", | |
| "nixpkgs": "nixpkgs" | |
| } | |
| } | |
| }, | |
| "root": "root", | |
| "version": 7 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| description = "NixOS Phase 0 Bootstrap - creates jacob user with minimal system config"; | |
| inputs = { | |
| nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; | |
| # NixOS-WSL for Windows Subsystem for Linux | |
| nixos-wsl = { | |
| url = "github:nix-community/NixOS-WSL/main"; | |
| inputs.nixpkgs.follows = "nixpkgs"; | |
| }; | |
| }; | |
| outputs = { self, nixpkgs, nixos-wsl }: let | |
| # Shared user configuration for all bootstrap variants | |
| userConfig = { pkgs, ... }: { | |
| # Target user | |
| users.users.jacob = { | |
| isNormalUser = true; | |
| home = "/home/jacob"; | |
| shell = pkgs.zsh; | |
| extraGroups = ["wheel" "docker" "systemd-journal"]; | |
| openssh.authorizedKeys.keys = [ | |
| "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIASYCo8dKnRJ0Gc01yKMWRm4Afw7nXNASVtd5g8XV+vW jacob@mac" | |
| "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDibOr+tge8OHe8sDZ+Hlhn83vN1P4Xcat1f4MJuYytM jacob@iphone" | |
| "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILWh7JO+95XPqc41Up5gBCRkENckIj5/6zIIXZTobTzV jacob@pc1" | |
| ]; | |
| }; | |
| # Allow sudo without password | |
| security.sudo.wheelNeedsPassword = false; | |
| # Minimal shell setup | |
| programs.zsh.enable = true; | |
| # Minimal packages for Phase 1 | |
| environment.systemPackages = with pkgs; [ git curl vim chezmoi delta gh ]; | |
| # Locale | |
| time.timeZone = "America/Denver"; | |
| i18n.defaultLocale = "en_US.UTF-8"; | |
| # Flakes | |
| nix.settings.experimental-features = ["nix-command" "flakes"]; | |
| system.stateVersion = "24.11"; | |
| }; | |
| in { | |
| # Standard NixOS bootstrap (for bare metal / VPS) | |
| nixosConfigurations.bootstrap = nixpkgs.lib.nixosSystem { | |
| system = "x86_64-linux"; | |
| modules = [ | |
| userConfig | |
| ({ ... }: { | |
| # Console autologin — disk is LUKS-encrypted so no password needed | |
| services.getty.autologinUser = "jacob"; | |
| # SSH | |
| services.openssh = { | |
| enable = true; | |
| settings = { | |
| PasswordAuthentication = false; | |
| PermitRootLogin = "prohibit-password"; | |
| }; | |
| }; | |
| # WiFi — install.sh bakes creds into /var/lib/nixos-bootstrap/wifi.conf | |
| networking.useDHCP = true; | |
| networking.wireless = { | |
| enable = true; | |
| userControlled = true; | |
| allowAuxiliaryImperativeNetworks = true; | |
| }; | |
| }) | |
| ({ pkgs, ... }: { | |
| # Tailscale (for remote access via Tailnet) | |
| # Auth state in /var/lib/tailscale/ persists across rebuilds | |
| services.tailscale.enable = true; | |
| # One-shot: if install.sh baked WiFi creds, install them for wpa_supplicant | |
| systemd.services.wifi-firstboot = { | |
| description = "First-boot WiFi configuration"; | |
| before = [ "wpa_supplicant.service" ]; | |
| wantedBy = [ "multi-user.target" ]; | |
| serviceConfig = { | |
| Type = "oneshot"; | |
| RemainAfterExit = true; | |
| }; | |
| script = '' | |
| WIFI_CONF="/var/lib/nixos-bootstrap/wifi.conf" | |
| [ -f "$WIFI_CONF" ] || exit 0 | |
| echo "Installing baked WiFi credentials..." | |
| mkdir -p /etc/wpa_supplicant | |
| cp "$WIFI_CONF" /etc/wpa_supplicant/imperative.conf | |
| chmod 600 /etc/wpa_supplicant/imperative.conf | |
| rm -f "$WIFI_CONF" | |
| echo "WiFi credentials installed" | |
| ''; | |
| }; | |
| # One-shot: if install.sh baked an auth key, authenticate then delete it | |
| systemd.services.tailscale-firstboot = { | |
| description = "First-boot Tailscale authentication"; | |
| after = [ "tailscaled.service" "network-online.target" ]; | |
| wants = [ "network-online.target" ]; | |
| wantedBy = [ "multi-user.target" ]; | |
| serviceConfig = { | |
| Type = "oneshot"; | |
| RemainAfterExit = true; | |
| }; | |
| script = '' | |
| KEY_FILE="/var/lib/nixos-bootstrap/tailscale-auth-key" | |
| [ -f "$KEY_FILE" ] || exit 0 | |
| echo "Waiting for tailscaled..." | |
| for i in $(${pkgs.coreutils}/bin/seq 1 30); do | |
| ${pkgs.tailscale}/bin/tailscale status >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| echo "Authenticating with Tailscale..." | |
| if ${pkgs.tailscale}/bin/tailscale up --login-server https://headscale.jacobbrugh.net --auth-key="$(cat "$KEY_FILE")"; then | |
| echo "Connected to tailnet" | |
| rm -f "$KEY_FILE" | |
| else | |
| echo "Failed — run 'sudo tailscale up' manually" | |
| fi | |
| ''; | |
| }; | |
| }) | |
| # Hardware config - must be copied here before running | |
| ( | |
| if builtins.pathExists ./host-hardware.nix | |
| then ./host-hardware.nix | |
| else throw "Copy hardware config first: cp /etc/nixos/hardware-configuration.nix host-hardware.nix (in the bootstrap flake directory)" | |
| ) | |
| # Optional networking config | |
| ( | |
| if builtins.pathExists ./host-networking.nix | |
| then ./host-networking.nix | |
| else { ... }: {} | |
| ) | |
| ]; | |
| }; | |
| # WSL bootstrap (for NixOS-WSL instances) | |
| nixosConfigurations.wsl-bootstrap = nixpkgs.lib.nixosSystem { | |
| system = "x86_64-linux"; | |
| modules = [ | |
| # NixOS-WSL module | |
| nixos-wsl.nixosModules.default | |
| userConfig | |
| ({ pkgs, ... }: { | |
| # WSL-specific settings | |
| wsl = { | |
| enable = true; | |
| defaultUser = "jacob"; | |
| }; | |
| }) | |
| # Optional: host-hardware.nix (usually empty for WSL) | |
| ( | |
| if builtins.pathExists ./host-hardware.nix | |
| then ./host-hardware.nix | |
| else { ... }: {} | |
| ) | |
| ({ ... }: { | |
| programs.git.enable = true; | |
| programs.git.config = { | |
| core = { | |
| sshCommand = "/mnt/c/Windows/System32/OpenSSH/ssh.exe -o StrictHostKeyChecking=accept-new"; | |
| }; | |
| }; | |
| }) | |
| ]; | |
| }; | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Windows dotfiles bootstrap: Scoop -> Git -> SSH -> Chezmoi -> DSC -> Tailscale -> NixOS-WSL | |
| .DESCRIPTION | |
| Bootstraps a fresh Windows machine from zero to a configured development | |
| environment. Handles admin elevation, package installation, SSH setup, | |
| and NixOS-WSL installation. | |
| This script is the Windows counterpart to bootstrap.sh. While bootstrap.sh | |
| handles macOS/Linux (Nix-based), this script handles Windows (DSC-based) | |
| with NixOS running inside WSL for the actual development environment. | |
| .PARAMETER Command | |
| The command to run: | |
| - boot (default): Full bootstrap | |
| - validate: Check all components without changes | |
| - scoop: Ensure Scoop is installed | |
| - packages: Install required packages (via Scoop) | |
| - ssh: Setup Windows OpenSSH infrastructure | |
| - chezmoi: Initialize chezmoi | |
| - dsc: Apply DSC configuration | |
| - tailscale: Connect to Tailscale/Headscale | |
| - wsl: Install/configure WSL with NixOS | |
| .PARAMETER SkipWsl | |
| Skip WSL installation | |
| .PARAMETER SkipSsh | |
| Skip SSH infrastructure setup | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .EXAMPLE | |
| # Download and run (PowerShell 5.1+) | |
| iex ((New-Object System.Net.WebClient).DownloadString('https://gist.githubusercontent.com/jacobbrugh/4999a2a58bfb9d0c99f71599f1bc4b41/raw/bootstrap.ps1')) | |
| .EXAMPLE | |
| # Run locally | |
| .\bootstrap.ps1 | |
| .EXAMPLE | |
| # Validate only | |
| .\bootstrap.ps1 validate | |
| .EXAMPLE | |
| # Custom WSL instance name | |
| $env:WSL_INSTANCE_NAME = 'wsl1'; .\bootstrap.ps1 | |
| .NOTES | |
| After Windows bootstrap completes, run the Unix bootstrap inside WSL: | |
| curl -fsSL https://gist.githubusercontent.com/jacobbrugh/4999a2a58bfb9d0c99f71599f1bc4b41/raw/bootstrap.sh | bash | |
| Environment variables: | |
| - WSL_INSTANCE_NAME: Name for the NixOS-WSL instance (default: wsl# for pc# hostname, else wsl-<uuid>) | |
| - GITHUB_TOKEN: GitHub token for SSH key upload (fetched from 1Password if not set) | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Position = 0)] | |
| [ValidateSet('boot', 'validate', 'scoop', 'packages', 'ssh', 'chezmoi', 'dsc', 'tailscale', 'wsl')] | |
| [string]$Command = 'boot', | |
| [switch]$SkipWsl, | |
| [switch]$SkipSsh, | |
| [switch]$DryRun | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| # ============================================================================ | |
| # Configuration | |
| # ============================================================================ | |
| $Script:GistUrl = 'https://gist.githubusercontent.com/jacobbrugh/4999a2a58bfb9d0c99f71599f1bc4b41/raw' | |
| $Script:ChezmoiRepo = 'git@github.com:jacobbrugh/dotfiles.git' | |
| $Script:ChezmoiSourceDir = Join-Path $env:USERPROFILE '.local\share\chezmoi' | |
| # GitHub configuration | |
| $Script:GhHost = 'github.com' | |
| $Script:GhUser = 'jacobbrugh' | |
| # SSH configuration | |
| $Script:SshDir = Join-Path $env:USERPROFILE '.ssh' | |
| $Script:SshKeyName = "id_ed25519_$(hostname)" | |
| $Script:SshKeyPath = Join-Path $Script:SshDir $Script:SshKeyName | |
| # 1Password configuration | |
| $Script:OpVault = 'Personal' | |
| $Script:OpGitHubItem = 'GitHub PAT (SSH Key Upload)' | |
| $Script:OpGitHubField = 'credential' | |
| $Script:OpMacPubKeyRef = 'op://Personal/id_ed25519_jacobmac/public key' | |
| # WSL configuration | |
| $Script:WslInstanceName = 'NixOS' | |
| $Script:WslInstallPath = Join-Path $env:USERPROFILE "wsl\NixOS" | |
| # NixOS-WSL release URL | |
| $Script:NixosWslRelease = 'https://github.com/nix-community/NixOS-WSL/releases/latest/download/nixos.wsl' | |
| # Tailscale configuration | |
| $Script:TailscaleLoginServer = 'https://headscale.jacobbrugh.net' | |
| # ============================================================================ | |
| # Download and Import BootstrapUtils Module | |
| # ============================================================================ | |
| Write-Host "Downloading BootstrapUtils module..." -ForegroundColor Cyan | |
| $moduleUrl = "$Script:GistUrl/BootstrapUtils.psm1" | |
| $modulePath = Join-Path $env:TEMP 'BootstrapUtils.psm1' | |
| try { | |
| Invoke-WebRequest -Uri $moduleUrl -OutFile $modulePath -UseBasicParsing | |
| Import-Module $modulePath -Force -DisableNameChecking | |
| Write-Host "BootstrapUtils module loaded successfully" -ForegroundColor Green | |
| } catch { | |
| Write-Host "[ERROR] Failed to download BootstrapUtils module: $_" -ForegroundColor Red | |
| Write-Host "Please check your internet connection and try again." -ForegroundColor Yellow | |
| exit 1 | |
| } | |
| # ============================================================================ | |
| # Orchestration Functions (Bootstrap-specific) | |
| # ============================================================================ | |
| function Install-Prerequisites { | |
| <# | |
| .SYNOPSIS | |
| Install Scoop, buckets, and bootstrap-critical tools | |
| #> | |
| # Install Scoop and add buckets | |
| Install-Scoop -DryRun:$DryRun | |
| Install-ScoopBuckets -DryRun:$DryRun | |
| # Bootstrap-critical tools via Scoop (needed before chezmoi apply) | |
| Install-ScoopPackage -Name 'git' -DisplayName 'Git' -DryRun:$DryRun | |
| Install-ScoopPackage -Name 'chezmoi' -DisplayName 'chezmoi' -DryRun:$DryRun | |
| Install-ScoopPackage -Name '1password-cli' -DisplayName '1Password CLI' -DryRun:$DryRun | |
| # Refresh PATH to pick up newly installed tools | |
| Update-PathEnvironment | |
| # Note: 1Password desktop app is installed via DSC (WinGetPackage resource) | |
| # Note: Additional packages (delta, pwsh, pnpm, uv, vscode, fonts) are installed | |
| # via scoopfile.json when chezmoi apply triggers run_onchange_after_01scoop-import.ps1 | |
| } | |
| function Install-SshInfrastructure { | |
| <# | |
| .SYNOPSIS | |
| Setup complete Windows OpenSSH infrastructure | |
| #> | |
| if ($SkipSsh) { | |
| Write-Info "Skipping SSH infrastructure (--SkipSsh specified)" | |
| return | |
| } | |
| # Install OpenSSH capabilities (requires elevation) | |
| $null = Install-OpenSshCapabilities -DryRun:$DryRun | |
| # Configure services | |
| $null = Initialize-SshAgent -DryRun:$DryRun | |
| $null = Initialize-SshServer -DryRun:$DryRun | |
| # Generate and configure SSH key | |
| $null = New-SshKey -SshDir $Script:SshDir -KeyName $Script:SshKeyName -Comment "$Script:GhUser@$(hostname)" -DryRun:$DryRun | |
| $null = Add-SshKeyToAgent -KeyPath $Script:SshKeyPath -DryRun:$DryRun | |
| # Upload key to GitHub | |
| if (-not (Test-GitHubSshAccess -GitHubHost $Script:GhHost -GitHubUser $Script:GhUser)) { | |
| $token = Get-GitHubToken -OpVault $Script:OpVault -OpItem $Script:OpGitHubItem -OpField $Script:OpGitHubField | |
| if (-not $token) { | |
| Write-Err "Cannot upload SSH key without GitHub token" | |
| Write-Err "Set GITHUB_TOKEN environment variable or sign in to 1Password" | |
| exit 1 | |
| } | |
| $null = Add-SshKeyToGitHub -PublicKeyPath "$Script:SshKeyPath.pub" -KeyTitle $Script:SshKeyName -Token $token -DryRun:$DryRun | |
| } | |
| # Setup authorized_keys for inbound SSH from Mac | |
| if (-not (Get-Command op -ErrorAction SilentlyContinue)) { | |
| Write-Err "1Password CLI not found - required for fetching Mac public key" | |
| exit 1 | |
| } | |
| # Check if signed in | |
| $opAccount = op account get 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Info "Please sign in to 1Password..." | |
| op signin | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Err "Failed to sign in to 1Password" | |
| exit 1 | |
| } | |
| } | |
| $macPubKey = op read $Script:OpMacPubKeyRef 2>&1 | |
| if ($LASTEXITCODE -ne 0 -or -not $macPubKey) { | |
| Write-Err "Failed to fetch Mac public key from 1Password" | |
| exit 1 | |
| } | |
| $null = Add-AuthorizedKeys -SshDir $Script:SshDir -PublicKey $macPubKey -DryRun:$DryRun | |
| } | |
| function Initialize-Chezmoi { | |
| <# | |
| .SYNOPSIS | |
| Initialize or update chezmoi configuration | |
| #> | |
| if (Test-Path (Join-Path $Script:ChezmoiSourceDir '.git')) { | |
| Write-Info "Chezmoi already initialized at $Script:ChezmoiSourceDir" | |
| Write-Info "Running chezmoi apply..." | |
| if (-not $DryRun) { | |
| chezmoi apply | |
| } | |
| return | |
| } | |
| Write-Info "Initializing chezmoi from $Script:ChezmoiRepo..." | |
| # Check for SSH key (try both the machine-specific and default locations) | |
| $sshKeyExists = (Test-Path $Script:SshKeyPath) -or (Test-Path (Join-Path $env:USERPROFILE ".ssh\id_ed25519")) | |
| if (-not $sshKeyExists) { | |
| Write-Warn "No SSH key found" | |
| Write-Warn "You may need to set up SSH access to GitHub first." | |
| Write-Warn "Trying HTTPS fallback..." | |
| $httpsRepo = 'https://github.com/jacobbrugh/dotfiles.git' | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would run: chezmoi init --apply $httpsRepo" | |
| } else { | |
| chezmoi init --apply $httpsRepo | |
| } | |
| } else { | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would run: chezmoi init --apply $Script:ChezmoiRepo" | |
| } else { | |
| chezmoi init --apply $Script:ChezmoiRepo | |
| } | |
| } | |
| } | |
| function Invoke-DscConfiguration { | |
| <# | |
| .SYNOPSIS | |
| Run DSC configuration script | |
| #> | |
| $dscScript = Join-Path $Script:ChezmoiSourceDir 'home\windows\windows\dsc\Invoke-Dsc.ps1' | |
| if (-not (Test-Path $dscScript)) { | |
| Write-Warn "DSC script not found: $dscScript" | |
| Write-Warn "Skipping DSC configuration (will be applied on next chezmoi apply)" | |
| return | |
| } | |
| if (-not (Test-AdminElevation)) { | |
| Write-Warn "DSC requires elevation. Skipping automatic DSC application." | |
| Write-Host "" | |
| Write-Host "To apply DSC configuration manually, run as Administrator:" -ForegroundColor Yellow | |
| Write-Host " & '$dscScript'" -ForegroundColor Cyan | |
| return | |
| } | |
| Write-Info "Installing required PowerShell modules for DSC..." | |
| Install-PsModules | |
| Write-Info "Running DSC configuration..." | |
| # DSC requires PowerShell 7+. If running in PS 5.1, invoke via pwsh.exe | |
| if ($PSVersionTable.PSVersion.Major -lt 7) { | |
| Write-Info "Launching DSC in PowerShell 7..." | |
| if ($DryRun) { | |
| Start-Process pwsh -NoNewWindow -Wait -ArgumentList "-ExecutionPolicy Bypass -File `"$dscScript`" -WhatIf" | |
| } else { | |
| Start-Process pwsh -NoNewWindow -Wait -ArgumentList "-ExecutionPolicy Bypass -File `"$dscScript`"" | |
| } | |
| } else { | |
| if ($DryRun) { | |
| & $dscScript -WhatIf | |
| } else { | |
| & $dscScript | |
| } | |
| } | |
| } | |
| function Install-Wsl { | |
| <# | |
| .SYNOPSIS | |
| Install WSL with NixOS | |
| #> | |
| if ($SkipWsl) { | |
| Write-Info "Skipping WSL installation (--SkipWsl specified)" | |
| return | |
| } | |
| # Install WSL platform (without default distro) | |
| if (-not (Install-WslPlatform -DryRun:$DryRun)) { | |
| return | |
| } | |
| # Install NixOS-WSL | |
| $null = Install-NixosWsl -InstanceName $Script:WslInstanceName -InstallPath $Script:WslInstallPath -ReleaseUrl $Script:NixosWslRelease -DryRun:$DryRun | |
| # Note: Firewall rules (including WSL SSH port 2222) are managed via DSC | |
| } | |
| function Initialize-Tailscale { | |
| <# | |
| .SYNOPSIS | |
| Connect to Tailscale/Headscale tailnet | |
| #> | |
| $null = Connect-Tailscale -LoginServer $Script:TailscaleLoginServer -DryRun:$DryRun | |
| } | |
| function Invoke-PackagesValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate bootstrap-critical packages | |
| #> | |
| $allOk = $true | |
| # Check bootstrap-critical tools | |
| @( | |
| @{Name = 'Git'; Cmd = 'git'}, | |
| @{Name = 'chezmoi'; Cmd = 'chezmoi'}, | |
| @{Name = '1Password CLI'; Cmd = 'op'} | |
| ) | ForEach-Object { | |
| if (Get-Command $_.Cmd -ErrorAction SilentlyContinue) { | |
| $version = & $_.Cmd --version 2>$null | Select-Object -First 1 | |
| Write-Info "$($_.Name): OK ($version)" | |
| } else { | |
| Write-Err "$($_.Name): NOT INSTALLED" | |
| $allOk = $false | |
| } | |
| } | |
| return $allOk | |
| } | |
| function Invoke-ChezmoiValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate chezmoi initialization | |
| #> | |
| if (Test-Path (Join-Path $Script:ChezmoiSourceDir '.git')) { | |
| Write-Info "chezmoi source: OK ($Script:ChezmoiSourceDir)" | |
| return $true | |
| } else { | |
| Write-Err "chezmoi source: NOT INITIALIZED" | |
| return $false | |
| } | |
| } | |
| function Invoke-DscValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate DSC configuration | |
| #> | |
| $dscPath = Join-Path $Script:ChezmoiSourceDir 'home\windows\windows\dsc\pc1.dsc.config.yaml' | |
| if (Test-Path $dscPath) { | |
| Write-Info "DSC config: OK ($dscPath)" | |
| if (Get-Command dsc -ErrorAction SilentlyContinue) { | |
| Write-Info "DSC v3: INSTALLED" | |
| } else { | |
| Write-Warn "DSC v3: NOT INSTALLED (run Invoke-Dsc.ps1 to install)" | |
| } | |
| return $true | |
| } else { | |
| Write-Err "DSC config: NOT FOUND" | |
| return $false | |
| } | |
| } | |
| # ============================================================================ | |
| # Main Commands | |
| # ============================================================================ | |
| function Invoke-BootstrapValidate { | |
| Write-Host "" | |
| Write-Host "============================================" -ForegroundColor Cyan | |
| Write-Host " Windows Bootstrap Validation" -ForegroundColor Cyan | |
| Write-Host "============================================" -ForegroundColor Cyan | |
| Write-Host "" | |
| $allOk = $true | |
| $allOk = (Invoke-ScoopValidate) -and $allOk | |
| $allOk = (Invoke-PackagesValidate) -and $allOk | |
| $allOk = (Invoke-SshValidate -SshKeyPath $Script:SshKeyPath -GitHubUser $Script:GhUser) -and $allOk | |
| $allOk = (Invoke-ChezmoiValidate) -and $allOk | |
| $allOk = (Invoke-DscValidate) -and $allOk | |
| $allOk = (Invoke-TailscaleValidate) -and $allOk | |
| $allOk = (Invoke-WslValidate -InstanceName $Script:WslInstanceName) -and $allOk | |
| Write-Host "" | |
| if ($allOk) { | |
| Write-Host "All components validated successfully!" -ForegroundColor Green | |
| exit 0 | |
| } else { | |
| Write-Host "Some components need attention." -ForegroundColor Yellow | |
| exit 1 | |
| } | |
| } | |
| function Invoke-BootstrapFull { | |
| Write-Host "" | |
| Write-Host "============================================" -ForegroundColor Cyan | |
| Write-Host " Windows Dotfiles Bootstrap" -ForegroundColor Cyan | |
| Write-Host "============================================" -ForegroundColor Cyan | |
| Write-Host "" | |
| $totalSteps = 6 | |
| # Step 1: Ensure admin for features | |
| Request-AdminElevation -ScriptPath $PSCommandPath -Command $Command | |
| # Step 2: Install prerequisites (Scoop + bootstrap tools + 1Password) | |
| Write-Step 1 $totalSteps "Install prerequisites (Scoop, Git, chezmoi, 1Password)" | |
| Install-Prerequisites | |
| # Step 3: Setup SSH infrastructure | |
| Write-Step 2 $totalSteps "Setup Windows OpenSSH infrastructure" | |
| Install-SshInfrastructure | |
| # Step 4: Initialize chezmoi (triggers scoop import via run_onchange script) | |
| Write-Step 3 $totalSteps "Initialize chezmoi" | |
| Initialize-Chezmoi | |
| # Step 5: Run DSC | |
| Write-Step 4 $totalSteps "Apply DSC configuration" | |
| Invoke-DscConfiguration | |
| # Step 6: Connect to Tailscale | |
| Write-Step 5 $totalSteps "Connect to Tailscale" | |
| Initialize-Tailscale | |
| # Step 7: Install NixOS-WSL | |
| Write-Step 6 $totalSteps "Setup NixOS-WSL" | |
| Install-Wsl | |
| # Final instructions | |
| Write-Host "" | |
| Write-Host "============================================" -ForegroundColor Green | |
| Write-Host " Windows Bootstrap Complete!" -ForegroundColor Green | |
| Write-Host "============================================" -ForegroundColor Green | |
| Write-Host "" | |
| Write-Host "Configuration:" -ForegroundColor Yellow | |
| Write-Host " - WSL Instance: $Script:WslInstanceName" | |
| Write-Host " - SSH Key: $Script:SshKeyPath" | |
| Write-Host " - Windows SSH: port 22" | |
| Write-Host " - WSL SSH: port 2222" | |
| Write-Host "" | |
| Write-Host "Next steps:" -ForegroundColor Yellow | |
| Write-Host " 1. Reboot if this was your first time installing WSL" | |
| Write-Host " 2. Launch NixOS-WSL: wsl -d $Script:WslInstanceName" | |
| Write-Host " 3. Run the Unix bootstrap inside WSL:" | |
| Write-Host "" | |
| Write-Host " curl -fsSL $Script:GistUrl/bootstrap.sh | bash" -ForegroundColor Cyan | |
| Write-Host "" | |
| Write-Host "SSH Access (from other machines on your LAN):" -ForegroundColor Yellow | |
| Write-Host " - Windows: ssh -p 22 $env:USERNAME@<this-pc-ip>" | |
| Write-Host " - NixOS-WSL: ssh -p 2222 jacob@<this-pc-ip>" | |
| Write-Host "" | |
| } | |
| # ============================================================================ | |
| # Entry Point | |
| # ============================================================================ | |
| switch ($Command) { | |
| 'boot' { Invoke-BootstrapFull } | |
| 'validate' { Invoke-BootstrapValidate } | |
| 'scoop' { Install-Scoop -DryRun:$DryRun; Install-ScoopBuckets -DryRun:$DryRun } | |
| 'packages' { Install-Prerequisites } | |
| 'ssh' { Install-SshInfrastructure } | |
| 'chezmoi' { Initialize-Chezmoi } | |
| 'dsc' { Invoke-DscConfiguration } | |
| 'tailscale' { Initialize-Tailscale } | |
| 'wsl' { Install-Wsl } | |
| default { Invoke-BootstrapFull } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Headless dotfiles bootstrap: Nix → GitHub SSH → chezmoi → zsh | |
| # | |
| # Primary usage (curl pipe to shell): | |
| # curl -fsSL https://gist.githubusercontent.com/jacobbrugh/4999a2a58bfb9d0c99f71599f1bc4b41/raw/bootstrap.sh | bash -s -- boot | |
| # | |
| # This script bootstraps a fresh machine from zero to a fully configured | |
| # zsh shell. Steps: install Nix, ensure GitHub SSH access, run chezmoi | |
| # init --apply, then exec into zsh. | |
| # | |
| # Commands: | |
| # (default) / boot -> boot_main (full bootstrap, ends with exec zsh) | |
| # validate -> boot_validate (validate all components) | |
| # nix [validate] -> boot_nix (Nix installation) | |
| # gh [validate] -> boot_gh (GitHub SSH access) | |
| # chezmoi [validate] -> boot_chezmoi (chezmoi init/apply) | |
| # | |
| # Sourcing (for testing individual functions): | |
| # source ./bootstrap.sh # define functions only | |
| # source ./bootstrap.sh nix validate # run specific command in current shell | |
| # 0 = sourced mode, 1 = executed as script | |
| # Note: when run via `curl | bash`, BASH_SOURCE[0] is empty and $0 is "bash", | |
| # so we also check if stdin is not a terminal (piped input) | |
| BOOTSTRAP_IN_SCRIPT_MODE=0 | |
| if [[ ${BASH_SOURCE[0]} == "$0" ]] || [[ ! -t 0 ]]; then | |
| BOOTSTRAP_IN_SCRIPT_MODE=1 | |
| fi | |
| ######################################## | |
| # Shared logging / helpers | |
| ######################################## | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| NC='\033[0m' # No Color | |
| log_info() { | |
| echo -e "${GREEN}[INFO]${NC} $*" | |
| } | |
| log_warn() { | |
| echo -e "${YELLOW}[WARN]${NC} $*" | |
| } | |
| log_error() { | |
| echo -e "${RED}[ERROR]${NC} $*" | |
| } | |
| fatal() { | |
| log_error "$*" | |
| if [[ $BOOTSTRAP_IN_SCRIPT_MODE -eq 1 ]]; then | |
| exit 1 | |
| else | |
| return 1 | |
| fi | |
| } | |
| detect_os() { | |
| if [[ $OSTYPE == "darwin"* ]]; then | |
| echo "macos" | |
| elif [[ $OSTYPE == "linux-gnu"* ]]; then | |
| echo "linux" | |
| else | |
| fatal "Unsupported OS: $OSTYPE" | |
| fi | |
| } | |
| # Check if Nix is already installed | |
| check_nix_installed() { | |
| if command -v nix &>/dev/null; then | |
| log_warn "Nix is already installed" | |
| nix --version | |
| return 0 | |
| else | |
| return 1 | |
| fi | |
| } | |
| ######################################## | |
| # Global defaults (configurable via CLI) | |
| ######################################## | |
| INSTALL_TYPE="daemon" # daemon or no-daemon | |
| NIX_CONF_OPTIONS=() | |
| EXTRA_NIX_CONFIG="" | |
| YES_TO_ALL=false | |
| DRY_RUN=false | |
| NIX_INSTALLER_URL="https://nixos.org/nix/install" | |
| # Bootstrapping GH / repo | |
| GH_HOST="${GH_HOST:-github.com}" | |
| GH_USER="${GH_USER:-jacobbrugh}" | |
| GH_REPO_SSH="${GH_REPO_SSH:-git@github.com:jacobbrugh/dotfiles.git}" | |
| GH_KEY_PATH_DEFAULT="${GH_KEY_PATH_DEFAULT:-"$HOME/.ssh/id_ed25519_$(hostname)"}" | |
| # Base SSH command - only include -i if key exists to avoid warnings on fresh machines | |
| GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new" | |
| if [[ -f $GH_KEY_PATH_DEFAULT ]]; then | |
| GIT_SSH_COMMAND="$GIT_SSH_COMMAND -i $GH_KEY_PATH_DEFAULT" | |
| fi | |
| # Chezmoi | |
| CHEZMOI_REPO="${CHEZMOI_REPO:-git@github.com:jacobbrugh/dotfiles.git}" | |
| CHEZMOI_SOURCE_DIR="${CHEZMOI_SOURCE_DIR:-$HOME/.local/share/chezmoi}" | |
| # Bootstrap gist (contains flake.nix and flake.lock for NixOS Phase 0) | |
| BOOTSTRAP_GIST_RAW="https://gist.githubusercontent.com/jacobbrugh/4999a2a58bfb9d0c99f71599f1bc4b41/raw" | |
| # NixOS bootstrap parameters (env var or prompted interactively) | |
| BOOTSTRAP_USER="${BOOTSTRAP_USER:-jacob}" | |
| BOOTSTRAP_TARGET_HOSTNAME="${BOOTSTRAP_TARGET_HOSTNAME:-}" | |
| BOOTSTRAP_TAG="${BOOTSTRAP_TAG:-}" | |
| # Final shell exec | |
| EXEC_SHELL=true | |
| SHELL_CMD="${SHELL_CMD:-zsh}" | |
| # 1Password (Darwin only) | |
| OP_VAULT="${OP_VAULT:-Private}" | |
| OP_GITHUB_ITEM="${OP_GITHUB_ITEM:-jcug3qmurhr7npn56tu72g4dni}" | |
| OP_GITHUB_FIELD="${OP_GITHUB_FIELD:-credential}" | |
| ######################################## | |
| # Usage / CLI parsing | |
| ######################################## | |
| usage() { | |
| cat <<EOF | |
| Usage: $0 [GLOBAL_OPTIONS] [COMMAND] [SUBCOMMAND] | |
| Headless dotfiles bootstrap (Linux and macOS). | |
| Primary usage (curl pipe to shell): | |
| exec sh -c "\$(curl -fsSL https://example.com/bootstrap.sh)" | |
| Commands (default is 'boot'): | |
| boot Full bootstrap (OS-aware): | |
| - Darwin: Homebrew → 1Password → Nix → GH SSH → chezmoi | |
| - Linux: Nix → GH SSH → chezmoi → zsh | |
| - NixOS: GH SSH → chezmoi → harvest config → nix-switch | |
| validate Validate all components without making changes | |
| nix [validate] Nix operations | |
| gh [validate] GitHub SSH operations | |
| chezmoi [validate] Chezmoi operations | |
| Darwin-only commands: | |
| homebrew [validate] Homebrew installation | |
| 1password [validate] 1Password app + CLI setup and authentication | |
| Linux-only commands: | |
| zsh [validate] Zsh installation (via romkatv/zsh-bin) | |
| Global options: | |
| --daemon Use multi-user Nix installation (default) | |
| --no-daemon Use single-user Nix installation (Linux only) | |
| --yes Auto-confirm prompts (default for boot) | |
| --nix-conf KEY=VALUE Add nix.conf option (repeatable) | |
| --extra-conf STRING Add raw nix.conf block | |
| --no-exec-shell Don't exec into shell after boot (for testing/automation) | |
| --shell CMD Shell to exec into (default: zsh) | |
| --dry-run Show what would be done without executing | |
| --help Show this help message | |
| Environment variables: | |
| CHEZMOI_REPO Dotfiles repo (default: $CHEZMOI_REPO) | |
| GH_USER GitHub username (default: $GH_USER) | |
| GH_REPO_SSH Private repo for SSH validation (default: $GH_REPO_SSH) | |
| SHELL_CMD Shell to exec into (default: zsh) | |
| # 1Password (Darwin only) | |
| OP_VAULT 1Password vault name (default: $OP_VAULT) | |
| OP_GITHUB_ITEM Item name for GitHub token (default: $OP_GITHUB_ITEM) | |
| OP_GITHUB_FIELD Field name for token (default: $OP_GITHUB_FIELD) | |
| Examples: | |
| # Full bootstrap from curl (typical usage) | |
| exec sh -c "\$(curl -fsSL https://example.com/bootstrap.sh)" | |
| # Full bootstrap locally | |
| $0 boot | |
| # Validate all components | |
| $0 validate | |
| # Individual steps | |
| $0 nix | |
| $0 gh | |
| $0 chezmoi | |
| $0 homebrew # Darwin only | |
| $0 1password # Darwin only | |
| $0 zsh # Linux only | |
| # Check status without changes | |
| $0 nix validate | |
| $0 gh validate | |
| $0 chezmoi validate | |
| $0 homebrew validate # Darwin only | |
| $0 1password validate # Darwin only | |
| $0 zsh validate # Linux only | |
| # Bootstrap without exec'ing into shell | |
| $0 --no-exec-shell boot | |
| EOF | |
| } | |
| ######################################## | |
| # Nix installer internals | |
| ######################################## | |
| install_nix() { | |
| log_info "Starting Nix installation (type: $INSTALL_TYPE)" | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would download and run: curl --proto '=https' --tlsv1.2 -L $NIX_INSTALLER_URL" | |
| log_info "[DRY RUN] Installation type: $INSTALL_TYPE" | |
| return 0 | |
| fi | |
| if ! sudo -v; then | |
| fatal "sudo is required to install Nix (multi-user); bailing." | |
| fi | |
| # Download installer | |
| log_info "Downloading Nix installer..." | |
| local installer_script | |
| installer_script=$(curl --proto '=https' --tlsv1.2 -sSfL "$NIX_INSTALLER_URL") | |
| # Prepare installation command based on type | |
| local install_cmd | |
| if [[ $INSTALL_TYPE == "daemon" ]]; then | |
| install_cmd="--daemon" | |
| else | |
| install_cmd="--no-daemon" | |
| fi | |
| # Run installer with appropriate flags for headless operation | |
| log_info "Running Nix installer..." | |
| if [[ $YES_TO_ALL == true ]]; then | |
| # Save installer to temp file to avoid pipe conflicts | |
| local temp_installer | |
| temp_installer=$(mktemp) | |
| echo "$installer_script" >"$temp_installer" | |
| yes | sh "$temp_installer" "$install_cmd" || { | |
| log_info "Installer completed (exit code: $?)" | |
| } | |
| rm -f "$temp_installer" | |
| else | |
| # Interactive mode | |
| sh -s -- "$install_cmd" <<<"$installer_script" | |
| fi | |
| log_info "Nix installation completed" | |
| } | |
| configure_nix_conf() { | |
| local nix_conf_path | |
| # Determine nix.conf path based on installation type | |
| if [[ $INSTALL_TYPE == "daemon" ]]; then | |
| nix_conf_path="/etc/nix/nix.conf" | |
| else | |
| nix_conf_path="$HOME/.config/nix/nix.conf" | |
| mkdir -p "$(dirname "$nix_conf_path")" | |
| fi | |
| log_info "Configuring nix.conf at: $nix_conf_path" | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would configure $nix_conf_path with:" | |
| for opt in "${NIX_CONF_OPTIONS[@]}"; do | |
| log_info " $opt" | |
| done | |
| if [[ -n $EXTRA_NIX_CONFIG ]]; then | |
| log_info " Extra config: $EXTRA_NIX_CONFIG" | |
| fi | |
| return 0 | |
| fi | |
| # Backup existing config if it exists | |
| if [[ -f $nix_conf_path ]]; then | |
| log_info "Backing up existing nix.conf to ${nix_conf_path}.backup" | |
| if [[ $INSTALL_TYPE == "daemon" ]]; then | |
| sudo cp "$nix_conf_path" "${nix_conf_path}.backup" | |
| else | |
| cp "$nix_conf_path" "${nix_conf_path}.backup" | |
| fi | |
| fi | |
| # Build new config in temp file | |
| local temp_conf | |
| temp_conf=$(mktemp) | |
| # Preserve existing config | |
| if [[ -f $nix_conf_path ]]; then | |
| cat "$nix_conf_path" >"$temp_conf" | |
| echo "" >>"$temp_conf" | |
| fi | |
| # Add header | |
| echo "# Added by bootstrap.sh on $(date)" >>"$temp_conf" | |
| # Add individual options | |
| for opt in "${NIX_CONF_OPTIONS[@]}"; do | |
| log_info "Adding to nix.conf: $opt" | |
| echo "$opt" >>"$temp_conf" | |
| done | |
| # Add extra configuration block | |
| if [[ -n $EXTRA_NIX_CONFIG ]]; then | |
| log_info "Adding extra configuration block" | |
| echo "$EXTRA_NIX_CONFIG" >>"$temp_conf" | |
| fi | |
| # Write to nix.conf with appropriate permissions | |
| if [[ $INSTALL_TYPE == "daemon" ]]; then | |
| sudo mv "$temp_conf" "$nix_conf_path" | |
| sudo chmod 644 "$nix_conf_path" | |
| else | |
| mv "$temp_conf" "$nix_conf_path" | |
| chmod 644 "$nix_conf_path" | |
| fi | |
| log_info "nix.conf configured successfully" | |
| } | |
| source_nix() { | |
| log_info "Sourcing Nix environment..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would source Nix environment" | |
| return 0 | |
| fi | |
| # Try to source nix | |
| if [[ -e '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ]]; then | |
| # shellcheck disable=SC1091 | |
| . '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' | |
| log_info "Sourced daemon profile" | |
| elif [[ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]]; then | |
| # shellcheck disable=SC1091 | |
| . "$HOME/.nix-profile/etc/profile.d/nix.sh" | |
| log_info "Sourced user profile" | |
| else | |
| log_warn "Could not find Nix profile to source. You may need to restart your shell." | |
| return 1 | |
| fi | |
| } | |
| verify_installation() { | |
| log_info "Verifying Nix installation..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would verify Nix installation" | |
| return 0 | |
| fi | |
| if command -v nix &>/dev/null; then | |
| log_info "Nix is successfully installed!" | |
| nix --version | |
| # Show nix.conf if configured | |
| local nix_conf_path | |
| if [[ $INSTALL_TYPE == "daemon" ]]; then | |
| nix_conf_path="/etc/nix/nix.conf" | |
| else | |
| nix_conf_path="$HOME/.config/nix/nix.conf" | |
| fi | |
| if [[ -f $nix_conf_path ]]; then | |
| log_info "Current nix.conf:" | |
| if [[ $INSTALL_TYPE == "daemon" ]]; then | |
| sudo cat "$nix_conf_path" | |
| else | |
| cat "$nix_conf_path" | |
| fi | |
| fi | |
| return 0 | |
| else | |
| fatal "Nix installation verification failed" | |
| fi | |
| } | |
| validate_install_type_for_os() { | |
| local os_type=$1 | |
| if [[ $os_type == "macos" ]] && [[ $INSTALL_TYPE == "no-daemon" ]]; then | |
| fatal "Single-user (--no-daemon) installations are no longer supported on macOS. Use --daemon." | |
| fi | |
| } | |
| ######################################## | |
| # GitHub SSH helper logic | |
| ######################################## | |
| # Extract "Hi <username>! ..." from GitHub's SSH greeting | |
| extract_gh_username() { | |
| local text=$1 | |
| if [[ $text =~ ^Hi\ ([^!]+)! ]]; then | |
| printf '%s\n' "${BASH_REMATCH[1]}" | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| check_with_agent() { | |
| local gh_user=$1 | |
| if [[ -z ${SSH_AUTH_SOCK:-} ]]; then | |
| return 1 | |
| fi | |
| log_info "Checking SSH agent for $gh_user on $GH_HOST..." | |
| local out | |
| out=$(ssh -T \ | |
| -o BatchMode=yes \ | |
| -o StrictHostKeyChecking=accept-new \ | |
| "git@${GH_HOST}" 2>&1 || true) | |
| if extract_gh_username "$out" >/dev/null; then | |
| local u | |
| u=$(extract_gh_username "$out") | |
| if [[ $u == "$gh_user" ]]; then | |
| log_info "SSH agent already authenticates as $gh_user on $GH_HOST." | |
| return 0 | |
| else | |
| log_info "SSH agent authenticates as '$u', not '$gh_user'; ignoring." | |
| return 1 | |
| fi | |
| else | |
| log_info "SSH agent did not successfully authenticate to $GH_HOST." | |
| return 1 | |
| fi | |
| } | |
| check_with_keyfile() { | |
| local gh_user=$1 | |
| local key=$2 | |
| if [[ ! -f $key ]]; then | |
| return 1 | |
| fi | |
| log_info "Checking key file $key for $gh_user on $GH_HOST..." | |
| local out | |
| out=$(ssh -T \ | |
| -o BatchMode=yes \ | |
| -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new \ | |
| -i "$key" \ | |
| "git@${GH_HOST}" 2>&1 || true) | |
| if extract_gh_username "$out" >/dev/null; then | |
| local u | |
| u=$(extract_gh_username "$out") | |
| if [[ $u == "$gh_user" ]]; then | |
| log_info "Key $key authenticates as $gh_user on $GH_HOST." | |
| return 0 | |
| elif is_ai_host && [[ $u == "$gh_user/"* ]]; then | |
| # Deploy keys authenticate as "owner/repo", not just "owner" | |
| log_info "Key $key authenticates as deploy key '$u' on $GH_HOST." | |
| return 0 | |
| else | |
| log_info "Key $key authenticates as '$u', not '$gh_user'; ignoring." | |
| return 1 | |
| fi | |
| else | |
| log_info "Key $key failed to authenticate to $GH_HOST." | |
| return 1 | |
| fi | |
| } | |
| pick_gh_cmd() { | |
| if command -v gh >/dev/null 2>&1; then | |
| GH_CMD=(gh) | |
| elif command -v nix >/dev/null 2>&1; then | |
| # Fresh nix installs don't have experimental features enabled by default | |
| GH_CMD=(nix --extra-experimental-features "nix-command flakes" run "nixpkgs#gh" --) | |
| else | |
| fatal "Neither 'gh' nor 'nix' found on PATH; cannot manage GitHub SSH keys." | |
| fi | |
| } | |
| gh_get_auth_login() { | |
| # Get the authenticated user's login via gh api | |
| "${GH_CMD[@]}" api user --jq '.login' 2>/dev/null | |
| } | |
| ensure_keyfile_exists() { | |
| local gh_user=$1 | |
| local key=$2 | |
| if [[ -f $key ]]; then | |
| log_info "Key file $key already exists." | |
| return 0 | |
| fi | |
| log_info "Key file $key does not exist; generating new ed25519 key..." | |
| mkdir -p "$HOME/.ssh" | |
| chmod 700 "$HOME/.ssh" || true | |
| ssh-keygen -t ed25519 \ | |
| -C "${gh_user}@$(hostname)" \ | |
| -f "$key" \ | |
| -N "" \ | |
| >/dev/null | |
| log_info "Generated new key at $key." | |
| } | |
| upload_pubkey_with_gh() { | |
| local gh_user=$1 | |
| local key=$2 | |
| pick_gh_cmd || return 1 | |
| log_info "Checking gh authentication via API..." | |
| local login | |
| if ! login=$(gh_get_auth_login) || [[ -z $login ]]; then | |
| fatal "Not authenticated to GitHub. Set GITHUB_TOKEN or run 'gh auth login'." | |
| fi | |
| log_info "Authenticated as: $login" | |
| if [[ $login != "$gh_user" ]]; then | |
| fatal "gh is logged in as '$login', not '$gh_user'; refusing to upload key." | |
| fi | |
| local pub="${key}.pub" | |
| if [[ ! -f $pub ]]; then | |
| fatal "Public key $pub is missing." | |
| fi | |
| local key_hostname="${BOOTSTRAP_TARGET_HOSTNAME:-$(hostname)}" | |
| local title="id_ed25519_$key_hostname" | |
| log_info "Uploading $pub to GitHub account $login with title '$title'..." | |
| local -a UPLOAD_CMD | |
| if is_ai_host; then | |
| UPLOAD_CMD=("${GH_CMD[@]}" repo deploy-key add "$pub" -R jacobbrugh/dotfiles -w -t "$title") | |
| else | |
| UPLOAD_CMD=("${GH_CMD[@]}" ssh-key add "$pub" --title "$title" --type authentication) | |
| fi | |
| if ! "${UPLOAD_CMD[@]}" 2>&1; then | |
| fatal "Failed to upload SSH key. Ensure your token has 'admin:public_key' or 'write:public_key' scope." | |
| fi | |
| log_info "SSH key uploaded successfully." | |
| } | |
| # Public helper: ensure GH SSH auth works for this user with some key | |
| ensure_github_ssh_for_user() { | |
| local gh_user=$1 | |
| local key_path="${2:-"$GH_KEY_PATH_DEFAULT"}" | |
| # 1. Try SSH agent | |
| if check_with_agent "$gh_user"; then | |
| return 0 | |
| fi | |
| # 2. Try specific keyfile | |
| if check_with_keyfile "$gh_user" "$key_path"; then | |
| return 0 | |
| fi | |
| log_info "No existing key source worked; attempting to create+upload key for $gh_user." | |
| if [[ -z ${BOOTSTRAP_SKIP_GH_SSH:-} ]]; then | |
| # 3. Ensure key exists | |
| ensure_keyfile_exists "$gh_user" "$key_path" || return 1 | |
| # 4. Upload key to GitHub via gh/nix | |
| upload_pubkey_with_gh "$gh_user" "$key_path" || return 1 | |
| # 5. Final verification with the new key | |
| if check_with_keyfile "$gh_user" "$key_path"; then | |
| log_info "Successfully established SSH access for $gh_user using $key_path." | |
| return 0 | |
| fi | |
| fatal "Uploaded key but SSH verification still failed." | |
| elif gh_get_auth_login; then | |
| log_info "Successfully connected to GH using an API token" | |
| fi | |
| fatal "Failed to connect to GH using either ssh or api token" | |
| } | |
| # Try to upload an existing SSH key to GitHub via gh CLI (non-fatal on failure) | |
| # Used in NixOS Phase 0 where we generate the key first, then optionally upload | |
| try_upload_ssh_key_via_gh() { | |
| local gh_user=$1 | |
| local key_path=$2 | |
| # Check if gh is available | |
| if ! command -v gh &>/dev/null; then | |
| log_info "gh not available, skipping key upload" | |
| return 1 | |
| fi | |
| # Check if authenticated | |
| if ! gh auth status &>/dev/null; then | |
| log_info "gh not authenticated, skipping key upload" | |
| return 1 | |
| fi | |
| # Verify correct user | |
| local login | |
| login=$(gh api user --jq '.login' 2>/dev/null || echo "") | |
| if [[ $login != "$gh_user" ]]; then | |
| log_info "gh authenticated as '$login', not '$gh_user', skipping key upload" | |
| return 1 | |
| fi | |
| # Check public key exists | |
| local pub="${key_path}.pub" | |
| if [[ ! -f $pub ]]; then | |
| log_warn "Public key $pub not found, skipping upload" | |
| return 1 | |
| fi | |
| # Upload key (deploy key for AI hosts, user SSH key otherwise) | |
| local title="id_ed25519_${BOOTSTRAP_TARGET_HOSTNAME:-$(hostname)}" | |
| log_info "Uploading SSH key to GitHub..." | |
| if is_ai_host; then | |
| log_info "AI host: uploading as deploy key to jacobbrugh/dotfiles..." | |
| if gh repo deploy-key add "$pub" -R jacobbrugh/dotfiles -w -t "$title" 2>&1; then | |
| log_info "Deploy key uploaded successfully" | |
| return 0 | |
| else | |
| log_warn "Failed to upload deploy key (may already exist)" | |
| return 0 | |
| fi | |
| else | |
| if gh ssh-key add "$pub" --title "$title" --type authentication 2>&1; then | |
| log_info "SSH key uploaded successfully" | |
| return 0 | |
| else | |
| log_warn "Failed to upload SSH key (may already exist)" | |
| return 0 | |
| fi | |
| fi | |
| } | |
| ######################################## | |
| # Darwin: Homebrew helpers | |
| ######################################## | |
| check_homebrew_installed() { | |
| if command -v brew &>/dev/null; then | |
| return 0 | |
| fi | |
| # Check common install locations even if not in PATH | |
| if [[ -x /opt/homebrew/bin/brew ]] || [[ -x /usr/local/bin/brew ]]; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| source_homebrew() { | |
| # Add Homebrew to PATH for current session | |
| if [[ -x /opt/homebrew/bin/brew ]]; then | |
| eval "$(/opt/homebrew/bin/brew shellenv)" | |
| elif [[ -x /usr/local/bin/brew ]]; then | |
| eval "$(/usr/local/bin/brew shellenv)" | |
| fi | |
| } | |
| install_homebrew() { | |
| log_info "Installing Homebrew..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would install Homebrew" | |
| return 0 | |
| fi | |
| /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" | |
| source_homebrew | |
| } | |
| boot_homebrew_validate() { | |
| if check_homebrew_installed; then | |
| log_info "Homebrew is installed" | |
| source_homebrew | |
| return 0 | |
| else | |
| log_warn "Homebrew is not installed" | |
| return 1 | |
| fi | |
| } | |
| boot_homebrew_default() { | |
| if boot_homebrew_validate; then | |
| return 0 | |
| fi | |
| install_homebrew | |
| if ! boot_homebrew_validate; then | |
| fatal "Homebrew installation failed" | |
| fi | |
| } | |
| boot_homebrew() { | |
| local subcmd="${1:-default}" | |
| case "$subcmd" in | |
| validate) | |
| boot_homebrew_validate | |
| ;; | |
| "" | default) | |
| boot_homebrew_default | |
| ;; | |
| *) | |
| fatal "Unknown homebrew subcommand: $subcmd" | |
| ;; | |
| esac | |
| } | |
| ######################################## | |
| # Darwin: 1Password helpers | |
| ######################################## | |
| check_1password_app_installed() { | |
| # Check if 1Password.app is installed | |
| if [[ -d "/Applications/1Password.app" ]]; then | |
| return 0 | |
| fi | |
| # Also check user's Applications folder | |
| if [[ -d "$HOME/Applications/1Password.app" ]]; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| check_1password_cli_installed() { | |
| command -v op &>/dev/null | |
| } | |
| install_1password_app() { | |
| log_info "Installing 1Password app via Homebrew..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would run: brew install --cask 1password" | |
| return 0 | |
| fi | |
| brew install --cask 1password | |
| } | |
| install_1password_cli() { | |
| log_info "Installing 1Password CLI via Homebrew..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would run: brew install 1password-cli" | |
| return 0 | |
| fi | |
| brew install 1password-cli | |
| } | |
| launch_1password_app() { | |
| log_info "Launching 1Password app..." | |
| open -a "1Password" | |
| } | |
| wait_for_1password_auth() { | |
| # Wait for 1Password CLI to be authenticated via app integration | |
| log_info "Waiting for 1Password CLI authentication..." | |
| cat <<'EOF' | |
| ------------------------------------------------------------------ | |
| 1Password Setup Required: | |
| 1) Sign in to the 1Password app if you haven't already | |
| 2) Enable CLI integration: | |
| Settings → Developer → "Integrate with 1Password CLI" | |
| 3) You may see a Touch ID / password prompt when ready | |
| The script will continue automatically once CLI auth succeeds. | |
| ------------------------------------------------------------------ | |
| EOF | |
| local max_attempts=60 # 2 minutes max | |
| local attempt=0 | |
| while [[ $attempt -lt $max_attempts ]]; do | |
| if op account list &>/dev/null; then | |
| log_info "1Password CLI is authenticated via app integration." | |
| return 0 | |
| fi | |
| ((attempt++)) | |
| if [[ $((attempt % 10)) -eq 0 ]]; then | |
| log_info "Still waiting for 1Password... (${attempt}s elapsed)" | |
| fi | |
| sleep 2 | |
| done | |
| fatal "Timed out waiting for 1Password authentication. Please ensure the app is unlocked and CLI integration is enabled." | |
| } | |
| get_github_token_from_1password() { | |
| log_info "Retrieving GitHub token from 1Password..." | |
| local token | |
| if ! token=$(op read "op://${OP_VAULT}/${OP_GITHUB_ITEM}/${OP_GITHUB_FIELD}" 2>/dev/null); then | |
| fatal "Failed to read GitHub token from 1Password. Check that the item exists: ${OP_VAULT}/${OP_GITHUB_ITEM}" | |
| fi | |
| if [[ -z $token ]]; then | |
| fatal "GitHub token from 1Password is empty" | |
| fi | |
| log_info "Retrieved GitHub token from 1Password (length=${#token})" | |
| echo "$token" | |
| } | |
| boot_1password_validate() { | |
| local ok=0 | |
| if check_1password_app_installed; then | |
| log_info "1Password app is installed" | |
| else | |
| log_warn "1Password app is not installed" | |
| ok=1 | |
| fi | |
| if check_1password_cli_installed; then | |
| log_info "1Password CLI is installed" | |
| else | |
| log_warn "1Password CLI is not installed" | |
| ok=1 | |
| fi | |
| if [[ $ok -eq 0 ]] && op account list &>/dev/null; then | |
| log_info "1Password CLI is authenticated" | |
| else | |
| log_warn "1Password CLI is not authenticated" | |
| ok=1 | |
| fi | |
| return "$ok" | |
| } | |
| boot_1password_default() { | |
| # Ensure Homebrew is available first | |
| if ! command -v brew &>/dev/null; then | |
| boot_homebrew_default || return 1 | |
| fi | |
| # Install 1Password app if needed | |
| if ! check_1password_app_installed; then | |
| install_1password_app || return 1 | |
| else | |
| log_info "1Password app already installed" | |
| fi | |
| # Install 1Password CLI if needed | |
| if ! check_1password_cli_installed; then | |
| install_1password_cli || return 1 | |
| else | |
| log_info "1Password CLI already installed" | |
| fi | |
| # Launch app and wait for authentication | |
| launch_1password_app | |
| wait_for_1password_auth || return 1 | |
| # Export GITHUB_TOKEN for subsequent steps | |
| GITHUB_TOKEN=$(get_github_token_from_1password) | |
| export GITHUB_TOKEN | |
| log_info "GITHUB_TOKEN exported for this session" | |
| } | |
| boot_1password() { | |
| local subcmd="${1:-default}" | |
| case "$subcmd" in | |
| validate) | |
| boot_1password_validate | |
| ;; | |
| "" | default) | |
| boot_1password_default | |
| ;; | |
| *) | |
| fatal "Unknown 1password subcommand: $subcmd" | |
| ;; | |
| esac | |
| } | |
| ######################################## | |
| # Linux: Zsh installation | |
| ######################################## | |
| ZSH_BIN_INSTALLER_URL="https://raw.githubusercontent.com/romkatv/zsh-bin/master/install" | |
| check_zsh_installed() { | |
| command -v zsh &>/dev/null | |
| } | |
| install_zsh() { | |
| log_info "Installing zsh via romkatv/zsh-bin..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would run: sh -c \"\$(curl -fsSL $ZSH_BIN_INSTALLER_URL)\" -- -d /usr/local -e no -q" | |
| return 0 | |
| fi | |
| # -d /usr/local: install to /usr/local | |
| # -e no: don't modify shell configs | |
| # -q: quiet mode | |
| sh -c "$(curl -fsSL "$ZSH_BIN_INSTALLER_URL")" -- -d /usr/local -e no -q | |
| } | |
| boot_zsh_validate() { | |
| if check_zsh_installed; then | |
| log_info "zsh is installed: $(command -v zsh)" | |
| return 0 | |
| else | |
| log_warn "zsh is not installed" | |
| return 1 | |
| fi | |
| } | |
| boot_zsh_default() { | |
| if boot_zsh_validate; then | |
| return 0 | |
| fi | |
| install_zsh | |
| if ! boot_zsh_validate; then | |
| fatal "zsh installation failed" | |
| fi | |
| } | |
| boot_zsh() { | |
| local subcmd="${1:-default}" | |
| case "$subcmd" in | |
| validate) | |
| boot_zsh_validate | |
| ;; | |
| "" | default) | |
| boot_zsh_default | |
| ;; | |
| *) | |
| fatal "Unknown zsh subcommand: $subcmd" | |
| ;; | |
| esac | |
| } | |
| ######################################## | |
| # NixOS detection and harvesting | |
| ######################################## | |
| is_nixos() { | |
| [[ -f /etc/os-release ]] && grep -q 'ID=nixos' /etc/os-release | |
| } | |
| is_wsl() { | |
| [[ -f /etc/wsl.conf ]] | |
| } | |
| is_lima() { | |
| # Lima VMs mount cidata ISO containing lima.env at boot | |
| [[ -f /mnt/lima-cidata/lima.env ]] | |
| } | |
| # Phase 0 completion stamp (persists across NixOS generations, unlike /etc/) | |
| PHASE0_STAMP="/var/lib/nixos-bootstrap/phase0" | |
| phase0_completed() { | |
| [[ -f $PHASE0_STAMP ]] | |
| } | |
| mark_phase0_done() { | |
| mkdir -p "$(dirname "$PHASE0_STAMP")" | |
| date -Iseconds >"$PHASE0_STAMP" | |
| } | |
| # Persisted bootstrap config (shared between Phase 0 and Phase 1) | |
| BOOTSTRAP_CONFIG="/var/lib/nixos-bootstrap/config" | |
| is_ai_host() { | |
| [[ ${BOOTSTRAP_TAG:-} =~ [Aa][Ii] ]] | |
| } | |
| resolve_nixos_bootstrap_params() { | |
| # Load any persisted config from Phase 0 | |
| if [[ -f $BOOTSTRAP_CONFIG ]]; then | |
| # shellcheck disable=SC1090 | |
| source "$BOOTSTRAP_CONFIG" | |
| fi | |
| # Resolve target hostname | |
| if [[ -z $BOOTSTRAP_TARGET_HOSTNAME ]]; then | |
| if [[ -t 0 ]]; then | |
| read -rp "Target hostname for this machine [$(hostname)]: " BOOTSTRAP_TARGET_HOSTNAME | |
| BOOTSTRAP_TARGET_HOSTNAME="${BOOTSTRAP_TARGET_HOSTNAME:-$(hostname)}" | |
| else | |
| BOOTSTRAP_TARGET_HOSTNAME="$(hostname)" | |
| fi | |
| fi | |
| # Resolve tag | |
| if [[ -z $BOOTSTRAP_TAG ]]; then | |
| if [[ -t 0 ]]; then | |
| log_info "Available tags: home-mac-lima, home-mac-lima-ai, home-remote-nixos, home-wsl-nixos, work-mac-lima, work-mac-lima-ai" | |
| read -rp "Host tag: " BOOTSTRAP_TAG | |
| else | |
| if is_lima; then | |
| BOOTSTRAP_TAG="home-mac-lima" | |
| elif is_wsl; then | |
| BOOTSTRAP_TAG="home-wsl-nixos" | |
| else | |
| BOOTSTRAP_TAG="home-remote-nixos" | |
| fi | |
| fi | |
| fi | |
| # Do NOT set the transient hostname here. NixOS manages hostname declaratively | |
| # via networking.hostName (set from the host registry in nix/hosts/). The | |
| # first nix-switch builds under the current hostname (e.g., "nixos"), and after | |
| # reboot the hostname becomes the target. Setting it transiently would cause | |
| # the kernel hostname to diverge from /etc/hostname, which chezmoi reads. | |
| # BOOTSTRAP_TARGET_HOSTNAME is used for SSH key naming and config only. | |
| # Persist for Phase 1 (only when running as root during Phase 0) | |
| if [[ "$(id -un)" == "root" ]]; then | |
| mkdir -p "$(dirname "$BOOTSTRAP_CONFIG")" | |
| cat >"$BOOTSTRAP_CONFIG" <<EOF | |
| BOOTSTRAP_TARGET_HOSTNAME=$BOOTSTRAP_TARGET_HOSTNAME | |
| BOOTSTRAP_TAG=$BOOTSTRAP_TAG | |
| EOF | |
| fi | |
| log_info "Bootstrap params: hostname=$BOOTSTRAP_TARGET_HOSTNAME tag=$BOOTSTRAP_TAG user=$BOOTSTRAP_USER" | |
| } | |
| boot_harvest_nixos_config() { | |
| log_info "Harvesting NixOS machine configuration..." | |
| local target_hostname="${BOOTSTRAP_TARGET_HOSTNAME:-$(hostname)}" | |
| local dest_dir="$HOME/.local/share/chezmoi/nix/hosts/$target_hostname" | |
| mkdir -p "$dest_dir" | |
| # 1. Harvest Hardware Config (commit to per-host directory) | |
| if [[ -f /etc/nixos/hardware-configuration.nix ]]; then | |
| if [[ -f "$dest_dir/hardware.nix" ]]; then | |
| log_warn "hardware.nix already exists for $target_hostname. Keeping existing file." | |
| else | |
| log_info "Copying hardware-configuration.nix -> nix/hosts/$target_hostname/hardware.nix" | |
| cp /etc/nixos/hardware-configuration.nix "$dest_dir/hardware.nix" | |
| chmod 644 "$dest_dir/hardware.nix" | |
| fi | |
| else | |
| # Fallback for containers/WSL where there's no hardware config | |
| log_warn "No hardware config found at /etc/nixos/hardware-configuration.nix" | |
| fi | |
| # 2. Harvest Networking Config (for cloud/VPS deployments like nixos-infect) | |
| if [[ -f /etc/nixos/networking.nix ]]; then | |
| if [[ -f "$dest_dir/networking.nix" ]]; then | |
| log_warn "networking.nix already exists for $target_hostname. Keeping existing file." | |
| else | |
| log_info "Copying networking.nix -> nix/hosts/$target_hostname/networking.nix" | |
| cp /etc/nixos/networking.nix "$dest_dir/networking.nix" | |
| chmod 644 "$dest_dir/networking.nix" | |
| fi | |
| fi | |
| log_info "NixOS configuration harvesting complete" | |
| } | |
| ######################################## | |
| # NixOS Bootstrap - Two Phase Approach | |
| ######################################## | |
| # | |
| # Phase 0 (root): Create jacob user with minimal system config | |
| # Phase 1 (jacob): Full chezmoi + home-manager bootstrap | |
| # | |
| # This separation is necessary because NixOS creates users declaratively, | |
| # so we can't run user-level bootstrap until the user exists. | |
| boot_nixos_phase0() { | |
| # Phase 0: Minimal system bootstrap | |
| # Run as root on a fresh NixOS system (or Lima VM) | |
| log_info "=== NixOS Phase 0: System Bootstrap ===" | |
| local TARGET_USER="jacob" | |
| # Step 1+2: Apply system configuration (platform-dependent) | |
| if is_lima; then | |
| # Lima VMs boot with a pre-configured nixos-lima image that already has | |
| # the correct hardware/boot/FS config. Running nixos-rebuild with the | |
| # bootstrap flake would clobber the Lima-specific config, so we skip it. | |
| log_info "=== Steps 1-2/4 - Lima VM detected, skipping nixos-rebuild ===" | |
| log_info "Using pre-built nixos-lima image configuration." | |
| # Ensure git and gh are available for dotfiles clone and key upload | |
| if ! command -v git &>/dev/null; then | |
| log_info "Installing git via nix profile..." | |
| nix --extra-experimental-features "nix-command flakes" profile install "nixpkgs#git" | |
| fi | |
| if ! command -v gh &>/dev/null; then | |
| log_info "Installing gh via nix profile..." | |
| nix --extra-experimental-features "nix-command flakes" profile install "nixpkgs#gh" | |
| fi | |
| else | |
| # Standard NixOS / WSL: download and apply bootstrap flake | |
| local FLAKE_DIR="/tmp/nixos-bootstrap" | |
| log_info "=== Step 1/4 - Download bootstrap flake from gist ===" | |
| rm -rf "$FLAKE_DIR" | |
| mkdir -p "$FLAKE_DIR" | |
| log_info "Downloading flake.nix..." | |
| curl -fsSL "$BOOTSTRAP_GIST_RAW/bootstrap-flake.nix" -o "$FLAKE_DIR/flake.nix" | |
| log_info "Downloading flake.lock..." | |
| curl -fsSL "$BOOTSTRAP_GIST_RAW/bootstrap-flake.lock" -o "$FLAKE_DIR/flake.lock" | |
| # Harvest hardware config | |
| if [[ -f /etc/nixos/hardware-configuration.nix ]]; then | |
| log_info "Copying hardware-configuration.nix..." | |
| cp /etc/nixos/hardware-configuration.nix "$FLAKE_DIR/host-hardware.nix" | |
| else | |
| log_info "Creating empty host-hardware.nix placeholder..." | |
| echo "{ ... }: { }" >"$FLAKE_DIR/host-hardware.nix" | |
| fi | |
| # Harvest networking config (for cloud VPS like Hetzner) | |
| if [[ -f /etc/nixos/networking.nix ]]; then | |
| log_info "Copying networking.nix..." | |
| cp /etc/nixos/networking.nix "$FLAKE_DIR/host-networking.nix" | |
| fi | |
| # Step 2: Run nixos-rebuild (installs packages, creates user) | |
| log_info "=== Step 2/4 - Apply minimal NixOS configuration ===" | |
| log_info "This will create user '$TARGET_USER' and configure basic system..." | |
| local target | |
| is_wsl && target="wsl-bootstrap" || target="bootstrap" | |
| nixos-rebuild switch --flake "path:$FLAKE_DIR#$target" | |
| fi | |
| # Verify user exists (created by flake or pre-existing on Lima) | |
| if ! id "$TARGET_USER" &>/dev/null; then | |
| fatal "User $TARGET_USER does not exist. Check the NixOS configuration." | |
| fi | |
| # Step 3: Generate SSH key for target user (always) and optionally upload via gh | |
| log_info "=== Step 3/4 - Set up GitHub SSH access ===" | |
| local target_home | |
| target_home=$(getent passwd "$TARGET_USER" | cut -d: -f6) | |
| local target_ssh_dir="$target_home/.ssh" | |
| local target_key | |
| target_key="$target_ssh_dir/id_ed25519_$BOOTSTRAP_TARGET_HOSTNAME" | |
| # Always generate key for target user | |
| mkdir -p "$target_ssh_dir" | |
| if [[ ! -f $target_key ]]; then | |
| log_info "Generating SSH key for $TARGET_USER at $target_key..." | |
| ssh-keygen -t ed25519 -C "${GH_USER}@${BOOTSTRAP_TARGET_HOSTNAME}" -f "$target_key" -N "" | |
| fi | |
| # Write SSH config so all tools (git, chezmoi, etc.) use the correct key | |
| local ssh_config="$target_ssh_dir/config" | |
| if [[ ! -f $ssh_config ]]; then | |
| cat >"$ssh_config" <<SSHCFG | |
| Host github.com | |
| IdentityFile $target_key | |
| IdentitiesOnly yes | |
| StrictHostKeyChecking accept-new | |
| SSHCFG | |
| chmod 600 "$ssh_config" | |
| fi | |
| chown -R "$TARGET_USER:users" "$target_ssh_dir" | |
| chmod 700 "$target_ssh_dir" | |
| chmod 600 "$target_key" | |
| # Try to upload key via gh if authenticated (non-fatal if not) | |
| local key_uploaded=false | |
| if try_upload_ssh_key_via_gh "$GH_USER" "$target_key"; then | |
| log_info "SSH key uploaded to GitHub via gh" | |
| key_uploaded=true | |
| else | |
| log_info "gh auth not available, will rely on SSH agent for clone" | |
| fi | |
| # Step 4: Clone dotfiles to target user's chezmoi dir | |
| log_info "=== Step 4/4 - Clone dotfiles for user ===" | |
| local target_chezmoi_dir="$target_home/.local/share/chezmoi" | |
| mkdir -p "$(dirname "$target_chezmoi_dir")" | |
| # Build GIT_SSH_COMMAND: use the generated key only if it was uploaded to GitHub. | |
| # Otherwise, let SSH use the forwarded agent (Lima trusted hosts) or default keys. | |
| local clone_ssh_cmd="ssh -o StrictHostKeyChecking=accept-new" | |
| if [[ $key_uploaded == "true" ]] && [[ -f $target_key ]]; then | |
| clone_ssh_cmd="$clone_ssh_cmd -i $target_key" | |
| fi | |
| if [[ -d "$target_chezmoi_dir/.git" ]]; then | |
| log_info "Dotfiles already cloned at $target_chezmoi_dir, skipping." | |
| else | |
| log_info "Cloning dotfiles to $target_chezmoi_dir..." | |
| if [[ $key_uploaded == "true" ]]; then | |
| # Key is on GitHub — clone directly as root using the uploaded key | |
| GIT_SSH_COMMAND="$clone_ssh_cmd" git clone "$CHEZMOI_REPO" "$target_chezmoi_dir" | |
| elif is_lima; then | |
| # Lima trusted host: clone as target user who has the forwarded SSH agent | |
| log_info "Using target user's SSH agent for clone..." | |
| sudo -u "$TARGET_USER" GIT_SSH_COMMAND="$clone_ssh_cmd" git clone "$CHEZMOI_REPO" "$target_chezmoi_dir" | |
| else | |
| # Fallback: try cloning as root (may work with root's SSH agent or keys) | |
| GIT_SSH_COMMAND="$clone_ssh_cmd" git clone "$CHEZMOI_REPO" "$target_chezmoi_dir" | |
| fi | |
| fi | |
| chown -R "$TARGET_USER:users" "$target_home/.local" | |
| # AI hosts: revoke GitHub API credentials after dotfiles are cloned | |
| # Deploy key persists on GitHub (logout only removes local credential store) | |
| if is_ai_host; then | |
| log_info "AI host: revoking GitHub API credentials..." | |
| if command -v gh &>/dev/null; then | |
| gh auth logout --hostname github.com 2>/dev/null || true | |
| fi | |
| unset GITHUB_TOKEN | |
| rm -rf "/root/.config/gh" "$target_home/.config/gh" 2>/dev/null || true | |
| log_info "GitHub credentials cleaned. SSH deploy key remains for dotfiles repo access." | |
| fi | |
| # Mark Phase 0 as completed | |
| mark_phase0_done | |
| log_info "" | |
| log_info "============================================" | |
| log_info " Phase 0 complete! User '$TARGET_USER' ready." | |
| log_info "============================================" | |
| log_info "" | |
| log_info "Next step: run bootstrap as $TARGET_USER:" | |
| log_info "" | |
| log_info " ssh $TARGET_USER@$(hostname)" | |
| log_info " curl -fsSL $BOOTSTRAP_GIST_RAW/bootstrap.sh | bash" | |
| log_info "" | |
| } | |
| boot_nixos_phase1() { | |
| # Phase 1: User-level bootstrap (chezmoi + home-manager) | |
| # Run as jacob after Phase 0 has created the user | |
| log_info "=== NixOS Phase 1: User Bootstrap ===" | |
| # Step 1: Ensure GitHub SSH access | |
| log_info "=== Step 1/3 - Ensure GitHub SSH access ===" | |
| if ! boot_gh_validate; then | |
| boot_gh_default || return 1 | |
| fi | |
| # Step 2: Initialize chezmoi and apply dotfiles | |
| log_info "=== Step 2/3 - Initialize chezmoi ===" | |
| # Pre-create chezmoi config to avoid interactive prompts | |
| mkdir -p ~/.config/chezmoi | |
| if [[ -n ${BOOTSTRAP_TAG:-} ]]; then | |
| hostTag="$BOOTSTRAP_TAG" | |
| elif is_wsl; then | |
| hostTag="home-wsl-nixos" | |
| elif is_lima; then | |
| hostTag="home-mac-lima" | |
| else | |
| hostTag="home-remote-nixos" | |
| fi | |
| cat >~/.config/chezmoi/chezmoi.toml <<CHEZMOICFG | |
| [data] | |
| email = "jacobpbrugh@gmail.com" | |
| hostTag = "$hostTag" | |
| workGitOriginHost = "" | |
| openAiApiKey = "" | |
| openAiBaseUrl = "" | |
| jiraApiToken = "" | |
| sshTunnelPort = "" | |
| simpleBarServerPort = "7777" | |
| jacob_py = "/usr/bin/env python3" | |
| CHEZMOICFG | |
| pick_chezmoi_cmd | |
| if [[ -d ~/.local/share/chezmoi/.git ]]; then | |
| echo 'Chezmoi source already exists, updating...' | |
| "${CHEZMOI_CMD[@]}" update --no-tty | |
| else | |
| echo 'Initializing chezmoi...' | |
| "${CHEZMOI_CMD[@]}" init "$CHEZMOI_REPO" | |
| "${CHEZMOI_CMD[@]}" apply --no-tty --exclude=scripts | |
| fi | |
| # Step 3: Run full NixOS rebuild with home-manager | |
| log_info "=== Step 3/3 - Apply full NixOS + home-manager configuration ===" | |
| local switch_cmd="$HOME/.local/bin/nix-switch" | |
| if [[ -x $switch_cmd ]]; then | |
| # Harvest any missing hardware/networking config | |
| boot_harvest_nixos_config | |
| # Ensure flake.lock exists in the repo root | |
| local flake_dir="$HOME/.local/share/chezmoi" | |
| if [[ ! -f "$flake_dir/flake.lock" ]]; then | |
| log_info "Creating initial flake.lock..." | |
| nix --extra-experimental-features 'nix-command flakes' flake lock "path:$flake_dir" | |
| fi | |
| "$switch_cmd" | |
| else | |
| log_warn "nix-switch script not found. Running chezmoi apply to generate it..." | |
| "${CHEZMOI_CMD[@]}" apply --no-tty | |
| if [[ -x $switch_cmd ]]; then | |
| boot_harvest_nixos_config | |
| "$switch_cmd" | |
| else | |
| fatal "nix-switch still not found after chezmoi apply" | |
| fi | |
| fi | |
| log_info "=== NixOS bootstrap completed successfully ===" | |
| if [[ $EXEC_SHELL == true ]]; then | |
| log_info "Launching $SHELL_CMD (login shell)..." | |
| exec "$SHELL_CMD" -l | |
| fi | |
| } | |
| boot_main_nixos() { | |
| log_info "=== NixOS Bootstrap ===" | |
| local TARGET_USER="jacob" | |
| if is_wsl; then | |
| GIT_SSH_COMMAND="ssh.exe -o StrictHostKeyChecking=accept-new" | |
| fi | |
| # Resolve hostname, tag, and user before any phase runs | |
| resolve_nixos_bootstrap_params | |
| # Determine which phase to run based on current user | |
| if [[ "$(id -un)" == "root" ]]; then | |
| # Running as root - check if Phase 0 already completed | |
| if phase0_completed; then | |
| log_warn "Phase 0 already completed (stamp: $PHASE0_STAMP)." | |
| log_warn "To re-run Phase 0, remove the stamp: rm $PHASE0_STAMP" | |
| log_info "" | |
| log_info "To continue with Phase 1, run bootstrap as $TARGET_USER:" | |
| log_info " ssh $TARGET_USER@$(hostname)" | |
| log_info " curl -fsSL $BOOTSTRAP_GIST_RAW/bootstrap.sh | bash" | |
| return 0 | |
| fi | |
| # Phase 0: System bootstrap | |
| boot_nixos_phase0 | |
| else | |
| # Running as non-root user | |
| if [[ "$(id -un)" != "$TARGET_USER" ]]; then | |
| fatal "NixOS bootstrap must run as root (Phase 0) or as $TARGET_USER (Phase 1)" | |
| fi | |
| # Phase 1: User bootstrap | |
| boot_nixos_phase1 | |
| fi | |
| } | |
| ######################################## | |
| # Component-level validators | |
| ######################################## | |
| boot_nix_validate() { | |
| if check_nix_installed; then | |
| return 0 | |
| else | |
| return 1 | |
| fi | |
| } | |
| boot_gh_validate() { | |
| if ! command -v git >/dev/null 2>&1; then | |
| fatal "git is required to validate GitHub repo access." | |
| fi | |
| log_info "Validating SSH access to private repo: $GH_REPO_SSH" | |
| # Use StrictHostKeyChecking=accept-new to auto-accept new host keys (headless) | |
| if GIT_SSH_COMMAND=$GIT_SSH_COMMAND git ls-remote "$GH_REPO_SSH" &>/dev/null; then | |
| log_info "Successfully accessed $GH_REPO_SSH via SSH." | |
| return 0 | |
| else | |
| log_warn "Cannot access $GH_REPO_SSH via SSH." | |
| return 1 | |
| fi | |
| } | |
| boot_chezmoi_validate() { | |
| log_info "Validating chezmoi initialization..." | |
| # Check if source directory exists and is a git repo | |
| if [[ -d "$CHEZMOI_SOURCE_DIR/.git" ]]; then | |
| log_info "Chezmoi source directory exists at $CHEZMOI_SOURCE_DIR" | |
| # Verify the remote matches expected repo | |
| local remote | |
| remote=$(git -C "$CHEZMOI_SOURCE_DIR" remote get-url origin 2>/dev/null || echo "") | |
| if [[ -n $remote ]]; then | |
| log_info "Chezmoi repo remote: $remote" | |
| fi | |
| return 0 | |
| else | |
| log_warn "Chezmoi source directory not found or not initialized" | |
| return 1 | |
| fi | |
| } | |
| ######################################## | |
| # High-level commands | |
| ######################################## | |
| # default command: full bootstrap | |
| boot_main() { | |
| local os_type | |
| os_type=$(detect_os) | |
| # For bootstrap, we want non-interactive Nix install | |
| YES_TO_ALL=true | |
| if [[ $os_type == "macos" ]]; then | |
| boot_main_darwin | |
| else | |
| boot_main_linux | |
| fi | |
| } | |
| boot_main_darwin() { | |
| log_info "=== Darwin Bootstrap ===" | |
| log_info "=== Step 1/5 - Ensure Homebrew installed ===" | |
| boot_homebrew_default || return 1 | |
| log_info "=== Step 2/5 - Setup 1Password and get GitHub token ===" | |
| boot_1password_default || return 1 | |
| log_info "=== Step 3/5 - Ensure Nix installed ===" | |
| boot_nix_default || return 1 | |
| log_info "=== Step 4/5 - Ensure GitHub SSH access ===" | |
| boot_gh_default || return 1 | |
| log_info "=== Step 5/5 - Initialize chezmoi and apply dotfiles ===" | |
| boot_chezmoi_default || return 1 | |
| log_info "=== Darwin bootstrap completed successfully ===" | |
| if [[ $EXEC_SHELL == true ]]; then | |
| log_info "Launching $SHELL_CMD (login shell)..." | |
| exec "$SHELL_CMD" -l | |
| fi | |
| } | |
| boot_main_linux() { | |
| log_info "=== Linux Bootstrap ===" | |
| # Fork to NixOS-specific bootstrap if running on NixOS | |
| if is_nixos; then | |
| boot_main_nixos | |
| return | |
| fi | |
| # Standard Linux bootstrap (non-NixOS) | |
| log_info "=== Step 1/4 - Ensure Nix installed ===" | |
| boot_nix_default || return 1 | |
| log_info "=== Step 2/4 - Ensure GitHub SSH access ===" | |
| boot_gh_default || return 1 | |
| log_info "=== Step 3/4 - Initialize chezmoi and apply dotfiles ===" | |
| boot_chezmoi_default || return 1 | |
| log_info "=== Step 4/4 - Ensure zsh installed ===" | |
| boot_zsh_default || return 1 | |
| log_info "=== Linux bootstrap completed successfully ===" | |
| if [[ $EXEC_SHELL == true ]]; then | |
| log_info "Launching $SHELL_CMD (login shell)..." | |
| exec "$SHELL_CMD" -l | |
| fi | |
| } | |
| boot_validate() { | |
| local os_type | |
| os_type=$(detect_os) | |
| local ok=0 | |
| # Darwin-specific validations | |
| if [[ $os_type == "macos" ]]; then | |
| log_info "=== Validating Homebrew ===" | |
| if ! boot_homebrew_validate; then | |
| ok=1 | |
| fi | |
| log_info "=== Validating 1Password ===" | |
| if ! boot_1password_validate; then | |
| ok=1 | |
| fi | |
| fi | |
| # NixOS skips Nix installation validation (it's built-in) | |
| if [[ $os_type == "linux" ]] && is_nixos; then | |
| log_info "=== NixOS detected, skipping Nix installation validation ===" | |
| log_info "=== Validating NixOS hardware config ===" | |
| local hw_path="$HOME/.local/share/chezmoi/nix/hosts/$(hostname)/hardware.nix" | |
| if [[ -f $hw_path ]]; then | |
| log_info "hardware.nix exists for $(hostname)" | |
| else | |
| log_warn "hardware.nix not found for $(hostname) (will be created on first bootstrap)" | |
| fi | |
| else | |
| log_info "=== Validating Nix ===" | |
| if ! boot_nix_validate; then | |
| ok=1 | |
| fi | |
| fi | |
| log_info "=== Validating GitHub SSH access ===" | |
| if ! boot_gh_validate; then | |
| ok=1 | |
| fi | |
| log_info "=== Validating chezmoi ===" | |
| if ! boot_chezmoi_validate; then | |
| ok=1 | |
| fi | |
| # Linux-specific validations (non-NixOS only) | |
| if [[ $os_type == "linux" ]] && ! is_nixos; then | |
| log_info "=== Validating zsh ===" | |
| if ! boot_zsh_validate; then | |
| ok=1 | |
| fi | |
| fi | |
| if [[ $ok -eq 0 ]]; then | |
| log_info "All validations passed" | |
| else | |
| log_warn "Some validations failed" | |
| fi | |
| return "$ok" | |
| } | |
| boot_nix_default() { | |
| if boot_nix_validate; then | |
| log_info "Nix is already installed; skipping install." | |
| return 0 | |
| fi | |
| local os_type | |
| os_type=$(detect_os) | |
| validate_install_type_for_os "$os_type" | |
| log_info "Nix is not installed. Proceeding with installation." | |
| install_nix | |
| if [[ ${#NIX_CONF_OPTIONS[@]} -gt 0 ]] || [[ -n $EXTRA_NIX_CONFIG ]]; then | |
| configure_nix_conf | |
| fi | |
| source_nix || true | |
| verify_installation | |
| } | |
| boot_nix() { | |
| local subcmd="${1:-default}" | |
| case "$subcmd" in | |
| validate) | |
| boot_nix_validate | |
| ;; | |
| "" | default) | |
| boot_nix_default | |
| ;; | |
| *) | |
| fatal "Unknown nix subcommand: $subcmd" | |
| ;; | |
| esac | |
| } | |
| boot_gh_default() { | |
| # First, see if repo is already readable with existing SSH setup | |
| if boot_gh_validate; then | |
| log_info "GitHub private repo already readable; no changes needed." | |
| return 0 | |
| fi | |
| log_info "Private repo not readable; ensuring SSH key for $GH_USER and uploading via gh." | |
| ensure_github_ssh_for_user "$GH_USER" "$GH_KEY_PATH_DEFAULT" || return 1 | |
| # Re-validate repo after uploading key | |
| if ! boot_gh_validate; then | |
| fatal "GitHub SSH setup complete but repo $GH_REPO_SSH is still not readable." | |
| fi | |
| } | |
| boot_gh() { | |
| local subcmd="${1:-default}" | |
| case "$subcmd" in | |
| validate) | |
| boot_gh_validate | |
| ;; | |
| "" | default) | |
| boot_gh_default | |
| ;; | |
| *) | |
| fatal "Unknown gh subcommand: $subcmd" | |
| ;; | |
| esac | |
| } | |
| pick_chezmoi_cmd() { | |
| if command -v chezmoi >/dev/null 2>&1; then | |
| CHEZMOI_CMD=(chezmoi) | |
| elif command -v nix >/dev/null 2>&1; then | |
| # Fresh nix installs don't have experimental features enabled by default | |
| CHEZMOI_CMD=(nix --extra-experimental-features "nix-command flakes" run "nixpkgs#chezmoi" --) | |
| else | |
| fatal "Neither 'chezmoi' nor 'nix' found on PATH" | |
| fi | |
| } | |
| boot_chezmoi_default() { | |
| # If already initialized and chezmoi is available, we're done | |
| if boot_chezmoi_validate && command -v chezmoi &>/dev/null; then | |
| log_info "Chezmoi already initialized and available." | |
| return 0 | |
| fi | |
| log_info "Installing chezmoi and initializing from $CHEZMOI_REPO..." | |
| if [[ $DRY_RUN == true ]]; then | |
| log_info "[DRY RUN] Would run: sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- init --apply $CHEZMOI_REPO" | |
| return 0 | |
| fi | |
| # The chezmoi installer handles: | |
| # - Installing chezmoi to ~/.local/bin if not present | |
| # - Running chezmoi init --apply with the provided repo | |
| GIT_SSH_COMMAND=$GIT_SSH_COMMAND sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin" init --apply "$CHEZMOI_REPO" | |
| if boot_chezmoi_validate; then | |
| log_info "Chezmoi initialization completed successfully" | |
| return 0 | |
| else | |
| fatal "Chezmoi initialization failed" | |
| fi | |
| } | |
| boot_chezmoi() { | |
| local subcmd="${1:-default}" | |
| case "$subcmd" in | |
| validate) | |
| boot_chezmoi_validate | |
| ;; | |
| "" | default) | |
| boot_chezmoi_default | |
| ;; | |
| *) | |
| fatal "Unknown chezmoi subcommand: $subcmd" | |
| ;; | |
| esac | |
| } | |
| ######################################## | |
| # CLI dispatcher (script vs sourced) | |
| ######################################## | |
| parse_args_and_dispatch() { | |
| local cmd="" # boot / validate / nix / gh / chezmoi | |
| local -a cmd_args=() | |
| # Parse global options until first non-option (command) | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --daemon) | |
| INSTALL_TYPE="daemon" | |
| shift | |
| ;; | |
| --no-daemon) | |
| INSTALL_TYPE="no-daemon" | |
| shift | |
| ;; | |
| --yes) | |
| YES_TO_ALL=true | |
| shift | |
| ;; | |
| --nix-conf) | |
| if [[ -z ${2:-} ]]; then | |
| fatal "--nix-conf requires a KEY=VALUE argument" | |
| fi | |
| NIX_CONF_OPTIONS+=("$2") | |
| shift 2 | |
| ;; | |
| --extra-conf) | |
| if [[ -z ${2:-} ]]; then | |
| fatal "--extra-conf requires a STRING argument" | |
| fi | |
| EXTRA_NIX_CONFIG="$2" | |
| shift 2 | |
| ;; | |
| --dry-run) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| --no-exec-shell) | |
| EXEC_SHELL=false | |
| shift | |
| ;; | |
| --shell) | |
| if [[ -z ${2:-} ]]; then | |
| fatal "--shell requires a COMMAND argument" | |
| fi | |
| SHELL_CMD="$2" | |
| shift 2 | |
| ;; | |
| --help) | |
| usage | |
| if [[ $BOOTSTRAP_IN_SCRIPT_MODE -eq 1 ]]; then | |
| exit 0 | |
| else | |
| return 0 | |
| fi | |
| ;; | |
| --bootstrap) | |
| # legacy alias: treat --bootstrap as "boot" | |
| cmd="boot" | |
| shift | |
| cmd_args=("$@") | |
| break | |
| ;; | |
| -*) | |
| fatal "Unknown option: $1" | |
| ;; | |
| *) | |
| # First non-option is the command | |
| cmd="$1" | |
| shift | |
| cmd_args=("$@") | |
| break | |
| ;; | |
| esac | |
| done | |
| # Default command if none specified | |
| if [[ -z $cmd ]]; then | |
| cmd="boot" | |
| fi | |
| # Note: ${arr[@]+"${arr[@]}"} is a portable way to expand potentially empty arrays | |
| # Older bash versions (< 4.4) treat "${arr[@]}" as unbound with set -u | |
| case "$cmd" in | |
| boot) | |
| boot_main ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| validate) | |
| boot_validate ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| nix) | |
| boot_nix ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| gh) | |
| boot_gh ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| chezmoi) | |
| boot_chezmoi ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| homebrew) | |
| if [[ "$(detect_os)" != "macos" ]]; then | |
| fatal "homebrew command is only available on macOS" | |
| fi | |
| boot_homebrew ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| 1password) | |
| if [[ "$(detect_os)" != "macos" ]]; then | |
| fatal "1password command is only available on macOS" | |
| fi | |
| boot_1password ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| zsh) | |
| if [[ "$(detect_os)" != "linux" ]]; then | |
| fatal "zsh command is only available on Linux" | |
| fi | |
| boot_zsh ${cmd_args[@]+"${cmd_args[@]}"} | |
| ;; | |
| *) | |
| fatal "Unknown command: $cmd" | |
| ;; | |
| esac | |
| } | |
| # Dispatcher: | |
| # - Executed directly: behave like a normal CLI script | |
| # - Sourced with args: run flows in current shell | |
| # - Sourced with no args: define functions only | |
| if [[ $BOOTSTRAP_IN_SCRIPT_MODE -eq 1 ]]; then | |
| # Script mode: enable strict options and exit on failure | |
| set -euo pipefail | |
| parse_args_and_dispatch "$@" | |
| else | |
| # Sourced: if user passed args to `source`, treat them like CLI args, | |
| # but do NOT set -euo pipefail globally. | |
| if [[ $# -gt 0 ]]; then | |
| parse_args_and_dispatch "$@" | |
| fi | |
| fi |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Version 5.1 | |
| <# | |
| .SYNOPSIS | |
| BootstrapUtils - Reusable PowerShell utilities for Windows bootstrap and configuration | |
| .DESCRIPTION | |
| This module provides common utilities for: | |
| - Logging with consistent formatting | |
| - Admin privilege management | |
| - Scoop package manager operations | |
| - Winget package manager operations | |
| - OpenSSH infrastructure setup | |
| - GitHub SSH key management | |
| - WSL installation and configuration | |
| - Tailscale/Headscale connection | |
| - Environment PATH management | |
| Used by bootstrap.ps1 (downloaded from gist) and available in PowerShell profile | |
| after chezmoi apply. | |
| .NOTES | |
| This file is published to a GitHub Gist alongside bootstrap.ps1 and bootstrap.sh. | |
| The bootstrap script downloads and imports this module at runtime. | |
| #> | |
| # ============================================================================ | |
| # Logging Functions | |
| # ============================================================================ | |
| function Write-Info { | |
| <# | |
| .SYNOPSIS | |
| Write an informational message in green | |
| #> | |
| param([Parameter(Mandatory)][string]$Message) | |
| Write-Host "[INFO] $Message" -ForegroundColor Green | |
| } | |
| function Write-Warn { | |
| <# | |
| .SYNOPSIS | |
| Write a warning message in yellow | |
| #> | |
| param([Parameter(Mandatory)][string]$Message) | |
| Write-Host "[WARN] $Message" -ForegroundColor Yellow | |
| } | |
| function Write-Err { | |
| <# | |
| .SYNOPSIS | |
| Write an error message in red | |
| #> | |
| param([Parameter(Mandatory)][string]$Message) | |
| Write-Host "[ERROR] $Message" -ForegroundColor Red | |
| } | |
| function Write-Step { | |
| <# | |
| .SYNOPSIS | |
| Write a step progress indicator | |
| #> | |
| param( | |
| [Parameter(Mandatory)][int]$Step, | |
| [Parameter(Mandatory)][int]$Total, | |
| [Parameter(Mandatory)][string]$Message | |
| ) | |
| Write-Host "" | |
| Write-Host "=== Step $Step/$Total : $Message ===" -ForegroundColor Cyan | |
| } | |
| # ============================================================================ | |
| # Admin Functions | |
| # ============================================================================ | |
| function Test-AdminElevation { | |
| <# | |
| .SYNOPSIS | |
| Check if the current process is running with Administrator privileges | |
| .OUTPUTS | |
| [bool] True if running as Administrator | |
| #> | |
| $identity = [Security.Principal.WindowsIdentity]::GetCurrent() | |
| $principal = New-Object Security.Principal.WindowsPrincipal($identity) | |
| return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) | |
| } | |
| function Test-SudoEnabled { | |
| <# | |
| .SYNOPSIS | |
| Check if Windows Sudo for Windows feature is enabled | |
| #> | |
| try { | |
| $val = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo' -Name Enabled -ErrorAction Stop).Enabled | |
| return $val -ne 0 | |
| } catch { | |
| return $false | |
| } | |
| } | |
| $ModSourcePath = $PSScriptRoot | |
| $ModName = Split-Path $ModSourcePath -Leaf | |
| $SystemInstallPath = Join-Path $Env:ProgramFiles "WindowsPowerShell\Modules\$ModName" | |
| $SettingLink = $false | |
| function Test-SystemModuleInstalled { | |
| $LinkPath = Get-Item -Path $SystemInstallPath -ErrorAction SilentlyContinue | |
| if ($LinkPath){ | |
| if ($LinkPath.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint)) { | |
| $CurrentTarget = $LinkPath.Target | |
| if ($CurrentTarget -eq $ModSourcePath) { | |
| return $true | |
| } | |
| } | |
| } | |
| return $false | |
| } | |
| function Install-SystemModule { | |
| if (-not (Test-SystemModuleInstalled)) { | |
| if (-not $script:SettingLink) { | |
| $script:SettingLink = $true | |
| & $script:sudo "pwsh" New-Item -ItemType SymbolicLink -Path $SystemInstallPath -Target $ModSourcePath -Force | Out-Null | |
| Write-Host "Success! Module '$ModName' is now linked system-wide." -ForegroundColor Green | |
| } | |
| } | |
| } | |
| function sudo { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory, Position=0)] | |
| [string]$__Command, | |
| [Parameter(ValueFromRemainingArguments, Position=1)] | |
| [string[]]$__CommandArgs | |
| ) | |
| Install-SystemModule | |
| $isElevated = Test-AdminElevation | |
| if ($isElevated) { | |
| & $__Command @__CommandArgs | |
| } else { | |
| & sudo.exe $__Command @__CommandArgs | |
| } | |
| } | |
| function Request-AdminElevation { | |
| <# | |
| .SYNOPSIS | |
| Prompt user to relaunch the script as Administrator | |
| .PARAMETER ScriptPath | |
| Path to the script to relaunch. If not specified, uses $PSCommandPath from caller. | |
| .PARAMETER Command | |
| Command/arguments to pass to the relaunched script | |
| #> | |
| param( | |
| [string]$ScriptPath, | |
| [string]$Command | |
| ) | |
| if (-not (Test-AdminElevation)) { | |
| Write-Warn "Some operations require Administrator privileges:" | |
| Write-Warn " - Enabling Windows features (WSL, VirtualMachinePlatform)" | |
| Write-Warn " - Modifying system registry settings" | |
| Write-Host "" | |
| $response = Read-Host "Would you like to relaunch as Administrator? (y/N)" | |
| if ($response -eq 'y' -or $response -eq 'Y') { | |
| if ([string]::IsNullOrEmpty($ScriptPath)) { | |
| Write-Err "Cannot auto-elevate when running from web. Please run PowerShell as Administrator." | |
| exit 1 | |
| } | |
| Start-Process pwsh -Verb RunAs -ArgumentList "-ExecutionPolicy Bypass -File `"$ScriptPath`" $Command" | |
| exit 0 | |
| } else { | |
| Write-Warn "Continuing without elevation. Some features may not be configured." | |
| } | |
| } | |
| } | |
| function Invoke-SudoPwshSwitch { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][ValidateSet('pwsh', 'powershell')] | |
| [string]$Shell, | |
| [Parameter(Mandatory)] | |
| [string]$__Command, | |
| [Parameter(ValueFromRemainingArguments)] | |
| [string[]]$__CommandArgs | |
| ) | |
| $isAdmin = Test-AdminElevation | |
| $currentShell = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' } | |
| if ($isAdmin -and ($currentShell -eq $Shell)) { | |
| & $__Command @__CommandArgs | |
| exit $LASTEXITCODE | |
| } | |
| # Write-Host $Command | |
| # $bytes = [System.Text.Encoding]::Unicode.GetBytes($Command) | |
| # $encoded = [Convert]::ToBase64String($bytes) | |
| $exe = if ($Shell -eq 'pwsh') { 'pwsh' } else { 'PowerShell.exe' } | |
| $commandString = (@($__Command) + $__CommandArgs) -join ' ' | |
| $bytes = [System.Text.Encoding]::Unicode.GetBytes($commandString) | |
| $encodedCommand = [Convert]::ToBase64String($bytes) | |
| if ($isAdmin) { | |
| # $p = Start-Process -FilePath $exe -ArgumentList "-NoProfile -EncodedCommand $encoded" -Wait -NoNewWindow -PassThru | |
| # return $p.ExitCode | |
| & $exe -EncodedCommand $encodedCommand | |
| } else { | |
| Write-Host "Elevating via sudo ($exe)..." -ForegroundColor Yellow | |
| # sudo $exe -NoProfile -EncodedCommand $encoded | |
| sudo $exe -EncodedCommand $encodedCommand | |
| } | |
| } | |
| function Invoke-AsNonAdmin { | |
| <# | |
| .SYNOPSIS | |
| Runs a PowerShell script block with de-elevated (non-admin) privileges. | |
| .DESCRIPTION | |
| Useful for installing Scoop or other user-level tools from an Admin script. | |
| Uses runas.exe with trustlevel:0x20000 to drop privileges. | |
| .PARAMETER ScriptBlock | |
| The script block to execute as non-admin | |
| .EXAMPLE | |
| Invoke-AsNonAdmin -ScriptBlock { irm get.scoop.sh | iex } | |
| #> | |
| param ( | |
| [Parameter(Mandatory)] | |
| [ScriptBlock]$ScriptBlock | |
| ) | |
| # Convert the scriptblock to a string and Base64 encode it | |
| $commandStr = $ScriptBlock.ToString() | |
| $bytes = [System.Text.Encoding]::Unicode.GetBytes($commandStr) | |
| $encodedCommand = [Convert]::ToBase64String($bytes) | |
| # Construct the PowerShell command | |
| $psCommand = "powershell.exe -EncodedCommand $encodedCommand" | |
| Write-Host "[Invoke-AsNonAdmin] Launching de-elevated process..." -ForegroundColor Cyan | |
| # Use runas with the "Basic User" trust level (0x20000) | |
| Start-Process "runas.exe" -ArgumentList "/machine:amd64 /trustlevel:0x20000 `"$psCommand`"" -NoNewWindow -Wait | |
| } | |
| # ============================================================================ | |
| # Environment Functions | |
| # ============================================================================ | |
| function Update-PathEnvironment { | |
| <# | |
| .SYNOPSIS | |
| Refresh the PATH environment variable from the registry | |
| .DESCRIPTION | |
| Reloads PATH from both Machine and User registry keys without requiring | |
| a new shell session. | |
| #> | |
| $env:PATH = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + | |
| [System.Environment]::GetEnvironmentVariable("Path", "User") | |
| } | |
| # ============================================================================ | |
| # Scoop Functions | |
| # ============================================================================ | |
| function Test-ScoopInstalled { | |
| <# | |
| .SYNOPSIS | |
| Check if Scoop package manager is installed | |
| .OUTPUTS | |
| [bool] True if Scoop is available in PATH | |
| #> | |
| return [bool](Get-Command scoop -ErrorAction SilentlyContinue) | |
| } | |
| function Install-Scoop { | |
| <# | |
| .SYNOPSIS | |
| Install the Scoop package manager | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| #> | |
| [CmdletBinding()] | |
| param([switch]$DryRun) | |
| if (Test-ScoopInstalled) { | |
| Write-Info "Scoop is already installed" | |
| return | |
| } | |
| Write-Info "Installing Scoop..." | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would install Scoop" | |
| return | |
| } | |
| # Scoop should be installed as non-admin for user-level package management | |
| if (Test-AdminElevation) { | |
| Write-Info "Running as admin - installing Scoop via de-elevated process..." | |
| Invoke-AsNonAdmin -ScriptBlock { | |
| Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force | |
| Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression | |
| } | |
| } else { | |
| Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force | |
| Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression | |
| } | |
| # Refresh PATH | |
| Update-PathEnvironment | |
| if (-not (Test-ScoopInstalled)) { | |
| Write-Err "Failed to install Scoop" | |
| exit 1 | |
| } | |
| Write-Info "Scoop installed successfully" | |
| } | |
| function Install-ScoopBuckets { | |
| <# | |
| .SYNOPSIS | |
| Add standard Scoop buckets (main, extras, nerd-fonts) | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| #> | |
| [CmdletBinding()] | |
| param([switch]$DryRun) | |
| Write-Info "Adding Scoop buckets..." | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would add buckets: main, extras, nerd-fonts" | |
| return | |
| } | |
| # Get list of bucket names (scoop bucket list returns objects with Name property) | |
| $bucketNames = (scoop bucket list 2>$null).Name | |
| # Main bucket (usually added by default, but ensure it exists) | |
| if ($bucketNames -notcontains 'main') { | |
| Write-Info "Adding main bucket..." | |
| scoop bucket add main | |
| } else { | |
| Write-Info "main bucket already added" | |
| } | |
| # Add extras bucket (for vscode, etc.) | |
| if ($bucketNames -notcontains 'extras') { | |
| Write-Info "Adding extras bucket..." | |
| scoop bucket add extras | |
| } else { | |
| Write-Info "extras bucket already added" | |
| } | |
| # Add nerd-fonts bucket | |
| if ($bucketNames -notcontains 'nerd-fonts') { | |
| Write-Info "Adding nerd-fonts bucket..." | |
| scoop bucket add nerd-fonts https://github.com/matthewjberger/scoop-nerd-fonts | |
| } else { | |
| Write-Info "nerd-fonts bucket already added" | |
| } | |
| } | |
| function Install-ScoopPackage { | |
| <# | |
| .SYNOPSIS | |
| Install a package via Scoop | |
| .PARAMETER Name | |
| The Scoop package name | |
| .PARAMETER DisplayName | |
| Friendly name for logging | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][string]$Name, | |
| [string]$DisplayName, | |
| [switch]$DryRun | |
| ) | |
| if (-not $DisplayName) { $DisplayName = $Name } | |
| $installed = scoop list $Name 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $installed -match $Name) { | |
| Write-Info "$DisplayName is already installed" | |
| return | |
| } | |
| Write-Info "Installing $DisplayName..." | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would run: scoop install $Name" | |
| } else { | |
| scoop install $Name | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Warn "Failed to install $DisplayName (may need manual installation)" | |
| } | |
| } | |
| } | |
| function Invoke-ScoopValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate that Scoop is installed correctly | |
| .OUTPUTS | |
| [bool] True if validation passes | |
| #> | |
| if (Test-ScoopInstalled) { | |
| Write-Info "Scoop: OK" | |
| return $true | |
| } else { | |
| Write-Err "Scoop: NOT INSTALLED" | |
| return $false | |
| } | |
| } | |
| # ============================================================================ | |
| # OpenSSH Functions | |
| # ============================================================================ | |
| function New-SshKey { | |
| <# | |
| .SYNOPSIS | |
| Generate a new ED25519 SSH key | |
| .PARAMETER SshDir | |
| Directory to store the key (default: ~/.ssh) | |
| .PARAMETER KeyName | |
| Name of the key file (default: id_ed25519_<hostname>) | |
| .PARAMETER Comment | |
| Comment/email for the key | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .OUTPUTS | |
| [bool] True if key exists or was created | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$SshDir = (Join-Path $env:USERPROFILE '.ssh'), | |
| [string]$KeyName = "id_ed25519_$(hostname)", | |
| [string]$Comment, | |
| [switch]$DryRun | |
| ) | |
| $keyPath = Join-Path $SshDir $KeyName | |
| # Create .ssh directory if needed | |
| if (-not (Test-Path $SshDir)) { | |
| Write-Info "Creating $SshDir..." | |
| if (-not $DryRun) { | |
| New-Item -ItemType Directory -Path $SshDir -Force | Out-Null | |
| } | |
| } | |
| # Check if key already exists | |
| if (Test-Path $keyPath) { | |
| Write-Info "SSH key already exists: $keyPath" | |
| return $true | |
| } | |
| Write-Info "Generating SSH key: $keyPath" | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would generate SSH key" | |
| return $true | |
| } | |
| if (-not $Comment) { | |
| $Comment = "$env:USERNAME@$(hostname)" | |
| } | |
| ssh-keygen -t ed25519 -C $Comment -f $keyPath -N '""' | |
| if (-not (Test-Path $keyPath)) { | |
| Write-Err "Failed to generate SSH key" | |
| return $false | |
| } | |
| Write-Info "SSH key generated successfully" | |
| return $true | |
| } | |
| function Add-SshKeyToAgent { | |
| <# | |
| .SYNOPSIS | |
| Add an SSH key to the ssh-agent | |
| .PARAMETER KeyPath | |
| Path to the private key file | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .OUTPUTS | |
| [bool] True if key was added or already present | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][string]$KeyPath, | |
| [switch]$DryRun | |
| ) | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would add key to ssh-agent" | |
| return $true | |
| } | |
| # Get fingerprint of the key file | |
| $keyFingerprint = ssh-keygen -lf $KeyPath 2>$null | |
| if (-not $keyFingerprint) { | |
| Write-Warn "Could not read key fingerprint from $KeyPath" | |
| return $false | |
| } | |
| # Extract just the fingerprint hash (e.g., SHA256:xxxx) | |
| if ($keyFingerprint -match '(SHA256:\S+)') { | |
| $fingerprint = $Matches[1] | |
| } else { | |
| Write-Warn "Could not parse key fingerprint" | |
| return $false | |
| } | |
| # Check if key is already in agent by fingerprint | |
| $agentKeys = ssh-add -l 2>&1 | |
| if ($agentKeys -match [regex]::Escape($fingerprint)) { | |
| Write-Info "SSH key already in agent" | |
| return $true | |
| } | |
| Write-Info "Adding SSH key to agent..." | |
| ssh-add $KeyPath 2>&1 | Out-Null | |
| if ($LASTEXITCODE -eq 0) { | |
| Write-Info "Key added to ssh-agent" | |
| return $true | |
| } else { | |
| Write-Warn "Failed to add key to ssh-agent" | |
| return $false | |
| } | |
| } | |
| function Invoke-SshValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate SSH infrastructure | |
| .PARAMETER SshKeyPath | |
| Path to the SSH private key to check | |
| .PARAMETER GitHubUser | |
| GitHub username to check SSH access for | |
| .OUTPUTS | |
| [bool] True if all validations pass | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$SshKeyPath, | |
| [string]$GitHubUser | |
| ) | |
| $allOk = $true | |
| # Check OpenSSH Client | |
| $sshClient = Get-WindowsCapability -Online -Name 'OpenSSH.Client~~~~0.0.1.0' -ErrorAction SilentlyContinue | |
| if ($sshClient -and $sshClient.State -eq 'Installed') { | |
| Write-Info "OpenSSH Client: INSTALLED" | |
| } else { | |
| Write-Warn "OpenSSH Client: NOT INSTALLED" | |
| $allOk = $false | |
| } | |
| # Check OpenSSH Server | |
| $sshServer = Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -ErrorAction SilentlyContinue | |
| if ($sshServer -and $sshServer.State -eq 'Installed') { | |
| Write-Info "OpenSSH Server: INSTALLED" | |
| } else { | |
| Write-Warn "OpenSSH Server: NOT INSTALLED" | |
| $allOk = $false | |
| } | |
| # Check ssh-agent | |
| $sshAgent = Get-Service ssh-agent -ErrorAction SilentlyContinue | |
| if ($sshAgent -and $sshAgent.Status -eq 'Running') { | |
| Write-Info "ssh-agent: RUNNING ($($sshAgent.StartType))" | |
| } else { | |
| Write-Warn "ssh-agent: NOT RUNNING" | |
| $allOk = $false | |
| } | |
| # Check SSH key | |
| if ($SshKeyPath -and (Test-Path $SshKeyPath)) { | |
| Write-Info "SSH key: OK ($SshKeyPath)" | |
| } elseif ($SshKeyPath) { | |
| Write-Warn "SSH key: NOT FOUND" | |
| $allOk = $false | |
| } | |
| # Check GitHub access | |
| if ($GitHubUser -and (Test-GitHubSshAccess -GitHubUser $GitHubUser)) { | |
| Write-Info "GitHub SSH: AUTHENTICATED" | |
| } elseif ($GitHubUser) { | |
| Write-Warn "GitHub SSH: NOT AUTHENTICATED" | |
| $allOk = $false | |
| } | |
| return $allOk | |
| } | |
| # ============================================================================ | |
| # GitHub Functions | |
| # ============================================================================ | |
| function Get-GitHubToken { | |
| <# | |
| .SYNOPSIS | |
| Get a GitHub token from environment or 1Password | |
| .PARAMETER OpVault | |
| 1Password vault name | |
| .PARAMETER OpItem | |
| 1Password item name | |
| .PARAMETER OpField | |
| 1Password field name | |
| .OUTPUTS | |
| [string] The GitHub token, or $null if not found | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$OpVault = 'Personal', | |
| [string]$OpItem = 'GitHub PAT (SSH Key Upload)', | |
| [string]$OpField = 'credential' | |
| ) | |
| # Check environment variable first | |
| if ($env:GITHUB_TOKEN) { | |
| Write-Info "Using GITHUB_TOKEN from environment" | |
| return $env:GITHUB_TOKEN | |
| } | |
| # Try 1Password | |
| if (-not (Get-Command op -ErrorAction SilentlyContinue)) { | |
| Write-Warn "1Password CLI not found and GITHUB_TOKEN not set" | |
| return $null | |
| } | |
| Write-Info "Fetching GitHub token from 1Password..." | |
| # Check if signed in | |
| $opAccount = op account get 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Info "Please sign in to 1Password..." | |
| op signin 2>&1 | Out-Null | |
| } | |
| $token = op read "op://$OpVault/$OpItem/$OpField" 2>&1 | |
| if ($LASTEXITCODE -eq 0 -and $token) { | |
| return $token | |
| } | |
| Write-Warn "Failed to retrieve GitHub token from 1Password" | |
| return $null | |
| } | |
| function Test-GitHubSshAccess { | |
| <# | |
| .SYNOPSIS | |
| Test SSH access to GitHub | |
| .PARAMETER GitHubHost | |
| GitHub host (default: github.com) | |
| .PARAMETER GitHubUser | |
| Expected GitHub username | |
| .OUTPUTS | |
| [bool] True if SSH access is working | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$GitHubHost = 'github.com', | |
| [string]$GitHubUser | |
| ) | |
| Write-Info "Testing GitHub SSH access..." | |
| $result = ssh -T -o BatchMode=yes -o StrictHostKeyChecking=accept-new "git@$GitHubHost" 2>&1 | |
| if ($GitHubUser -and $result -match "Hi $GitHubUser") { | |
| Write-Info "GitHub SSH access confirmed for $GitHubUser" | |
| return $true | |
| } elseif ($result -match "Hi \w+") { | |
| Write-Info "GitHub SSH access working" | |
| return $true | |
| } | |
| return $false | |
| } | |
| function Add-SshKeyToGitHub { | |
| <# | |
| .SYNOPSIS | |
| Upload an SSH public key to GitHub | |
| .PARAMETER PublicKeyPath | |
| Path to the public key file | |
| .PARAMETER KeyTitle | |
| Title for the key on GitHub (default: key filename) | |
| .PARAMETER Token | |
| GitHub personal access token with admin:public_key scope | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .OUTPUTS | |
| [bool] True if key was uploaded or already exists | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][string]$PublicKeyPath, | |
| [string]$KeyTitle, | |
| [Parameter(Mandatory)][string]$Token, | |
| [switch]$DryRun | |
| ) | |
| if (-not (Test-Path $PublicKeyPath)) { | |
| Write-Err "Public key not found: $PublicKeyPath" | |
| return $false | |
| } | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would upload SSH key to GitHub" | |
| return $true | |
| } | |
| $pubKeyContent = Get-Content $PublicKeyPath -Raw | |
| if (-not $KeyTitle) { | |
| $KeyTitle = [System.IO.Path]::GetFileNameWithoutExtension($PublicKeyPath) | |
| } | |
| Write-Info "Uploading SSH key to GitHub as '$KeyTitle'..." | |
| $headers = @{ | |
| 'Accept' = 'application/vnd.github+json' | |
| 'Authorization' = "Bearer $Token" | |
| 'X-GitHub-Api-Version' = '2022-11-28' | |
| } | |
| $body = @{ | |
| title = $KeyTitle | |
| key = $pubKeyContent.Trim() | |
| } | ConvertTo-Json | |
| try { | |
| $response = Invoke-RestMethod -Uri 'https://api.github.com/user/keys' ` | |
| -Method Post -Headers $headers -Body $body -ContentType 'application/json' | |
| Write-Info "SSH key uploaded successfully (ID: $($response.id))" | |
| return $true | |
| } catch { | |
| if ($_.Exception.Response.StatusCode -eq 422) { | |
| Write-Info "SSH key already exists on GitHub" | |
| return $true | |
| } | |
| Write-Err "Failed to upload SSH key: $_" | |
| return $false | |
| } | |
| } | |
| # ============================================================================ | |
| # WSL Functions | |
| # ============================================================================ | |
| function Get-WslInstanceName { | |
| <# | |
| .SYNOPSIS | |
| Generate a WSL instance name from hostname or environment | |
| .DESCRIPTION | |
| If WSL_INSTANCE_NAME env var is set, use that. | |
| If hostname matches 'pc#', return 'wsl#'. | |
| Otherwise return 'wsl-<uuid>'. | |
| .OUTPUTS | |
| [string] The WSL instance name | |
| #> | |
| if ($env:WSL_INSTANCE_NAME) { | |
| return $env:WSL_INSTANCE_NAME | |
| } | |
| $hostname = hostname | |
| if ($hostname -match '^pc(\d+)$') { | |
| return "wsl$($Matches[1])" | |
| } | |
| # Fallback to UUID-based name | |
| return "wsl-$([guid]::NewGuid().ToString().Substring(0,8))" | |
| } | |
| function Install-WslPlatform { | |
| <# | |
| .SYNOPSIS | |
| Install WSL platform without a default distribution | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .OUTPUTS | |
| [bool] True if WSL is installed or was installed successfully | |
| #> | |
| [CmdletBinding()] | |
| param([switch]$DryRun) | |
| # Check if WSL is already installed | |
| $null = wsl --status 2>&1 | |
| $wslInstalled = $LASTEXITCODE -eq 0 | |
| if (-not $wslInstalled) { | |
| Write-Info "Installing WSL platform..." | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would run: wsl --install --no-distribution" | |
| } else { | |
| wsl --install --no-distribution | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Warn "WSL installation may require a reboot. Please reboot and re-run." | |
| return $false | |
| } | |
| } | |
| } else { | |
| Write-Info "WSL platform already installed" | |
| } | |
| return $true | |
| } | |
| function Install-NixosWsl { | |
| <# | |
| .SYNOPSIS | |
| Download and import NixOS-WSL | |
| .PARAMETER InstanceName | |
| Name for the WSL instance | |
| .PARAMETER InstallPath | |
| Path to install the WSL instance | |
| .PARAMETER ReleaseUrl | |
| URL to the NixOS-WSL tarball | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .OUTPUTS | |
| [bool] True if installation succeeded | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][string]$InstanceName, | |
| [Parameter(Mandatory)][string]$InstallPath, | |
| [string]$ReleaseUrl = 'https://github.com/nix-community/NixOS-WSL/releases/latest/download/nixos-wsl.tar.gz', | |
| [switch]$DryRun | |
| ) | |
| # Check if instance already exists | |
| $distroList = wsl --list --quiet 2>$null | |
| if ($distroList -contains $InstanceName) { | |
| Write-Info "WSL instance '$InstanceName' already exists" | |
| return $true | |
| } | |
| Write-Info "Installing NixOS-WSL as '$InstanceName'..." | |
| if ($DryRun) { | |
| Write-Info "[DRY RUN] Would download and import NixOS-WSL" | |
| return $true | |
| } | |
| # Create install directory | |
| if (-not (Test-Path $InstallPath)) { | |
| New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null | |
| } | |
| # Download NixOS-WSL image | |
| $imagePath = Join-Path $env:TEMP 'nixos.wsl' | |
| Write-Info "Downloading NixOS-WSL from $ReleaseUrl..." | |
| try { | |
| Invoke-WebRequest -Uri $ReleaseUrl -OutFile $imagePath -UseBasicParsing | |
| } catch { | |
| Write-Err "Failed to download NixOS-WSL: $_" | |
| return $false | |
| } | |
| # Import the distribution | |
| Write-Info "Importing NixOS-WSL..." | |
| wsl --import $InstanceName $InstallPath $imagePath | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Err "Failed to import NixOS-WSL" | |
| return $false | |
| } | |
| # Clean up downloaded image | |
| Remove-Item $imagePath -Force -ErrorAction SilentlyContinue | |
| Write-Info "NixOS-WSL installed successfully as '$InstanceName'" | |
| return $true | |
| } | |
| function Invoke-WslValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate WSL installation | |
| .PARAMETER InstanceName | |
| Name of the WSL instance to check | |
| .OUTPUTS | |
| [bool] True if all validations pass | |
| #> | |
| [CmdletBinding()] | |
| param([string]$InstanceName) | |
| $allOk = $true | |
| # Check WSL platform | |
| $null = wsl --status 2>&1 | |
| if ($LASTEXITCODE -eq 0) { | |
| Write-Info "WSL platform: INSTALLED" | |
| } else { | |
| Write-Err "WSL platform: NOT INSTALLED" | |
| $allOk = $false | |
| } | |
| # Check specific instance if provided | |
| if ($InstanceName) { | |
| $distros = wsl --list --quiet 2>$null | |
| if ($distros -match "^$InstanceName$") { | |
| Write-Info "WSL instance ($InstanceName): INSTALLED" | |
| # Check if running | |
| $running = wsl -l -v 2>$null | Where-Object { $_ -match $InstanceName -and $_ -match 'Running' } | |
| if ($running) { | |
| Write-Info "WSL instance state: RUNNING" | |
| } else { | |
| Write-Info "WSL instance state: STOPPED" | |
| } | |
| } else { | |
| Write-Warn "WSL instance ($InstanceName): NOT INSTALLED" | |
| $allOk = $false | |
| } | |
| } | |
| return $allOk | |
| } | |
| # ============================================================================ | |
| # PSResource / DSC Functions | |
| # ============================================================================ | |
| function Install-PsModules { | |
| <# | |
| .SYNOPSIS | |
| Install PowerShell modules declared in psmodules.psd1 | |
| .DESCRIPTION | |
| Uses Import-PowerShellDataFile (native, safe, supports comments). | |
| Installs pwsh7_modules via Install-PSResource, windows_powershell_modules | |
| via Install-Module in a powershell.exe subprocess. | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$Path = (Join-Path $env:USERPROFILE '.config\powershell\psmodules.psd1') | |
| ) | |
| Write-Host "`n=== PowerShell DSC Module Installer ===`n" -ForegroundColor Cyan | |
| if (-not (Test-Path $Path)) { throw "Config not found: $Path" } | |
| $config = Import-PowerShellDataFile -Path $Path | |
| # Install Windows PowerShell modules (shell out to PS 5.1) | |
| if ($config.windows_powershell_modules) { | |
| Write-Host "--- Windows PowerShell Modules ---" -ForegroundColor Cyan | |
| $modArgs = $config.windows_powershell_modules | ForEach-Object { "$($_.Name)=$($_.Version)" } | |
| $argString = $modArgs -join ',' | |
| Invoke-SudoPwshSwitch 'powershell' Install-WindowsPowerShellModules -ModuleSpecs `"$argString`" | |
| } | |
| # Install PowerShell 7 modules (current process) | |
| if ($config.pwsh7_modules) { | |
| Write-Host "--- PowerShell 7 Modules ---" -ForegroundColor Cyan | |
| # Ensure PSGallery is trusted | |
| Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue | |
| foreach ($mod in $config.pwsh7_modules) { | |
| $installed = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Version -ge [version]$mod.Version } | | |
| Select-Object -First 1 | |
| if ($installed) { | |
| Write-Host "[OK] $($mod.Name) v$($installed.Version)" -ForegroundColor Green | |
| } else { | |
| Write-Host "[INSTALL] $($mod.Name) v$($mod.Version)..." -ForegroundColor Yellow | |
| Install-PSResource -Name $mod.Name -Version $mod.Version -TrustRepository -Scope AllUsers -ErrorAction Stop | |
| Write-Host "[OK] $($mod.Name) installed" -ForegroundColor Green | |
| } | |
| } | |
| } | |
| Write-Host "`nPowerShell DSC modules ready." -ForegroundColor Green | |
| } | |
| function Install-WindowsPowerShellModules { | |
| <# | |
| .SYNOPSIS | |
| Install modules for Windows PowerShell (runs in powershell.exe) | |
| .DESCRIPTION | |
| Uses Install-Module (PowerShellGet v2) which is available in PS5.1. | |
| .PARAMETER ModuleSpecs | |
| Comma-separated Name=Version pairs | |
| #> | |
| param([Parameter(Mandatory)][string]$ModuleSpecs) | |
| # Ensure NuGet is installed silently to avoid blocking prompts | |
| if (-not (Get-PackageProvider -ListAvailable -Name "NuGet" -ErrorAction SilentlyContinue)) { | |
| Write-Host "Installing NuGet provider..." | |
| [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | |
| Install-PackageProvider -Name "NuGet" -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | |
| } | |
| # Ensure PSGallery is trusted | |
| Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue | |
| foreach ($spec in ($ModuleSpecs -split ',')) { | |
| $name, $version = $spec -split '=' | |
| $installed = Get-Module -ListAvailable -Name $name -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Version -ge [version]$version } | | |
| Select-Object -First 1 | |
| if ($installed) { | |
| Write-Host "[OK] $name v$($installed.Version)" -ForegroundColor Green | |
| } else { | |
| Write-Host "[INSTALL] $name v$version..." -ForegroundColor Yellow | |
| Install-Module -Name $name -RequiredVersion $version -Force -AllowClobber -Scope AllUsers -ErrorAction Stop | |
| Write-Host "[OK] $name installed" -ForegroundColor Green | |
| } | |
| } | |
| } | |
| # ============================================================================ | |
| # Tailscale Functions | |
| # ============================================================================ | |
| function Test-TailscaleConnected { | |
| <# | |
| .SYNOPSIS | |
| Check if connected to a Tailscale tailnet | |
| .OUTPUTS | |
| [bool] True if connected to a tailnet | |
| #> | |
| [CmdletBinding()] | |
| param() | |
| if (-not (Get-Command tailscale -ErrorAction SilentlyContinue)) { | |
| return $false | |
| } | |
| $status = tailscale status 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| return $false | |
| } | |
| # If we get status output without error, we're connected | |
| # "Tailscale is stopped" or similar errors return non-zero exit code | |
| return $true | |
| } | |
| function Connect-Tailscale { | |
| <# | |
| .SYNOPSIS | |
| Connect to a Tailscale tailnet | |
| .PARAMETER LoginServer | |
| Custom login server URL (e.g., for Headscale) | |
| .PARAMETER DryRun | |
| Show what would happen without making changes | |
| .OUTPUTS | |
| [bool] True if connection succeeded or already connected | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string]$LoginServer, | |
| [switch]$DryRun | |
| ) | |
| if (-not (Get-Command tailscale -ErrorAction SilentlyContinue)) { | |
| Write-Err "Tailscale is not installed" | |
| return $false | |
| } | |
| if (Test-TailscaleConnected) { | |
| Write-Info "Already connected to Tailscale tailnet" | |
| return $true | |
| } | |
| Write-Info "Connecting to Tailscale..." | |
| if ($DryRun) { | |
| if ($LoginServer) { | |
| Write-Info "[DRY RUN] Would run: sudo tailscale up --login-server '$LoginServer'" | |
| } else { | |
| Write-Info "[DRY RUN] Would run: sudo tailscale up" | |
| } | |
| return $true | |
| } | |
| if ($LoginServer) { | |
| sudo tailscale up --login-server $LoginServer | |
| } else { | |
| sudo tailscale up | |
| } | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Err "Failed to connect to Tailscale" | |
| return $false | |
| } | |
| Write-Info "Connected to Tailscale successfully" | |
| return $true | |
| } | |
| function Invoke-TailscaleValidate { | |
| <# | |
| .SYNOPSIS | |
| Validate Tailscale installation and connection | |
| .OUTPUTS | |
| [bool] True if validation passes | |
| #> | |
| [CmdletBinding()] | |
| param() | |
| $allOk = $true | |
| if (-not (Get-Command tailscale -ErrorAction SilentlyContinue)) { | |
| Write-Err "Tailscale: NOT INSTALLED" | |
| return $false | |
| } | |
| Write-Info "Tailscale: INSTALLED" | |
| if (Test-TailscaleConnected) { | |
| # Get more details about the connection | |
| $status = tailscale status --json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue | |
| if ($status -and $status.Self) { | |
| Write-Info "Tailscale: CONNECTED ($($status.Self.HostName))" | |
| } else { | |
| Write-Info "Tailscale: CONNECTED" | |
| } | |
| } else { | |
| Write-Warn "Tailscale: NOT CONNECTED" | |
| $allOk = $false | |
| } | |
| return $allOk | |
| } | |
| # ============================================================================ | |
| # Export Module Members | |
| # ============================================================================ | |
| Export-ModuleMember -Function @( | |
| # Logging | |
| 'Write-Info' | |
| 'Write-Warn' | |
| 'Write-Err' | |
| 'Write-Step' | |
| # Environment | |
| 'Update-PathEnvironment' | |
| # Admin | |
| 'Test-AdminElevation' | |
| 'Test-SudoEnabled' | |
| 'sudo' | |
| 'Request-AdminElevation' | |
| 'Invoke-SudoPwshSwitch' | |
| 'Invoke-AsNonAdmin' | |
| # Scoop | |
| 'Test-ScoopInstalled' | |
| 'Install-Scoop' | |
| 'Install-ScoopBuckets' | |
| 'Install-ScoopPackage' | |
| 'Invoke-ScoopValidate' | |
| # OpenSSH | |
| 'New-SshKey' | |
| 'Add-SshKeyToAgent' | |
| 'Invoke-SshValidate' | |
| # GitHub | |
| 'Get-GitHubToken' | |
| 'Test-GitHubSshAccess' | |
| 'Add-SshKeyToGitHub' | |
| # WSL | |
| 'Get-WslInstanceName' | |
| 'Install-WslPlatform' | |
| 'Install-NixosWsl' | |
| 'Invoke-WslValidate' | |
| # PSResource / DSC | |
| 'Install-PsModules' | |
| 'Install-WindowsPowerShellModules' | |
| # Tailscale | |
| 'Test-TailscaleConnected' | |
| 'Connect-Tailscale' | |
| 'Invoke-TailscaleValidate' | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment