Something went wrong. Try again.
My personal dotfiles, for now.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863#!/usr/bin/env -S nu --no-config-file
# Post-clone bootstrap for a Zircadia workstation.## Run this from the cloned dotfiles repository with a stock Nushell:# nu --no-config-file ./bootstrap.nu## This is deliberately a standalone repository script, not a chezmoi run script.# It must restore packages and licensed assets before chezmoi applies configs that# depend on them.
use home/dot_config/nushell/scripts/dotfiles.nu *
const YUBIKEY_LABELS = [primary backup1 backup2]const REQUIRED_FONT_FAMILIES = ["PragmataPro Mono" "Berkeley Mono"]const PCSC_SOCKET = "/run/pcscd/pcscd.comm"
def fail [message: string] { error make {msg: $message}}
def section [title: string] { print "" print $"=== ($title) ==="}
def print-command-output [result: record] { if not ($result.stdout | is-empty) { print --no-newline $result.stdout if not ($result.stdout | str ends-with "\n") { print "" } } if not ($result.stderr | is-empty) { print --stderr --no-newline $result.stderr if not ($result.stderr | str ends-with "\n") { print --stderr "" } }}
def run-checked [description: string, command: closure] { print $"-> ($description)" let result = (do $command | complete) print-command-output $result if $result.exit_code != 0 { fail $"($description) failed with exit code ($result.exit_code)" } $result.stdout}
def run-interactive-checked [description: string, command: closure] { print $"-> ($description)" let result = try { do $command {exit_code: 0, error: null} } catch {|error| {exit_code: ($env.LAST_EXIT_CODE? | default 1), error: $error} } if $result.exit_code != 0 { let detail = if $result.error == null { "" } else { $result.error.msg? | default "" } let suffix = if ($detail | is-empty) { "" } else { $": ($detail)" } fail $"($description) failed with exit code ($result.exit_code)($suffix)" }}
def require-command [name: string] { if (which $name | is-empty) { fail $"Required command is missing: ($name)" }}
def confirm [question: string, assume_yes: bool] { if $assume_yes { return } loop { let answer = (input $"($question) [y/N] " | str trim | str lowercase) match $answer { "y" | "yes" => { return } "" | "n" | "no" => { fail $"Stopped: ($question)" } _ => { print "Please answer y or n." } } }}
def pause [message: string, assume_yes: bool] { print $message if not $assume_yes { input "Press Enter to continue... " | ignore }}
def repository-root [] { $env.FILE_PWD}
def --env validate-pcsc [] { if not ($PCSC_SOCKET | path exists) { fail $"PC/SC socket is missing: ($PCSC_SOCKET). Ensure pcscd.socket is active" } if ($PCSC_SOCKET | path type) != "socket" { fail $"Expected a PC/SC socket at ($PCSC_SOCKET)" } # The system-linked plugin treats this as a harmless explicit socket; the # Homebrew-linked plugin needs it because its compiled default points into # the Homebrew prefix. bootstrap.nu runs with --no-config-file and therefore # cannot rely on the later-applied environment.d file. $env.PCSCLITE_CSOCK_NAME = $PCSC_SOCKET}
def validate-repository [repo: path] { let expected = ($env.HOME | path join ".local" "share" "chezmoi") let actual_real = (^realpath $repo | str trim) let expected_real = (^realpath $expected | str trim) if $actual_real != $expected_real { fail $"Run the cloned repository from ($expected); script is in ($repo)" }
for required in [ ".jj" ".chezmoiroot" "home/.chezmoi.toml.tmpl" "home/dot_Brewfile" "home/dot_config/nushell/scripts/dotfiles.nu" "secrets/ssh.sops.yaml" ] { let path = ($repo | path join $required) if not ($path | path exists) { fail $"Cloned repository is missing required path: ($required)" } }
let source_root = (open --raw ($repo | path join ".chezmoiroot") | str trim) if $source_root != "home" { fail $"Expected .chezmoiroot to contain 'home', found: ($source_root)" }
let marker = ($repo | path join ".git") if not ($marker | path exists) { mkdir $marker } else if ($marker | path type) != "dir" { fail ".git must be an empty directory used as chezmoi's source marker" } else if not (ls --all $marker | is-empty) { fail ".git must remain an empty chezmoi marker, not a Git repository" }}
def assert-font-family [family: string] { let result = (do { ^fc-match --format "%{family}\n" $family } | complete) if $result.exit_code != 0 { print-command-output $result fail $"Fontconfig could not resolve ($family)" } let resolved = ($result.stdout | lines | first | str trim) if not ($resolved | str contains $family) { fail $"($family) resolved to fallback family: ($resolved)" } print $"OK: ($family) -> ($resolved)"}
# Find font files below a directory, tolerating uppercase extensions.def find-font-files [directory: path] { glob ($directory | path join "**" "*") | where {|file| ($file | path type) == "file" and (($file | path parse | get extension | str lowercase) in ["otf" "ttf"]) }}
def install-fonts [assume_yes: bool, configured_source: any] { section "Restore licensed fonts" pause "Insert and mount the flash drive containing PragmataPro Mono and Berkeley Mono." $assume_yes
mut font_source = "" mut font_files: list<string> = [] loop { let response = if $configured_source == null { input "Path to the mounted fonts directory: " | str trim } else { $configured_source | into string } if ($response | is-empty) { print "A font source directory is required." continue } let candidate = ($response | path expand --no-symlink) if not ($candidate | path exists) or ($candidate | path type) != "dir" { if $configured_source != null { fail $"Not a font source directory: ($candidate)" } print $"Not a directory: ($candidate)" continue } let matches = (find-font-files $candidate) if ($matches | is-empty) { if $configured_source != null { fail $"No .otf or .ttf files found under ($candidate)" } print $"No .otf or .ttf files found under ($candidate)" continue } $font_source = $candidate $font_files = $matches break }
let duplicate_names = ( $font_files | each {|font| $font | path basename } | group-by | transpose name paths | where {|entry| ($entry.paths | length) > 1 } | get name? | default [] ) if not ($duplicate_names | is-empty) { fail $"The font drive contains duplicate filenames: ($duplicate_names | str join ', ')" }
let destination = ($env.HOME | path join ".local" "share" "fonts") mkdir $destination for font in $font_files { let installed = ($destination | path join ($font | path basename)) cp --force $font $installed chmod 644 $installed }
run-checked "refreshing Fontconfig" { ^fc-cache --force $destination } | ignore for family in $REQUIRED_FONT_FAMILIES { assert-font-family $family }
pause $"Fonts were copied from ($font_source). Unmount and remove the flash drive now." $assume_yes}
def trust-third-party-brewfile-entries [] { for entry in (brewfile-third-party-entries) { let flag = if $entry.kind == "cask" { "--cask" } else { "--formula" } run-checked $"trusting ($entry.kind) ($entry.name)" { ^brew trust $flag $entry.name } | ignore }}
def brewfile-names [brewfile: path, kind: string] { let pattern = '^\s*' + $kind + '\s+"(?P<name>[^"]+)"' open --raw $brewfile | lines | parse --regex $pattern | get name? | default []}
def install-flatpaks [brewfile: path] { let packages = (brewfile-names $brewfile "flatpak") if ($packages | is-empty) { return }
section "Install system Flatpaks" print "Flatpak applications are installed system-wide and may open a graphical administrator-authentication prompt." for package in $packages { let installed = (do { ^flatpak info --system $package } | complete) if $installed.exit_code == 0 { print $"Using Flatpak ($package)" continue } run-interactive-checked $"installing Flatpak ($package)" { ^flatpak install --system --noninteractive flathub $package } }}
def install-cargo-packages [brewfile: path] { let packages = (brewfile-names $brewfile "cargo") if ($packages | is-empty) { return }
section "Install Cargo packages" if (which rustup | is-empty) { run-interactive-checked "installing rustup" { ^brew install rustup } } let cargo_ready = (not (which cargo | is-empty)) and ((do { ^cargo --version } | complete).exit_code == 0) if not $cargo_ready { run-interactive-checked "initializing the default stable Rust toolchain" { ^rustup default stable } }
# A stock Nushell process does not rerun shell startup after rustup creates # ~/.cargo/bin. Resolve Cargo directly from rustup instead of assuming PATH # changed during this process. let cargo = if not (which cargo | is-empty) { (which cargo | first).path } else { let result = (do { ^rustup which cargo } | complete) if $result.exit_code != 0 { print-command-output $result fail "rustup initialized a toolchain but could not locate cargo" } $result.stdout | str trim } if ($cargo | is-empty) or not ($cargo | path exists) { fail $"Cargo executable is missing after rustup initialization: ($cargo)" }
let installed = (run-checked "reading installed Cargo packages" { run-external $cargo install -- --list } | lines) for package in $packages { if ($installed | any {|line| $line | str starts-with $"($package) v" }) { print $"Using Cargo package ($package)" continue } run-interactive-checked $"installing Cargo package ($package)" { run-external $cargo install -- --locked $package } }}
def install-vscode-extensions [brewfile: path] { let extensions = (brewfile-names $brewfile "vscode") if ($extensions | is-empty) { return }
section "Install Visual Studio Code extensions" # Brew may have installed the Linux cask after this Nushell process started. # Resolve its executable without depending on startup-time PATH caching. let code = if not (which code | is-empty) { (which code | first).path } else { let prefix = (run-checked "locating the Homebrew prefix" { ^brew --prefix } | str trim) $prefix | path join "bin" "code" } if ($code | is-empty) or not ($code | path exists) { fail $"Visual Studio Code executable is missing after cask installation: ($code)" } let installed = ( run-checked "reading installed Visual Studio Code extensions" { run-external $code -- --list-extensions } | lines | each {|extension| $extension | str lowercase } ) for extension in $extensions { if ($extension | str lowercase) in $installed { print $"Using Visual Studio Code extension ($extension)" continue } run-interactive-checked $"installing Visual Studio Code extension ($extension)" { run-external $code -- --install-extension $extension } }}
def install-packages [repo: path] { section "Restore Brewfile packages" let brewfile = ($repo | path join "home" "dot_Brewfile") trust-third-party-brewfile-entries
# Homebrew Bundle always installs Flatpaks system-wide, and its aggregate # error can cascade into misleading Cargo/VSCode failures after Polkit cannot # open a terminal. Install those package types explicitly, then give Bundle a # filtered Brewfile for taps, formulae, casks, and the remaining supported types. install-flatpaks $brewfile
let filtered = (mktemp --tmpdir "dotfiles-base.Brewfile.XXXXXX") try { ( open --raw $brewfile | lines | where {|line| not ($line =~ '^\s*(cargo|vscode|flatpak)\s+"') } | str join "\n" ) + "\n" | save --force $filtered run-interactive-checked "installing Brewfile formulae and casks" { ^brew bundle --no-upgrade --file $filtered } } catch {|error| if ($filtered | path exists) { rm --force $filtered } error make $error } rm --force $filtered
install-cargo-packages $brewfile install-vscode-extensions $brewfile}
def install-chezmoi [assume_yes: bool] { section "Initialize and inspect chezmoi" let config = ($env.HOME | path join ".config" "chezmoi" "chezmoi.toml") if ($config | path exists) { print $"Keeping existing chezmoi configuration: ($config)" } else { # Deliberately not run-checked: .chezmoi.toml.tmpl prompts for an email # address and GitHub username on first run, and capturing chezmoi's output # would hide those prompts while it waits on the terminal. print "-> initializing chezmoi configuration" print " First run: chezmoi will prompt for an email address and GitHub username." ^chezmoi init if $env.LAST_EXIT_CODE != 0 { fail $"initializing chezmoi configuration failed with exit code ($env.LAST_EXIT_CODE)" } }
print "" print "Review the rendered diff:" run-checked "rendering chezmoi diff" { ^chezmoi diff --no-pager } | ignore
print "" print "Review the dry-run operations:" print "-> running chezmoi dry-run" print " Chezmoi may ask what to do with changed target files; answer the prompt in this terminal." ^chezmoi apply --dry-run --verbose if $env.LAST_EXIT_CODE != 0 { fail $"running chezmoi dry-run failed with exit code ($env.LAST_EXIT_CODE)" }
confirm "Do the chezmoi diff and dry-run look safe?" $assume_yes}
def apply-chezmoi [] { section "Apply dotfiles" print "-> applying chezmoi state" print " Chezmoi may ask what to do with changed target files; answer the prompt in this terminal." ^chezmoi apply if $env.LAST_EXIT_CODE != 0 { fail $"applying chezmoi state failed with exit code ($env.LAST_EXIT_CODE)" }}
def wait-for-one-yubikey [label: string, assume_yes: bool] { loop { pause $"Remove other YubiKeys, insert only the '($label)' YubiKey, and wait for it to settle." $assume_yes let result = (do { ^ykman list --serials } | complete) if $result.exit_code != 0 { print-command-output $result print "ykman could not enumerate YubiKeys; check PC/SC and retry." } else { let serials = ($result.stdout | lines | where {|line| not ($line | str trim | is-empty) }) if ($serials | length) == 1 { return } print $"Expected exactly one inserted YubiKey; ykman found ($serials | length)." } # Pace retries. Without --yes the pause above blocks on Enter, but with # --yes the loop would otherwise re-run ykman against PC/SC as fast as the # process can, forever. sleep 2sec }}
def create-sops-identities [assume_yes: bool] { section "Create YubiKey-backed SOPS identity pointers" let sops_dir = ($env.HOME | path join ".config" "sops") let age_dir = ($sops_dir | path join "age") mkdir $sops_dir mkdir $age_dir chmod 700 $sops_dir chmod 700 $age_dir
mut identity_paths = [] for label in $YUBIKEY_LABELS { wait-for-one-yubikey $label $assume_yes let result = (do { ^age-plugin-yubikey --identity } | complete) print-command-output $result if $result.exit_code != 0 { fail $"Could not read the age identity from the '($label)' YubiKey" } let identity_lines = ( $result.stdout | lines | where {|line| $line | str starts-with "AGE-PLUGIN-YUBIKEY-" } ) if ($identity_lines | length) != 1 { fail $"Expected one age-plugin identity on the '($label)' YubiKey; found ($identity_lines | length)" }
let destination = ($age_dir | path join $"yubikey-($label).identity.txt") let temporary = (mktemp --tmpdir-path $age_dir $".yubikey-($label).XXXXXX") try { $result.stdout | save --force $temporary chmod 600 $temporary mv --force $temporary $destination } catch {|error| if ($temporary | path exists) { rm --force $temporary } error make $error } $identity_paths = ($identity_paths | append $destination) pause $"Saved the '($label)' identity pointer. Remove that YubiKey before continuing." $assume_yes }
let unique_identities = ( $identity_paths | each {|path| open --raw $path | lines | where {|line| $line | str starts-with "AGE-PLUGIN-YUBIKEY-" } | first } | uniq ) if ($unique_identities | length) != ($identity_paths | length) { fail "The generated identity files do not represent three distinct YubiKeys" }
let keys_file = ($age_dir | path join "keys.txt") let combined = ( $identity_paths | each {|path| open --raw $path | str trim } | str join "\n" ) + "\n" $combined | save --force $keys_file chmod 600 $keys_file print $"Created ($keys_file)" $keys_file}
def check-ssh-for-each-yubikey [assume_yes: bool] { section "Check serial-selected SSH rendering" for label in $YUBIKEY_LABELS { wait-for-one-yubikey $label $assume_yes print $"Checking renderer with the '($label)' YubiKey..." dotfiles secrets ssh --check }}
def effective-identities [host: string] { let result = (do { ^ssh -G $host } | complete) if $result.exit_code != 0 { print-command-output $result fail $"OpenSSH rejected the live configuration for ($host)" } $result.stdout | lines | where {|line| $line | str starts-with "identityfile " }}
def verify-live-ssh-selection [assume_yes: bool, skip_auth_tests: bool] { section "Verify live SSH identities and authentication" for label in $YUBIKEY_LABELS { wait-for-one-yubikey $label $assume_yes for host in [github.com tangled.org yggdrasil] { let identities = (effective-identities $host) let real_identities = ($identities | where {|line| $line != "identityfile none" }) if ($real_identities | length) != 1 { fail $"Expected one selected identity for ($host) with '($label)', found ($real_identities | length)" } print $"($label) / ($host): ($real_identities.0)" }
if not $skip_auth_tests { print "Run these authentication checks and follow any touch prompts:" print " ssh -T github.com" print " ssh -T tangled.org" print " ssh yggdrasil true" print "GitHub may return a non-zero status after successful authentication because it does not provide a shell." confirm $"Did all three remote authentication checks succeed with the '($label)' YubiKey?" $assume_yes } }}
def apply-secrets [assume_yes: bool, --skip-auth-tests] { let keys_file = (create-sops-identities $assume_yes) if not ($keys_file | path exists) { fail $"SOPS identity file was not created: ($keys_file)" }
# bootstrap.nu deliberately runs with --no-config-file, so the persistent # SOPS_AGE_KEY_FILE assignment in the newly applied config.nu is not loaded # into this process. Scope it explicitly around every renderer operation. with-env {SOPS_AGE_KEY_FILE: $keys_file} { check-ssh-for-each-yubikey $assume_yes confirm "Install the checked SSH fragment and complete authorized_keys file?" $assume_yes dotfiles secrets ssh --apply }
run-checked "validating installed authorized_keys" { ^ssh-keygen -lf ($env.HOME | path join ".ssh" "authorized_keys") } | ignore
# Live ssh -G/authentication checks must wait until the final chezmoi apply # replaces the temporary Tangled-only config with the managed Include base. {assume_yes: $assume_yes, skip_auth_tests: $skip_auth_tests}}
def download [url: string, destination: path] { let temporary = $"($destination).partial" if ($temporary | path exists) { rm --force $temporary } try { run-checked $"downloading ($url)" { ^curl --fail --location --output $temporary $url } | ignore mv --force $temporary $destination } catch {|error| if ($temporary | path exists) { rm --force $temporary } error make $error }}
# Replace a destination directory atomically enough for theme assets: move# any existing directory aside, copy the new one in, and roll back on failure.def replace-directory [source: path, destination: path, assume_yes: bool] { let backup = if ($destination | path exists) { confirm $"Replace existing directory ($destination)?" $assume_yes let staging = (mktemp --directory --tmpdir-path ($destination | path dirname) $".backup-($destination | path basename).XXXXXX") rm --recursive --force $staging mv $destination $staging $staging } else { "" } try { cp --recursive $source $destination if ($backup | path exists) { rm --recursive --force $backup } } catch {|error| rm --recursive --force $destination if ($backup | path exists) { mv $backup $destination print $"Restored the previous ($destination) after a failed replacement." } error make $error }}
def install-themes [assume_yes: bool] { section "Install wallpapers, cursor, GTK theme, and icons" for command in [curl git tar mktemp] { require-command $command }
let pictures = ($env.HOME | path join "Pictures") mkdir $pictures let wallpapers = ($pictures | path join "rose-pine-wallpapers") if not ($wallpapers | path exists) { run-checked "cloning Rose Pine wallpapers" { ^git clone https://github.com/p4rfait/rose-pine-wallpapers.git $wallpapers } | ignore } else { print $"Keeping existing wallpaper checkout: ($wallpapers)" }
let cache = ($env.HOME | path join ".cache" "dotfiles-bootstrap") mkdir $cache let cursor_archive = ($cache | path join "BreezeX-RosePine-Linux.tar.xz") let gtk_archive = ($cache | path join "gtk3.tar.gz") let icon_archive = ($cache | path join "rose-pine-icons.tar.gz")
download "https://github.com/rose-pine/cursors/releases/latest/download/BreezeX-RosePine-Linux.tar.xz" $cursor_archive download "https://github.com/rose-pine/gtk/releases/latest/download/gtk3.tar.gz" $gtk_archive download "https://github.com/rose-pine/gtk/releases/latest/download/rose-pine-icons.tar.gz" $icon_archive
let staging = (mktemp --directory --tmpdir "dotfiles-themes.XXXXXX") try { run-checked "extracting Rose Pine cursor" { ^tar -xJf $cursor_archive -C $staging } | ignore run-checked "extracting Rose Pine GTK theme" { ^tar -xzf $gtk_archive -C $staging } | ignore run-checked "extracting Rose Pine icons" { ^tar -xzf $icon_archive -C $staging } | ignore
let cursor_source = ($staging | path join "BreezeX-RosePine-Linux") let gtk_source = ($staging | path join "gtk3" "rose-pine-gtk") let icon_source = ($staging | path join "icons" "rose-pine-icons") for required in [ ($cursor_source | path join "index.theme") ($gtk_source | path join "index.theme") ($icon_source | path join "index.theme") ] { if not ($required | path exists) { fail $"Downloaded theme archive is missing expected path: ($required)" } }
let cursor_parent = ($env.HOME | path join ".local" "share" "icons") let theme_parent = ($env.HOME | path join ".themes") let icon_parent = ($env.HOME | path join ".icons") mkdir $cursor_parent mkdir $theme_parent mkdir $icon_parent replace-directory $cursor_source ($cursor_parent | path join "BreezeX-RosePine-Linux") $assume_yes replace-directory $gtk_source ($theme_parent | path join "rose-pine-gtk") $assume_yes replace-directory $icon_source ($icon_parent | path join "rose-pine-icons") $assume_yes } catch {|error| rm --recursive --force $staging error make $error } rm --recursive --force $staging}
def final-validation [repo: path, --skip-fonts, --skip-secrets, --skip-themes] { section "Final automated validation" if not $skip_fonts { for family in $REQUIRED_FONT_FAMILIES { assert-font-family $family } } let brewfile = ($repo | path join "home" "dot_Brewfile") trust-third-party-brewfile-entries let filtered = (mktemp --tmpdir "dotfiles-base.Brewfile.XXXXXX") try { ( open --raw $brewfile | lines | where {|line| not ($line =~ '^\s*(cargo|vscode|flatpak)\s+"') } | str join "\n" ) + "\n" | save --force $filtered run-checked "checking Brewfile formulae and casks" { ^brew bundle check --no-upgrade --verbose --file $filtered } | ignore } catch {|error| if ($filtered | path exists) { rm --force $filtered } error make $error } rm --force $filtered
for package in (brewfile-names $brewfile "flatpak") { let result = (do { ^flatpak info --system $package } | complete) if $result.exit_code != 0 { fail $"Missing system Flatpak: ($package)" } } let cargo = if not (which cargo | is-empty) { (which cargo | first).path } else { run-checked "locating Cargo through rustup" { ^rustup which cargo } | str trim } let cargo_packages = (run-checked "reading installed Cargo packages" { run-external $cargo install -- --list } | lines) for package in (brewfile-names $brewfile "cargo") { if not ($cargo_packages | any {|line| $line | str starts-with $"($package) v" }) { fail $"Missing Cargo package: ($package)" } } let code = if not (which code | is-empty) { (which code | first).path } else { let prefix = (run-checked "locating the Homebrew prefix" { ^brew --prefix } | str trim) $prefix | path join "bin" "code" } let vscode_extensions = (run-checked "reading installed Visual Studio Code extensions" { run-external $code -- --list-extensions } | lines | each {|extension| $extension | str lowercase }) for extension in (brewfile-names $brewfile "vscode") { if ($extension | str lowercase) not-in $vscode_extensions { fail $"Missing Visual Studio Code extension: ($extension)" } } dotfiles audit run-checked "checking chezmoi status" { ^chezmoi status } | ignore
print "" print "Manual completion gates:" if $skip_secrets { print " - SOPS/SSH setup was skipped; complete it before relying on remote access." } if $skip_themes { print " - Theme installation was skipped." } print " - Configure and test the MEGAsync distrobox if it is still required." print " - Decide whether VS Code launch arguments are still required." print " - Log out and back in so the tracked environment.d variables enter the graphical session." print " - Verify Kitty, Foot, DMS, GTK, Qt, cursor, wallpaper, and default applications." print " - Move the intended jj bookmark and push deliberately; this script never commits, moves bookmarks, or pushes." print " - Prove the pushed state with a disposable fresh clone and chezmoi dry-run."}
def maybe-stop [stage: string, test_stop_after: string] { if $test_stop_after == $stage { print $"Test stop reached after stage: ($stage)" exit 0 }}
def show-plan [] { [ "Validate the cloned jj/chezmoi repository layout" "Initialize chezmoi, display diff and dry-run, then pause for approval" "Pause for the licensed-font flash drive and verify installed families" "Trust Brewfile-declared third-party entries and restore Brewfile packages" "Pause for each YubiKey; generate SOPS identity pointers; check and stage SSH secrets" "Install public Rose Pine wallpapers, cursor, GTK theme, and icons" "Apply chezmoi once, after dependencies and generated assets are ready" "Verify live SSH selection, then run package, dotfiles, and chezmoi checks" ]}
def main [ --plan # Print the stages without changing anything. --skip-fonts # Skip licensed-font restoration (not recommended for a fresh machine). --skip-secrets # Skip YubiKey/SOPS/SSH setup. --skip-themes # Skip network downloads and installation of public theme assets. --skip-auth-tests # Apply SSH secrets but skip per-YubiKey remote authentication prompts. --font-source: path # Use this mounted font directory instead of prompting for its path. --yes # Accept confirmation prompts; pauses requiring physical actions still print instructions. --test-stop-after: string # Test harness checkpoint; not part of the normal bootstrap workflow.] { let stop_after = ($test_stop_after | default "")
if $plan { return (show-plan) }
let repo = (repository-root) validate-repository $repo
for command in [chezmoi jj brew nu realpath mktemp chmod cp mv rm] { require-command $command } if not $skip_fonts { for command in [fc-cache fc-match] { require-command $command } } if not $skip_secrets { for command in [age-plugin-yubikey ykman sops ssh ssh-keygen grep] { require-command $command } validate-pcsc }
section "Post-clone bootstrap" print $"Repository: ($repo)" print "This script is interactive and stops on the first failed check." print "It does not commit, move jj bookmarks, push, or reboot." confirm "Begin post-clone bootstrap?" $yes
install-chezmoi $yes maybe-stop "chezmoi-review" $stop_after if not $skip_fonts { install-fonts $yes $font_source } maybe-stop "fonts" $stop_after install-packages $repo maybe-stop "packages" $stop_after
let ssh_verification = if $skip_secrets { null } else { apply-secrets $yes --skip-auth-tests=$skip_auth_tests } maybe-stop "secrets" $stop_after
if not $skip_themes { install-themes $yes } maybe-stop "themes" $stop_after
# This is intentionally the only real chezmoi apply. Until the encrypted SSH # outputs are ready, preserve the temporary Tangled config used to clone. apply-chezmoi maybe-stop "apply" $stop_after
if $ssh_verification != null { verify-live-ssh-selection $ssh_verification.assume_yes $ssh_verification.skip_auth_tests } final-validation $repo --skip-fonts=$skip_fonts --skip-secrets=$skip_secrets --skip-themes=$skip_themes}