Skip to content

Instantly share code, notes, and snippets.

@thimslugga
Forked from jacobbrugh/BootstrapUtils.psm1
Created April 13, 2026 15:47
Show Gist options
  • Select an option

  • Save thimslugga/3d9f1eed40830789227971549579f65a to your computer and use it in GitHub Desktop.

Select an option

Save thimslugga/3d9f1eed40830789227971549579f65a to your computer and use it in GitHub Desktop.
@jacobpbrugh's dotfiles bootstrap script
{
"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
}
{
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";
};
};
})
];
};
};
}
<#
.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 }
}
#!/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
#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