diff --git a/.assets/provision/check_distro.sh b/.assets/check/check_distro.sh similarity index 88% rename from .assets/provision/check_distro.sh rename to .assets/check/check_distro.sh index 26a40b28..53c6ae3e 100755 --- a/.assets/provision/check_distro.sh +++ b/.assets/check/check_distro.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash : ' -.assets/provision/check_distro.sh | jq -.assets/provision/check_distro.sh array +.assets/check/check_distro.sh | jq +.assets/check/check_distro.sh array ' set -euo pipefail @@ -18,11 +18,11 @@ declare -A state=( ["git_email"]=$([ -n "$(git config --global --get user.email 2>/dev/null)" ] && echo true || echo false) ["gtkd"]=$(grep -Fqw "dark" /etc/profile.d/gtk_theme.sh 2>/dev/null && echo true || echo false) ["k8s_base"]=$([ -x '/usr/bin/kubectl' ] && echo true || echo false) + ["nix"]=$([ -d /nix/store ] && echo true || echo false) ["k8s_dev"]=$([ -x '/usr/local/bin/helm' ] && echo true || echo false) ["k8s_ext"]=$([ -x '/usr/local/bin/k3d' ] && echo true || echo false) ["oh_my_posh"]=$([ -x '/usr/bin/oh-my-posh' ] && echo true || echo false) - ["pixi"]=$([ -x "$HOME/.pixi/bin/pixi" ] && echo true || echo false) - ["python"]=$([ -x "$HOME/.local/bin/uv" ] && echo true || echo false) + ["python"]=$({ [ -x "$HOME/.local/bin/uv" ] || [ -x "$HOME/.nix-profile/bin/uv" ]; } && echo true || echo false) ["pwsh"]=$([ -x '/usr/bin/pwsh' ] && echo true || echo false) ["shell"]=$([ -x '/usr/bin/rg' ] && echo true || echo false) ["ssh_key"]=$([ -f "$HOME/.ssh/id_ed25519" ] && [ -f "$HOME/.ssh/id_ed25519.pub" ] && echo true || echo false) @@ -47,7 +47,7 @@ is_excluded() { # check if array parameter is provided if [ "${1:-}" = 'array' ]; then # keys to exclude - exclude_keys=('git_user' 'git_email' 'ssh_key' 'systemd' 'wslg' 'wsl_boot' 'gtkd') + exclude_keys=('git_user' 'git_email' 'nix' 'ssh_key' 'systemd' 'wslg' 'wsl_boot' 'gtkd') # print only the keys with true values, excluding specified keys for key in "${!state[@]}"; do if [ "${state[$key]}" = true ] && ! is_excluded "$key"; then diff --git a/.assets/provision/check_dns.sh b/.assets/check/check_dns.sh similarity index 75% rename from .assets/provision/check_dns.sh rename to .assets/check/check_dns.sh index 48bc96eb..e75d0551 100755 --- a/.assets/provision/check_dns.sh +++ b/.assets/check/check_dns.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash : ' -.assets/provision/check_dns.sh +.assets/check/check_dns.sh ' getent hosts github.com >/dev/null 2>&1 && echo true || echo false diff --git a/.assets/provision/check_ssl.sh b/.assets/check/check_ssl.sh similarity index 70% rename from .assets/provision/check_ssl.sh rename to .assets/check/check_ssl.sh index 72586ce4..024b22b3 100755 --- a/.assets/provision/check_ssl.sh +++ b/.assets/check/check_ssl.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -sudo .assets/provision/check_ssl.sh +sudo .assets/check/check_ssl.sh ' # install curl if not available @@ -27,12 +27,13 @@ if ! command -v curl >/dev/null 2>&1; then fi # check SSL connectivity +: "${NIX_ENV_TLS_PROBE_URL:=https://www.google.com}" if command -v curl >/dev/null 2>&1; then - curl -sS https://www.google.com >/dev/null 2>&1 && echo true || echo false + curl -sS "$NIX_ENV_TLS_PROBE_URL" >/dev/null 2>&1 && echo true || echo false elif command -v wget >/dev/null 2>&1; then - wget -q --spider https://www.google.com 2>&1 && echo true || echo false + wget -q --spider "$NIX_ENV_TLS_PROBE_URL" 2>&1 && echo true || echo false elif command -v python3 >/dev/null 2>&1; then - python3 -c "import urllib.request; urllib.request.urlopen('https://www.google.com')" 2>/dev/null && echo true || echo false + python3 -c "import urllib.request; urllib.request.urlopen('$NIX_ENV_TLS_PROBE_URL')" 2>/dev/null && echo true || echo false else echo unknown fi diff --git a/.assets/config/bash_cfg/aliases.sh b/.assets/config/bash_cfg/aliases.sh index e43daa39..4f300232 100644 --- a/.assets/config/bash_cfg/aliases.sh +++ b/.assets/config/bash_cfg/aliases.sh @@ -1,3 +1,6 @@ +# guard: skip when sourced by non-bash shells (e.g. dash via /etc/profile.d/) +[ -z "$BASH_VERSION" ] && return 0 + #region aliases export SWD=$(pwd) alias swd="echo $SWD" @@ -99,5 +102,4 @@ fi [ -x /usr/bin/rg ] && alias rg='rg --ignore-case' || true [ -x /usr/bin/fastfetch ] && alias ff='fastfetch' || true -[ -x /usr/local/bin/tfswitch ] && alias tfswitch="tfswitch --bin='$HOME/.local/bin/terraform'" || true #endregion diff --git a/.assets/config/bash_cfg/aliases_git.sh b/.assets/config/bash_cfg/aliases_git.sh index 99191212..339914e8 100644 --- a/.assets/config/bash_cfg/aliases_git.sh +++ b/.assets/config/bash_cfg/aliases_git.sh @@ -1,9 +1,9 @@ #region functions -function git_current_branch { +git_current_branch() { git branch --show-current } -function git_resolve_branch { +git_resolve_branch() { case "$1" in '') pattern='(^|/)dev(|el|elop|elopment)$|(^|/)ma(in|ster)$|(^|/)trunk$' @@ -30,12 +30,12 @@ function git_resolve_branch { [ -n "$br" ] && echo "$br" || echo "$pattern" } -function gsw { +gsw() { br=$(git_resolve_branch $1) git switch "$(git_resolve_branch "$br")" } -function grmb { +grmb() { br=$(git_resolve_branch $1) git reset "$(git merge-base "$(grt)"/"$br" HEAD)" } diff --git a/.assets/config/bash_cfg/aliases_nix.sh b/.assets/config/bash_cfg/aliases_nix.sh new file mode 100644 index 00000000..d45e2a19 --- /dev/null +++ b/.assets/config/bash_cfg/aliases_nix.sh @@ -0,0 +1,1034 @@ +#region common aliases +# navigation +alias ..='cd ../' +alias ...='cd ../../' +alias .3='cd ../../../' +alias .4='cd ../../../../' +alias .5='cd ../../../../../' +alias .6='cd ../../../../../../' +alias cd..='cd ../' + +# saved working directory +export SWD=$(pwd) +alias swd="echo $SWD" +alias cds="cd $SWD" + +# sudo +alias sudo='sudo ' +alias _='sudo' +alias please='sudo' + +# file operations +alias cp='cp -iv' +alias mv='mv -iv' +alias mkdir='mkdir -pv' +alias md='mkdir -p' +alias rd='rmdir' + +# tools +alias c='clear' +alias grep='grep -i --color=auto' +alias less='less -FRX' +alias nano='nano -W' +alias tree='tree -C' +alias vi='vim' +alias wget='wget -c' + +# info / shell +alias path='printf "${PATH//:/\\n}\n"' +alias src='source ~/.bashrc' +alias fix_stty='stty sane' +alias fix_term='printf "\ec"' + +# linux-specific +if [ -f /etc/os-release ]; then + alias osr='cat /etc/os-release' + alias systemctl='systemctl --no-pager' + if grep -qEw 'ID="?alpine' /etc/os-release 2>/dev/null; then + alias bsh='/usr/bin/env -i ash --noprofile --norc' + alias ls='ls -h --color=auto --group-directories-first' + else + alias bsh='/usr/bin/env -i bash --noprofile --norc' + alias ip='ip --color=auto' + alias ls='ls -h --color=auto --group-directories-first --time-style=long-iso' + fi +else + alias bsh='/usr/bin/env -i bash --noprofile --norc' +fi +#endregion + +#region dev tool aliases +_nb="$HOME/.nix-profile/bin" + +if [ -x "$_nb/eza" ]; then + alias eza='eza -g --color=auto --time-style=long-iso --group-directories-first --color-scale=all --git-repos' + alias l='eza -1' + alias lsa='eza -a' + alias ll='eza -lah' + alias lt='eza -Th' + alias lta='eza -aTh --git-ignore' + alias ltd='eza -DTh' + alias ltad='eza -aDTh --git-ignore' + alias llt='eza -lTh' + alias llta='eza -laTh --git-ignore' +else + alias l='ls -1' + alias lsa='ls -a' + alias ll='ls -lah' +fi + +[ -x "$_nb/bat" ] && alias batp='bat -pP' || true +[ -x "$_nb/rg" ] && alias rg='rg --ignore-case' || true +[ -x "$_nb/fastfetch" ] && alias ff='fastfetch' || true +[ -x "$_nb/pwsh" ] && alias pwsh='pwsh -NoProfileLoadTime' && alias p='pwsh -NoProfileLoadTime' || true +[ -x "$_nb/kubectx" ] && alias kc='kubectx' || true +[ -x "$_nb/kubens" ] && alias kn='kubens' || true +[ -x "$_nb/kubecolor" ] && alias kubectl='kubecolor' || true + +unset _nb +#endregion + +#region nix package management wrapper (apt/brew-like UX) +if command -v nix &>/dev/null; then + # packages.nix location (user state, not managed by setup.sh) + _NX_ENV_DIR="$HOME/.config/nix-env" + _NX_PKG_FILE="$_NX_ENV_DIR/packages.nix" + + # helper: read packages.nix into a newline-separated list on stdout + _nx_read_pkgs() { + [ -f "$_NX_PKG_FILE" ] && sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' "$_NX_PKG_FILE" + } + + # helper: write a sorted package list (one name per line on stdin) to packages.nix + _nx_write_pkgs() { + local tmp + tmp="$(mktemp)" + printf '[\n' >"$tmp" + sort -u | while IFS= read -r name; do + [ -n "$name" ] && printf ' "%s"\n' "$name" >>"$tmp" + done + printf ']\n' >>"$tmp" + mv "$tmp" "$_NX_PKG_FILE" + } + + # helper: apply changes by upgrading the nix profile + _nx_apply() { + printf "\e[96mapplying changes...\e[0m\n" + nix profile upgrade nix-env || { + printf "\e[31mnix profile upgrade failed\e[0m\n" >&2 + return 1 + } + printf "\e[32mdone.\e[0m\n" + } + + # helper: validate that a package name exists in nixpkgs (returns 0/1) + _nx_validate_pkg() { + nix eval "nixpkgs#${1}.name" &>/dev/null + } + + # helper: add packages to a scope .nix file, preserving the { pkgs }: with pkgs; [...] format + _nx_scope_file_add() { + local file="$1" + shift + local existing + existing="$(_nx_scope_pkgs "$file")" + local all_pkgs=() + if [ -n "$existing" ]; then + while IFS= read -r p; do + all_pkgs+=("$p") + done <<<"$existing" + fi + local p added=false + for p in "$@"; do + if printf '%s\n' "${all_pkgs[@]}" | grep -qx "$p" 2>/dev/null; then + printf "\e[33m%s is already in scope\e[0m\n" "$p" >&2 + else + all_pkgs+=("$p") + printf "\e[32madded %s\e[0m\n" "$p" >&2 + added=true + fi + done + [ "$added" = false ] && return 1 + local sorted + sorted="$(printf '%s\n' "${all_pkgs[@]}" | sort -u)" + local content="{ pkgs }: with pkgs; [" + while IFS= read -r p; do + [ -n "$p" ] && content+=$'\n'" $p" + done <<<"$sorted" + content+=$'\n'"]"$'\n' + printf '%s' "$content" >"$file" + return 0 + } + + # helper: extract package names from a scope .nix file (sed-based, instant) + # Scope files follow: { pkgs }: with pkgs; [ name1 name2 ... ] + _nx_scope_pkgs() { + local file="$1" + [ -f "$file" ] || return 0 + sed -n '/\[/,/\]/{ + s/^[[:space:]]*\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p + }' "$file" + } + + # helper: read enabled scopes from config.nix (newline-separated on stdout) + # Parses the generated config.nix with sed (instant) instead of nix eval (~1-2s). + _nx_scopes() { + local config_nix="$_NX_ENV_DIR/config.nix" + [ -f "$config_nix" ] || return 0 + sed -n '/scopes[[:space:]]*=[[:space:]]*\[/,/\]/{ + s/^[[:space:]]*"\([^"]*\)".*/\1/p + }' "$config_nix" + } + + # helper: read isInit from config.nix + _nx_is_init() { + local config_nix="$_NX_ENV_DIR/config.nix" + [ -f "$config_nix" ] || { echo "false"; return; } + sed -n -E 's/^[[:space:]]*isInit[[:space:]]*=[[:space:]]*(true|false).*/\1/p' "$config_nix" + } + + # helper: list all scope packages as "pkg\tscope" lines (base + configured scopes) + _nx_all_scope_pkgs() { + local scopes_dir="$_NX_ENV_DIR/scopes" + [ -d "$scopes_dir" ] || return 0 + local pkg + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t%s\n' "$pkg" "base" + done < <(_nx_scope_pkgs "$scopes_dir/base.nix") + if [ "$(_nx_is_init)" = "true" ]; then + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t%s\n' "$pkg" "base_init" + done < <(_nx_scope_pkgs "$scopes_dir/base_init.nix") + fi + local scopes s + scopes="$(_nx_scopes)" + if [ -n "$scopes" ]; then + while IFS= read -r s; do + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t%s\n' "$pkg" "$s" + done < <(_nx_scope_pkgs "$scopes_dir/$s.nix") + done <<<"$scopes" + fi + } + + nx() { + case "${1:-help}" in + search) + shift + [ $# -eq 0 ] && { + echo "Usage: nx search " >&2 + return 1 + } + local query="$*" + # search against the locked nixpkgs in ENV_DIR (offline-capable, stable interface) + nix search nixpkgs "$query" --json 2>/dev/null | + jq -r 'to_entries[] | "\u001b[1m* \(.key | split(".")[-1])\u001b[0m (\(.value.version))\n \(.value.description // "")\n"' + ;; + install | add) + shift + [ $# -eq 0 ] && { + echo "Usage: nx install [pkg...]" >&2 + return 1 + } + # validate packages exist in nixpkgs + local validated=() p + for p in "$@"; do + printf "\e[90mvalidating %s...\e[0m\r" "$p" + if _nx_validate_pkg "$p"; then + validated+=("$p") + else + printf "\e[31m%s not found in nixpkgs\e[0m\n" "$p" >&2 + fi + done + [ ${#validated[@]} -eq 0 ] && return 1 + # build scope package lookup (pkg\tscope lines) + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local current added=false + current="$(_nx_read_pkgs)" + { + [ -n "$current" ] && printf '%s\n' "$current" + for p in "${validated[@]}"; do + # check if already in a scope + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^${p} " 2>/dev/null | cut -f2)" + if [ -n "$in_scope" ]; then + printf "\e[33m%s is already installed in scope '%s'\e[0m\n" "$p" "$in_scope" >&2 + elif printf '%s\n' "$current" | grep -qx "$p" 2>/dev/null; then + printf "\e[33m%s is already installed (extra)\e[0m\n" "$p" >&2 + else + printf '%s\n' "$p" + printf "\e[32madded %s\e[0m\n" "$p" >&2 + added=true + fi + done + } | _nx_write_pkgs + [ "$added" = true ] && _nx_apply + ;; + remove | uninstall) + shift + [ $# -eq 0 ] && { + echo "Usage: nx remove [pkg...]" >&2 + return 1 + } + # check for scope-managed packages first + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local filtered_args=() + local p + for p in "$@"; do + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^${p} " 2>/dev/null | cut -f2)" + if [ -n "$in_scope" ]; then + printf "\e[33m%s is managed by scope '%s' - use: nx scope remove %s\e[0m\n" "$p" "$in_scope" "$in_scope" >&2 + else + filtered_args+=("$p") + fi + done + [ ${#filtered_args[@]} -eq 0 ] && return 0 + local current removed=false + current="$(_nx_read_pkgs)" + if [ -z "$current" ]; then + printf "\e[33mNo user packages installed.\e[0m\n" + return 0 + fi + local remove_pattern=" ${filtered_args[*]} " + { + while IFS= read -r p; do + if [[ " $remove_pattern " == *" $p "* ]]; then + printf "\e[32mremoved %s\e[0m\n" "$p" >&2 + removed=true + else + printf '%s\n' "$p" + fi + done <<<"$current" + } | _nx_write_pkgs + # warn about packages not found in extra + for p in "${filtered_args[@]}"; do + if ! printf '%s\n' "$current" | grep -qx "$p" 2>/dev/null; then + printf "\e[33m%s is not installed - skipping\e[0m\n" "$p" >&2 + fi + done + [ "$removed" = true ] && _nx_apply + ;; + upgrade | update) + shift + printf "\e[96mupgrading packages...\e[0m\n" + local _pinned_rev="" + [ -f "$_NX_ENV_DIR/pinned_rev" ] && _pinned_rev="$(tr -d '[:space:]' <"$_NX_ENV_DIR/pinned_rev")" + if [ -n "$_pinned_rev" ]; then + printf "\e[96mpinning nixpkgs to %s\e[0m\n" "$_pinned_rev" + nix flake lock --override-input nixpkgs "github:nixos/nixpkgs/$_pinned_rev" --flake "$_NX_ENV_DIR" 2>/dev/null || + printf "\e[33mflake lock failed - using existing lock\e[0m\n" >&2 + else + nix flake update --flake "$_NX_ENV_DIR" 2>/dev/null || + printf "\e[33mflake update failed (network issue?) - using existing lock\e[0m\n" >&2 + fi + nix profile upgrade nix-env || { + printf "\e[31mnix profile upgrade failed\e[0m\n" >&2 + return 1 + } + printf "\e[32mdone.\e[0m\n" + ;; + list | ls) + local env_dir="$_NX_ENV_DIR" + local scopes_dir="$env_dir/scopes" + # collect all packages with scope annotations, then sort + local all_pkgs + all_pkgs="$({ + # base packages (always present) + if [ -d "$scopes_dir" ]; then + local pkg + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t(base)\n' "$pkg" + done < <(_nx_scope_pkgs "$scopes_dir/base.nix") + # base_init packages (when isInit is true) + if [ "$(_nx_is_init)" = "true" ]; then + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t(base_init)\n' "$pkg" + done < <(_nx_scope_pkgs "$scopes_dir/base_init.nix") + fi + fi + # configured scopes + local scopes s + scopes="$(_nx_scopes)" + if [ -n "$scopes" ]; then + while IFS= read -r s; do + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t(%s)\n' "$pkg" "$s" + done < <(_nx_scope_pkgs "$scopes_dir/$s.nix") + done <<<"$scopes" + fi + # user packages (extra) + local pkgs + pkgs="$(_nx_read_pkgs)" + if [ -n "$pkgs" ]; then + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t(extra)\n' "$pkg" + done <<<"$pkgs" + fi + } | sort -t$'\t' -k1,1 -u)" + if [ -n "$all_pkgs" ]; then + while IFS=$'\t' read -r name scope; do + printf " \e[1m*\e[0m %-24s \e[90m%s\e[0m\n" "$name" "$scope" + done <<<"$all_pkgs" + else + printf "\e[33mNo packages installed.\e[0m Use \e[1mnx install \e[0m or run \e[1mnix/setup.sh\e[0m.\n" + fi + ;; + scope) + shift + local env_dir="$_NX_ENV_DIR" + local config_nix="$env_dir/config.nix" + local scopes_dir="$env_dir/scopes" + case "${1:-help}" in + list | ls) + local scopes + scopes="$(_nx_scopes)" + if [ -n "$scopes" ]; then + printf "\e[96mInstalled scopes:\e[0m\n" + while IFS= read -r s; do + printf " \e[1m*\e[0m %s\n" "$s" + done <<<"$scopes" + else + printf "\e[33mNo scopes configured.\e[0m Run \e[1mnix/setup.sh\e[0m to initialize.\n" + fi + ;; + show) + shift + [ $# -eq 0 ] && { + echo "Usage: nx scope show " >&2 + return 1 + } + local scope_file="$scopes_dir/$1.nix" + if [ ! -f "$scope_file" ]; then + printf "\e[31mScope '%s' not found.\e[0m\n" "$1" >&2 + return 1 + fi + printf "\e[96m%s:\e[0m\n" "$1" + local pkg + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf " \e[1m*\e[0m %s\n" "$pkg" + done < <(_nx_scope_pkgs "$scope_file") + ;; + tree) + local scopes s + # base is always present + if [ -d "$scopes_dir" ]; then + printf "\e[96mbase:\e[0m\n" + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf " \e[1m*\e[0m %s\n" "$pkg" + done < <(_nx_scope_pkgs "$scopes_dir/base.nix") + fi + scopes="$(_nx_scopes)" + if [ -n "$scopes" ]; then + while IFS= read -r s; do + printf "\e[96m%s:\e[0m\n" "$s" + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf " \e[1m*\e[0m %s\n" "$pkg" + done < <(_nx_scope_pkgs "$scopes_dir/$s.nix") + done <<<"$scopes" + fi + local pkgs + pkgs="$(_nx_read_pkgs)" + if [ -n "$pkgs" ]; then + printf "\e[96mextra:\e[0m\n" + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf " \e[1m*\e[0m %s\n" "$pkg" + done <<<"$pkgs" + fi + ;; + remove | rm) + shift + [ $# -eq 0 ] && { + echo "Usage: nx scope remove [scope...]" >&2 + return 1 + } + if [ ! -f "$config_nix" ]; then + printf "\e[31mNo nix-env config found. Run nix/setup.sh to initialize.\e[0m\n" >&2 + return 1 + fi + local current_scopes is_init + current_scopes="$(_nx_scopes)" + is_init="$(_nx_is_init)" + if [ -z "$current_scopes" ]; then + printf "\e[33mNo scopes configured - nothing to remove.\e[0m\n" + return 0 + fi + local ov_dir="$env_dir/local" + if [ -n "${NIX_ENV_OVERLAY_DIR:-}" ] && [ -d "$NIX_ENV_OVERLAY_DIR" ]; then + ov_dir="$NIX_ENV_OVERLAY_DIR" + fi + # build removal set: for each name, match both "name" and "local_name" in config + local remove_set=" " + local r + for r in "$@"; do + remove_set+="$r local_$r " + done + local remaining=() removed=false + while IFS= read -r s; do + if [[ " $remove_set " == *" $s "* ]]; then + printf "\e[32mremoved scope: %s\e[0m\n" "${s#local_}" + removed=true + else + remaining+=("$s") + fi + done <<<"$current_scopes" + # clean up overlay + installed scope files + for r in "$@"; do + rm -f "$ov_dir/scopes/$r.nix" "$scopes_dir/local_$r.nix" + if [ -f "$scopes_dir/$r.nix" ] && [[ "$r" != local_* ]]; then + : # repo scope - don't delete the file, setup.sh will re-sync it + fi + done + # report names not found anywhere + for r in "$@"; do + if [[ " $remove_set " == *" $r "* ]]; then + local _found=false + printf '%s\n' "$current_scopes" | grep -qx "$r" 2>/dev/null && _found=true + printf '%s\n' "$current_scopes" | grep -qx "local_$r" 2>/dev/null && _found=true + [ "$_found" = false ] && printf "\e[33mscope '%s' is not configured - skipping\e[0m\n" "$r" >&2 + fi + done + if [ "$removed" = false ]; then + return 0 + fi + local nix_scopes="" + local s + for s in "${remaining[@]}"; do + nix_scopes+=" \"$s\""$'\n' + done + local tmp + tmp="$(mktemp)" + cat >"$tmp" < [pkg...]" >&2 + return 1 + } + local name="${1//-/_}" + shift + local ov_dir="$env_dir/local" + if [ -n "${NIX_ENV_OVERLAY_DIR:-}" ] && [ -d "$NIX_ENV_OVERLAY_DIR" ]; then + ov_dir="$NIX_ENV_OVERLAY_DIR" + fi + local scope_file="$ov_dir/scopes/$name.nix" + local created=false + if [ ! -f "$scope_file" ]; then + mkdir -p "$ov_dir/scopes" "$scopes_dir" + printf '{ pkgs }: with pkgs; []\n' >"$scope_file" + command cp "$scope_file" "$scopes_dir/local_$name.nix" + if [ -f "$config_nix" ]; then + local current_scopes + current_scopes="$(_nx_scopes)" + if ! printf '%s\n' "$current_scopes" | grep -qx "local_$name" 2>/dev/null; then + local is_init + is_init="$(_nx_is_init)" + local all_scopes=() + if [ -n "$current_scopes" ]; then + while IFS= read -r s; do + all_scopes+=("$s") + done <<<"$current_scopes" + fi + all_scopes+=("local_$name") + local nix_scopes="" + for s in "${all_scopes[@]}"; do + nix_scopes+=" \"$s\""$'\n' + done + cat >"$config_nix" <&2 + fi + done + if [ ${#validated[@]} -gt 0 ]; then + _nx_scope_file_add "$scope_file" "${validated[@]}" + command cp "$scope_file" "$scopes_dir/local_$name.nix" + _nx_apply + fi + elif [ "$created" = true ]; then + printf "Add packages: \e[1mnx scope add %s [pkg...]\e[0m\n" "$name" + else + printf "\e[33mScope '%s' already exists.\e[0m Add packages: nx scope add %s \n" "$name" "$name" + fi + ;; + edit) + shift + [ $# -eq 0 ] && { + echo "Usage: nx scope edit " >&2 + return 1 + } + local name="${1//-/_}" + local ov_dir="$env_dir/local" + if [ -n "${NIX_ENV_OVERLAY_DIR:-}" ] && [ -d "$NIX_ENV_OVERLAY_DIR" ]; then + ov_dir="$NIX_ENV_OVERLAY_DIR" + fi + local scope_file="$ov_dir/scopes/$name.nix" + if [ ! -f "$scope_file" ]; then + printf "\e[31mScope '%s' not found.\e[0m Create it first: nx scope add %s\n" "$name" "$name" >&2 + return 1 + fi + "${EDITOR:-vi}" "$scope_file" + command cp "$scope_file" "$scopes_dir/local_$name.nix" + printf "\e[32mSynced scope '%s'.\e[0m Run \e[1mnx upgrade\e[0m to apply.\n" "$name" + ;; + *) + cat <<'EOF' +Usage: nx scope [args] + +Commands: + list List enabled scopes + show Show packages in a scope + tree Show all scopes with their packages + add [pkg...] Create a scope or add packages to it + edit Open a scope file in $EDITOR + remove [scope...] Remove one or more scopes +EOF + ;; + esac + ;; + profile) + shift + # shell rc profile block management (not the nix profile) + local _pb_marker="nix-env managed" + # legacy marker strings injected by the old grep-then-append pattern + local _pb_legacy_markers=( + 'aliases_nix' 'aliases_git' 'aliases_kubectl' 'functions.sh' + 'fzf --bash' 'fzf --zsh' 'uv generate-shell-completion' + 'kubectl completion' 'Makefile' + 'NODE_EXTRA_CA_CERTS' 'REQUESTS_CA_BUNDLE' 'CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE' + 'nix-profile/bin:' '.local/bin' 'nix-daemon.sh' + ) + # rc files managed by setup + local _pb_rc_files=("$HOME/.bashrc" "$HOME/.zshrc") + + # source manage_block if available + local _pb_lib + _pb_lib="$(dirname "$(realpath "${BASH_SOURCE[0]}" 2>/dev/null || echo "$HOME/.config/bash/aliases_nix.sh")")" + local _pb_lib_path + for _pb_lib_path in \ + "$_pb_lib/../../lib/profile_block.sh" \ + "$HOME/.config/bash/../../../.assets/lib/profile_block.sh"; do + [ -f "$_pb_lib_path" ] && { source "$_pb_lib_path"; break; } + done + + case "${1:-help}" in + doctor) + # Check managed block health in rc files + local _pb_ok=true + local _pb_rc + for _pb_rc in "${_pb_rc_files[@]}"; do + [ -f "$_pb_rc" ] || continue + local _pb_count + _pb_count="$(grep -cF "# >>> $_pb_marker >>>" "$_pb_rc" 2>/dev/null || true)" + if [ "$_pb_count" -eq 0 ] 2>/dev/null; then + printf "\e[33m[warn] no managed block in %s - run: nix/configure/profiles.sh\e[0m\n" "$_pb_rc" >&2 + _pb_ok=false + elif [ "$_pb_count" -gt 1 ] 2>/dev/null; then + printf "\e[31m[fail] %s duplicate managed blocks in %s - run: nx profile migrate\e[0m\n" \ + "$_pb_count" "$_pb_rc" >&2 + _pb_ok=false + fi + local _pb_m + for _pb_m in "${_pb_legacy_markers[@]}"; do + if grep -qF "$_pb_m" "$_pb_rc" 2>/dev/null; then + # only warn if it's outside the managed block + local _pb_outside + _pb_outside="$(awk -v begin="# >>> $_pb_marker >>>" -v end="# <<< $_pb_marker <<<" ' + $0==begin{skip=1;next} skip&&$0==end{skip=0;next} !skip{print} + ' "$_pb_rc" | grep -cF "$_pb_m" 2>/dev/null || true)" + if [ "$_pb_outside" -gt 0 ] 2>/dev/null; then + printf "\e[33m[warn] legacy injection '%s' found outside managed block in %s\e[0m\n" \ + "$_pb_m" "$_pb_rc" >&2 + _pb_ok=false + fi + fi + done + done + [ "$_pb_ok" = true ] && printf "\e[32m[ok] managed profile blocks look healthy\e[0m\n" + [ "$_pb_ok" = true ] || return 1 + ;; + migrate) + # Remove legacy injections and re-run profiles.sh / profiles.zsh + local _dry_run=false + [ "${2:-}" = "--dry-run" ] && _dry_run=true + printf "\e[96mScanning for legacy profile injections...\e[0m\n" + local _pb_rc _pb_m _found=false + for _pb_rc in "${_pb_rc_files[@]}"; do + [ -f "$_pb_rc" ] || continue + # only scan lines outside the managed block + local _outside + _outside="$(awk -v begin="# >>> $_pb_marker >>>" -v end="# <<< $_pb_marker <<<" ' + $0==begin{skip=1;next} skip&&$0==end{skip=0;next} !skip{print} + ' "$_pb_rc")" + for _pb_m in "${_pb_legacy_markers[@]}"; do + if printf '%s\n' "$_outside" | grep -qF "$_pb_m" 2>/dev/null; then + _found=true + printf " found legacy marker '%s' in %s\n" "$_pb_m" "$_pb_rc" + fi + done + done + if [ "$_found" = false ]; then + printf "\e[32mNo legacy injections found. Nothing to migrate.\e[0m\n" + return 0 + fi + if [ "$_dry_run" = true ]; then + printf "\e[96m(dry-run) would remove the above markers and regenerate managed block.\e[0m\n" + return 0 + fi + printf "\e[96mRemoving legacy injections...\e[0m\n" + for _pb_rc in "${_pb_rc_files[@]}"; do + [ -f "$_pb_rc" ] || continue + # determine which legacy markers exist outside managed block in this file + local _outside + _outside="$(awk -v begin="# >>> $_pb_marker >>>" -v end="# <<< $_pb_marker <<<" ' + $0==begin{skip=1;next} skip&&$0==end{skip=0;next} !skip{print} + ' "$_pb_rc")" + local _has_legacy=false + for _pb_m in "${_pb_legacy_markers[@]}"; do + printf '%s\n' "$_outside" | grep -qF "$_pb_m" 2>/dev/null && _has_legacy=true + done + [ "$_has_legacy" = false ] && continue + local _backup="${_pb_rc}.nixenv-backup-$(date +%Y%m%d%H%M%S)" + command cp -p "$_pb_rc" "$_backup" + # remove legacy lines (and blank line immediately before them) + # only outside the managed block + for _pb_m in "${_pb_legacy_markers[@]}"; do + local _tmp + _tmp="$(mktemp)" + awk -v begin="# >>> $_pb_marker >>>" -v end="# <<< $_pb_marker <<<" \ + -v marker="$_pb_m" ' + $0==begin { in_block=1; print; next } + in_block&&$0==end{ in_block=0; print; next } + in_block { print; next } + /^[[:space:]]*$/ { prev_blank=1; buf=$0; next } + index($0, marker) && !in_block { + prev_blank=0; buf="" + next + } + { + if (prev_blank) { print buf } + prev_blank=0; buf="" + print + } + END { if (prev_blank) print buf } + ' "$_pb_rc" >"$_tmp" + mv -f "$_tmp" "$_pb_rc" + done + printf "\e[32mmigrated %s (backup: %s)\e[0m\n" "$_pb_rc" "$_backup" + done + printf "\e[96mRe-run nix/configure/profiles.sh (and profiles.zsh if using zsh) to install managed block.\e[0m\n" + ;; + uninstall) + # Remove the managed block from all rc files + if ! command -v manage_block &>/dev/null 2>&1 && ! type manage_block &>/dev/null 2>&1; then + printf "\e[31mmanage_block not loaded - cannot uninstall profile\e[0m\n" >&2 + return 1 + fi + local _pb_rc + for _pb_rc in "${_pb_rc_files[@]}"; do + [ -f "$_pb_rc" ] || continue + manage_block "$_pb_rc" "$_pb_marker" remove + printf "\e[32mremoved managed block from %s\e[0m\n" "$_pb_rc" + done + printf "\e[96mProfile blocks removed. Sourced files in ~/.config/bash/ are untouched.\e[0m\n" + ;; + help | *) + cat <<'PROFILE_HELP' +Usage: nx profile + +Commands: + doctor Check managed block health in rc files + migrate Remove legacy injected lines (dry-run with --dry-run) + uninstall Remove managed blocks from ~/.bashrc and ~/.zshrc + help Show this help +PROFILE_HELP + ;; + esac + ;; + overlay) + shift + local env_dir="$_NX_ENV_DIR" + local ov_dir="" + if [ -n "${NIX_ENV_OVERLAY_DIR:-}" ] && [ -d "$NIX_ENV_OVERLAY_DIR" ]; then + ov_dir="$NIX_ENV_OVERLAY_DIR" + elif [ -d "$env_dir/local" ]; then + ov_dir="$env_dir/local" + fi + case "${1:-list}" in + list | ls) + if [ -z "$ov_dir" ]; then + printf "\e[33mNo overlay directory active.\e[0m\n" + printf "Create one at %s/local/ or set NIX_ENV_OVERLAY_DIR.\n" "$env_dir" + return 0 + fi + printf "\e[96mOverlay directory:\e[0m %s\n" "$ov_dir" + local f hdr + hdr=false + for f in "$ov_dir/scopes"/*.nix; do + [ -f "$f" ] || continue + [ "$hdr" = false ] && printf "\e[96mScopes:\e[0m\n" && hdr=true + printf " \e[1m*\e[0m %s\n" "$(basename "$f" .nix)" + done + hdr=false + for f in "$ov_dir/bash_cfg"/*.sh; do + [ -f "$f" ] || continue + [ "$hdr" = false ] && printf "\e[96mShell config:\e[0m\n" && hdr=true + printf " \e[1m*\e[0m %s\n" "$(basename "$f")" + done + local hook_dir + for hook_dir in pre-setup.d post-setup.d; do + hdr=false + for f in "$ov_dir/hooks/$hook_dir"/*.sh; do + [ -f "$f" ] || continue + [ "$hdr" = false ] && printf "\e[96mHooks (%s):\e[0m\n" "$hook_dir" && hdr=true + printf " \e[1m*\e[0m %s\n" "$(basename "$f")" + done + done + ;; + status) + local scopes_dir="$env_dir/scopes" + printf "\e[96mOverlay:\e[0m " + if [ -n "$ov_dir" ]; then + printf "%s\n" "$ov_dir" + else + printf "\e[33mnone\e[0m\n" + fi + local f hdr name indicator + hdr=false + for f in "$scopes_dir"/local_*.nix; do + [ -f "$f" ] || continue + [ "$hdr" = false ] && printf "\e[96mOverlay scopes (synced):\e[0m\n" && hdr=true + name="$(basename "$f" .nix)" + name="${name#local_}" + indicator="" + if [ -n "$ov_dir" ] && [ -f "$ov_dir/scopes/$name.nix" ]; then + if ! cmp -s "$ov_dir/scopes/$name.nix" "$f" 2>/dev/null; then + indicator=" \e[33m(modified)\e[0m" + fi + else + indicator=" \e[33m(source missing)\e[0m" + fi + printf " \e[1m*\e[0m %s%b\n" "$name" "$indicator" + done + [ "$hdr" = false ] && printf "\e[90mNo overlay scopes synced.\e[0m\n" + if [ -n "$ov_dir" ] && [ -d "$ov_dir/bash_cfg" ]; then + hdr=false + for f in "$ov_dir/bash_cfg"/*.sh; do + [ -f "$f" ] || continue + [ "$hdr" = false ] && printf "\e[96mOverlay shell config:\e[0m\n" && hdr=true + local bname installed + bname="$(basename "$f")" + installed="$HOME/.config/bash/$bname" + indicator="" + if [ -f "$installed" ]; then + if cmp -s "$f" "$installed" 2>/dev/null; then + indicator=" \e[32m(synced)\e[0m" + else + indicator=" \e[33m(differs)\e[0m" + fi + else + indicator=" \e[33m(not installed)\e[0m" + fi + printf " \e[1m*\e[0m %s%b\n" "$bname" "$indicator" + done + fi + ;; + help | *) + cat <<'OVERLAY_HELP' +Usage: nx overlay + +Commands: + list Show active overlay directory and contents + status Show sync status of overlay files + help Show this help +OVERLAY_HELP + ;; + esac + ;; + prune) + # remove stale imperative profile entries (anything not 'nix-env') + local profile_json stale_names name + profile_json="$(nix profile list --json 2>/dev/null)" || { + printf "\e[31mFailed to list nix profile.\e[0m\n" >&2 + return 1 + } + stale_names="$(printf '%s\n' "$profile_json" | jq -r '.elements | keys[] | select(. != "nix-env")')" + if [ -z "$stale_names" ]; then + printf "\e[32mNo stale profile entries found.\e[0m\n" + return 0 + fi + printf "\e[96mStale profile entries:\e[0m\n" + while IFS= read -r name; do + printf " \e[1m*\e[0m %s\n" "$name" + done <<<"$stale_names" + printf "\e[96mRemoving...\e[0m\n" + while IFS= read -r name; do + nix profile remove "$name" && printf "\e[32mremoved %s\e[0m\n" "$name" + done <<<"$stale_names" + printf "\e[32mdone.\e[0m Run \e[1mnx gc\e[0m to free disk space.\n" + ;; + gc | clean) + nix profile wipe-history + nix store gc + ;; + rollback) + nix profile rollback || { + printf "\e[31mnix profile rollback failed\e[0m\n" >&2 + return 1 + } + printf "\e[32mRolled back to previous profile generation.\e[0m\n" + printf "Restart your shell to apply changes.\n" + ;; + pin) + shift + local _pin_file="$_NX_ENV_DIR/pinned_rev" + case "${1:-show}" in + set) + shift + local _rev="${1:-}" + if [ -z "$_rev" ]; then + local _lock="$_NX_ENV_DIR/flake.lock" + [ -f "$_lock" ] || { + printf "\e[31mNo flake.lock found - run nx upgrade first.\e[0m\n" >&2 + return 1 + } + _rev="$(jq -r '.nodes.nixpkgs.locked.rev' "$_lock" 2>/dev/null)" || true + [ -n "$_rev" ] && [ "$_rev" != "null" ] || { + printf "\e[31mCould not read nixpkgs revision from flake.lock.\e[0m\n" >&2 + return 1 + } + fi + printf '%s\n' "$_rev" >"$_pin_file" + printf "\e[32mPinned nixpkgs to %s\e[0m\n" "$_rev" + ;; + remove | rm) + if [ -f "$_pin_file" ]; then + rm "$_pin_file" + printf "\e[32mPin removed.\e[0m Upgrades will use latest nixpkgs-unstable.\n" + else + printf "\e[90mNo pin set.\e[0m\n" + fi + ;; + show) + if [ -f "$_pin_file" ]; then + printf "\e[96mPinned to:\e[0m %s\n" "$(tr -d '[:space:]' <"$_pin_file")" + else + printf "\e[90mNo pin set.\e[0m Upgrades use latest nixpkgs-unstable.\n" + fi + ;; + help | *) + cat <<'PIN_HELP' +Usage: nx pin + +Commands: + set [rev] Pin nixpkgs to a commit SHA (default: current flake.lock rev) + remove Remove the pin (use latest nixpkgs-unstable) + show Show current pin status (default) + help Show this help + +The pin takes effect on the next `nx upgrade` or `nix/setup.sh --upgrade`. +PIN_HELP + ;; + esac + ;; + doctor) + local _dr_script + for _dr_script in \ + "$(dirname "$(realpath "${BASH_SOURCE[0]}" 2>/dev/null || echo "$HOME/.config/bash/aliases_nix.sh")")/../../.assets/lib/nx_doctor.sh" \ + "$HOME/.config/nix-env/nx_doctor.sh"; do + if [ -f "$_dr_script" ]; then + bash "$_dr_script" "${@:2}" + return $? + fi + done + printf '\e[31mnx doctor not found\e[0m\n' >&2 + return 1 + ;; + version) + devenv + ;; + help | -h | --help) + cat <<'EOF' +Usage: nx [args] + +Commands: + search Search for packages in nixpkgs + install [pkg...] Install packages (declarative, via packages.nix) + remove [pkg...] Remove user-installed packages + upgrade Upgrade all packages to latest nixpkgs + rollback Roll back to previous profile generation + pin Pin nixpkgs to a specific revision (nx pin help) + list List all installed packages with scope annotations + scope Manage scopes (nx scope help) + overlay Manage overlay directory (nx overlay help) + profile Manage shell rc profile blocks (nx profile help) + doctor Run health checks on the nix-env environment + prune Remove stale imperative profile entries + gc Garbage collect old versions and free disk space + version Show installation provenance and version info + help Show this help +EOF + ;; + *) + printf "\e[31mUnknown command: %s\e[0m\n" "$1" >&2 + nx help + return 1 + ;; + esac + } + + # bash completion for nx (bash-only builtins: COMP_WORDS, compgen, complete) + if [ -n "$BASH_VERSION" ]; then + _nx_completions() { + local cur prev + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD - 1]}" + + if [ "$COMP_CWORD" -eq 1 ]; then + while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen -W "search install remove upgrade rollback pin list scope overlay profile doctor prune gc version help" -- "$cur") + elif [ "$COMP_CWORD" -eq 2 ] && [ "$prev" = "scope" ]; then + while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen -W "list show tree add edit remove" -- "$cur") + elif [ "$COMP_CWORD" -eq 2 ] && [ "$prev" = "overlay" ]; then + while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen -W "list status help" -- "$cur") + elif [ "$COMP_CWORD" -eq 2 ] && [ "$prev" = "pin" ]; then + while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen -W "set remove show help" -- "$cur") + elif [ "$COMP_CWORD" -eq 2 ] && [ "$prev" = "profile" ]; then + while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen -W "doctor migrate uninstall help" -- "$cur") + fi + } + complete -F _nx_completions nx + fi +fi +#endregion diff --git a/.assets/config/bash_cfg/functions.sh b/.assets/config/bash_cfg/functions.sh index ff97b051..604fcdf8 100644 --- a/.assets/config/bash_cfg/functions.sh +++ b/.assets/config/bash_cfg/functions.sh @@ -1,15 +1,21 @@ : ' . .assets/config/bash_cfg/functions.sh ' + # *Function to display system information in a user-friendly format -function sysinfo { +sysinfo() { # dot-source os-release file . /etc/os-release # get cpu info - cpu_name="$(sed -En '/^model name\s*: (.+)/{s//\1/;p;q}' /proc/cpuinfo)" - cpu_cores="$(sed -En '/^cpu cores\s*: ([0-9]+)/{s//\1/;p;q}' /proc/cpuinfo)" + cpu_name="$(sed -En '/^model name[[:space:]]*: (.+)/{ + s//\1/;p;q + }' /proc/cpuinfo)" + cpu_cores="$(sed -En '/^cpu cores[[:space:]]*: ([0-9]+)/{ + s//\1/;p;q + }' /proc/cpuinfo)" # calculate memory usage - mapfile -t mem_inf < <(awk -F ':|kB' '/MemTotal:|MemAvailable:/ {print $2}' /proc/meminfo) + local mem_inf=() + while IFS= read -r _l; do mem_inf+=("$_l"); done < <(awk -F ':|kB' '/MemTotal:|MemAvailable:/ {print $2}' /proc/meminfo) mem_total=${mem_inf[0]} mem_used=$((mem_total - mem_inf[1])) mem_perc=$(awk '{printf "%.0f", $1 * $2 / $3}' <<<"$mem_used 100 $mem_total") @@ -40,105 +46,144 @@ function sysinfo { alias gsi='sysinfo' # *Function for fixing Python SSL certificate issues by adding custom certificates to certifi's cacert.pem -function fixcertpy { - # check if pip and openssl are available - type pip &>/dev/null && true || return 1 - type openssl &>/dev/null && true || return 1 - - # determine system id - SYS_ID="$(sed -En '/^ID.*(fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" - - # specify path for installed custom certificates - case $SYS_ID in - fedora) - CERT_PATH='/etc/pki/ca-trust/source/anchors' - ;; - debian | ubuntu) - CERT_PATH='/usr/local/share/ca-certificates' - ;; - opensuse) - CERT_PATH='/usr/share/pki/trust/anchors' - ;; - *) - return 0 - ;; - esac +# Usage: fixcertpy [path ...] +# If paths are provided, patches only those cacert.pem files. +# If no paths are given, auto-discovers Python certifi bundles (venv, pip). +fixcertpy() { + # openssl is always needed for serial/fingerprint extraction + type openssl &>/dev/null || return 1 + + # load custom certificates into in-memory PEM array + local cert_pems=() + local CERT_BUNDLE="$HOME/.config/certs/ca-custom.crt" + if [ -f "$CERT_BUNDLE" ]; then + # parse individual PEM certs from bundle into array + local current_pem="" + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + cert_pems+=("$current_pem") + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done < "$CERT_BUNDLE" + else + # fall back to distro-specific cert paths + local SYS_ID + SYS_ID="$(sed -En '/^ID.*(alpine|fedora|debian|ubuntu|opensuse).*/{ + s//\1/;p;q + }' /etc/os-release 2>/dev/null)" + local CERT_PATH + case ${SYS_ID:-} in + alpine) + return 0 + ;; + fedora) + CERT_PATH='/etc/pki/ca-trust/source/anchors' + ;; + debian | ubuntu) + CERT_PATH='/usr/local/share/ca-certificates' + ;; + opensuse) + CERT_PATH='/usr/share/pki/trust/anchors' + ;; + *) + return 0 + ;; + esac + # read each .crt file into the PEM array + for f in "$CERT_PATH"/*.crt; do + [ -f "$f" ] && cert_pems+=("$(cat "$f")") || true + done + fi - # get list of installed certificates - mapfile -t cert_paths < <(ls "$CERT_PATH"/*.crt 2>/dev/null || true) - if [ "${#cert_paths[@]}" -eq 0 ]; then - printf '\033[36mno custom certificates found in \033[4m%s\n\033[0m' "$CERT_PATH" >&2 + if [ "${#cert_pems[@]}" -eq 0 ]; then + printf '\033[36mno custom certificates found\033[0m\n' >&2 return 0 fi - certify_paths=() - # determine venv certifi cacert.pem path - if . .venv/bin/activate 2>/dev/null; then - [ -x $HOME/.local/bin/uv ] && SHOW=$(uv pip show -f certifi 2>/dev/null) || true - [ -n "$SHOW" ] && true || SHOW=$(pip show -f certifi 2>/dev/null) + # discover certifi cacert.pem bundles + local certifi_paths=() + if [ $# -gt 0 ]; then + # use explicitly provided paths + for p in "$@"; do + [ -f "$p" ] && certifi_paths+=("$p") + done + else + # auto-discover Python certifi bundles + type pip &>/dev/null || return 1 + local SHOW location cacert + # check venv certifi + if . .venv/bin/activate 2>/dev/null; then + SHOW="" + { [ -x "$HOME/.local/bin/uv" ] || [ -x "$HOME/.nix-profile/bin/uv" ]; } && SHOW=$(uv pip show -f certifi 2>/dev/null) || true + [ -n "$SHOW" ] || SHOW=$(pip show -f certifi 2>/dev/null) || true + if [ -n "$SHOW" ]; then + location=$(echo "$SHOW" | sed -n 's/^Location: //p') + if [ -n "$location" ]; then + cacert=$(echo "$SHOW" | grep -oE '[^[:space:]]+cacert\.pem$') + [ -n "$cacert" ] && certifi_paths+=("${location}/${cacert}") + fi + fi + fi + # check pip certifi + SHOW=$(pip show -f certifi 2>/dev/null) || true if [ -n "$SHOW" ]; then - location=$(echo "$SHOW" | grep -oP '^Location: \K.+') + location=$(echo "$SHOW" | sed -n 's/^Location: //p') if [ -n "$location" ]; then - cacert=$(echo "$SHOW" | grep -oE '\S+cacert\.pem$') - if [ -n "$cacert" ]; then - certify_paths+=("${location}/${cacert}") - fi + cacert=$(echo "$SHOW" | grep -oE '[^[:space:]]+cacert\.pem$') + [ -n "$cacert" ] && certifi_paths+=("${location}/${cacert}") fi fi - fi - # determine pip cacert.pem path - SHOW=$(pip show -f pip 2>/dev/null) - if [ -n "$SHOW" ]; then - location=$(echo "$SHOW" | grep -oP '^Location: \K.+') - if [ -n "$location" ]; then - cacert=$(echo "$SHOW" | grep -oE '\S+cacert\.pem$') - if [ -n "$cacert" ]; then - certify_paths+=("${location}/${cacert}") + # check pip's own cacert.pem + SHOW=$(pip show -f pip 2>/dev/null) || true + if [ -n "$SHOW" ]; then + location=$(echo "$SHOW" | sed -n 's/^Location: //p') + if [ -n "$location" ]; then + cacert=$(echo "$SHOW" | grep -oE '[^[:space:]]+cacert\.pem$') + [ -n "$cacert" ] && certifi_paths+=("${location}/${cacert}") fi fi fi - # print notification about cacert.pem file(s) update - if [ ${#certify_paths[@]} -gt 0 ]; then - printf '\e[36madding custom certificates to the following files:\e[0m\n' >&2 - else - printf '\e[33mno certify/cacert.pem files found to be updated\e[0m\n' >&2 + # exit if no target bundles found + if [ ${#certifi_paths[@]} -eq 0 ]; then + printf '\e[33mno certifi/cacert.pem bundles found\e[0m\n' >&2 return 0 fi - # instantiate variable about number of certificates added - cert_count=0 - # track unique serials that have been added across all certify files - declare -A added_serials=() - # iterate over certify files - for certify in "${certify_paths[@]}"; do - echo "${certify//$HOME/\~}" >&2 - # iterate over installed certificates - for path in "${cert_paths[@]}"; do - serial=$(openssl x509 -in "$path" -noout -serial -nameopt RFC2253 2>/dev/null | cut -d= -f2) - # skip if openssl didn't produce a serial + + # append custom certificates to each target bundle + local cert_count=0 + local _added_serials=" " + local certifi pem serial CERT + for certifi in "${certifi_paths[@]}"; do + echo "${certifi//$HOME/\~}" >&2 + for pem in "${cert_pems[@]}"; do + serial=$(openssl x509 -noout -serial -nameopt RFC2253 <<< "$pem" 2>/dev/null | cut -d= -f2) [ -n "$serial" ] || continue - if ! grep -qw "$serial" "$certify"; then - # add certificate to array (print subject) - echo " - $(openssl x509 -in "$path" -noout -subject -nameopt RFC2253 | sed 's/\\//g')" >&2 + if ! grep -qw "$serial" "$certifi"; then + echo " - $(openssl x509 -noout -subject -nameopt RFC2253 <<< "$pem" | sed 's/\\//g')" >&2 CERT=" -$(openssl x509 -in "$path" -noout -issuer -subject -serial -fingerprint -nameopt RFC2253 | sed 's/\\//g' | xargs -I {} echo "# {}") -$(openssl x509 -in "$path" -outform PEM)" - # append new certificates to certify cacert.pem - if [ -w "$certify" ]; then - echo "$CERT" >>"$certify" +$(openssl x509 -noout -issuer -subject -serial -fingerprint -nameopt RFC2253 <<< "$pem" | sed 's/\\//g' | xargs -I {} echo "# {}") +$(openssl x509 -outform PEM <<< "$pem")" + if [ -w "$certifi" ]; then + echo "$CERT" >>"$certifi" else - echo "$CERT" | sudo tee -a "$certify" >/dev/null + printf '\e[33minsufficient permissions to write to %s, run the script as root.\e[0m\n' "$certifi" >&2 + break fi - # increment unique certificate count only once per serial - if [ -z "${added_serials[$serial]+x}" ]; then - added_serials[$serial]=1 + if [[ " $_added_serials " != *" $serial "* ]]; then + _added_serials+="$serial " cert_count=$((cert_count + 1)) fi fi done done if [ $cert_count -gt 0 ]; then - printf "\e[34madded $cert_count certificate(s) to certifi/cacert.pem file(s)\e[0m\n" >&2 + printf "\e[34madded $cert_count certificate(s) to certifi bundle(s)\e[0m\n" >&2 else printf '\e[34mno new certificates to add\e[0m\n' >&2 fi @@ -146,3 +191,152 @@ $(openssl x509 -in "$path" -outform PEM)" # alias for backward compatibility alias fxcertpy='fixcertpy' + +# *Function to show dev environment install provenance +devenv() { + local install_json="$HOME/.config/dev-env/install.json" + if [ ! -f "$install_json" ]; then + printf "\e[33mNo install record found.\e[0m\n" + return 0 + fi + if ! type jq &>/dev/null; then + cat "$install_json" + return 0 + fi + local ver entry src src_ref scopes installed_at mode status phase plat nix_ver err_msg + ver="$(jq -r '.version // "unknown"' "$install_json")" + entry="$(jq -r '.entry_point // "unknown"' "$install_json")" + src="$(jq -r '.source // "unknown"' "$install_json")" + src_ref="$(jq -r '.source_ref // "" | if . == "" then "n/a" else .[0:12] end' "$install_json")" + scopes="$(jq -r '.scopes // [] | join(", ")' "$install_json")" + installed_at="$(jq -r '.installed_at // "unknown"' "$install_json")" + mode="$(jq -r '.mode // "unknown"' "$install_json")" + status="$(jq -r '.status // "unknown"' "$install_json")" + phase="$(jq -r '.phase // "unknown"' "$install_json")" + plat="$(jq -r '"\(.platform // "unknown")/\(.arch // "unknown")"' "$install_json")" + nix_ver="$(jq -r '.nix_version // ""' "$install_json")" + err_msg="$(jq -r '.error // ""' "$install_json")" + + printf "\e[96mdev-env\e[0m %s\n" "$ver" + printf " \e[90mEntry: \e[0m%s\n" "$entry" + printf " \e[90mSource: \e[0m%s (%s)\n" "$src" "$src_ref" + printf " \e[90mPlatform: \e[0m%s\n" "$plat" + printf " \e[90mMode: \e[0m%s\n" "$mode" + if [ "$status" = "success" ]; then + printf " \e[90mStatus: \e[32m%s\e[0m\n" "$status" + else + printf " \e[90mStatus: \e[31m%s\e[0m (phase: %s)\n" "$status" "$phase" + [ -n "$err_msg" ] && printf " \e[90mError: \e[31m%s\e[0m\n" "$err_msg" + fi + printf " \e[90mInstalled: \e[0m%s\n" "$installed_at" + [ -n "$nix_ver" ] && printf " \e[90mNix: \e[0m%s\n" "$nix_ver" + printf " \e[90mScopes: \e[0m%s\n" "$scopes" +} + +# *Function for intercepting MITM proxy certificates from TLS chain and saving to user cert bundle +cert_intercept() { + # check if openssl is available + if ! type openssl &>/dev/null; then + printf '\e[31mopenssl is required but not installed.\e[0m\n' >&2 + return 1 + fi + + local _default_host="${NIX_ENV_TLS_PROBE_URL:-https://www.google.com}" + _default_host="${_default_host#https://}" + _default_host="${_default_host#http://}" + local uris=("${@:-$_default_host}") + local cert_bundle="$HOME/.config/certs/ca-custom.crt" + local cert_count=0 + local skip_count=0 + + # ensure cert directory exists + mkdir -p "$HOME/.config/certs" + + # read existing serials from bundle for deduplication + local _existing_serials=" " + if [ -f "$cert_bundle" ]; then + while IFS= read -r serial; do + [ -n "$serial" ] && _existing_serials+="$serial " + done < <(openssl storeutl -noout -text -certs "$cert_bundle" 2>/dev/null | sed -n 's/.*Serial Number: *//p' || true) + # fallback: parse PEM blocks and extract serials individually + if [ "$_existing_serials" = " " ]; then + local current_pem="" + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + local ser + ser=$(openssl x509 -noout -serial <<< "$current_pem" 2>/dev/null | cut -d= -f2) + [ -n "$ser" ] && _existing_serials+="$ser " + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done < "$cert_bundle" + fi + fi + + for uri in "${uris[@]}"; do + printf '\e[36mintercepting certificates from %s...\e[0m\n' "$uri" >&2 + + # get full TLS chain + local chain_pem + chain_pem=$(openssl s_client -showcerts -connect "${uri}:443" /dev/null) || { + printf '\e[33mfailed to connect to %s\e[0m\n' "$uri" >&2 + continue + } + + # parse individual PEM blocks from chain, skip the first (leaf) cert + local pem_blocks=() + local current_pem="" + local cert_index=0 + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + cert_index=$((cert_index + 1)) + # skip the first cert (leaf/server cert) + if [ $cert_index -gt 1 ]; then + pem_blocks+=("$current_pem") + fi + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done <<< "$chain_pem" + + # process each intermediate/root cert + for pem in "${pem_blocks[@]}"; do + local serial + serial=$(openssl x509 -noout -serial -nameopt RFC2253 <<< "$pem" 2>/dev/null | cut -d= -f2) + [ -n "$serial" ] || continue + + # check for duplicate + if [[ " $_existing_serials " == *" $serial "* ]]; then + skip_count=$((skip_count + 1)) + continue + fi + + # format cert with header comments and append to bundle + local header + header=$(openssl x509 -noout -issuer -subject -serial -fingerprint -nameopt RFC2253 <<< "$pem" 2>/dev/null | sed 's/\\//g' | xargs -I {} echo "# {}") + local cert_pem + cert_pem=$(openssl x509 -outform PEM <<< "$pem" 2>/dev/null) + + printf '%s\n%s\n' "$header" "$cert_pem" >> "$cert_bundle" + _existing_serials+="$serial " + cert_count=$((cert_count + 1)) + printf ' \e[32m+ %s\e[0m\n' "$(openssl x509 -noout -subject -nameopt RFC2253 <<< "$pem" 2>/dev/null | sed 's/\\//g')" >&2 + done + done + + # print summary + if [ $cert_count -gt 0 ]; then + printf '\e[34madded %d certificate(s) to %s\e[0m\n' "$cert_count" "${cert_bundle/$HOME/\~}" >&2 + else + printf '\e[34mno new certificates to add\e[0m\n' >&2 + fi + [ $skip_count -gt 0 ] && printf '\e[90m(%d already existing, skipped)\e[0m\n' "$skip_count" >&2 +} diff --git a/.assets/config/omp_cfg/base.omp.json b/.assets/config/omp_cfg/base.omp.json index 54d78a25..689474a5 100644 --- a/.assets/config/omp_cfg/base.omp.json +++ b/.assets/config/omp_cfg/base.omp.json @@ -43,7 +43,7 @@ { "foreground": "#FFE082", "style": "plain", - "template": "{{ if .SSHSession }}{{ .UserName }}@{{ .HostName }} {{ end }}", + "template": "{{ if .SSHSession }}ssh {{ end }}", "type": "session" }, { diff --git a/.assets/config/omp_cfg/nerd.omp.json b/.assets/config/omp_cfg/nerd.omp.json index 6cb32927..f4b3d7fc 100644 --- a/.assets/config/omp_cfg/nerd.omp.json +++ b/.assets/config/omp_cfg/nerd.omp.json @@ -50,9 +50,9 @@ "type": "status" }, { - "foreground": "#2CC7EE", + "foreground": "#FFE082", "style": "plain", - "template": "{{ if .SSHSession }}{{ .UserName }}<#4CD7FF>@{{ .HostName }} {{ end }}", + "template": "{{ if .SSHSession }} {{ end }}", "type": "session" }, { diff --git a/.assets/config/pwsh_cfg/_aliases_linux.ps1 b/.assets/config/pwsh_cfg/_aliases_linux.ps1 index cc1b4a60..d4dab4a8 100644 --- a/.assets/config/pwsh_cfg/_aliases_linux.ps1 +++ b/.assets/config/pwsh_cfg/_aliases_linux.ps1 @@ -38,9 +38,6 @@ if (Test-Path '/usr/bin/rg' -PathType Leaf) { if (Test-Path '/usr/bin/bat' -PathType Leaf) { function batp { $input | & /usr/bin/env bat -pP @args } } -if (Test-Path '/usr/local/bin/tfswitch' -PathType Leaf) { - function tfswitch { & /usr/bin/env tfswitch --bin="$HOME/.local/bin/terraform" @args } -} # *Aliases Set-Alias -Name rd -Value rmdir diff --git a/.assets/config/pwsh_cfg/_aliases_nix.ps1 b/.assets/config/pwsh_cfg/_aliases_nix.ps1 new file mode 100644 index 00000000..0b2eba67 --- /dev/null +++ b/.assets/config/pwsh_cfg/_aliases_nix.ps1 @@ -0,0 +1,783 @@ +#region common aliases +function cd.. { Set-Location ../ } +function .. { Set-Location ../ } +function ... { Set-Location ../../ } +function .... { Set-Location ../../../ } +function la { Get-ChildItem @args -Force } + +Set-Alias -Name c -Value Clear-Host +Set-Alias -Name type -Value Get-Command +#endregion + +#region platform aliases +if ($IsLinux) { + if ($env:DISTRO_FAMILY -eq 'alpine') { + function bsh { & /usr/bin/env -i ash --noprofile --norc } + function ls { & /usr/bin/env ls -h --color=auto --group-directories-first @args } + } else { + function bsh { & /usr/bin/env -i bash --noprofile --norc } + function ip { $input | & /usr/bin/env ip --color=auto @args } + function ls { & /usr/bin/env ls -h --color=auto --group-directories-first --time-style=long-iso @args } + } +} elseif ($IsMacOS) { + function bsh { & /usr/bin/env -i bash --noprofile --norc } +} +function grep { $input | & /usr/bin/env grep --ignore-case --color=auto @args } +function less { $input | & /usr/bin/env less -FRXc @args } +function mkdir { & /usr/bin/env mkdir -pv @args } +function mv { & /usr/bin/env mv -iv @args } +function nano { & /usr/bin/env nano -W @args } +function tree { & /usr/bin/env tree -C @args } +function wget { & /usr/bin/env wget -c @args } + +Set-Alias -Name rd -Value rmdir +Set-Alias -Name vi -Value vim +#endregion + +#region dev tool aliases +$_nb = "$HOME/.nix-profile/bin" + +if (Test-Path "$_nb/eza" -PathType Leaf) { + function eza { & /usr/bin/env eza -g --color=auto --time-style=long-iso --group-directories-first --color-scale=all --git-repos @args } + function l { eza -1 @args } + function lsa { eza -a @args } + function ll { eza -lah @args } + function lt { eza -Th @args } + function lta { eza -aTh --git-ignore @args } + function ltd { eza -DTh @args } + function ltad { eza -aDTh --git-ignore @args } + function llt { eza -lTh @args } + function llta { eza -laTh --git-ignore @args } +} else { + function l { ls -1 @args } + function lsa { ls -a @args } + function ll { ls -lah @args } +} +if (Test-Path "$_nb/rg" -PathType Leaf) { + function rg { $input | & /usr/bin/env rg --ignore-case @args } +} +if (Test-Path "$_nb/bat" -PathType Leaf) { + function batp { $input | & /usr/bin/env bat -pP @args } +} +if (Test-Path "$_nb/fastfetch" -PathType Leaf) { + Set-Alias -Name ff -Value fastfetch +} +if (Test-Path "$_nb/pwsh" -PathType Leaf) { + function p { & /usr/bin/env pwsh -NoProfileLoadTime @args } +} +if (Test-Path "$_nb/kubectx" -PathType Leaf) { + Set-Alias -Name kc -Value kubectx +} +if (Test-Path "$_nb/kubens" -PathType Leaf) { + Set-Alias -Name kn -Value kubens +} +if (Test-Path "$_nb/kubecolor" -PathType Leaf) { + Set-Alias -Name kubectl -Value kubecolor +} + +Remove-Variable _nb +#endregion + +#region nix package management wrapper (apt/brew-like UX) +$_nxEnvDir = [IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.config/nix-env') +$_nxPkgFile = [IO.Path]::Combine($_nxEnvDir, 'packages.nix') + +function _nxReadPkgs { + if ([IO.File]::Exists($Script:_nxPkgFile)) { + (Get-Content $Script:_nxPkgFile) | ForEach-Object { + if ($_ -match '^\s*"([^"]+)"') { $Matches[1] } + } + } +} + +function _nxWritePkgs { + param([string[]]$Packages) + $sorted = $Packages | Where-Object { $_ } | Sort-Object -Unique + $lines = @('[') + foreach ($p in $sorted) { $lines += " `"$p`"" } + $lines += ']' + $tmp = [IO.Path]::GetTempFileName() + [IO.File]::WriteAllLines($tmp, $lines) + [IO.File]::Move($tmp, $Script:_nxPkgFile, $true) +} + +function _nxApply { + Write-Host "`e[96mapplying changes...`e[0m" + nix profile upgrade nix-env + if ($?) { + Write-Host "`e[32mdone.`e[0m" + } else { + Write-Host "`e[31mnix profile upgrade failed`e[0m" -ForegroundColor Red + } +} + +function _nxScopePkgs { + param([string]$File) + if ([IO.File]::Exists($File)) { + (Get-Content $File) | ForEach-Object { + if ($_ -match '^\s*([a-zA-Z][a-zA-Z0-9_-]*)') { + $name = $Matches[1] + if ($name -notin 'pkgs', 'with') { $name } + } + } + } +} + +function _nxValidatePkg { + param([string]$Name) + $null = nix eval "nixpkgs#$Name.name" 2>$null + return $LASTEXITCODE -eq 0 +} + +function _nxScopeFileAdd { + param([string]$File, [string[]]$Packages) + $existing = @(_nxScopePkgs $File) + $added = $false + foreach ($p in $Packages) { + if ($p -in $existing) { + Write-Host "`e[33m$p is already in scope`e[0m" + } else { + $existing += $p + Write-Host "`e[32madded $p`e[0m" + $added = $true + } + } + if (-not $added) { return $false } + $sorted = $existing | Sort-Object -Unique + $lines = @('{ pkgs }: with pkgs; [') + foreach ($p in $sorted) { $lines += " $p" } + $lines += ']' + [IO.File]::WriteAllLines($File, $lines) + return $true +} + +function _nxScopes { + $configNix = [IO.Path]::Combine($Script:_nxEnvDir, 'config.nix') + if ([IO.File]::Exists($configNix)) { + $inScopes = $false + foreach ($line in (Get-Content $configNix)) { + if ($line -match 'scopes\s*=\s*\[') { $inScopes = $true; continue } + if ($inScopes -and $line -match '\]') { break } + if ($inScopes -and $line -match '^\s*"([^"]+)"') { $Matches[1] } + } + } +} + +function _nxIsInit { + $configNix = [IO.Path]::Combine($Script:_nxEnvDir, 'config.nix') + if ([IO.File]::Exists($configNix)) { + foreach ($line in (Get-Content $configNix)) { + if ($line -match '\bisInit\s*=\s*(true|false)') { return $Matches[1] } + } + } + 'false' +} + +function _nxAllScopePkgMap { + $scopesDir = [IO.Path]::Combine($Script:_nxEnvDir, 'scopes') + $map = @{} + if (-not (Test-Path $scopesDir -PathType Container)) { return $map } + $baseFile = [IO.Path]::Combine($scopesDir, 'base.nix') + foreach ($p in @(_nxScopePkgs $baseFile)) { $map[$p] = 'base' } + if ((_nxIsInit) -eq 'true') { + $initFile = [IO.Path]::Combine($scopesDir, 'base_init.nix') + foreach ($p in @(_nxScopePkgs $initFile)) { $map[$p] = 'base_init' } + } + foreach ($s in @(_nxScopes)) { + $scopeFile = [IO.Path]::Combine($scopesDir, "$s.nix") + foreach ($p in @(_nxScopePkgs $scopeFile)) { $map[$p] = $s } + } + return $map +} + +function nx { + [CmdletBinding()] + param( + [Parameter(Position = 0)] + [string]$Command, + + [Parameter(Position = 1, ValueFromRemainingArguments)] + [string[]]$Xargs + ) + + $Xargs = @($Xargs | ForEach-Object { $_ -split '[,\s]+' } | Where-Object { $_ }) + $envDir = $Script:_nxEnvDir + $scopesDir = [IO.Path]::Combine($envDir, 'scopes') + + switch ($Command) { + 'search' { + if (-not $Xargs) { Write-Host 'Usage: nx search ' -ForegroundColor Yellow; return } + nix search nixpkgs @Xargs + } + { $_ -in 'install', 'add' } { + if (-not $Xargs) { Write-Host 'Usage: nx install [pkg...]' -ForegroundColor Yellow; return } + # validate packages exist in nixpkgs + $validated = [System.Collections.Generic.List[string]]::new() + foreach ($p in $Xargs) { + Write-Host "`e[90mvalidating $p...`e[0m" -NoNewline + if (_nxValidatePkg $p) { + Write-Host "`r" -NoNewline + $validated.Add($p) + } else { + Write-Host "`r`e[31m$p not found in nixpkgs`e[0m" + } + } + if ($validated.Count -eq 0) { return } + # build scope package lookup + $scopePkgMap = _nxAllScopePkgMap + $current = @(_nxReadPkgs) + $added = $false + $newList = [System.Collections.Generic.List[string]]::new() + if ($current.Count -gt 0) { $newList.AddRange([string[]]$current) } + foreach ($p in $validated) { + if ($scopePkgMap.ContainsKey($p)) { + Write-Host "`e[33m$p is already installed in scope '$($scopePkgMap[$p])'`e[0m" + } elseif ($p -in $current) { + Write-Host "`e[33m$p is already installed (extra)`e[0m" + } else { + $newList.Add($p) + Write-Host "`e[32madded $p`e[0m" + $added = $true + } + } + _nxWritePkgs -Packages $newList.ToArray() + if ($added) { _nxApply } + } + { $_ -in 'remove', 'uninstall' } { + if (-not $Xargs) { Write-Host 'Usage: nx remove [pkg...]' -ForegroundColor Yellow; return } + # check for scope-managed packages first + $scopePkgMap = _nxAllScopePkgMap + $filteredArgs = [System.Collections.Generic.List[string]]::new() + foreach ($p in $Xargs) { + if ($scopePkgMap.ContainsKey($p)) { + Write-Host "`e[33m$p is managed by scope '$($scopePkgMap[$p])' - use: nx scope remove $($scopePkgMap[$p])`e[0m" + } else { + $filteredArgs.Add($p) + } + } + if ($filteredArgs.Count -eq 0) { return } + $current = @(_nxReadPkgs) + if ($current.Count -eq 0) { + Write-Host "`e[33mNo user packages installed.`e[0m" + return + } + $removed = $false + $remaining = [System.Collections.Generic.List[string]]::new() + foreach ($p in $current) { + if ($p -in $filteredArgs) { + Write-Host "`e[32mremoved $p`e[0m" + $removed = $true + } else { + $remaining.Add($p) + } + } + foreach ($p in $filteredArgs) { + if ($p -notin $current) { + Write-Host "`e[33m$p is not installed - skipping`e[0m" + } + } + _nxWritePkgs -Packages $remaining.ToArray() + if ($removed) { _nxApply } + } + { $_ -in 'upgrade', 'update' } { + Write-Host "`e[96mupgrading packages...`e[0m" + $pinFile = Join-Path $envDir 'pinned_rev' + $pinnedRev = if (Test-Path $pinFile) { (Get-Content $pinFile -Raw).Trim() } else { '' } + if ($pinnedRev) { + Write-Host "`e[96mpinning nixpkgs to $pinnedRev`e[0m" + nix flake lock --override-input nixpkgs "github:nixos/nixpkgs/$pinnedRev" --flake $envDir 2>$null + } else { + nix flake update --flake $envDir 2>$null + } + nix profile upgrade nix-env + if ($?) { + Write-Host "`e[32mdone.`e[0m" + } else { + Write-Host "`e[31mnix profile upgrade failed`e[0m" -ForegroundColor Red + } + } + { $_ -in 'list', 'ls' } { + $allPkgs = [System.Collections.Generic.List[object]]::new() + # base packages + $baseFile = [IO.Path]::Combine($scopesDir, 'base.nix') + foreach ($p in @(_nxScopePkgs $baseFile)) { + $allPkgs.Add([PSCustomObject]@{ Name = $p; Scope = 'base' }) + } + # base_init packages + if ((_nxIsInit) -eq 'true') { + $initFile = [IO.Path]::Combine($scopesDir, 'base_init.nix') + foreach ($p in @(_nxScopePkgs $initFile)) { + $allPkgs.Add([PSCustomObject]@{ Name = $p; Scope = 'base_init' }) + } + } + # configured scopes + foreach ($s in @(_nxScopes)) { + $scopeFile = [IO.Path]::Combine($scopesDir, "$s.nix") + foreach ($p in @(_nxScopePkgs $scopeFile)) { + $allPkgs.Add([PSCustomObject]@{ Name = $p; Scope = $s }) + } + } + # user packages (extra) + foreach ($p in @(_nxReadPkgs)) { + $allPkgs.Add([PSCustomObject]@{ Name = $p; Scope = 'extra' }) + } + if ($allPkgs.Count -gt 0) { + $allPkgs | Sort-Object Name -Unique | ForEach-Object { + Write-Host (" `e[1m*`e[0m {0,-24} `e[90m({1})`e[0m" -f $_.Name, $_.Scope) + } + } else { + Write-Host "`e[33mNo packages installed.`e[0m Use `e[1mnx install `e[0m or run `e[1mnix/setup.sh`e[0m." + } + } + 'scope' { + $configNix = [IO.Path]::Combine($envDir, 'config.nix') + $subCmd = if ($Xargs.Count -gt 0) { $Xargs[0] } else { 'help' } + [string[]]$subArgs = if ($Xargs.Count -gt 1) { $Xargs[1..($Xargs.Count - 1)] } else { @() } + + switch ($subCmd) { + { $_ -in 'list', 'ls' } { + $scopes = @(_nxScopes) + if ($scopes.Count -gt 0) { + Write-Host "`e[96mInstalled scopes:`e[0m" + $scopes | ForEach-Object { Write-Host " `e[1m*`e[0m $_" } + } else { + Write-Host "`e[33mNo scopes configured.`e[0m Run `e[1mnix/setup.sh`e[0m to initialize." + } + } + 'show' { + if (-not $subArgs) { Write-Host 'Usage: nx scope show ' -ForegroundColor Yellow; return } + $scopeFile = [IO.Path]::Combine($scopesDir, "$($subArgs[0]).nix") + if (-not [IO.File]::Exists($scopeFile)) { + Write-Host "`e[31mScope '$($subArgs[0])' not found.`e[0m" -ForegroundColor Red + return + } + Write-Host "`e[96m$($subArgs[0]):`e[0m" + foreach ($p in @(_nxScopePkgs $scopeFile)) { + Write-Host " `e[1m*`e[0m $p" + } + } + 'tree' { + # base is always present + $baseFile = [IO.Path]::Combine($scopesDir, 'base.nix') + if ([IO.File]::Exists($baseFile)) { + Write-Host "`e[96mbase:`e[0m" + foreach ($p in @(_nxScopePkgs $baseFile)) { + Write-Host " `e[1m*`e[0m $p" + } + } + foreach ($s in @(_nxScopes)) { + Write-Host "`e[96m${s}:`e[0m" + $scopeFile = [IO.Path]::Combine($scopesDir, "$s.nix") + foreach ($p in @(_nxScopePkgs $scopeFile)) { + Write-Host " `e[1m*`e[0m $p" + } + } + $userPkgs = @(_nxReadPkgs) + if ($userPkgs.Count -gt 0) { + Write-Host "`e[96mextra:`e[0m" + $userPkgs | ForEach-Object { Write-Host " `e[1m*`e[0m $_" } + } + } + { $_ -in 'remove', 'rm' } { + if (-not $subArgs) { Write-Host 'Usage: nx scope remove [scope...]' -ForegroundColor Yellow; return } + if (-not [IO.File]::Exists($configNix)) { + Write-Host "`e[31mNo nix-env config found. Run nix/setup.sh to initialize.`e[0m" -ForegroundColor Red + return + } + $currentScopes = @(_nxScopes) + $isInit = _nxIsInit + if ($currentScopes.Count -eq 0) { + Write-Host "`e[33mNo scopes configured - nothing to remove.`e[0m" + return + } + $ovDir = if ($env:NIX_ENV_OVERLAY_DIR -and (Test-Path $env:NIX_ENV_OVERLAY_DIR -PathType Container)) { + $env:NIX_ENV_OVERLAY_DIR + } else { + [IO.Path]::Combine($envDir, 'local') + } + $removeSet = [System.Collections.Generic.HashSet[string]]::new() + foreach ($r in $subArgs) { $removeSet.Add($r) | Out-Null; $removeSet.Add("local_$r") | Out-Null } + $scopeList = [System.Collections.Generic.List[string]]::new() + $removed = $false + foreach ($s in $currentScopes) { + if ($removeSet.Contains($s)) { + $displayName = $s -replace '^local_', '' + Write-Host "`e[32mremoved scope: $displayName`e[0m" + $removed = $true + } else { + $scopeList.Add($s) + } + } + foreach ($r in $subArgs) { + $overlayFile = [IO.Path]::Combine($ovDir, 'scopes', "$r.nix") + $installedFile = [IO.Path]::Combine($scopesDir, "local_$r.nix") + if (Test-Path $overlayFile) { Remove-Item $overlayFile } + if (Test-Path $installedFile) { Remove-Item $installedFile } + if ($r -notin $currentScopes -and "local_$r" -notin $currentScopes) { + Write-Host "`e[33mscope '$r' is not configured - skipping`e[0m" + } + } + if (-not $removed) { return } + # rewrite config.nix + $nixScopes = ($scopeList | ForEach-Object { " `"$_`"" }) -join "`n" + if ($nixScopes) { $nixScopes = "`n$nixScopes`n" } else { $nixScopes = "`n" } + $content = "# Generated by nx scope remove - re-run nix/setup.sh to reconfigure.`n{`n isInit = $($isInit ?? 'false');`n`n scopes = [$nixScopes ];`n}`n" + $tmp = [IO.Path]::GetTempFileName() + [IO.File]::WriteAllText($tmp, $content) + [IO.File]::Move($tmp, $configNix, $true) + _nxApply + Write-Host "Restart your shell to apply changes." + } + 'add' { + if (-not $subArgs) { Write-Host 'Usage: nx scope add [pkg...]' -ForegroundColor Yellow; return } + $name = $subArgs[0] -replace '-', '_' + $pkgs = if ($subArgs.Count -gt 1) { $subArgs[1..($subArgs.Count - 1)] } else { @() } + $ovDir = if ($env:NIX_ENV_OVERLAY_DIR -and (Test-Path $env:NIX_ENV_OVERLAY_DIR -PathType Container)) { + $env:NIX_ENV_OVERLAY_DIR + } else { + [IO.Path]::Combine($envDir, 'local') + } + $scopeFile = [IO.Path]::Combine($ovDir, 'scopes', "$name.nix") + $created = $false + if (-not [IO.File]::Exists($scopeFile)) { + $null = New-Item -ItemType Directory -Path ([IO.Path]::Combine($ovDir, 'scopes')) -Force + $null = New-Item -ItemType Directory -Path $scopesDir -Force + [IO.File]::WriteAllText($scopeFile, "{ pkgs }: with pkgs; []`n") + Copy-Item $scopeFile ([IO.Path]::Combine($scopesDir, "local_$name.nix")) + if ([IO.File]::Exists($configNix)) { + $currentScopes = @(_nxScopes) + if ("local_$name" -notin $currentScopes) { + $isInit = _nxIsInit + $allScopes = $currentScopes + "local_$name" + $nixScopes = ($allScopes | ForEach-Object { " `"$_`"" }) -join "`n" + if ($nixScopes) { $nixScopes = "`n$nixScopes`n" } else { $nixScopes = "`n" } + $content = "# Generated by nx scope add - re-run nix/setup.sh to reconfigure.`n{`n isInit = $($isInit ?? 'false');`n`n scopes = [$nixScopes ];`n}`n" + $tmp = [IO.Path]::GetTempFileName() + [IO.File]::WriteAllText($tmp, $content) + [IO.File]::Move($tmp, $configNix, $true) + } + } + $created = $true + Write-Host "`e[32mCreated scope '$name' at $scopeFile`e[0m" + } + if ($pkgs.Count -gt 0) { + $validated = [System.Collections.Generic.List[string]]::new() + foreach ($p in $pkgs) { + Write-Host "`e[90mvalidating $p...`e[0m" -NoNewline + if (_nxValidatePkg $p) { + Write-Host "`r" -NoNewline + $validated.Add($p) + } else { + Write-Host "`r`e[31m$p not found in nixpkgs`e[0m" + } + } + if ($validated.Count -gt 0) { + if (_nxScopeFileAdd -File $scopeFile -Packages $validated.ToArray()) { + Copy-Item $scopeFile ([IO.Path]::Combine($scopesDir, "local_$name.nix")) + _nxApply + } + } + } elseif ($created) { + Write-Host "Add packages: `e[1mnx scope add $name [pkg...]`e[0m" + } else { + Write-Host "`e[33mScope '$name' already exists.`e[0m Add packages: nx scope add $name " + } + } + 'edit' { + if (-not $subArgs) { Write-Host 'Usage: nx scope edit ' -ForegroundColor Yellow; return } + $name = $subArgs[0] -replace '-', '_' + $ovDir = if ($env:NIX_ENV_OVERLAY_DIR -and (Test-Path $env:NIX_ENV_OVERLAY_DIR -PathType Container)) { + $env:NIX_ENV_OVERLAY_DIR + } else { + [IO.Path]::Combine($envDir, 'local') + } + $scopeFile = [IO.Path]::Combine($ovDir, 'scopes', "$name.nix") + if (-not [IO.File]::Exists($scopeFile)) { + Write-Host "`e[31mScope '$name' not found.`e[0m Create it first: nx scope add $name" + return + } + $editor = if ($env:EDITOR) { $env:EDITOR } else { 'vi' } + & $editor $scopeFile + Copy-Item $scopeFile ([IO.Path]::Combine($scopesDir, "local_$name.nix")) + Write-Host "`e[32mSynced scope '$name'.`e[0m Run `e[1mnx upgrade`e[0m to apply." + } + default { + Write-Host @' +Usage: nx scope [args] + +Commands: + list List enabled scopes + show Show packages in a scope + tree Show all scopes with their packages + add [pkg...] Create a scope or add packages to it + edit Open a scope file in $EDITOR + remove [scope...] Remove one or more scopes +'@ + } + } + } + 'overlay' { + $subCmd = if ($Xargs.Count -gt 0) { $Xargs[0] } else { 'list' } + $ovDir = if ($env:NIX_ENV_OVERLAY_DIR -and (Test-Path $env:NIX_ENV_OVERLAY_DIR -PathType Container)) { + $env:NIX_ENV_OVERLAY_DIR + } elseif (Test-Path ([IO.Path]::Combine($envDir, 'local')) -PathType Container) { + [IO.Path]::Combine($envDir, 'local') + } else { + $null + } + + switch ($subCmd) { + { $_ -in 'list', 'ls' } { + if (-not $ovDir) { + Write-Host "`e[33mNo overlay directory active.`e[0m" + Write-Host "Create one at $envDir/local/ or set NIX_ENV_OVERLAY_DIR." + return + } + Write-Host "`e[96mOverlay directory:`e[0m $ovDir" + $scDir = [IO.Path]::Combine($ovDir, 'scopes') + if (Test-Path $scDir -PathType Container) { + $nixFiles = Get-ChildItem "$scDir/*.nix" -ErrorAction SilentlyContinue + if ($nixFiles) { + Write-Host "`e[96mScopes:`e[0m" + $nixFiles | ForEach-Object { Write-Host " `e[1m*`e[0m $($_.BaseName)" } + } + } + $cfgDir = [IO.Path]::Combine($ovDir, 'bash_cfg') + if (Test-Path $cfgDir -PathType Container) { + $shFiles = Get-ChildItem "$cfgDir/*.sh" -ErrorAction SilentlyContinue + if ($shFiles) { + Write-Host "`e[96mShell config:`e[0m" + $shFiles | ForEach-Object { Write-Host " `e[1m*`e[0m $($_.Name)" } + } + } + foreach ($hookDir in 'pre-setup.d', 'post-setup.d') { + $hPath = [IO.Path]::Combine($ovDir, 'hooks', $hookDir) + if (Test-Path $hPath -PathType Container) { + $hooks = Get-ChildItem "$hPath/*.sh" -ErrorAction SilentlyContinue + if ($hooks) { + Write-Host "`e[96mHooks ($hookDir):`e[0m" + $hooks | ForEach-Object { Write-Host " `e[1m*`e[0m $($_.Name)" } + } + } + } + } + 'status' { + Write-Host -NoNewline "`e[96mOverlay:`e[0m " + if ($ovDir) { + Write-Host $ovDir + } else { + Write-Host "`e[33mnone`e[0m" + } + $hasLocal = $false + if (Test-Path $scopesDir -PathType Container) { + $localFiles = Get-ChildItem "$scopesDir/local_*.nix" -ErrorAction SilentlyContinue + if ($localFiles) { + Write-Host "`e[96mOverlay scopes (synced):`e[0m" + foreach ($f in $localFiles) { + $name = $f.BaseName -replace '^local_', '' + $indicator = '' + if ($ovDir) { + $srcFile = [IO.Path]::Combine($ovDir, 'scopes', "$name.nix") + if ([IO.File]::Exists($srcFile)) { + if ((Get-FileHash $srcFile).Hash -ne (Get-FileHash $f.FullName).Hash) { + $indicator = " `e[33m(modified)`e[0m" + } + } else { + $indicator = " `e[33m(source missing)`e[0m" + } + } else { + $indicator = " `e[33m(source missing)`e[0m" + } + Write-Host " `e[1m*`e[0m $name$indicator" + } + $hasLocal = $true + } + } + if (-not $hasLocal) { + Write-Host "`e[90mNo overlay scopes synced.`e[0m" + } + if ($ovDir) { + $cfgDir = [IO.Path]::Combine($ovDir, 'bash_cfg') + if (Test-Path $cfgDir -PathType Container) { + $shFiles = Get-ChildItem "$cfgDir/*.sh" -ErrorAction SilentlyContinue + if ($shFiles) { + Write-Host "`e[96mOverlay shell config:`e[0m" + foreach ($f in $shFiles) { + $installed = [IO.Path]::Combine($env:HOME, '.config', 'bash', $f.Name) + $indicator = '' + if ([IO.File]::Exists($installed)) { + if ((Get-FileHash $f.FullName).Hash -eq (Get-FileHash $installed).Hash) { + $indicator = " `e[32m(synced)`e[0m" + } else { + $indicator = " `e[33m(differs)`e[0m" + } + } else { + $indicator = " `e[33m(not installed)`e[0m" + } + Write-Host " `e[1m*`e[0m $($f.Name)$indicator" + } + } + } + } + } + default { + Write-Host @' +Usage: nx overlay + +Commands: + list Show active overlay directory and contents + status Show sync status of overlay files + help Show this help +'@ + } + } + } + { $_ -in 'prune' } { + # remove stale imperative profile entries (anything not 'nix-env') + try { + $profileJson = nix profile list --json 2>$null | ConvertFrom-Json + } catch { + Write-Host "`e[31mFailed to list nix profile.`e[0m" -NoNewline + return + } + $staleNames = [System.Collections.Generic.List[string]]::new() + foreach ($prop in $profileJson.elements.PSObject.Properties) { + if ($prop.Name -ne 'nix-env') { + $staleNames.Add($prop.Name) + } + } + if ($staleNames.Count -eq 0) { + Write-Host "`e[32mNo stale profile entries found.`e[0m" + return + } + Write-Host "`e[96mStale profile entries:`e[0m" + foreach ($name in $staleNames) { + Write-Host " `e[1m*`e[0m $name" + } + Write-Host "`e[96mRemoving...`e[0m" + foreach ($name in $staleNames) { + nix profile remove $name + Write-Host "`e[32mremoved $name`e[0m" + } + Write-Host "`e[32mdone.`e[0m Run `e[1mnx gc`e[0m to free disk space." + } + { $_ -in 'gc', 'clean' } { + nix profile wipe-history + nix store gc + } + 'rollback' { + nix profile rollback + if ($?) { + Write-Host "`e[32mRolled back to previous profile generation.`e[0m" + Write-Host "Restart your shell to apply changes." + } else { + Write-Host "`e[31mnix profile rollback failed`e[0m" -ForegroundColor Red + } + } + 'pin' { + $pinFile = Join-Path $envDir 'pinned_rev' + $sub = if ($Xargs.Count -gt 0) { $Xargs[0] } else { 'show' } + switch ($sub) { + 'set' { + $rev = if ($Xargs.Count -ge 2) { $Xargs[1] } else { '' } + if (-not $rev) { + $lockFile = Join-Path $envDir 'flake.lock' + if (-not (Test-Path $lockFile)) { + Write-Host "`e[31mNo flake.lock found - run nx upgrade first.`e[0m" + return + } + $lock = Get-Content $lockFile -Raw | ConvertFrom-Json + $rev = $lock.nodes.nixpkgs.locked.rev + if (-not $rev) { + Write-Host "`e[31mCould not read nixpkgs revision from flake.lock.`e[0m" + return + } + } + [IO.File]::WriteAllText($pinFile, "$rev`n") + Write-Host "`e[32mPinned nixpkgs to $rev`e[0m" + } + { $_ -in 'remove', 'rm' } { + if (Test-Path $pinFile) { + Remove-Item $pinFile + Write-Host "`e[32mPin removed.`e[0m Upgrades will use latest nixpkgs-unstable." + } else { + Write-Host "`e[90mNo pin set.`e[0m" + } + } + 'show' { + if (Test-Path $pinFile) { + $rev = (Get-Content $pinFile -Raw).Trim() + Write-Host "`e[96mPinned to:`e[0m $rev" + } else { + Write-Host "`e[90mNo pin set.`e[0m Upgrades use latest nixpkgs-unstable." + } + } + default { + Write-Host @' +Usage: nx pin + +Commands: + set [rev] Pin nixpkgs to a commit SHA (default: current flake.lock rev) + remove Remove the pin (use latest nixpkgs-unstable) + show Show current pin status (default) + help Show this help + +The pin takes effect on the next `nx upgrade` or `nix/setup.sh --upgrade`. +'@ + } + } + } + 'doctor' { + $doctorScript = Join-Path $env:HOME '.config/nix-env/nx_doctor.sh' + if (Test-Path $doctorScript) { + & bash $doctorScript @Xargs + } else { + Write-Host "`e[31mnx doctor not found`e[0m" + } + } + 'version' { + devenv + } + default { + Write-Host @' +Usage: nx [args] + +Commands: + search Search for packages in nixpkgs + install [pkg...] Install packages (declarative, via packages.nix) + remove [pkg...] Remove user-installed packages + upgrade Upgrade all packages to latest nixpkgs + rollback Roll back to previous profile generation + pin Pin nixpkgs to a specific revision (nx pin help) + list List all installed packages with scope annotations + scope Manage scopes (nx scope help) + overlay Manage overlay directory (nx overlay help) + doctor Run health checks on the nix-env environment + prune Remove stale imperative profile entries + gc Garbage collect old versions and free disk space + version Show installation provenance and version info + help Show this help +'@ + } + } +} + +Register-ArgumentCompleter -CommandName nx -Native -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $tokens = $commandAst.CommandElements + $pos = $tokens.Count + if ($wordToComplete) { $pos-- } + + $completions = switch ($pos) { + 1 { 'search', 'install', 'remove', 'upgrade', 'rollback', 'pin', 'list', 'scope', 'overlay', 'doctor', 'prune', 'gc', 'version', 'help' } + 2 { + if ($tokens[1].Value -eq 'scope') { 'list', 'show', 'tree', 'add', 'edit', 'remove' } + elseif ($tokens[1].Value -eq 'overlay') { 'list', 'status', 'help' } + elseif ($tokens[1].Value -eq 'pin') { 'set', 'remove', 'show', 'help' } + } + } + $completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} +#endregion diff --git a/.assets/config/pwsh_cfg/profile.ps1 b/.assets/config/pwsh_cfg/profile.ps1 index 634e8762..ff0ab239 100644 --- a/.assets/config/pwsh_cfg/profile.ps1 +++ b/.assets/config/pwsh_cfg/profile.ps1 @@ -34,6 +34,12 @@ if (-not $isWSL1) { #endregion #region environment variables and aliases +# fast multi-location check: /usr/bin, ~/.local/bin, ~/.nix-profile/bin +function _HasCmd ([string]$Name) { + (Test-Path "/usr/bin/$Name" -PathType Leaf) -or + (Test-Path "$HOME/.local/bin/$Name" -PathType Leaf) -or + (Test-Path "$HOME/.nix-profile/bin/$Name" -PathType Leaf) +} [Environment]::SetEnvironmentVariable('OMP_PATH', '/usr/local/share/oh-my-posh') [Environment]::SetEnvironmentVariable('SCRIPTS_PATH', '/usr/local/share/powershell/Scripts') [Environment]::SetEnvironmentVariable('USER_SCRIPTS_PATH', "$HOME/.config/powershell/Scripts") @@ -46,7 +52,6 @@ if (-not $isWSL1) { [IO.Path]::Combine($HOME, '.local', 'bin') [IO.Path]::Combine($HOME, '.bun', 'bin') [IO.Path]::Combine($HOME, '.cargo', 'bin') - [IO.Path]::Combine($HOME, '.pixi', 'bin') ) | ForEach-Object { if ((Test-Path $_ -PathType Container) -and $_ -notin $env:PATH.Split([IO.Path]::PathSeparator)) { [Environment]::SetEnvironmentVariable('PATH', [string]::Join([IO.Path]::PathSeparator, $_, $env:PATH)) @@ -61,20 +66,8 @@ if (Test-Path $env:USER_SCRIPTS_PATH -PathType Container) { } #endregion -#region initializations -# brew -foreach ($path in @('/home/linuxbrew/.linuxbrew', "$HOME/.linuxbrew")) { - if (Test-Path $path/bin/brew -PathType Leaf) { - (& $path/bin/brew 'shellenv') | Out-String | Invoke-Expression - $env:HOMEBREW_NO_ENV_HINTS = 1 - continue - } -} -Remove-Variable path -#endregion - #region prompt -if (-not $isWSL1 -and (Test-Path /usr/bin/oh-my-posh -PathType Leaf) -and (Test-Path "$env:OMP_PATH/theme.omp.json" -PathType Leaf)) { +if (-not $isWSL1 -and (_HasCmd 'oh-my-posh') -and (Test-Path "$env:OMP_PATH/theme.omp.json" -PathType Leaf)) { oh-my-posh init pwsh --config "$env:OMP_PATH/theme.omp.json" | Invoke-Expression | Out-Null # disable venv prompt as it is handled in oh-my-posh theme [Environment]::SetEnvironmentVariable('VIRTUAL_ENV_DISABLE_PROMPT', $true) diff --git a/.assets/config/pwsh_cfg/profile_nix.ps1 b/.assets/config/pwsh_cfg/profile_nix.ps1 new file mode 100644 index 00000000..a31bff57 --- /dev/null +++ b/.assets/config/pwsh_cfg/profile_nix.ps1 @@ -0,0 +1,106 @@ +# PowerShell base profile for Nix-managed environments. +# Dot-sourced from $PROFILE.CurrentUserAllHosts by nix/configure/profiles.ps1. +# For the legacy (AllUsers) equivalent, see profile.ps1. + +#region startup settings +# import posh-git module for git autocompletion. +try { + Import-Module posh-git -ErrorAction Stop + $GitPromptSettings.EnablePromptStatus = $false +} catch { + Out-Null +} +# make PowerShell console Unicode (UTF-8) aware +$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new() +# set culture to English Sweden for ISO-8601 datetime settings +[Threading.Thread]::CurrentThread.CurrentCulture = 'en-SE' +# change PSStyle for directory coloring. +$PSStyle.FileInfo.Directory = "$($PSStyle.Bold)$($PSStyle.Foreground.Blue)" +# determine WSL version +$isWSL1 = (Test-Path /usr/bin/uname) ? (uname -r | Select-String '\bMicrosoft$' -Quiet) : $null +# configure PSReadLine setting. +Set-PSReadLineOption -EditMode Emacs +Set-PSReadLineOption -AddToHistoryHandler { param([string]$line) return $line.Length -gt 1 } +Set-PSReadLineKeyHandler -Chord Tab -Function MenuComplete +Set-PSReadLineKeyHandler -Chord F2 -Function SwitchPredictionView +Set-PSReadLineKeyHandler -Chord Shift+Tab -Function AcceptSuggestion +Set-PSReadLineKeyHandler -Chord Alt+j -Function NextHistory +Set-PSReadLineKeyHandler -Chord Alt+k -Function PreviousHistory +Set-PSReadLineKeyHandler -Chord Ctrl+LeftArrow -Function BackwardWord +Set-PSReadLineKeyHandler -Chord Ctrl+RightArrow -Function ForwardWord +Set-PSReadLineKeyHandler -Chord Alt+Delete -Function DeleteLine +if (-not $isWSL1) { + Set-PSReadLineOption -PredictionSource History -PredictionViewStyle ListView + Set-PSReadLineOption -MaximumHistoryCount 16384 -HistoryNoDuplicates +} +#endregion + +#region environment variables and aliases +[Environment]::SetEnvironmentVariable('USER_SCRIPTS_PATH', "$HOME/.config/powershell/Scripts") +if ($IsLinux -and (Test-Path /etc/os-release -PathType Leaf)) { + (Select-String '(?<=^ID.+)(alpine|arch|fedora|debian|ubuntu|opensuse)' -List /etc/os-release).Matches.Value.ForEach({ + [Environment]::SetEnvironmentVariable('DISTRO_FAMILY', $_) + } + ) +} +# $env:PATH variable +@( + [IO.Path]::Combine($HOME, '.local', 'bin') + [IO.Path]::Combine($HOME, '.bun', 'bin') + [IO.Path]::Combine($HOME, '.cargo', 'bin') +) | ForEach-Object { + if ((Test-Path $_ -PathType Container) -and $_ -notin $env:PATH.Split([IO.Path]::PathSeparator)) { + [Environment]::SetEnvironmentVariable('PATH', [string]::Join([IO.Path]::PathSeparator, $_, $env:PATH)) + } +} +# dot source PowerShell alias scripts +if (Test-Path $env:USER_SCRIPTS_PATH -PathType Container) { + Get-ChildItem -Path $env:USER_SCRIPTS_PATH -Filter '_aliases_*.ps1' -File | ForEach-Object { . $_.FullName } +} +#endregion + +#region prompt +# fallback prompt - overridden by oh-my-posh or starship if configured +function Prompt { + $execStatus = $? + # get execution time of the last command + if (Get-Command Format-Duration -CommandType Function -ErrorAction SilentlyContinue) { + $executionTime = (Get-History).Count -gt 0 ? (Format-Duration -TimeSpan (Get-History)[-1].Duration) : $null + } + # build current prompt path + $pathString = $PWD.Path.Replace($HOME, '~').Replace('Microsoft.PowerShell.Core\FileSystem::', '') -replace '\\$' + $split = $pathString.Split([IO.Path]::DirectorySeparatorChar, [StringSplitOptions]::RemoveEmptyEntries) + $promptPath = if ($split.Count -gt 3) { + [string]::Join('/', $split[0], '..', $split[-1]) + } else { + [string]::Join('/', $split) + } + # run elevated indicator + if ((id -u) -eq 0) { + [Console]::Write("`e[91m#`e[0m ") + } + # write last execution time + if ($executionTime) { + [Console]::Write("[`e[93m$executionTime`e[0m] ") + } + # write last execution status + [Console]::Write("$($PSStyle.Bold){0}`u{2192} ", $execStatus ? $PSStyle.Foreground.BrightGreen : $PSStyle.Foreground.BrightRed) + # write prompt path + [Console]::Write("`e[1;94m$promptPath`e[0m ") + # write git branch/status + if ($GitPromptSettings) { + # get git status + $gitStatus = @(git status -b --porcelain=v2 2>$null)[1..4] + if ($gitStatus) { + # get branch name and upstream status + $branch = $gitStatus[0].Split(' ')[2] + ($gitStatus[1] -match 'branch.upstream' ? $null : " `u{21E1}") + # format branch name color depending on working tree status + [Console]::Write( + "`e[38;2;232;204;151m({0}$branch`e[38;2;232;204;151m) ", + ($gitStatus | Select-String -Pattern '^(?!#)' -Quiet) ? "`e[38;2;255;146;72m" : "`e[38;2;212;170;252m" + ) + } + } + return '{0}{1} ' -f ($PSStyle.Reset, '>' * ($nestedPromptLevel + 1)) +} +#endregion diff --git a/.assets/config/starship_cfg/base.toml b/.assets/config/starship_cfg/base.toml new file mode 100644 index 00000000..ac8ad37f --- /dev/null +++ b/.assets/config/starship_cfg/base.toml @@ -0,0 +1,44 @@ +"$schema" = 'https://starship.rs/config-schema.json' + +# custom +[cmd_duration] +min_time = 200 +show_milliseconds = true + +[directory] +style = 'bold blue' + +[git_branch] +symbol = '' + +[hostname] +disabled = false + +[kubernetes] +disabled = false + +[shell] +disabled = false + +# base +[battery] +full_symbol = "• " +charging_symbol = "⇡ " +discharging_symbol = "⇣ " +unknown_symbol = "❓ " +empty_symbol = "❗ " + +[erlang] +symbol = "ⓔ " + +[fortran] +symbol = "F " + +[nodejs] +symbol = "[⬢](bold green) " + +[pulumi] +symbol = "🧊 " + +[typst] +symbol = "t " diff --git a/.assets/config/starship_cfg/nerd.toml b/.assets/config/starship_cfg/nerd.toml new file mode 100644 index 00000000..91615466 --- /dev/null +++ b/.assets/config/starship_cfg/nerd.toml @@ -0,0 +1,215 @@ +"$schema" = 'https://starship.rs/config-schema.json' + +# custom +[cmd_duration] +min_time = 200 +show_milliseconds = true + +[directory] +style = 'bold blue' +read_only = " 󰌾" + +[kubernetes] +disabled = false + +[shell] +disabled = false + +# base +[aws] +symbol = " " + +[buf] +symbol = " " + +[bun] +symbol = " " + +[c] +symbol = " " + +[cpp] +symbol = " " + +[cmake] +symbol = " " + +[conda] +symbol = " " + +[crystal] +symbol = " " + +[dart] +symbol = " " + +[deno] +symbol = " " + +[docker_context] +symbol = " " + +[elixir] +symbol = " " + +[elm] +symbol = " " + +[fennel] +symbol = " " + +[fortran] +symbol = " " + +[fossil_branch] +symbol = " " + +[gcloud] +symbol = " " + +[git_branch] +symbol = " " + +[git_commit] +tag_symbol = '  ' + +[golang] +symbol = " " + +[gradle] +symbol = " " + +[guix_shell] +symbol = " " + +[haskell] +symbol = " " + +[haxe] +symbol = " " + +[hg_branch] +symbol = " " + +[hostname] +disabled = false + +[java] +symbol = " " + +[julia] +symbol = " " + +[kotlin] +symbol = " " + +[lua] +symbol = " " + +[memory_usage] +symbol = "󰍛 " + +[meson] +symbol = "󰔷 " + +[nim] +symbol = "󰆥 " + +[nix_shell] +symbol = " " + +[nodejs] +symbol = " " + +[ocaml] +symbol = " " + +[os.symbols] +Alpaquita = " " +Alpine = " " +AlmaLinux = " " +Amazon = " " +Android = " " +AOSC = " " +Arch = " " +Artix = " " +CachyOS = " " +CentOS = " " +Debian = " " +DragonFly = " " +Elementary = " " +Emscripten = " " +EndeavourOS = " " +Fedora = " " +FreeBSD = " " +Garuda = "󰛓 " +Gentoo = " " +HardenedBSD = "󰞌 " +Illumos = "󰈸 " +Ios = "󰀷 " +Kali = " " +Linux = " " +Mabox = " " +Macos = " " +Manjaro = " " +Mariner = " " +MidnightBSD = " " +Mint = " " +NetBSD = " " +NixOS = " " +Nobara = " " +OpenBSD = "󰈺 " +openSUSE = " " +OracleLinux = "󰌷 " +Pop = " " +Raspbian = " " +Redhat = " " +RedHatEnterprise = " " +RockyLinux = " " +Redox = "󰀘 " +Solus = "󰠳 " +SUSE = " " +Ubuntu = " " +Unknown = " " +Void = " " +Windows = "󰍲 " +Zorin = " " + +[package] +symbol = "󰏗 " + +[perl] +symbol = " " + +[php] +symbol = " " + +[pijul_channel] +symbol = " " + +[python] +symbol = " " + +[rlang] +symbol = "󰟔 " + +[ruby] +symbol = " " + +[rust] +symbol = "󱘗 " + +[scala] +symbol = " " + +[status] +symbol = " " + +[swift] +symbol = " " + +[xmake] +symbol = " " + +[zig] +symbol = " " diff --git a/.assets/config/starship_cfg/omp_base.toml b/.assets/config/starship_cfg/omp_base.toml new file mode 100644 index 00000000..3b895802 --- /dev/null +++ b/.assets/config/starship_cfg/omp_base.toml @@ -0,0 +1,95 @@ +"$schema" = 'https://starship.rs/config-schema.json' +# Starship port of omp_base theme. +# Matches oh-my-posh base.omp.json: colors, segment order, and text-only indicators. +# Right prompt (right_format) requires zsh, fish, or bash+ble.sh. + +format = """\ +$python\ +$cmd_duration\ +$character\ +$username\ +$directory\ +$git_branch\ +$git_status\ +$shell""" + +right_format = '$kubernetes' + +add_newline = false + +# -- Arrow prompt symbol (green on success, red on error) ---------------------- +[character] +success_symbol = '[→](bold fg:#23D18B)' +error_symbol = '[→](bold fg:#F14C4C)' + +# -- Execution time: [123ms] --------------------------------------------------- +# OMP threshold: 1ms; gray brackets, yellow value +[cmd_duration] +min_time = 1 +show_milliseconds = true +format = '[\[](fg:#CCCCCC)[$duration](fg:#F5F543)[\]](fg:#CCCCCC) ' + +# -- Path: bold cyan, agnoster-short style, / separator ----------------------- +[directory] +style = 'bold fg:#56B6C2' +format = '[$path]($style) ' +truncation_length = 1 +truncate_to_repo = false + +# -- Git: gold parens, purple branch; orange status indicators ---------------- +# OMP changes branch color when dirty/ahead/behind; starship approximates this +# with orange indicators after the branch name. +[git_branch] +style = 'fg:#D4AAFC' +symbol = '' +format = '[\(](fg:#E8CC97)[**$branch**]($style)[\)](fg:#E8CC97) ' + +[git_status] +style = 'fg:#FF9248' +format = '[$all_status$ahead_behind]($style) ' +modified = '!' +staged = '+' +deleted = '-' +renamed = '»' +untracked = '?' +conflicted = '~' +ahead = '↑${count}' +behind = '↓${count}' +diverged = '↕' + +# -- Kubernetes (right prompt): context::namespace ---------------------------- +[kubernetes] +style = 'fg:#2E6CE6' +symbol = '' +format = '[$context([::$namespace](italic $style))]($style)' +disabled = false + +# -- Python: venv name in green (no version, matches omp fetch_version:false) - +[python] +style = 'fg:#23D18B' +format = '([$virtualenv]($style)) ' + +# -- Shell indicator: $/%/> in cyan ------------------------------------------- +[shell] +style = 'fg:#29B8DB' +bash_indicator = '\$' +zsh_indicator = '%' +powershell_indicator = '❭' +fish_indicator = '❭' +format = '[$indicator ]($style)' +disabled = false + +# -- SSH / root: yellow username on SSH, bold red when root ------------------- +# OMP base shows literal "ssh " / "# "; starship shows username instead. +[username] +style_root = 'bold fg:#FF6666' +style_user = 'fg:#FFE082' +format = '[$user ]($style)' +show_always = false + +# -- Suppress unused default modules ------------------------------------------ +[hostname] +disabled = true + +[sudo] +disabled = true diff --git a/.assets/config/starship_cfg/omp_nerd.toml b/.assets/config/starship_cfg/omp_nerd.toml new file mode 100644 index 00000000..2d94c94c --- /dev/null +++ b/.assets/config/starship_cfg/omp_nerd.toml @@ -0,0 +1,292 @@ +"$schema" = 'https://starship.rs/config-schema.json' +# Starship port of omp_nerd theme. +# Matches oh-my-posh nerd.omp.json: Nerd Font icons, diamond-style path badge, +# OS icon, and nerd symbols throughout. +# Right prompt (right_format) requires zsh, fish, or bash+ble.sh. + +format = """\ +$python\ +$os\ +$cmd_duration\ +$character\ +$username\ +$directory\ +$git_branch\ +$git_status\ +$shell""" + +right_format = '$kubernetes' + +add_newline = false + +# -- Arrow prompt symbol ------------------------------------------------------- +[character] +success_symbol = '[→](bold fg:#23D18B)' +error_symbol = '[→](bold fg:#F14C4C)' + +# -- Execution time: [123ms] --------------------------------------------------- +[cmd_duration] +min_time = 1 +show_milliseconds = true +format = '[\[](fg:#CCCCCC)[$duration](fg:#F5F543)[\]](fg:#CCCCCC) ' + +# -- Path: pill/badge with blue background (diamond style) -------------------- +# OMP uses leading_diamond + background + trailing_diamond. +# Starship replicates this with powerline arc characters (Nerd Fonts U+E0B6/E0B4). +[directory] +style = 'bold fg:white bg:#3B8EEA' +format = '[](fg:#3B8EEA)[$path ](bold fg:white bg:#3B8EEA)[](fg:#3B8EEA) ' +truncation_length = 1 +truncate_to_repo = false + +# -- Git: nerd font branch icon, purple; orange status indicators ------------- +[git_branch] +style = 'bold fg:#D4AAFC' +symbol = ' ' +format = '[$symbol$branch]($style) ' + +[git_status] +style = 'fg:#FF9248' +format = '[$all_status$ahead_behind]($style) ' +modified = '!' +staged = '+' +deleted = '-' +renamed = '»' +untracked = '?' +conflicted = '~' +ahead = '↑${count}' +behind = '↓${count}' +diverged = '↕' + +# -- Kubernetes (right prompt): ⎈ context::namespace -------------------------- +[kubernetes] +style = 'fg:#2E6CE6' +symbol = '⎈ ' +format = '[$symbol$context([::$namespace](italic $style))]($style)' +disabled = false + +# -- OS icon: cyan (matches OMP os segment; WSL color change not replicable) -- +[os] +style = 'fg:#26C6DA' +format = '[$symbol]($style)' +disabled = false + +[python] +style = 'fg:#23D18B' +symbol = " " +format = '\([$virtualenv]($style)\) ' + +# -- Shell indicator: $/%/❭ in green ------------------------------------------ +[shell] +style = 'fg:#23D18B' +bash_indicator = '\$' +zsh_indicator = '%' +powershell_indicator = '❭' +fish_indicator = '❭' +format = '[$indicator ]($style)' +disabled = false + +# -- SSH / root: yellow username on SSH, bold red when root ------------------- +# OMP nerd shows icon on SSH; starship shows username instead. +[username] +style_root = 'bold fg:#FF6666' +style_user = 'fg:#FFE082' +format = '[$user ]($style)' +show_always = false + +# -- Suppress unused default modules ------------------------------------------ +[hostname] +disabled = true + +[sudo] +disabled = true + +# base +[aws] +symbol = " " + +[buf] +symbol = " " + +[bun] +symbol = " " + +[c] +symbol = " " + +[cpp] +symbol = " " + +[cmake] +symbol = " " + +[conda] +symbol = " " + +[crystal] +symbol = " " + +[dart] +symbol = " " + +[deno] +symbol = " " + +[docker_context] +symbol = " " + +[elixir] +symbol = " " + +[elm] +symbol = " " + +[fennel] +symbol = " " + +[fortran] +symbol = " " + +[fossil_branch] +symbol = " " + +[gcloud] +symbol = " " + +[git_commit] +tag_symbol = '  ' + +[golang] +symbol = " " + +[gradle] +symbol = " " + +[guix_shell] +symbol = " " + +[haskell] +symbol = " " + +[haxe] +symbol = " " + +[hg_branch] +symbol = " " + +[java] +symbol = " " + +[julia] +symbol = " " + +[kotlin] +symbol = " " + +[lua] +symbol = " " + +[memory_usage] +symbol = "󰍛 " + +[meson] +symbol = "󰔷 " + +[nim] +symbol = "󰆥 " + +[nix_shell] +symbol = " " + +[nodejs] +symbol = " " + +[ocaml] +symbol = " " + +[os.symbols] +Alpaquita = " " +Alpine = " " +AlmaLinux = " " +Amazon = " " +Android = " " +AOSC = " " +Arch = " " +Artix = " " +CachyOS = " " +CentOS = " " +Debian = " " +DragonFly = " " +Elementary = " " +Emscripten = " " +EndeavourOS = " " +Fedora = " " +FreeBSD = " " +Garuda = "󰛓 " +Gentoo = " " +HardenedBSD = "󰞌 " +Illumos = "󰈸 " +Ios = "󰀷 " +Kali = " " +Linux = " " +Mabox = " " +Macos = " " +Manjaro = " " +Mariner = " " +MidnightBSD = " " +Mint = " " +NetBSD = " " +NixOS = " " +Nobara = " " +OpenBSD = "󰈺 " +openSUSE = " " +OracleLinux = "󰌷 " +Pop = " " +Raspbian = " " +Redhat = " " +RedHatEnterprise = " " +RockyLinux = " " +Redox = "󰀘 " +Solus = "󰠳 " +SUSE = " " +Ubuntu = " " +Unknown = " " +Void = " " +Windows = "󰍲 " +Zorin = " " + +[package] +symbol = "󰏗 " + +[perl] +symbol = " " + +[php] +symbol = " " + +[pijul_channel] +symbol = " " + +[rlang] +symbol = "󰟔 " + +[ruby] +symbol = " " + +[rust] +symbol = "󱘗 " + +[scala] +symbol = " " + +[status] +symbol = " " + +[swift] +symbol = " " + +[xmake] +symbol = " " + +[zig] +symbol = " " diff --git a/.assets/docker/Dockerfile b/.assets/docker/Dockerfile index 2b9fe08a..2447c506 100644 --- a/.assets/docker/Dockerfile +++ b/.assets/docker/Dockerfile @@ -16,7 +16,7 @@ RUN apt-get update \ # change ownership to the ubuntu user && chown -R ubuntu:ubuntu /home/ubuntu/source \ # setup the container for the ubuntu user - && su - ubuntu -c 'cd "$HOME/source/lss" && .assets/scripts/linux_setup.sh --skip_gh_auth true --scope "pwsh" --omp_theme base' \ + && su - ubuntu -c 'cd "$HOME/source/lss" && .assets/scripts/linux_setup.sh --unattended true --scope "pwsh" --omp_theme base' \ # # clean up && apt-get autoremove -y \ diff --git a/.assets/docker/Dockerfile.alpine b/.assets/docker/Dockerfile.alpine index 1ded1742..b8e420b4 100644 --- a/.assets/docker/Dockerfile.alpine +++ b/.assets/docker/Dockerfile.alpine @@ -27,13 +27,13 @@ RUN cd "$HOME/source/lss" \ && sudo apk add --no-cache bind-tools ca-certificates iputils curl jq lsb-release-minimal nmap openssh-client openssl tar tree unzip vim whois \ # install and setup oh-my-posh && sudo .assets/provision/install_omp.sh \ - && sudo .assets/provision/setup_omp.sh --theme base --user alpine \ + && sudo .assets/setup/setup_omp.sh --theme base --user alpine \ # setup shell && sudo .assets/provision/install_eza.sh \ && sudo .assets/provision/install_bat.sh \ && sudo .assets/provision/install_ripgrep.sh \ - && sudo .assets/provision/setup_profile_allusers.sh alpine \ - && .assets/provision/setup_profile_user.sh \ + && sudo .assets/setup/setup_profile_allusers.sh alpine \ + && .assets/setup/setup_profile_user.sh \ # # clean up && sudo rm -rf /var/cache/apk/* /home/alpine/source diff --git a/.assets/docker/Dockerfile.test-legacy b/.assets/docker/Dockerfile.test-legacy new file mode 100644 index 00000000..8274ad64 --- /dev/null +++ b/.assets/docker/Dockerfile.test-legacy @@ -0,0 +1,48 @@ +FROM ubuntu:24.04 + +# avoid warnings by switching to noninteractive +ENV DEBIAN_FRONTEND=noninteractive + +# copy repo into the container +COPY .assets /home/ubuntu/source/lss/.assets +COPY modules /home/ubuntu/source/lss/modules + +# install system dependencies and intercepted root cert +RUN apt-get update \ + && apt-get install -y --no-install-recommends sudo ca-certificates \ + # install intercepted MITM root cert if present + && if [ -f /home/ubuntu/source/lss/.assets/certs/ca-cert-root.crt ]; then \ + cp /home/ubuntu/source/lss/.assets/certs/ca-cert-root.crt /usr/local/share/ca-certificates/ \ + && update-ca-certificates; \ + fi \ + # allow ubuntu user to run sudo without password + && echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ubuntu-nopasswd \ + && chmod 0440 /etc/sudoers.d/ubuntu-nopasswd \ + # change ownership to the ubuntu user + && chown -R ubuntu:ubuntu /home/ubuntu/source \ + # run legacy setup as the ubuntu user + && su --whitelist-environment=DEBIAN_FRONTEND - ubuntu -c 'cd "$HOME/source/lss" && .assets/scripts/linux_setup.sh --unattended true --scope "python shell rice" --omp_theme base' \ + # + # clean up + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +# switch to the 'ubuntu' user +USER ubuntu +WORKDIR /home/ubuntu/source/lss + +# verify key binaries exist +RUN set -e \ + && echo "=== Verifying legacy setup ===" \ + && command -v git \ + && command -v jq \ + && command -v rg \ + && command -v eza \ + && command -v bat \ + && command -v oh-my-posh \ + && command -v fastfetch \ + && $HOME/.local/bin/uv --version \ + && echo "=== All legacy checks passed ===" + +ENTRYPOINT [ "/bin/bash" ] diff --git a/.assets/docker/Dockerfile.test-nix b/.assets/docker/Dockerfile.test-nix new file mode 100644 index 00000000..16ad937f --- /dev/null +++ b/.assets/docker/Dockerfile.test-nix @@ -0,0 +1,102 @@ +FROM ubuntu:24.04 + +# avoid warnings by switching to noninteractive +ENV DEBIAN_FRONTEND=noninteractive + +# copy repo into the container +COPY . /home/ubuntu/source/lss + +# Stage 1: system dependencies and /nix prep (as root) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates curl sudo xz-utils \ + # install intercepted MITM root cert if present + && if [ -f /home/ubuntu/source/lss/.assets/certs/ca-cert-root.crt ]; then \ + cp /home/ubuntu/source/lss/.assets/certs/ca-cert-root.crt /usr/local/share/ca-certificates/ \ + && update-ca-certificates; \ + fi \ + # change ownership to the ubuntu user + && chown -R ubuntu:ubuntu /home/ubuntu/source \ + # prepare /nix for single-user install + && mkdir -p /nix \ + && chown ubuntu:ubuntu /nix \ + # enable flakes and disable sandbox + && mkdir -p /home/ubuntu/.config/nix \ + && printf 'experimental-features = nix-command flakes\nsandbox = false\n' \ + > /home/ubuntu/.config/nix/nix.conf \ + && chown -R ubuntu:ubuntu /home/ubuntu/.config \ + # + # clean up + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +# Stage 2: install Nix + run setup (as user, no su needed) +USER ubuntu +WORKDIR /home/ubuntu/source/lss +SHELL ["/bin/bash", "-c"] + +RUN curl -sL https://nixos.org/nix/install | sh -s -- --no-daemon + +RUN nix/setup.sh --shell --rice --python --unattended + +# Verify key binaries are in the nix profile +RUN set -e \ + && export PATH="$HOME/.nix-profile/bin:$PATH" \ + && echo "=== Verifying nix setup ===" \ + && command -v git \ + && command -v jq \ + && command -v rg \ + && command -v eza \ + && command -v bat \ + && command -v fastfetch \ + && command -v uv \ + && echo "=== All nix checks passed ===" \ + # + # Verify nx CLI helpers (source aliases_nix.sh to get nx function) + && echo "=== Verifying nx CLI ===" \ + && source "$HOME/.config/bash/aliases_nix.sh" \ + && nx list \ + && nx scope list \ + && nx scope tree \ + && echo "=== nx CLI checks passed ===" \ + # + # Verify install provenance record + && echo "=== Verifying install.json ===" \ + && test -f "$HOME/.config/dev-env/install.json" \ + && jq -e '.status == "success"' "$HOME/.config/dev-env/install.json" \ + && echo "=== install.json checks passed ===" + +# Verify uninstaller (env-only: keeps Nix, removes nix-env state) +RUN set -e \ + && export PATH="$HOME/.nix-profile/bin:$PATH" \ + && echo "=== Running nix/uninstall.sh --env-only ===" \ + && nix/uninstall.sh --env-only \ + # + # nix-env managed block must be gone + && echo "--- Checking nix-env managed block removed ---" \ + && ! grep -qF '# >>> nix-env managed >>>' "$HOME/.bashrc" \ + # + # managed env block must survive + && echo "--- Checking managed env block preserved ---" \ + && grep -qF '# >>> managed env >>>' "$HOME/.bashrc" \ + # + # nix-env config dir must be gone + && echo "--- Checking nix-env config removed ---" \ + && test ! -d "$HOME/.config/nix-env" \ + # + # nix-specific alias file must be gone + && echo "--- Checking nix-specific aliases removed ---" \ + && test ! -f "$HOME/.config/bash/aliases_nix.sh" \ + # + # generic files must survive + && echo "--- Checking generic config files preserved ---" \ + && test -f "$HOME/.config/bash/functions.sh" \ + # + # nix itself must still be installed (not on PATH after ~/.nix-profile + # removal, but the nix store must exist) + && echo "--- Checking Nix still installed ---" \ + && test -d /nix/store \ + && echo "=== Uninstall checks passed ===" + +ENTRYPOINT [ "/bin/bash" ] diff --git a/.assets/fix/fix_azcli_certs.sh b/.assets/fix/fix_azcli_certs.sh new file mode 100755 index 00000000..9b82042a --- /dev/null +++ b/.assets/fix/fix_azcli_certs.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +: ' +# patch azure-cli certifi bundle with custom CA certificates +.assets/fix/fix_azcli_certs.sh +' +set -euo pipefail + +# resolve repo root dir +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +. "$SCRIPT_ROOT/.assets/config/bash_cfg/functions.sh" + +# *discover azure-cli certifi cacert.pem +AZCLI_CERTIFI="" +SYS_ID="$(sed -En '/^ID.*(fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release 2>/dev/null)" || true +case ${SYS_ID:-} in +fedora | opensuse) + AZCLI_CERTIFI=$(rpm -ql azure-cli 2>/dev/null | grep 'site-packages/certifi/cacert.pem') || true + ;; +debian | ubuntu) + AZCLI_CERTIFI=$(dpkg-query -L azure-cli 2>/dev/null | grep 'site-packages/certifi/cacert.pem') || true + ;; +esac +if [ -z "$AZCLI_CERTIFI" ]; then + # try azure-cli venv + AZ_VENV="$HOME/.azure/.venv/bin/activate" + if [ -f "$AZ_VENV" ]; then + source "$AZ_VENV" 2>/dev/null || true + location=$(pip show certifi 2>/dev/null | grep -oP '^Location: \K.+') || true + if [ -n "$location" ]; then + AZCLI_CERTIFI="${location}/certifi/cacert.pem" + fi + fi +fi + +if [ -z "$AZCLI_CERTIFI" ] || [ ! -f "$AZCLI_CERTIFI" ]; then + printf '\e[33mazure-cli certifi/cacert.pem not found\e[0m\n' >&2 + exit 0 +fi + +fixcertpy "$AZCLI_CERTIFI" diff --git a/.assets/fix/fix_gcloud_certs.sh b/.assets/fix/fix_gcloud_certs.sh new file mode 100755 index 00000000..1ed76b35 --- /dev/null +++ b/.assets/fix/fix_gcloud_certs.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +: ' +# patch gcloud SDK certifi bundle with custom CA certificates +.assets/fix/fix_gcloud_certs.sh +' +set -euo pipefail + +# resolve repo root dir +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +. "$SCRIPT_ROOT/.assets/config/bash_cfg/functions.sh" + +# *discover gcloud certifi cacert.pem +GCLOUD_CERTIFI="" +for loc in \ + "/usr/local/google-cloud-sdk/lib/third_party/certifi/cacert.pem" \ + "/usr/lib/google-cloud-sdk/lib/third_party/certifi/cacert.pem" \ + "/usr/lib64/google-cloud-sdk/lib/third_party/certifi/cacert.pem"; do + if [ -f "$loc" ]; then + GCLOUD_CERTIFI="$loc" + break + fi +done + +if [ -z "$GCLOUD_CERTIFI" ]; then + printf '\e[33mgcloud certifi/cacert.pem not found\e[0m\n' >&2 + exit 0 +fi + +fixcertpy "$GCLOUD_CERTIFI" diff --git a/.assets/provision/fix_no_file.sh b/.assets/fix/fix_no_file.sh similarity index 90% rename from .assets/provision/fix_no_file.sh rename to .assets/fix/fix_no_file.sh index 2c6fcfba..78d70765 100755 --- a/.assets/provision/fix_no_file.sh +++ b/.assets/fix/fix_no_file.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh : ' # Fixes the "Too many open files" error. -sudo .assets/provision/fix_no_file.sh +sudo .assets/fix/fix_no_file.sh ' set -eu diff --git a/.assets/fix/fix_nodejs_certs.sh b/.assets/fix/fix_nodejs_certs.sh new file mode 100755 index 00000000..39756811 --- /dev/null +++ b/.assets/fix/fix_nodejs_certs.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +: ' +# set user-scope npm cafile +.assets/fix/fix_nodejs_certs.sh +# set system-wide npm cafile (requires root) +sudo .assets/fix/fix_nodejs_certs.sh +' +set -euo pipefail + +if [ $EUID -eq 0 ]; then + # *root: set global cafile to the system CA bundle + SYS_ID="$(sed -En '/^ID.*(alpine|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" + case $SYS_ID in + arch | alpine | fedora | opensuse) + exit 0 + ;; + debian | ubuntu) + CERT_PATH='/etc/ssl/certs/ca-certificates.crt' + ;; + *) + printf '\e[1;33mWarning: Unsupported system id (%s).\e[0m\n' "$SYS_ID" >&2 + exit 0 + ;; + esac + if ! (npm config get | grep -q 'cafile'); then + npm config set -g cafile "$CERT_PATH" + fi +else + # *non-root: set user-scope cafile to the full trust store bundle + CERT_BUNDLE="$HOME/.config/certs/ca-bundle.crt" + if [ -f "$CERT_BUNDLE" ] && ! (npm config get | grep -q 'cafile'); then + npm config set cafile "$CERT_BUNDLE" + fi +fi diff --git a/.assets/provision/fix_secure_path.sh b/.assets/fix/fix_secure_path.sh similarity index 91% rename from .assets/provision/fix_secure_path.sh rename to .assets/fix/fix_secure_path.sh index ab8b2a3d..0799bc9d 100755 --- a/.assets/provision/fix_secure_path.sh +++ b/.assets/fix/fix_secure_path.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh : ' -sudo .assets/provision/fix_secure_path.sh +sudo .assets/fix/fix_secure_path.sh ' set -eu diff --git a/.assets/lib/certs.sh b/.assets/lib/certs.sh new file mode 100644 index 00000000..2436c0f6 --- /dev/null +++ b/.assets/lib/certs.sh @@ -0,0 +1,89 @@ +# Shared CA bundle builder and VS Code Server cert env setup. +# Compatible with bash 3.2 and zsh (sourced by nix and legacy setup paths). +# +# Usage: +# source .assets/lib/certs.sh +# build_ca_bundle # creates ca-bundle.crt if ca-custom.crt exists +# setup_vscode_certs # writes NODE_EXTRA_CA_CERTS to server-env-setup +# +# Requires: ok() helper defined by caller (printf green line). + +# Default TLS probe URL for MITM detection and cert interception. +: "${NIX_ENV_TLS_PROBE_URL:=https://www.google.com}" + +# build_ca_bundle +# Creates ~/.config/certs/ca-bundle.crt as a PEM bundle for nix-installed tools. +# Linux: symlinks to system CA bundle (requires ca-custom.crt to exist, since +# the system bundle already includes custom certs after update-ca-certificates). +# macOS: exports all trusted certificates from macOS Keychains (system roots + +# admin-installed corporate/proxy certs). No ca-custom.crt required - +# the Keychain is the authoritative trust store on macOS. +# Idempotent: skips if ca-bundle.crt already exists. +build_ca_bundle() { + local cert_dir="$HOME/.config/certs" + local custom_certs="$cert_dir/ca-custom.crt" + local bundle_link="$cert_dir/ca-bundle.crt" + + [ ! -e "$bundle_link" ] || return 0 + + mkdir -p "$cert_dir" + case "$(uname -s)" in + Linux) + [ -f "$custom_certs" ] || return 0 + for sys_bundle in \ + /etc/ssl/certs/ca-certificates.crt \ + /etc/pki/tls/certs/ca-bundle.crt; do + if [ -f "$sys_bundle" ]; then + ln -sf "$sys_bundle" "$bundle_link" + ok " symlinked ca-bundle.crt -> $sys_bundle" + break + fi + done + ;; + Darwin) + local bundle_tmp + bundle_tmp="$(mktemp)" + # Export all trusted certs from macOS Keychains (includes corporate/proxy CAs) + security find-certificate -a -p /System/Library/Keychains/SystemRootCertificates.keychain >"$bundle_tmp" 2>/dev/null + security find-certificate -a -p /Library/Keychains/System.keychain >>"$bundle_tmp" 2>/dev/null + # Append any manually intercepted certs + [ -f "$custom_certs" ] && cat "$custom_certs" >>"$bundle_tmp" + if [ -s "$bundle_tmp" ]; then + mv -f "$bundle_tmp" "$bundle_link" + ok " created ca-bundle.crt from macOS Keychain" + else + rm -f "$bundle_tmp" + fi + ;; + esac +} + +# setup_vscode_certs +# Writes NODE_EXTRA_CA_CERTS to ~/.vscode-server/server-env-setup so that +# VS Code Server (remote-SSH, WSL) picks up custom CA certs without needing +# a login shell. Creates the directory and file if they don't exist yet. +# Idempotent: updates the value if already present, appends otherwise. +setup_vscode_certs() { + local cert_dir="$HOME/.config/certs" + local custom_certs="$cert_dir/ca-custom.crt" + local env_file="$HOME/.vscode-server/server-env-setup" + + [ -f "$custom_certs" ] || return 0 + + mkdir -p "$HOME/.vscode-server" + + local export_line="export NODE_EXTRA_CA_CERTS=\"$custom_certs\"" + if [ -f "$env_file" ] && grep -q 'NODE_EXTRA_CA_CERTS' "$env_file" 2>/dev/null; then + if ! grep -qF "$export_line" "$env_file" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + grep -v 'NODE_EXTRA_CA_CERTS' "$env_file" >"$tmp" + printf '%s\n' "$export_line" >>"$tmp" + mv -f "$tmp" "$env_file" + ok " updated NODE_EXTRA_CA_CERTS in $env_file" + fi + else + printf '%s\n' "$export_line" >>"$env_file" + ok " added NODE_EXTRA_CA_CERTS to $env_file" + fi +} diff --git a/.assets/lib/env_block.sh b/.assets/lib/env_block.sh new file mode 100644 index 00000000..8b9ce643 --- /dev/null +++ b/.assets/lib/env_block.sh @@ -0,0 +1,59 @@ +# Generic managed-env block for shell rc files. +# Contains user-scope PATH and cert env vars that are not nix-specific. +# Shared by nix and legacy setup paths. +# Compatible with bash 3.2 and zsh (sourced by both). +# +# Usage: +# source .assets/lib/env_block.sh +# render_env_block # prints block content to stdout +# +# Requires: profile_block.sh must be sourced first (for manage_block). + +# shellcheck disable=SC2034 # used by sourcing scripts +ENV_BLOCK_MARKER="managed env" + +# render_env_block +# Prints the managed env block content to stdout. +# Two sections: local path and cert env vars. +# Caller writes output to a temp file and passes to manage_block. +render_env_block() { + # :local path + printf '# :local path\n' + printf 'if [ -d "$HOME/.local/bin" ]; then\n' + printf ' export PATH="$HOME/.local/bin:$PATH"\n' + printf 'fi\n' + + # :aliases (generic - nix-installed tools have their aliases in the nix block) + if [ -f "$HOME/.config/bash/functions.sh" ]; then + printf '\n# :aliases\n' + printf '[ -f "$HOME/.config/bash/functions.sh" ] && . "$HOME/.config/bash/functions.sh"\n' + fi + if [ -f "$HOME/.config/bash/aliases_git.sh" ] && command -v git &>/dev/null && [ ! -x "$HOME/.nix-profile/bin/git" ]; then + printf '[ -f "$HOME/.config/bash/aliases_git.sh" ] && . "$HOME/.config/bash/aliases_git.sh"\n' + fi + if [ -f "$HOME/.config/bash/aliases_kubectl.sh" ] && command -v kubectl &>/dev/null && [ ! -x "$HOME/.nix-profile/bin/kubectl" ]; then + printf '[ -f "$HOME/.config/bash/aliases_kubectl.sh" ] && . "$HOME/.config/bash/aliases_kubectl.sh"\n' + fi + + # :certs + local cert_dir="$HOME/.config/certs" + if [ -f "$cert_dir/ca-custom.crt" ] || [ -e "$cert_dir/ca-bundle.crt" ]; then + printf '\n# :certs\n' + fi + if [ -f "$cert_dir/ca-custom.crt" ]; then + printf 'if [ -f "$HOME/.config/certs/ca-custom.crt" ]; then\n' + printf ' export NODE_EXTRA_CA_CERTS="$HOME/.config/certs/ca-custom.crt"\n' + printf 'fi\n' + fi + if [ -e "$cert_dir/ca-bundle.crt" ]; then + printf 'if [ -f "$HOME/.config/certs/ca-bundle.crt" ]; then\n' + printf ' export REQUESTS_CA_BUNDLE="$HOME/.config/certs/ca-bundle.crt"\n' + printf ' export SSL_CERT_FILE="$HOME/.config/certs/ca-bundle.crt"\n' + printf 'fi\n' + if command -v gcloud &>/dev/null; then + printf 'if [ -f "$HOME/.config/certs/ca-bundle.crt" ]; then\n' + printf ' export CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE="$HOME/.config/certs/ca-bundle.crt"\n' + printf 'fi\n' + fi + fi +} diff --git a/.assets/lib/install_record.sh b/.assets/lib/install_record.sh new file mode 100644 index 00000000..c2ea7c8d --- /dev/null +++ b/.assets/lib/install_record.sh @@ -0,0 +1,102 @@ +# Installation provenance record writer. +# Writes ~/.config/dev-env/install.json with setup metadata. +# Compatible with bash 3.2 and zsh (sourced by both). +# +# Usage: +# source .assets/lib/install_record.sh +# +# # set variables before calling (or before trap fires): +# _IR_ENTRY_POINT="nix" # nix, legacy, wsl +# _IR_SCRIPT_ROOT="/path" # repo root, for git version detection +# _IR_SCOPES="az shell" # space-separated scope list +# _IR_MODE="install" # install, upgrade, reconfigure, remove +# _IR_PLATFORM="Linux" # macOS, Linux +# +# # call directly or from an EXIT trap: +# write_install_record [error_message] + +# shellcheck disable=SC2034 # DEV_ENV_DIR used by sourcing scripts +DEV_ENV_DIR="$HOME/.config/dev-env" + +# write_install_record [error_message] +write_install_record() { + local status="${1:-unknown}" phase="${2:-unknown}" error="${3:-}" + local entry_point="${_IR_ENTRY_POINT:-unknown}" + + mkdir -p "$DEV_ENV_DIR" + + # version priority: git describe > VERSION file (tarball) > "unknown" + local version="" source="" source_ref="" + local script_root="${_IR_SCRIPT_ROOT:-}" + if [ -n "$script_root" ] && git -C "$script_root" rev-parse --is-inside-work-tree &>/dev/null; then + version="$(git -C "$script_root" describe --tags --dirty 2>/dev/null)" || true + source="git" + source_ref="$(git -C "$script_root" rev-parse HEAD 2>/dev/null)" || true + elif [ -n "$script_root" ] && [ -f "$script_root/VERSION" ]; then + version="$(<"$script_root/VERSION")" + source="tarball" + else + source="tarball" + fi + version="${version:-unknown}" + + local nix_ver="" + nix_ver="$(nix --version 2>/dev/null)" || true + + if command -v jq &>/dev/null; then + local scopes_json="[]" + if [ -n "${_IR_SCOPES:-}" ]; then + # shellcheck disable=SC2086 # intentional word splitting + scopes_json="$(printf '%s\n' $_IR_SCOPES | jq -R 'select(length > 0)' | jq -sc .)" || scopes_json="[]" + fi + + jq -n \ + --arg entry_point "$entry_point" \ + --arg version "$version" \ + --arg source "$source" \ + --arg source_ref "${source_ref:-}" \ + --argjson scopes "$scopes_json" \ + --arg installed_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg installed_by "$(id -un)" \ + --arg platform "${_IR_PLATFORM:-unknown}" \ + --arg arch "$(uname -m)" \ + --arg mode "${_IR_MODE:-unknown}" \ + --arg status "$status" \ + --arg phase "$phase" \ + --arg error "$error" \ + --arg nix_version "${nix_ver:-}" \ + --arg shell "${SHELL:-}" \ + '{ + entry_point: $entry_point, + version: $version, + source: $source, + source_ref: $source_ref, + scopes: $scopes, + installed_at: $installed_at, + installed_by: $installed_by, + platform: $platform, + arch: $arch, + mode: $mode, + status: $status, + phase: $phase, + error: $error, + nix_version: $nix_version, + shell: $shell + }' >"$DEV_ENV_DIR/install.json" 2>/dev/null + else + # fallback: write minimal JSON without jq (early failures) + cat >"$DEV_ENV_DIR/install.json" </dev/null 2>&1; then + _check "nix_available" "pass" +else + _check "nix_available" "fail" "nix not found in PATH" +fi + +# -- 2. flake_lock ------------------------------------------------------------ +if [ -f "$ENV_DIR/flake.lock" ]; then + if command -v jq >/dev/null 2>&1; then + _nixpkgs_rev="$(jq -r '.nodes.nixpkgs.locked.rev // empty' "$ENV_DIR/flake.lock" 2>/dev/null)" || true + if [ -n "$_nixpkgs_rev" ]; then + _check "flake_lock" "pass" + else + _check "flake_lock" "warn" "flake.lock exists but nixpkgs node not found" + fi + else + _check "flake_lock" "warn" "flake.lock exists but jq not available to validate" + fi +else + _check "flake_lock" "fail" "$ENV_DIR/flake.lock not found" +fi + +# -- 3. install_record ------------------------------------------------------- +if [ -f "$DEV_ENV_DIR/install.json" ]; then + if command -v jq >/dev/null 2>&1; then + _ir_status="$(jq -r '.status // empty' "$DEV_ENV_DIR/install.json" 2>/dev/null)" || true + if [ -n "$_ir_status" ]; then + if [ "$_ir_status" = "success" ]; then + _check "install_record" "pass" + else + _ir_phase="$(jq -r '.phase // "unknown"' "$DEV_ENV_DIR/install.json" 2>/dev/null)" || true + _check "install_record" "warn" "last run status: $_ir_status (phase: $_ir_phase)" + fi + else + _check "install_record" "warn" "install.json exists but missing status field" + fi + else + _check "install_record" "pass" + fi +else + _check "install_record" "warn" "$DEV_ENV_DIR/install.json not found" +fi + +# -- 4. scope_binaries ------------------------------------------------------- +# Parse "# bins:" comments from scope .nix files (single source of truth). +_scopes_dir="" +for _sd_path in \ + "$ENV_DIR/scopes" \ + "$(cd "$(dirname "$0")/../../nix/scopes" 2>/dev/null && pwd)"; do + if [ -d "$_sd_path" ]; then + _scopes_dir="$_sd_path" + break + fi +done + +if [ -n "$_scopes_dir" ] && [ -f "$DEV_ENV_DIR/install.json" ] && command -v jq >/dev/null 2>&1; then + _installed_scopes="$(jq -r '.scopes[]? // empty' "$DEV_ENV_DIR/install.json" 2>/dev/null)" || true + _missing_bins="" + for _scope in $_installed_scopes; do + _nix_file="$_scopes_dir/$_scope.nix" + [ -f "$_nix_file" ] || continue + _bins="$(sed -n 's/^# bins: *//p' "$_nix_file")" || true + for _bin in $_bins; do + if ! command -v "$_bin" >/dev/null 2>&1; then + _missing_bins="${_missing_bins:+$_missing_bins, }$_scope/$_bin" + fi + done + done + if [ -z "$_missing_bins" ]; then + _check "scope_binaries" "pass" + else + _check "scope_binaries" "warn" "missing: $_missing_bins" + fi +else + _check "scope_binaries" "warn" "cannot verify (scope files or install.json not found)" +fi + +# -- 5. shell_profile -------------------------------------------------------- +_profile_ok=true +_profile_detail="" +_block_marker="nix-env managed" +for _rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [ -f "$_rc" ] || continue + _count="$(grep -cF "# >>> $_block_marker >>>" "$_rc" 2>/dev/null || true)" + _rc_name="$(basename "$_rc")" + if [ "$_count" = "0" ] 2>/dev/null; then + _profile_detail="${_profile_detail:+$_profile_detail; }no managed block in $_rc_name" + _profile_ok=false + elif [ "$_count" -gt 1 ] 2>/dev/null; then + _profile_detail="${_profile_detail:+$_profile_detail; }$_count duplicate blocks in $_rc_name" + _profile_ok=false + fi +done +if [ "$_profile_ok" = true ]; then + _check "shell_profile" "pass" +else + _check "shell_profile" "fail" "$_profile_detail" +fi + +# -- 6. cert_bundle ----------------------------------------------------------- +_cert_dir="$HOME/.config/certs" +if [ -f "$_cert_dir/ca-custom.crt" ]; then + _cert_ok=true + _cert_detail="" + if [ ! -e "$_cert_dir/ca-bundle.crt" ]; then + _cert_detail="ca-bundle.crt missing" + _cert_ok=false + fi + if [ ! -f "$HOME/.vscode-server/server-env-setup" ] || \ + ! grep -q 'NODE_EXTRA_CA_CERTS' "$HOME/.vscode-server/server-env-setup" 2>/dev/null; then + _cert_detail="${_cert_detail:+$_cert_detail; }NODE_EXTRA_CA_CERTS not in server-env-setup" + _cert_ok=false + fi + if [ "$_cert_ok" = true ]; then + _check "cert_bundle" "pass" + else + _check "cert_bundle" "fail" "$_cert_detail" + fi +else + _check "cert_bundle" "pass" +fi + +# -- 7. nix_profile ----------------------------------------------------------- +if command -v nix >/dev/null 2>&1; then + if nix profile list --json 2>/dev/null | grep -q 'nix-env'; then + _check "nix_profile" "pass" + elif nix profile list 2>/dev/null | grep -q 'nix-env'; then + _check "nix_profile" "pass" + else + _check "nix_profile" "fail" "nix-env not found in nix profile list" + fi +else + _check "nix_profile" "fail" "nix not available" +fi + +# -- 8. overlay_dir ----------------------------------------------------------- +if [ -n "${NIX_ENV_OVERLAY_DIR:-}" ]; then + if [ -d "$NIX_ENV_OVERLAY_DIR" ] && [ -r "$NIX_ENV_OVERLAY_DIR" ]; then + _check "overlay_dir" "pass" + else + _check "overlay_dir" "fail" "NIX_ENV_OVERLAY_DIR=$NIX_ENV_OVERLAY_DIR is not a readable directory" + fi +fi + +# -- Summary ------------------------------------------------------------------ +if [ "$_dr_json" = "true" ]; then + _overall="ok" + [ "$_dr_warn" -gt 0 ] && _overall="degraded" + [ "$_dr_fail" -gt 0 ] && _overall="broken" + if command -v jq >/dev/null 2>&1; then + printf '{"status":"%s","pass":%d,"warn":%d,"fail":%d,"checks":[%s]}' \ + "$_overall" "$_dr_pass" "$_dr_warn" "$_dr_fail" "$_dr_checks" | jq . + else + printf '{"status":"%s","pass":%d,"warn":%d,"fail":%d,"checks":[%s]}\n' \ + "$_overall" "$_dr_pass" "$_dr_warn" "$_dr_fail" "$_dr_checks" + fi +else + printf '\n' + if [ "$_dr_fail" -gt 0 ]; then + printf '\e[31m %d passed, %d warnings, %d failed\e[0m\n' "$_dr_pass" "$_dr_warn" "$_dr_fail" + elif [ "$_dr_warn" -gt 0 ]; then + printf '\e[33m %d passed, %d warnings\e[0m\n' "$_dr_pass" "$_dr_warn" + else + printf '\e[32m all %d checks passed\e[0m\n' "$_dr_pass" + fi +fi + +if [ "$_dr_strict" = "true" ]; then + [ $((_dr_fail + _dr_warn)) -eq 0 ] || exit 1 +else + [ "$_dr_fail" -eq 0 ] || exit 1 +fi diff --git a/.assets/lib/profile_block.sh b/.assets/lib/profile_block.sh new file mode 100644 index 00000000..327008f1 --- /dev/null +++ b/.assets/lib/profile_block.sh @@ -0,0 +1,158 @@ +# Managed-block helper for shell rc files. +# Compatible with bash 3.2 and BSD sed (macOS). +# +# Usage: +# source .assets/lib/profile_block.sh +# manage_block [] +# +# action = upsert -> replace the block (or insert if absent) with content-file +# action = remove -> delete the block; no-op if absent +# action = inspect -> print start and end line numbers; exits 0 if present, 1 if absent +# +# Marker format (written into the rc file): +# # >>> >>> +# ... block content ... +# # <<< <<< +# +# Guarantees: +# - Atomic write via tmp file + mv; rc is never half-written. +# - If the block appears more than once, all occurrences are replaced/removed +# with a warning message to stderr. +# - rc file is created empty if it does not exist. +# - File mode is preserved via cp + mv pattern. +# - Works with BSD sed (no -i ''; uses tmp file instead). + +_pb_begin_tag() { printf '# >>> %s >>>' "$1"; } +_pb_end_tag() { printf '# <<< %s <<<' "$1"; } + +# _pb_count_occurrences +# prints the number of begin-tag lines found +_pb_count_occurrences() { + local rc="$1" marker="$2" + local tag + tag="$(_pb_begin_tag "$marker")" + # grep -c returns 0 when no match on some implementations; guard with || true + grep -cF "$tag" "$rc" 2>/dev/null || true +} + +# manage_block [] +manage_block() { + local rc="$1" marker="$2" action="$3" content_file="${4:-}" + + # ensure rc exists + [ -f "$rc" ] || touch "$rc" + + local begin_tag end_tag + begin_tag="$(_pb_begin_tag "$marker")" + end_tag="$(_pb_end_tag "$marker")" + + case "$action" in + inspect) + local start_line end_line + start_line="$(grep -nF "$begin_tag" "$rc" 2>/dev/null | head -1 | cut -d: -f1)" + end_line="$(grep -nF "$end_tag" "$rc" 2>/dev/null | head -1 | cut -d: -f1)" + if [ -z "$start_line" ] || [ -z "$end_line" ]; then + return 1 + fi + printf '%s %s\n' "$start_line" "$end_line" + return 0 + ;; + + remove) + local count + count="$(_pb_count_occurrences "$rc" "$marker")" + if [ "$count" -eq 0 ] 2>/dev/null; then + return 0 + fi + if [ "$count" -gt 1 ] 2>/dev/null; then + printf '\e[33mwarning: found %s occurrences of managed block "%s" in %s; removing all\e[0m\n' \ + "$count" "$marker" "$rc" >&2 + fi + local tmp + tmp="$(mktemp)" + # Use awk to strip all occurrences (BSD awk compatible) + awk -v begin="$begin_tag" -v end="$end_tag" ' + $0 == begin { skip=1; next } + skip && $0 == end { skip=0; next } + !skip { print } + ' "$rc" >"$tmp" + # strip trailing blank lines then ensure final newline + _pb_normalize_trailing "$tmp" + cp -p "$rc" "${rc}.nixenv-backup-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true + mv -f "$tmp" "$rc" + return 0 + ;; + + upsert) + [ -z "$content_file" ] && { + printf '\e[31merror: manage_block upsert requires a content file\e[0m\n' >&2 + return 1 + } + [ -f "$content_file" ] || { + printf '\e[31merror: content file not found: %s\e[0m\n' "$content_file" >&2 + return 1 + } + + local count + count="$(_pb_count_occurrences "$rc" "$marker")" + if [ "$count" -gt 1 ] 2>/dev/null; then + printf '\e[33mwarning: found %s occurrences of managed block "%s" in %s; replacing all with one\e[0m\n' \ + "$count" "$marker" "$rc" >&2 + fi + + local tmp new_block + tmp="$(mktemp)" + + # Build the block string we will insert + new_block="$(printf '%s\n' "$begin_tag"; cat "$content_file"; printf '%s\n' "$end_tag")" + + if [ "$count" -eq 0 ] 2>/dev/null; then + # Append: ensure blank line separator before the block + { + if [ -s "$rc" ]; then + cat "$rc" + printf '\n' + fi + printf '%s\n' "$new_block" + } >"$tmp" + else + # Replace: use awk to substitute first occurrence, delete rest + awk -v begin="$begin_tag" -v end="$end_tag" -v replacement="$new_block" ' + BEGIN { done=0; skip=0 } + $0 == begin { + if (!done) { print replacement; done=1 } + skip=1; next + } + skip && $0 == end { skip=0; next } + !skip { print } + ' "$rc" >"$tmp" + fi + + _pb_normalize_trailing "$tmp" + cp -p "$rc" "${rc}.nixenv-backup-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true + mv -f "$tmp" "$rc" + return 0 + ;; + + *) + printf '\e[31merror: manage_block: unknown action "%s"\e[0m\n' "$action" >&2 + return 1 + ;; + esac +} + +# _pb_normalize_trailing +# Strips consecutive trailing blank lines, ensures exactly one trailing newline. +# BSD sed compatible (no -i ''). +_pb_normalize_trailing() { + local file="$1" + local tmp + tmp="$(mktemp)" + # awk: print all lines; at end, ensure file ends with exactly one newline. + # We strip runs of trailing empty lines by buffering them. + awk ' + /^[[:space:]]*$/ { blank++; next } + { for (i=0; i"$tmp" + mv -f "$tmp" "$file" +} diff --git a/.assets/lib/scopes.json b/.assets/lib/scopes.json new file mode 100644 index 00000000..bcb744a4 --- /dev/null +++ b/.assets/lib/scopes.json @@ -0,0 +1,88 @@ +{ + "valid_scopes": [ + "az", + "bun", + "conda", + "distrobox", + "docker", + "gcloud", + "k8s_base", + "k8s_dev", + "k8s_ext", + "nodejs", + "oh_my_posh", + "pwsh", + "python", + "rice", + "shell", + "starship", + "terraform", + "zsh" + ], + "install_order": [ + "docker", + "k8s_base", + "k8s_dev", + "k8s_ext", + "python", + "conda", + "az", + "gcloud", + "bun", + "nodejs", + "terraform", + "oh_my_posh", + "starship", + "shell", + "zsh", + "pwsh", + "distrobox", + "rice" + ], + "dependency_rules": [ + { + "if": "az", + "add": [ + "python" + ] + }, + { + "if": "k8s_dev", + "add": [ + "k8s_base" + ] + }, + { + "if": "k8s_ext", + "add": [ + "docker", + "k8s_base", + "k8s_dev" + ] + }, + { + "if": "pwsh", + "add": [ + "shell" + ] + }, + { + "if": "zsh", + "add": [ + "shell" + ] + }, + { + "if": "oh_my_posh", + "add": [ + "shell" + ] + }, + { + "if": "starship", + "add": [ + "shell" + ] + } + ] +} diff --git a/.assets/lib/scopes.sh b/.assets/lib/scopes.sh new file mode 100755 index 00000000..bf465ebe --- /dev/null +++ b/.assets/lib/scopes.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +: ' +Shared scope definitions, dependency resolution, and install ordering. +Loads scope data from scopes.json via jq. +Sourced by nix/setup.sh and .assets/scripts/linux_setup.sh. + +Contract: callers use a space-delimited string `_scope_set` (e.g. " shell python ") +with the helper functions below. This avoids bash 4+ associative arrays for macOS compat. + +Why JSON (and why jq)? +---------------------- +scopes.json is the single source of truth for scope metadata (valid names, +install order, dependency rules). It has three first-class consumers that +each parse it natively: + + - bash (this file) via jq + - PowerShell: modules/SetupUtils/SetupUtils.psm1, wsl/wsl_setup.ps1 + (ConvertFrom-Json - built-in, no dependency) + - Python: tests/hooks/validate_scopes.py (json stdlib) + +JSON is the only format all three parse without a custom parser. Switching +to a bash-sourceable format would break the PowerShell and Python consumers +or force parallel data files (source-of-truth split). + +The only cost of this choice is that bash 3.2 on bare macOS has no JSON +parser, so jq must be bootstrapped before scope resolution runs. See +nix/scopes/base_init.nix and the `needsBootstrap`/`isInit` handling in +nix/setup.sh:183-197 and nix/flake.nix. The bootstrap is ~13 lines, runs +once per machine (seconds), and is documented in ARCHITECTURE.md under +"Bootstrap dependency (base_init.nix)". +' + +SCOPES_JSON="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/scopes.json" + +if ! command -v jq &>/dev/null; then + printf '\e[31;1mjq is required but not installed.\e[0m\n' >&2 + exit 1 +fi + +# -- Load from JSON ---------------------------------------------------------- +VALID_SCOPES=() +while IFS= read -r _l; do VALID_SCOPES+=("$_l"); done < <(jq -r '.valid_scopes[]' "$SCOPES_JSON") +INSTALL_ORDER=() +while IFS= read -r _l; do INSTALL_ORDER+=("$_l"); done < <(jq -r '.install_order[]' "$SCOPES_JSON") + +# -- Scope-set helpers (bash 3.2 compatible) --------------------------------- +# _scope_set is a space-padded string: " scope1 scope2 scope3 " +# Callers must initialize: _scope_set=" " + +# Check if scope is in the set. Returns 0 (true) or 1 (false). +scope_has() { [[ " $_scope_set " == *" $1 "* ]]; } + +# Add a scope to the set (idempotent). +scope_add() { scope_has "$1" || _scope_set+="$1 "; } + +# Remove a scope from the set. +scope_del() { _scope_set="${_scope_set/ $1 / }"; } + +# -- Dependency resolution --------------------------------------------------- +# Expands implicit scope dependencies in-place. +# Expects: `_scope_set` string populated by the caller. +# Optional: variable `omp_theme` (non-empty triggers oh_my_posh). +resolve_scope_deps() { + if [[ -n "${omp_theme:-}" ]]; then + scope_add oh_my_posh + fi + + local rules + rules=$(jq -c '.dependency_rules[]' "$SCOPES_JSON") + while IFS= read -r rule; do + local trigger + trigger=$(jq -r '.if' <<<"$rule") + if scope_has "$trigger"; then + local adds=() + while IFS= read -r _l; do adds+=("$_l"); done < <(jq -r '.add[]' <<<"$rule") + for a in "${adds[@]}"; do + scope_add "$a" + done + fi + done <<<"$rules" +} + +# -- Sort scopes by install order -------------------------------------------- +# Populates `sorted_scopes` array from `_scope_set`. +sort_scopes() { + sorted_scopes=() + for sc in "${INSTALL_ORDER[@]}"; do + if scope_has "$sc"; then + sorted_scopes+=("$sc") + fi + done +} + +# -- Validate scope names --------------------------------------------------- +# Returns 0 if all arguments are valid scope names, 1 otherwise. +validate_scopes() { + local valid + for s in "$@"; do + valid=false + for v in "${VALID_SCOPES[@]}"; do + if [[ "$s" == "$v" ]]; then + valid=true + break + fi + done + if [[ "$valid" == "false" ]]; then + printf '\e[31;1mUnknown scope: %s\e[0m\n' "$s" >&2 + printf 'Valid scopes: %s\n' "${VALID_SCOPES[*]}" >&2 + return 1 + fi + done +} diff --git a/.assets/provision/fix_azcli_certs.sh b/.assets/provision/fix_azcli_certs.sh deleted file mode 100755 index f0daff42..00000000 --- a/.assets/provision/fix_azcli_certs.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -: ' -.assets/provision/fix_azcli_certs.sh -' -set -euo pipefail - -if [ $EUID -eq 0 ]; then - printf '\e[31;1mDo not run the script as root.\e[0m\n' >&2 - exit 1 -fi - -# determine system id -SYS_ID="$(sed -En '/^ID.*(alpine|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" - -# specify path for installed custom certificates -case $SYS_ID in -alpine) - exit 0 - ;; -fedora | opensuse) - [ "$SYS_ID" = 'fedora' ] && CERT_PATH='/etc/pki/ca-trust/source/anchors' || CERT_PATH='/usr/share/pki/trust/anchors' - CERTIFY_CRT=$(rpm -ql azure-cli 2>/dev/null | grep 'site-packages/certifi/cacert.pem') || true - ;; -debian | ubuntu) - CERT_PATH='/usr/local/share/ca-certificates' - CERTIFY_CRT=$(dpkg-query -L azure-cli 2>/dev/null | grep 'site-packages/certifi/cacert.pem') || true - ;; -esac - -# get list of installed certificates -mapfile -t cert_paths < <(ls "$CERT_PATH"/*.crt 2>/dev/null || true) -if [ "${#cert_paths[@]}" -eq 0 ]; then - printf '\nThere are no certificate(s) to install.\n' >&2 - exit 0 -fi - -# determine certifi path to add certificate -if [ -z "$CERTIFY_CRT" ]; then - # try to activate azure-cli venv - AZ_VENV="$HOME/.azure/.venv/bin/activate" - [ -f "$AZ_VENV" ] && source "$AZ_VENV" || true - # calculate certifi path - CERTIFY_CRT="$(pip show azure-cli 2>/dev/null | grep -oP '^Location: \K.+')/certifi/cacert.pem" - if [ ! -f "$CERTIFY_CRT" ]; then - printf '\e[33mcertifi/cacert.pem not found\e[0m\n' >&2 - exit 0 - fi -fi - -# instantiate variable about number of certificates added -cert_count=0 -# track unique serials that have been added across all certify files -declare -A added_serials=() -# iterate over certify files -for path in "${cert_paths[@]}"; do - serial=$(openssl x509 -in "$path" -noout -serial -nameopt RFC2253 | cut -d= -f2) - if ! grep -qw "$serial" "$CERTIFY_CRT"; then - echo "$(openssl x509 -in $path -noout -subject -nameopt RFC2253 | sed 's/\\//g')" >&2 - CERT=" -$(openssl x509 -in $path -noout -issuer -subject -serial -fingerprint -nameopt RFC2253 | sed 's/\\//g' | xargs -I {} echo "# {}") -$(openssl x509 -in $path -outform PEM)" - - if [ -w "$CERTIFY_CRT" ]; then - echo "$CERT" >>"$CERTIFY_CRT" - else - echo "$CERT" | sudo tee -a "$CERTIFY_CRT" >/dev/null - fi - # increment unique certificate count only once per serial - if [ -z "${added_serials[$serial]+x}" ]; then - added_serials[$serial]=1 - cert_count=$((cert_count + 1)) - fi - fi -done - -# print summary of added certificates -if [ $cert_count -gt 0 ]; then - printf "\e[34madded $cert_count certificate(s) to azure-cli certifi bundle\e[0m\n" >&2 -else - printf '\e[34mno new certificates to add to azure-cli certifi bundle\e[0m\n' >&2 -fi diff --git a/.assets/provision/fix_certifi_certs.sh b/.assets/provision/fix_certifi_certs.sh deleted file mode 100755 index 68232058..00000000 --- a/.assets/provision/fix_certifi_certs.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -: ' -.assets/provision/fix_certifi_certs.sh -' -set -euo pipefail - -if [ $EUID -eq 0 ]; then - printf '\e[31;1mDo not run the script as root.\e[0m\n' >&2 - exit 1 -fi - -# determine system id -SYS_ID="$(sed -En '/^ID.*(alpine|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" - -# specify path for installed custom certificates -case $SYS_ID in -alpine) - exit 0 - ;; -fedora) - CERT_PATH='/etc/pki/ca-trust/source/anchors' - ;; -debian | ubuntu) - CERT_PATH='/usr/local/share/ca-certificates' - ;; -opensuse) - CERT_PATH='/usr/share/pki/trust/anchors' - ;; -esac - -# get list of installed certificates -mapfile -t cert_paths < <(ls "$CERT_PATH"/*.crt 2>/dev/null || true) -if [ "${#cert_paths[@]}" -eq 0 ]; then - exit 0 -fi - -certify_paths=() -# determine certifi cacert.pem path -SHOW=$(pip show -f certifi 2>/dev/null) -if [ -n "$SHOW" ]; then - location=$(echo "$SHOW" | grep -oP '^Location: \K.+') - if [ -n "$location" ]; then - cacert=$(echo "$SHOW" | grep -oE '\S+cacert\.pem$') - if [ -n "$cacert" ]; then - certify_paths+=("${location}/${cacert}") - fi - fi -fi -# determine pip cacert.pem path -SHOW=$(pip show -f pip 2>/dev/null) -if [ -n "$SHOW" ]; then - location=$(echo "$SHOW" | grep -oP '^Location: \K.+') - if [ -n "$location" ]; then - cacert=$(echo "$SHOW" | grep -oE '\S+cacert\.pem$') - if [ -n "$cacert" ]; then - certify_paths+=("${location}/${cacert}") - fi - fi -fi - -# exit script if no certify cacert.pem found -if [ "${#certify_paths[@]}" -eq 0 ]; then - printf '\e[33mcertifi/cacert.pem not found\e[0m\n' >&2 - exit 0 -fi - -# iterate over certify files -for certify in "${certify_paths[@]}"; do - echo "${certify//$HOME/\~}" >&2 - # iterate over installed certificates - for path in "${cert_paths[@]}"; do - serial=$(openssl x509 -in "$path" -noout -serial -nameopt RFC2253 | cut -d= -f2) - if ! grep -qw "$serial" "$certify"; then - # add certificate to array - echo " - $(openssl x509 -in $path -noout -subject -nameopt RFC2253 | sed 's/\\//g')" >&2 - CERT=" -$(openssl x509 -in $path -noout -issuer -subject -serial -fingerprint -nameopt RFC2253 | sed 's/\\//g' | xargs -I {} echo "# {}") -$(openssl x509 -in $path -outform PEM)" - # append new certificates to certify cacert.pem - if [ -w "$certify" ]; then - echo "$CERT" >>"$certify" - else - echo "$CERT" | sudo tee -a "$certify" >/dev/null - fi - fi - done -done diff --git a/.assets/provision/fix_gcloud_certs.sh b/.assets/provision/fix_gcloud_certs.sh deleted file mode 100755 index c79236f6..00000000 --- a/.assets/provision/fix_gcloud_certs.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -: ' -sudo .assets/provision/fix_gcloud_certs.sh -' -set -euo pipefail - -if [ "$EUID" -ne 0 ]; then - printf '\e[31;1mRun the script as root.\e[0m\n' >&2 - exit 1 -fi - -SYS_ID="$(sed -En '/^ID.*(alpine|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" - -case "$SYS_ID" in -alpine) - exit 0 - ;; -fedora) - CERT_PATH='/etc/pki/ca-trust/source/anchors' - ;; -debian | ubuntu) - CERT_PATH='/usr/local/share/ca-certificates' - ;; -opensuse) - CERT_PATH='/usr/share/pki/trust/anchors' -esac - -mapfile -t cert_files < <(find "$CERT_PATH" -maxdepth 1 -name '*.crt' 2>/dev/null || true) -if [ "${#cert_files[@]}" -eq 0 ]; then - printf '\nNo custom certificates found in %s\n' "$CERT_PATH" >&2 - exit 0 -fi - -# locate gcloud's certifi bundle -certifi_locations=( - "/usr/local/google-cloud-sdk/lib/third_party/certifi/cacert.pem" - "/usr/lib/google-cloud-sdk/lib/third_party/certifi/cacert.pem" - "/usr/lib64/google-cloud-sdk/lib/third_party/certifi/cacert.pem" -) - -GCLOUD_CERTIFI="" -for loc in "${certifi_locations[@]}"; do - if [ -f "$loc" ]; then - GCLOUD_CERTIFI="$loc" - break - fi -done - -if [ -z "$GCLOUD_CERTIFI" ]; then - printf '\e[33mGoogle Cloud SDK certifi bundle not found\e[0m\n' >&2 - exit 0 -fi - -# instantiate variable about number of certificates added -cert_count=0 -# track unique serials that have been added across all certify files -declare -A added_serials=() -# iterate over certify files -for cert_path in "${cert_files[@]}"; do - serial=$(openssl x509 -in "$cert_path" -noout -serial -nameopt RFC2253 | cut -d= -f2) - if ! grep -qw "$serial" "$GCLOUD_CERTIFI"; then - openssl x509 -in "$cert_path" -noout -subject -nameopt RFC2253 | sed 's/\\//g' >&2 - cert_content=" -$(openssl x509 -in "$cert_path" -noout -issuer -subject -serial -fingerprint -nameopt RFC2253 | sed 's/\\//g' | sed 's/^/# /') -$(openssl x509 -in "$cert_path" -outform PEM)" - - if [ -w "$GCLOUD_CERTIFI" ]; then - echo "$cert_content" >>"$GCLOUD_CERTIFI" - else - printf '\e[33mInsufficient permissions to write to %s\e[0m\n' "$GCLOUD_CERTIFI" >&2 - fi - # increment unique certificate count only once per serial - if [ -z "${added_serials[$serial]+x}" ]; then - added_serials[$serial]=1 - cert_count=$((cert_count + 1)) - fi - fi -done -# print summary of added certificates -if [ $cert_count -gt 0 ]; then - printf "\e[34madded $cert_count certificate(s) to gcloud-cli certifi bundle\e[0m\n" >&2 -else - printf '\e[34mno new certificates to add to gcloud-cli certifi bundle\e[0m\n' >&2 -fi diff --git a/.assets/provision/fix_nodejs_certs.sh b/.assets/provision/fix_nodejs_certs.sh deleted file mode 100755 index 0a79aea3..00000000 --- a/.assets/provision/fix_nodejs_certs.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -: ' -sudo .assets/provision/fix_nodejs_certs.sh -' -set -euo pipefail - -if [ $EUID -ne 0 ]; then - printf '\e[31;1mRun the script as root.\e[0m\n' >&2 - exit 1 -fi - -# determine system id -SYS_ID="$(sed -En '/^ID.*(alpine|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" - -# specify path for installed custom certificates -case $SYS_ID in -arch | alpine | fedora | opensuse) - exit 0 - ;; -debian | ubuntu) - CERT_PATH='/etc/ssl/certs/ca-certificates.crt' - ;; -*) - printf '\e[1;33mWarning: Unsupported system id (%s).\e[0m\n' "$SYS_ID" >&2 - exit 0 - ;; -esac - -# set the system wide cafile for nodejs -if ! (npm config get | grep -q 'cafile'); then - npm config set -g cafile "$CERT_PATH" -fi diff --git a/.assets/provision/install_azurecli.sh b/.assets/provision/install_azurecli.sh index 471f0938..c6f1b24e 100755 --- a/.assets/provision/install_azurecli.sh +++ b/.assets/provision/install_azurecli.sh @@ -51,7 +51,7 @@ conda clean --yes --all # add certificates to azurecli certify if $fix_certify; then - .assets/provision/fix_azcli_certs.sh + .assets/fix/fix_azcli_certs.sh fi # deactivate azurecli conda environment diff --git a/.assets/provision/install_azurecli_uv.sh b/.assets/provision/install_azurecli_uv.sh index e991a42f..a227f84a 100755 --- a/.assets/provision/install_azurecli_uv.sh +++ b/.assets/provision/install_azurecli_uv.sh @@ -21,7 +21,11 @@ while [ $# -gt 0 ]; do done # check if uv installed -[ -x "$HOME/.local/bin/uv" ] || exit 0 +[ -x "$HOME/.local/bin/uv" ] || [ -x "$HOME/.nix-profile/bin/uv" ] || exit 0 +# ensure uv is in PATH (non-login shells may not have user bin paths) +for _p in "$HOME/.local/bin" "$HOME/.nix-profile/bin"; do + [[ -d "$_p" && ":$PATH:" != *":$_p:"* ]] && PATH="$_p:$PATH" +done # create pyproject.toml in the $HOME/.azure directory mkdir -p "$HOME/.azure" @@ -50,11 +54,11 @@ prerelease = "allow" EOF # install azure-cli -$HOME/.local/bin/uv sync --no-cache --upgrade --directory "$HOME/.azure" +uv sync --no-cache --upgrade --directory "$HOME/.azure" # add certificates to azurecli certify if $fix_certify; then - .assets/provision/fix_azcli_certs.sh + .assets/fix/fix_azcli_certs.sh fi # make symbolic link to az cli diff --git a/.assets/provision/install_base.sh b/.assets/provision/install_base.sh index e22a6c79..b754d01c 100755 --- a/.assets/provision/install_base.sh +++ b/.assets/provision/install_base.sh @@ -64,7 +64,7 @@ alpine) # update package index apk update 2>/dev/null || true # install base packages - pkgs="bash bind-tools build-base ca-certificates curl fd gawk git iputils jq less lsb-release-minimal mandoc nmap openssh-client shfmt openssl sudo tar tig tree unzip vim which whois" + pkgs="bash bats bind-tools build-base ca-certificates curl fd gawk git iputils jq less lsb-release-minimal mandoc nmap openssh-client shfmt openssl sudo tar tig tree unzip vim which whois" install_pkgs apk "$pkgs" ;; arch) @@ -73,7 +73,7 @@ arch) # refresh package database and install archlinux-keyring pacman -Sy --needed --noconfirm --color=auto archlinux-keyring # install base packages - pkgs="base-devel bash-completion curl dnsutils fd gawk git jq lsb-release man-db nmap openssh shfmt openssl tar tig tree unzip vim wget which whois" + pkgs="base-devel bash-completion bats curl dnsutils fd gawk git jq lsb-release man-db nmap openssh shfmt openssl tar tig tree unzip vim wget which whois" install_pkgs pacman "$pkgs" # install paru if ! pacman -Qqe paru >/dev/null 2>&1; then @@ -97,9 +97,9 @@ fedora) rpm -q patch >/dev/null 2>&1 || dnf group install -y development-tools 2>/dev/null || true # install base packages if [ "$(readlink "$(which dnf)")" = 'dnf5' ]; then - pkgs="bash-completion bind-utils curl dnf5-plugins fd gawk git iputils jq man-db nmap shfmt openssl tar tig tree unzip vim wget which whois" + pkgs="bash-completion bats bind-utils curl dnf5-plugins fd gawk git iputils jq man-db nmap shfmt openssl tar tig tree unzip vim wget which whois" else - pkgs="bash-completion bind-utils curl dnf-plugins-core fd gawk git iputils jq man-db nmap shfmt openssl tar tig tree unzip vim wget which whois" + pkgs="bash-completion bats bind-utils curl dnf-plugins-core fd gawk git iputils jq man-db nmap shfmt openssl tar tig tree unzip vim wget which whois" fi install_pkgs dnf "$pkgs" ;; @@ -108,7 +108,7 @@ debian | ubuntu) # refresh package index apt-get update 2>/dev/null # install base packages - pkgs="build-essential bash-completion ca-certificates curl fd gawk gnupg dnsutils git iputils-tracepath jq lsb-release man-db nmap shfmt openssl tar tig tree unzip vim wget which whois" + pkgs="build-essential bash-completion bats ca-certificates curl fd gawk gnupg dnsutils git iputils-tracepath jq lsb-release man-db nmap shfmt openssl tar tig tree unzip vim wget which whois" install_pkgs apt "$pkgs" # autoremove unnecessary packages and clean up apt cache apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* @@ -119,7 +119,7 @@ opensuse) # install development tools pattern rpm -q patch >/dev/null 2>&1 || zypper --non-interactive --no-refresh in -yt pattern devel_basis 2>/dev/null || true # install base packages - pkgs="bash-completion bind-utils curl fd gawk git jq lsb-release nmap shfmt openssl tar tig tree unzip vim wget which whois" + pkgs="bash-completion bats bind-utils curl fd gawk git jq lsb-release nmap shfmt openssl tar tig tree unzip vim wget which whois" install_pkgs zypper "$pkgs" ;; esac diff --git a/.assets/provision/install_base_nix.sh b/.assets/provision/install_base_nix.sh new file mode 100755 index 00000000..fec5a573 --- /dev/null +++ b/.assets/provision/install_base_nix.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +: ' +sudo .assets/provision/install_base_nix.sh +Install minimal system dependencies (curl, build tools) for the Nix setup path. +' +set -euo pipefail + +if [ $EUID -ne 0 ]; then + printf '\e[31;1mRun the script as root.\e[0m\n' >&2 + exit 1 +fi + +# skip on macOS - Xcode Command Line Tools provide these (xcode-select --install) +[ "$(uname -s)" = "Darwin" ] && exit 0 + +SYS_ID="$(sed -En '/^ID.*(alpine|arch|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" + +printf "\e[92minstalling \e[1msystem dependencies\e[0m\n" >&2 +case ${SYS_ID:-} in +alpine) + apk add --no-cache build-base curl jq + ;; +arch) + pacman -Sy --needed --noconfirm base-devel curl jq + ;; +fedora) + dnf install -y -q curl jq make gcc + ;; +debian | ubuntu) + apt-get update -qq && apt-get install -y -qq build-essential curl jq + ;; +opensuse) + zypper --non-interactive --no-refresh in -y curl jq make gcc + ;; +*) + printf '\e[33mUnsupported distro, skipping system deps install.\e[0m\n' >&2 + ;; +esac diff --git a/.assets/provision/install_brew.sh b/.assets/provision/install_brew.sh deleted file mode 100755 index 99465894..00000000 --- a/.assets/provision/install_brew.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -: ' -# https://docs.brew.sh/Installation -.assets/provision/install_brew.sh >/dev/null -' -set -euo pipefail - -if [ $EUID -eq 0 ]; then - printf '\e[31;1mDo not run the script as root.\e[0m\n' >&2 - exit 1 -fi - -# dotsource file with common functions -. .assets/provision/source.sh - -# define variables -APP='brew' -REL=${1:-} -# get latest release if not provided as a parameter -if [ -z "$REL" ]; then - REL="$(get_gh_release_latest --owner 'Homebrew' --repo 'brew')" - if [ -z "$REL" ]; then - printf "\e[31mFailed to get the latest version of $APP.\e[0m\n" >&2 - exit 1 - fi -fi -# return the release -echo $REL - -if type brew &>/dev/null; then - VER=$(brew --version | grep -Eo '[0-9\.]+\.[0-9]+\.[0-9]+' || true) - if [ "$REL" = "$VER" ]; then - printf "\e[32m$APP v$VER is already latest\e[0m\n" >&2 - exit 0 - else - brew update - fi -else - printf "\e[92minstalling \e[1m$APP\e[22m v$REL\e[0m\n" >&2 - # unattended installation - export NONINTERACTIVE=1 - # skip tap cloning - export HOMEBREW_INSTALL_FROM_API=1 - # create temporary dir for the downloaded binary - TMP_DIR=$(mktemp -d -p "$HOME") - trap 'rm -fr "$TMP_DIR"' EXIT - # calculate download uri - URL="https://raw.githubusercontent.com/Homebrew/install/master/install.sh" - # download and install homebrew - if download_file --uri "$URL" --target_dir "$TMP_DIR"; then - bash -c "$TMP_DIR/$(basename $URL)" - fi -fi diff --git a/.assets/provision/install_cmatrix.sh b/.assets/provision/install_cmatrix.sh index 3efd8baa..68e0cbcd 100755 --- a/.assets/provision/install_cmatrix.sh +++ b/.assets/provision/install_cmatrix.sh @@ -43,6 +43,7 @@ fedora) dnf install -y $APP ;; debian | ubuntu) + export DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y $APP ;; esac diff --git a/.assets/provision/install_miniconda.sh b/.assets/provision/install_miniconda.sh index b20aa14e..23dc3817 100755 --- a/.assets/provision/install_miniconda.sh +++ b/.assets/provision/install_miniconda.sh @@ -10,6 +10,11 @@ if [ $EUID -eq 0 ]; then exit 1 fi +# resolve repo root dir +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +. "$SCRIPT_ROOT/.assets/provision/source.sh" +. "$SCRIPT_ROOT/.assets/config/bash_cfg/functions.sh" + # parse named parameters fix_certify=${fix_certify:-false} while [ $# -gt 0 ]; do @@ -21,7 +26,7 @@ while [ $# -gt 0 ]; do done # *conda init function. -function conda_init { +conda_init() { if __conda_setup="$("$HOME/miniconda3/bin/conda" 'shell.bash' 'hook' 2>/dev/null)"; then eval "$__conda_setup" else @@ -39,8 +44,6 @@ if [ -x "$HOME/miniconda3/bin/conda" ]; then conda_init else printf "\e[92minstalling \e[1mminiconda\e[0m\n" - # dotsource file with common functions - . .assets/provision/source.sh # create temporary dir for the downloaded binary TMP_DIR=$(mktemp -d -p "$HOME") trap 'rm -fr "$TMP_DIR"' EXIT @@ -59,7 +62,7 @@ fi # *Add certificates to conda base certifi. if $fix_certify; then conda activate base - .assets/provision/fix_certifi_certs.sh + fixcertpy conda deactivate fi @@ -74,6 +77,6 @@ conda clean --yes --all # *Fix certificates after update. if $fix_certify; then conda activate base - .assets/provision/fix_certifi_certs.sh + fixcertpy conda deactivate fi diff --git a/.assets/provision/install_miniforge.sh b/.assets/provision/install_miniforge.sh index 5cd1109a..e40e5fd9 100755 --- a/.assets/provision/install_miniforge.sh +++ b/.assets/provision/install_miniforge.sh @@ -10,6 +10,11 @@ if [ $EUID -eq 0 ]; then exit 1 fi +# resolve repo root dir +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +. "$SCRIPT_ROOT/.assets/provision/source.sh" +. "$SCRIPT_ROOT/.assets/config/bash_cfg/functions.sh" + # parse named parameters fix_certify=${fix_certify:-false} while [ $# -gt 0 ]; do @@ -21,7 +26,7 @@ while [ $# -gt 0 ]; do done # *conda init function. -function conda_init { +conda_init() { if __conda_setup="$("$HOME/miniforge3/bin/conda" 'shell.bash' 'hook' 2>/dev/null)"; then eval "$__conda_setup" else @@ -39,8 +44,6 @@ if [ -x "$HOME/miniforge3/bin/conda" ]; then conda_init else printf "\e[92minstalling \e[1mminiforge\e[0m\n" - # dotsource file with common functions - . .assets/provision/source.sh # create temporary dir for the downloaded binary TMP_DIR=$(mktemp -d -p "$HOME") trap 'rm -fr "$TMP_DIR"' EXIT @@ -58,7 +61,7 @@ fi # *Add certificates to conda base certifi. if $fix_certify; then conda activate base - .assets/provision/fix_certifi_certs.sh + fixcertpy conda deactivate fi @@ -69,6 +72,6 @@ conda clean --yes --all # *Fix certificates after update. if $fix_certify; then conda activate base - .assets/provision/fix_certifi_certs.sh + fixcertpy conda deactivate fi diff --git a/.assets/provision/install_nix.sh b/.assets/provision/install_nix.sh new file mode 100755 index 00000000..4279f5b7 --- /dev/null +++ b/.assets/provision/install_nix.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +: ' +# https://docs.determinate.systems/ds-nix/how-to/install/ +sudo .assets/provision/install_base_nix.sh # install curl and build tools first +sudo .assets/provision/install_nix.sh +# :single-user install (no daemon, for Coder/containers) +sudo .assets/provision/install_nix.sh --no-daemon +# :uninstall (multi-user) +sudo /nix/nix-installer uninstall --no-confirm +# :uninstall (single-user) +# rm -rf /nix ~/.nix-profile ~/.nix-defexpr ~/.nix-channels ~/.config/nix +' +set -euo pipefail + +if [ $EUID -ne 0 ]; then + printf '\e[31;1mRun the script as root.\e[0m\n' >&2 + exit 1 +fi + +APP='nix' +no_daemon=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-daemon) no_daemon=true ;; + *) printf '\e[31;1mUnknown option: %s\e[0m\n' "$1" >&2; exit 2 ;; + esac + shift +done + +# check if nix is already installed +if [ -d /nix/store ]; then + nix_bin="" + if [ -x /nix/var/nix/profiles/default/bin/nix ]; then + nix_bin=/nix/var/nix/profiles/default/bin/nix + elif [ -n "${SUDO_USER:-}" ]; then + user_nix="$(getent passwd "$SUDO_USER" | cut -d: -f6)/.nix-profile/bin/nix" + [ -x "$user_nix" ] && nix_bin="$user_nix" + fi + if [ -n "$nix_bin" ]; then + VER=$("$nix_bin" --version 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' || true) + if [ -n "$VER" ]; then + printf "\e[32m$APP v$VER is already installed\e[0m\n" >&2 + exit 0 + fi + fi +fi + +printf "\e[92minstalling \e[1m$APP\e[0m\n" >&2 + +if [ "$no_daemon" = true ]; then + # Single-user install via upstream Nix installer (no daemon, no root at runtime). + # Intended for Coder workspaces and containers where running a root daemon + # is undesirable. The user owns /nix directly and nix commands access the + # store without a daemon. + if [ -z "${SUDO_USER:-}" ]; then + printf '\e[31;1m--no-daemon requires running via sudo (need SUDO_USER).\e[0m\n' >&2 + exit 1 + fi + user_home="$(getent passwd "$SUDO_USER" | cut -d: -f6)" + # create /nix owned by the calling user + mkdir -p /nix + chown "$SUDO_USER" /nix + # enable flakes and disable sandbox (containers lack namespace support) + nix_conf="$user_home/.config/nix/nix.conf" + mkdir -p "$(dirname "$nix_conf")" + cat >"$nix_conf" <<'NIXCONF' +experimental-features = nix-command flakes +sandbox = false +NIXCONF + chown -R "$SUDO_USER" "$(dirname "$nix_conf")" + # run upstream installer as the calling user + su - "$SUDO_USER" -c "curl -sL https://nixos.org/nix/install | sh -s -- --no-daemon" +else + # Multi-user install via Determinate Systems installer: + # - Enables flakes and nix-command by default + # - Works on Linux (all major distros), WSL, and macOS (including Apple Silicon) + # - Provides an uninstaller (/nix/nix-installer uninstall) + install_args=(install) + # detect environments without systemd (Docker, Coder, CI runners) + if [ "$(uname -s)" = "Linux" ] && ! pidof systemd &>/dev/null; then + install_args+=(linux --init none) + fi + install_args+=(--no-confirm) + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- "${install_args[@]}" +fi diff --git a/.assets/provision/install_nodejs.sh b/.assets/provision/install_nodejs.sh index 9e19f2fd..73267842 100755 --- a/.assets/provision/install_nodejs.sh +++ b/.assets/provision/install_nodejs.sh @@ -49,7 +49,7 @@ debian | ubuntu) trap 'rm -fr "$TMP_DIR"' EXIT # calculate download uri URL="https://deb.nodesource.com/setup_lts.x" - # download and install homebrew + # download and install nodejs setup script if download_file --uri "$URL" --target_dir "$TMP_DIR"; then chmod +x "$TMP_DIR/setup_lts.x" bash -c "$TMP_DIR/setup_lts.x" diff --git a/.assets/provision/install_pixi.sh b/.assets/provision/install_pixi.sh deleted file mode 100755 index d9fff76a..00000000 --- a/.assets/provision/install_pixi.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -: ' -.assets/provision/install_pixi.sh -' -set -euo pipefail - -if [ $EUID -eq 0 ]; then - printf '\e[31;1mDo not run the script as root.\e[0m\n' >&2 - exit 1 -fi - -if [ -x "$HOME/.pixi/bin/pixi" ]; then - $HOME/.pixi/bin/pixi self-update -else - printf "\e[92minstalling \e[1mpixi\e[22m\e[0m\n" >&2 - curl -fsSL https://pixi.sh/install.sh | sh -fi diff --git a/.assets/provision/setup_profile_user.zsh b/.assets/provision/setup_profile_user.zsh deleted file mode 100755 index 7cb41585..00000000 --- a/.assets/provision/setup_profile_user.zsh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env zsh -: ' -.assets/provision/setup_profile_user.zsh -' -# path variables -PROFILE_PATH='/etc/profile.d' -OMP_PATH='/usr/local/share/oh-my-posh' - -# *install plugins -# ~zsh-autocomplete -# https://github.com/marlonrichert/zsh-autocomplete -zsh_plugin='zsh-autocomplete' -if [ -d "$HOME/.zsh/$zsh_plugin" ]; then - git -C "$HOME/.zsh/$zsh_plugin" pull --quiet -else - git clone https://github.com/marlonrichert/$zsh_plugin.git "$HOME/.zsh/$zsh_plugin" -fi -if ! grep -w "$zsh_plugin.plugin.zsh" "$HOME/.zshrc" 2>/dev/null; then - cat <>"$HOME/.zshrc" -# *plugins -source "\$HOME/.zsh/$zsh_plugin/$zsh_plugin.plugin.zsh" -EOF -fi -# ~zsh-make-complete -# https://github.com/22peacemaker/zsh-make-complete -zsh_plugin='zsh-make-complete' -if [ -d "$HOME/.zsh/$zsh_plugin" ]; then - git -C "$HOME/.zsh/$zsh_plugin" pull --quiet -else - git clone https://github.com/22peacemaker/$zsh_plugin.git "$HOME/.zsh/$zsh_plugin" -fi -if ! grep -Fqw "$zsh_plugin.plugin.zsh" "$HOME/.zshrc" 2>/dev/null; then - echo "source \"\$HOME/.zsh/$zsh_plugin/$zsh_plugin.plugin.zsh\"" >>"$HOME/.zshrc" -fi -# ~zsh-autosuggestions -# https://github.com/zsh-users/zsh-autosuggestions -zsh_plugin='zsh-autosuggestions' -if [ -d "$HOME/.zsh/$zsh_plugin" ]; then - git -C "$HOME/.zsh/$zsh_plugin" pull --quiet -else - git clone https://github.com/zsh-users/$zsh_plugin.git "$HOME/.zsh/$zsh_plugin" -fi -if ! grep -w '$zsh_plugin.zsh' "$HOME/.zshrc" 2>/dev/null; then - echo "source \"\$HOME/.zsh/$zsh_plugin/$zsh_plugin.zsh\"" >>"$HOME/.zshrc" -fi -# ~zsh-syntax-highlighting -# https://github.com/zsh-users/zsh-syntax-highlighting -zsh_plugin='zsh-syntax-highlighting' -if [ -d "$HOME/.zsh/$zsh_plugin" ]; then - git -C "$HOME/.zsh/$zsh_plugin" pull --quiet -else - git clone https://github.com/zsh-users/$zsh_plugin.git "$HOME/.zsh/$zsh_plugin" -fi -if ! grep -w "$zsh_plugin.zsh" "$HOME/.zshrc" 2>/dev/null; then - echo "source \"\$HOME/.zsh/$zsh_plugin/$zsh_plugin.zsh\"" >>"$HOME/.zshrc" -fi -if ! grep -q '^bindkey .* autosuggest-accept' "$HOME/.zshrc"; then - echo "bindkey '^ ' autosuggest-accept\n" >>"$HOME/.zshrc" -fi - -# *aliases -# add common zsh aliases -grep -qw 'd/aliases.sh' "$HOME/.zshrc" 2>/dev/null || cat <>"$HOME/.zshrc" -# common aliases -if [ -f "$PROFILE_PATH/aliases.sh" ]; then - source "$PROFILE_PATH/aliases.sh" -fi -EOF - -# add git aliases -if ! grep -qw 'd/aliases_git.sh' "$HOME/.zshrc" 2>/dev/null && type git &>/dev/null; then - cat <>"$HOME/.zshrc" -# git aliases -if [ -f "$PROFILE_PATH/aliases_git.sh" ] && type git &>/dev/null; then - source "$PROFILE_PATH/aliases_git.sh" -fi -EOF -fi - -# add kubectl autocompletion and aliases -if ! grep -qw 'kubectl' "$HOME/.zshrc" 2>/dev/null && type -f kubectl &>/dev/null; then - cat <>"$HOME/.zshrc" -# kubectl autocompletion and aliases -if type -f kubectl &>/dev/null; then - if [ -f "$PROFILE_PATH/aliases_kubectl.sh" ]; then - source "$PROFILE_PATH/aliases_kubectl.sh" - fi -fi -EOF -fi - -# *add conda initialization -if ! grep -qw '__conda_setup' "$HOME/.zshrc" 2>/dev/null && [ -f $HOME/miniforge3/bin/conda ]; then - $HOME/miniforge3/bin/conda init zsh >/dev/null -fi - -# *set up uv -COMPLETION_CMD='uv generate-shell-completion zsh' -UV_PATH=".local/bin" -if ! grep -qw "$COMPLETION_CMD" "$HOME/.zshrc" 2>/dev/null && [ -x "$HOME/$UV_PATH/uv" ]; then - cat <>"$HOME/.zshrc" - -# initialize uv autocompletion -if [ -x "\$HOME/$UV_PATH/uv" ]; then - export UV_NATIVE_TLS=true - eval "\$(\$HOME/$UV_PATH/$COMPLETION_CMD)" -fi -EOF -fi - -# *set up pixi -COMPLETION_CMD='pixi completion --shell zsh' -PIXI_PATH=".pixi/bin" -if ! grep -qw "$COMPLETION_CMD" "$HOME/.zshrc" 2>/dev/null && [ -x "$HOME/$PIXI_PATH/pixi" ]; then - cat <>"$HOME/.zshrc" - -# initialize pixi autocompletion -if [ -x "\$HOME/$PIXI_PATH/pixi" ]; then - autoload -Uz compinit && compinit - eval "\$(\$HOME/$PIXI_PATH/$COMPLETION_CMD)" -fi -EOF -fi - -# *add oh-my-posh invocation -if ! grep -qw 'oh-my-posh' "$HOME/.zshrc" 2>/dev/null && type oh-my-posh &>/dev/null; then - cat <>"$HOME/.zshrc" -# initialize oh-my-posh prompt -if [ -f "$OMP_PATH/theme.omp.json" ] && type oh-my-posh &>/dev/null; then - eval "\$(oh-my-posh init zsh --config "$OMP_PATH/theme.omp.json")" -fi -EOF -elif grep -qw 'oh-my-posh --init' "$HOME/.zshrc" 2>/dev/null; then - # convert oh-my-posh initialization to the new API - sed -i 's/oh-my-posh --init --shell zsh/oh-my-posh init zsh/' "$HOME/.zshrc" -fi diff --git a/.assets/provision/source.sh b/.assets/provision/source.sh index bc0f2089..61092496 100755 --- a/.assets/provision/source.sh +++ b/.assets/provision/source.sh @@ -110,15 +110,14 @@ login_gh_user() { # *Function to download file from specified uri download_file() { - # initialize local variables used as named parameters local uri='' local target_dir='' # parse named parameters while [ $# -gt 0 ]; do - if [[ $1 == *"--"* ]]; then - param="${1/--/}" - declare $param="${2:-}" - fi + case "$1" in + --uri) uri="${2:-}"; shift ;; + --target_dir) target_dir="${2:-}"; shift ;; + esac shift done @@ -164,17 +163,18 @@ download_file() { # *Function to get the latest release from the specified GitHub repo get_gh_release_latest() { - # initialize local variables used as named parameters local owner='' local repo='' local asset='' local regex='' # parse named parameters while [ $# -gt 0 ]; do - if [[ $1 == *"--"* ]]; then - param="${1/--/}" - declare $param="${2:-}" - fi + case "$1" in + --owner) owner="${2:-}"; shift ;; + --repo) repo="${2:-}"; shift ;; + --asset) asset="${2:-}"; shift ;; + --regex) regex="${2:-}"; shift ;; + esac shift done @@ -205,9 +205,9 @@ get_gh_release_latest() { api_uri="https://api.github.com/repos/$owner/$repo/releases" # get the latest release if asset or regex is not specified [ -z "$asset" ] && [ -z "$regex" ] && api_uri+="/latest" || true - cmnd="curl -sk $api_uri -H 'Accept: application/vnd.github+json'" - # set the header with the token - [ -n "$token" ] && cmnd+=" -H 'Authorization: Bearer ${token}'" + # build curl arguments as an array (avoids eval) + local curl_args=(-sk "$api_uri" -H 'Accept: application/vnd.github+json') + [ -n "$token" ] && curl_args+=(-H "Authorization: Bearer ${token}") # send API request to GitHub while [ $retry_count -le $max_retries ]; do if echo "$api_response" | jq -r 'try .message catch empty' | grep -wq "API rate limit exceeded"; then @@ -215,7 +215,7 @@ get_gh_release_latest() { return 1 fi # get the latest release - api_response="$(eval $cmnd)" + api_response="$(curl "${curl_args[@]}")" # check for exceeded API rate limit if [ -n "$token" ] && echo "$api_response" | jq -r 'try .message catch empty' | grep -wq "API rate limit exceeded"; then @@ -297,7 +297,7 @@ find_file() { # *Function to download and install GitHub releases into user directory install_github_release_user() { local gh_owner gh_repo file_name binary_name current_version - local auth_header retry_count latest_release response http_code body file url binary_path + local retry_count latest_release response http_code body file url binary_path local tmp_dir cleanup saved_return saved_exit # Parse arguments @@ -341,9 +341,9 @@ install_github_release_user() { [ -z "$binary_name" ] && binary_name="$gh_repo" # check for GITHUB_TOKEN environment variable (should start with 'ghp_' or 'gho_' and be ~40 chars) - auth_header="" + local curl_auth_args=() if [ -n "$GITHUB_TOKEN" ] && echo "$GITHUB_TOKEN" | grep -qE "^gh[po]_" && [ ${#GITHUB_TOKEN} -ge 36 ]; then - auth_header="-H \"Authorization: Bearer $GITHUB_TOKEN\"" + curl_auth_args=(-H "Authorization: Bearer $GITHUB_TOKEN") fi # create temporary directory and set cleanup trap @@ -366,7 +366,7 @@ install_github_release_user() { retry_count=0 latest_release="" while :; do - response=$(eval curl -skw "\"\\n%{http_code}\"" $auth_header "\"https://api.github.com/repos/${gh_owner}/${gh_repo}/releases/latest\"" 2>/dev/null) + response=$(curl -skw $'\n%{http_code}' "${curl_auth_args[@]}" "https://api.github.com/repos/${gh_owner}/${gh_repo}/releases/latest" 2>/dev/null) http_code=$(echo "$response" | tail -n1) body=$(echo "$response" | sed '$d') diff --git a/.assets/scripts/linux_setup.sh b/.assets/scripts/linux_setup.sh index 1b3ae099..22bdf6c3 100755 --- a/.assets/scripts/linux_setup.sh +++ b/.assets/scripts/linux_setup.sh @@ -16,8 +16,11 @@ omp_theme="nerd" .assets/scripts/linux_setup.sh --omp_theme "$omp_theme" --scope "$scope" # :upgrade system first and then set up the system .assets/scripts/linux_setup.sh --sys_upgrade true --scope "$scope" --omp_theme "$omp_theme" -# :skip GitHub authentication setup -.assets/scripts/linux_setup.sh --skip_gh_auth true --scope "$scope" --omp_theme "$omp_theme" +# :unattended mode (skip all interactive steps) +.assets/scripts/linux_setup.sh --unattended true --scope "$scope" --omp_theme "$omp_theme" +# :set up the system using Nix package manager +.assets/scripts/linux_setup.sh --nix true --scope "$scope" +.assets/scripts/linux_setup.sh --nix true --scope "$scope" --omp_theme "$omp_theme" ' set -e @@ -31,8 +34,10 @@ fi # parse named parameters scope=${scope} omp_theme=${omp_theme} +nix=${nix:-false} sys_upgrade=${sys_upgrade:-false} -skip_gh_auth=${skip_gh_auth:-false} +unattended=${unattended:-false} +update_modules="${update_modules:-false}" while [ $# -gt 0 ]; do if [[ $1 == *"--"* ]]; then param="${1/--/}" @@ -45,82 +50,86 @@ done SCRIPT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) pushd "$(cd "${SCRIPT_ROOT}/../../" && pwd)" >/dev/null +# -- Installation provenance (trap-based, writes on exit) -------------------- +# shellcheck source=../../.assets/lib/install_record.sh +source .assets/lib/install_record.sh +_IR_SCRIPT_ROOT="$(pwd)" +_ir_phase="bootstrap" + +_on_exit() { + local exit_code=$? + local status="success" error="" + if [[ $exit_code -ne 0 ]]; then + status="failed" + error="${_ir_error:-exit code $exit_code}" + fi + _IR_ENTRY_POINT="legacy$([ "$nix" = true ] && echo '/nix')" + _IR_SCOPES="${scope_arr[*]:-}" + _IR_MODE="install" + _IR_PLATFORM="Linux" + write_install_record "$status" "$_ir_phase" "$error" +} +trap _on_exit EXIT + +# *Install base packages first (provides jq, git, curl, etc.) +if [ "$sys_upgrade" = true ]; then + printf "\e[96mupdating system...\e[0m\n" + sudo .assets/provision/upgrade_system.sh +fi +if [ "$nix" != true ]; then + # auto-detect Nix if installed (for re-runs without --nix flag) + [[ -d /nix/store ]] && nix="true" +fi +if [ "$nix" = true ]; then + printf "\e[96minstalling system dependencies...\e[0m\n" + sudo .assets/provision/install_base_nix.sh + printf "\e[96minstalling nix...\e[0m\n" + sudo .assets/provision/install_nix.sh +else + printf "\e[96minstalling base packages...\e[0m\n" + sudo .assets/provision/install_base.sh $user +fi + +_ir_phase="scope-resolve" +# -- Source shared scope library (requires jq from install_base) -------------- +# shellcheck source=../../.assets/lib/scopes.sh +source .assets/lib/scopes.sh + # *Calculate and show installation scopes # run the check_distro.sh script and capture the output -distro_check=$(.assets/provision/check_distro.sh array) +distro_check=$(.assets/check/check_distro.sh array) -# initialize the scopes array -read -ra array <<<"$scope" -# populate the scopes array based on the output of check_distro.sh +# build _scope_set from CLI parameter and distro check +_scope_set=" " +read -ra cli_scopes <<<"$scope" +for s in "${cli_scopes[@]}"; do + [[ -n "$s" ]] && scope_add "$s" +done while IFS= read -r line; do - array+=("$line") + [[ -n "$line" ]] && scope_add "$line" done <<<"$distro_check" -# add corresponding scopes -grep -qw 'az' <<<"${array[@]}" && array+=(python) || true -grep -qw 'k8s_dev' <<<"${array[@]}" && array+=(k8s_base) || true -grep -qw 'k8s_ext' <<<"${array[@]}" && array+=(docker) && array+=(k8s_base) && array+=(k8s_dev) || true -grep -qw 'pwsh' <<<"${array[@]}" && array+=(shell) || true -grep -qw 'zsh' <<<"${array[@]}" && array+=(shell) || true -# add oh_my_posh scope if necessary -if [[ -n "$omp_theme" || -f /usr/bin/oh-my-posh ]]; then - array+=(oh_my_posh) - array+=(shell) -fi -# remove duplicated and sort array -order=( - docker - k8s_base - k8s_dev - k8s_ext - python - conda - az - gcloud - bun - nodejs - terraform - oh_my_posh - shell - zsh - pwsh - distrobox - rice -) -scope_arr=() -declare -A seen -for item in "${order[@]}"; do - for val in "${array[@]}"; do - if [[ "$item" == "$val" && -z "${seen[$item]}" ]]; then - scope_arr+=("$item") - seen[$item]=1 - fi - done -done +# detect oh_my_posh from existing install +# shellcheck disable=SC2034 # _scope_set is used by resolve_scope_deps +[[ -f /usr/bin/oh-my-posh ]] && scope_add oh_my_posh + +# resolve dependencies and sort +resolve_scope_deps +sort_scopes +# shellcheck disable=SC2154 # sorted_scopes is populated by sort_scopes +scope_arr=("${sorted_scopes[@]}") # get distro name from os-release . /etc/os-release # display distro name and scopes to install printf "\e[95m$NAME$([ "${#scope_arr[@]}" -gt 0 ] && echo " : \e[3m${scope_arr[*]}" || true)\e[0m\n" -# *Install packages and setup profiles -if [ "$sys_upgrade" = true ]; then - printf "\e[96mupdating system...\e[0m\n" - sudo .assets/provision/upgrade_system.sh -fi -printf "\e[96minstalling base packages...\e[0m\n" -sudo .assets/provision/install_base.sh $user -# update pixi packages if pixi is installed -if grep -qw 'pixi' <<<"${array[@]}"; then - printf "\e[96mupdating pixi packages...\e[0m\n" - "$HOME/.pixi/bin/pixi" global update -fi - +_ir_phase="github" # *setup GitHub CLI -if [ "$skip_gh_auth" = true ]; then +if [ "$unattended" = true ]; then printf "\e[32mSkipping gh installation and authentication setup.\e[0m\n" >&2 else sudo .assets/provision/install_gh.sh - sudo .assets/provision/setup_gh_https.sh -u $user -k >/dev/null + sudo .assets/setup/setup_gh_https.sh -u $user -k >/dev/null # generate SSH key if not exists if ! ([ -f "$HOME/.ssh/id_ed25519" ] && [ -f "$HOME/.ssh/id_ed25519.pub" ]); then # prepare clean $HOME/.ssh directory @@ -133,150 +142,188 @@ else ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519" -N "" -q fi # add SSH key to GitHub - .assets/provision/setup_gh_ssh.sh >/dev/null + .assets/setup/setup_gh_ssh.sh >/dev/null fi -for sc in "${scope_arr[@]}"; do - case $sc in - az) - printf "\e[96minstalling azure cli...\e[0m\n" - .assets/provision/install_azurecli_uv.sh --fix_certify true - sudo .assets/provision/install_azcopy.sh >/dev/null - ;; - bun) - printf "\e[96minstalling bun...\e[0m\n" - .assets/provision/install_bun.sh - ;; - conda) - printf "\e[96minstalling python packages...\e[0m\n" - .assets/provision/install_miniforge.sh --fix_certify true - .assets/provision/install_pixi.sh - ;; - distrobox) - printf "\e[96minstalling distrobox...\e[0m\n" - sudo .assets/provision/install_podman.sh - sudo .assets/provision/install_distrobox.sh $user - ;; - docker) - printf "\e[96minstalling docker...\e[0m\n" - sudo .assets/provision/install_docker.sh $user - ;; - gcloud) - printf "\e[96minstalling google-cloud-cli...\e[0m\n" - sudo .assets/provision/install_gcloud.sh >/dev/null - sudo .assets/provision/fix_gcloud_certs.sh - ;; - k8s_base) - printf "\e[96minstalling kubernetes base packages...\e[0m\n" - sudo .assets/provision/install_kubectl.sh >/dev/null - sudo .assets/provision/install_kubelogin.sh >/dev/null - sudo .assets/provision/install_k9s.sh >/dev/null - sudo .assets/provision/install_kubecolor.sh >/dev/null - sudo .assets/provision/install_kubectx.sh >/dev/null - ;; - k8s_dev) - printf "\e[96minstalling kubernetes dev packages...\e[0m\n" - sudo .assets/provision/install_argorolloutscli.sh >/dev/null - sudo .assets/provision/install_cilium.sh >/dev/null - sudo .assets/provision/install_flux.sh >/dev/null - sudo .assets/provision/install_helm.sh >/dev/null - sudo .assets/provision/install_hubble.sh >/dev/null - sudo .assets/provision/install_kustomize.sh >/dev/null - sudo .assets/provision/install_trivy.sh >/dev/null - ;; - k8s_ext) - printf "\e[96minstalling local kubernetes tools...\e[0m\n" - sudo .assets/provision/install_minikube.sh >/dev/null - sudo .assets/provision/install_k3d.sh >/dev/null - sudo .assets/provision/install_kind.sh >/dev/null - ;; - nodejs) - printf "\e[96minstalling Node.js...\e[0m\n" - sudo .assets/provision/install_nodejs.sh - ;; - oh_my_posh) - printf "\e[96minstalling oh-my-posh...\e[0m\n" - sudo .assets/provision/install_omp.sh >/dev/null - if [ -n "$omp_theme" ]; then - sudo .assets/provision/setup_omp.sh --theme $omp_theme --user $user +_ir_phase="scopes" +if [ "$nix" = true ]; then + # -- Nix path: docker/distrobox need root, everything else via nix/setup.sh -- + for sc in "${scope_arr[@]}"; do + case $sc in + distrobox) + printf "\e[96minstalling distrobox...\e[0m\n" + sudo .assets/provision/install_podman.sh + sudo .assets/provision/install_distrobox.sh $user + ;; + docker) + printf "\e[96minstalling docker...\e[0m\n" + sudo .assets/provision/install_docker.sh $user + ;; + esac + done + # build nix/setup.sh arguments + nix_args=(--unattended --quiet-summary) + [ "$update_modules" = true ] && nix_args+=(--update-modules) + for sc in "${scope_arr[@]}"; do + # exclude scopes handled traditionally above or not available in nix + case $sc in + distrobox|docker) continue ;; + esac + nix_args+=("--${sc//_/-}") + done + [ -n "$omp_theme" ] && nix_args+=(--omp-theme "$omp_theme") + printf "\e[96mrunning nix setup...\e[0m\n" + nix/setup.sh "${nix_args[@]}" + _ir_phase="profiles" + # install pwsh system-wide for root-level profile setup + if printf '%s\n' "${scope_arr[@]}" | grep -qx 'pwsh'; then + if command -v pwsh &>/dev/null; then + printf "\e[96msetting up profile for all users...\e[0m\n" + update_flag="" + [ "$update_modules" = true ] && update_flag="-UpdateModules" + sudo pwsh -nop .assets/setup/setup_profile_allusers.ps1 -UserName $user $update_flag + # install do-common module for all users (requires root) + cmnd="Import-Module (Resolve-Path './modules/InstallUtils'); Invoke-GhRepoClone -OrgRepo 'szymonos/ps-modules'" + cloned=$(pwsh -nop -c "$cmnd") + if [ "$cloned" -gt 0 ]; then + printf "\e[3;32mAllUsers\e[23m : do-common\e[0m\n" + sudo pwsh -nop ../ps-modules/module_manage.ps1 'do-common' -CleanUp + else + printf '\e[33mps-modules repository cloning failed\e[0m.\n' + fi + fi + fi +else + # -- Traditional path: per-tool install scripts with sudo -------------------- + for sc in "${scope_arr[@]}"; do + case $sc in + az) + printf "\e[96minstalling azure cli...\e[0m\n" + .assets/provision/install_azurecli_uv.sh --fix_certify true + sudo .assets/provision/install_azcopy.sh >/dev/null + ;; + bun) + printf "\e[96minstalling bun...\e[0m\n" + .assets/provision/install_bun.sh + ;; + conda) + printf "\e[96minstalling python packages...\e[0m\n" + .assets/provision/install_miniforge.sh --fix_certify true + ;; + distrobox) + printf "\e[96minstalling distrobox...\e[0m\n" + sudo .assets/provision/install_podman.sh + sudo .assets/provision/install_distrobox.sh $user + ;; + docker) + printf "\e[96minstalling docker...\e[0m\n" + sudo .assets/provision/install_docker.sh $user + ;; + gcloud) + printf "\e[96minstalling google-cloud-cli...\e[0m\n" + sudo .assets/provision/install_gcloud.sh >/dev/null + sudo .assets/fix/fix_gcloud_certs.sh + ;; + k8s_base) + printf "\e[96minstalling kubernetes base packages...\e[0m\n" + sudo .assets/provision/install_kubectl.sh >/dev/null + sudo .assets/provision/install_kubelogin.sh >/dev/null + sudo .assets/provision/install_k9s.sh >/dev/null + sudo .assets/provision/install_kubecolor.sh >/dev/null + sudo .assets/provision/install_kubectx.sh >/dev/null + ;; + k8s_dev) + printf "\e[96minstalling kubernetes dev packages...\e[0m\n" + sudo .assets/provision/install_argorolloutscli.sh >/dev/null + sudo .assets/provision/install_cilium.sh >/dev/null + sudo .assets/provision/install_flux.sh >/dev/null + sudo .assets/provision/install_helm.sh >/dev/null + sudo .assets/provision/install_hubble.sh >/dev/null + sudo .assets/provision/install_kustomize.sh >/dev/null + sudo .assets/provision/install_trivy.sh >/dev/null + ;; + k8s_ext) + printf "\e[96minstalling local kubernetes tools...\e[0m\n" + sudo .assets/provision/install_minikube.sh >/dev/null + sudo .assets/provision/install_k3d.sh >/dev/null + sudo .assets/provision/install_kind.sh >/dev/null + ;; + nodejs) + printf "\e[96minstalling Node.js...\e[0m\n" + sudo .assets/provision/install_nodejs.sh + ;; + oh_my_posh) + printf "\e[96minstalling oh-my-posh...\e[0m\n" + sudo .assets/provision/install_omp.sh >/dev/null + if [ -n "$omp_theme" ]; then + sudo .assets/setup/setup_omp.sh --theme $omp_theme --user $user + fi + ;; + pwsh) + printf "\e[96minstalling pwsh...\e[0m\n" + sudo .assets/provision/install_pwsh.sh >/dev/null + printf "\e[96msetting up profile for all users...\e[0m\n" + update_flag="" + [ "$update_modules" = true ] && update_flag="-UpdateModules" + sudo pwsh -nop .assets/setup/setup_profile_allusers.ps1 -UserName $user $update_flag + ;; + python) + printf "\e[96minstalling python tools...\e[0m\n" + sudo .assets/setup/setup_python.sh + .assets/provision/install_uv.sh >/dev/null + .assets/provision/install_prek.sh >/dev/null + ;; + rice) + printf "\e[96mricing distro...\e[0m\n" + sudo .assets/provision/install_btop.sh + sudo .assets/provision/install_cmatrix.sh + sudo .assets/provision/install_cowsay.sh + sudo .assets/provision/install_fastfetch.sh >/dev/null + ;; + shell) + printf "\e[96minstalling shell packages...\e[0m\n" + sudo .assets/provision/install_fzf.sh + sudo .assets/provision/install_eza.sh >/dev/null + sudo .assets/provision/install_bat.sh >/dev/null + sudo .assets/provision/install_ripgrep.sh >/dev/null + sudo .assets/provision/install_yq.sh >/dev/null + ;; + terraform) + printf "\e[96minstalling terraform utils...\e[0m\n" + sudo .assets/provision/install_terraform.sh >/dev/null + sudo .assets/provision/install_terrascan.sh >/dev/null + sudo .assets/provision/install_tflint.sh >/dev/null + sudo .assets/provision/install_tfswitch.sh >/dev/null + ;; + zsh) + printf "\e[96minstalling zsh...\e[0m\n" + sudo .assets/provision/install_zsh.sh + ;; + esac + done + _ir_phase="profiles" + # setup bash profiles + printf "\e[96msetting up profile for all users...\e[0m\n" + sudo .assets/setup/setup_profile_allusers.sh $user + printf "\e[96msetting up profile for current user...\e[0m\n" + .assets/setup/setup_profile_user.sh + # install do-common module for all users (requires root) + if [ -f /usr/bin/pwsh ]; then + cmnd="Import-Module (Resolve-Path './modules/InstallUtils'); Invoke-GhRepoClone -OrgRepo 'szymonos/ps-modules'" + cloned=$(pwsh -nop -c "$cmnd") + if [ $cloned -gt 0 ]; then + printf "\e[3;32mAllUsers\e[23m : do-common\e[0m\n" + sudo ../ps-modules/module_manage.ps1 'do-common' -CleanUp + else + printf '\e[33mps-modules repository cloning failed\e[0m.\n' fi - ;; - pwsh) - printf "\e[96minstalling pwsh...\e[0m\n" - sudo .assets/provision/install_pwsh.sh >/dev/null - printf "\e[96msetting up profile for all users...\e[0m\n" - sudo .assets/provision/setup_profile_allusers.ps1 -UserName $user - printf "\e[96msetting up profile for current user...\e[0m\n" - .assets/provision/setup_profile_user.ps1 - ;; - python) - printf "\e[96minstalling python tools...\e[0m\n" - sudo .assets/provision/setup_python.sh - .assets/provision/install_uv.sh >/dev/null - .assets/provision/install_prek.sh >/dev/null - ;; - rice) - printf "\e[96mricing distro...\e[0m\n" - sudo .assets/provision/install_btop.sh - sudo .assets/provision/install_cmatrix.sh - sudo .assets/provision/install_cowsay.sh - sudo .assets/provision/install_fastfetch.sh >/dev/null - ;; - shell) - printf "\e[96minstalling shell packages...\e[0m\n" - sudo .assets/provision/install_fzf.sh - sudo .assets/provision/install_eza.sh >/dev/null - sudo .assets/provision/install_bat.sh >/dev/null - sudo .assets/provision/install_ripgrep.sh >/dev/null - sudo .assets/provision/install_yq.sh >/dev/null - .assets/provision/install_copilot.sh - ;; - terraform) - printf "\e[96minstalling terraform utils...\e[0m\n" - sudo .assets/provision/install_terraform.sh >/dev/null - sudo .assets/provision/install_terrascan.sh >/dev/null - sudo .assets/provision/install_tflint.sh >/dev/null - sudo .assets/provision/install_tfswitch.sh >/dev/null - ;; - zsh) - printf "\e[96minstalling zsh...\e[0m\n" - sudo .assets/provision/install_zsh.sh - printf "\e[96msetting up zsh profile for current user...\e[0m\n" - .assets/provision/setup_profile_user.zsh - ;; - esac -done -# setup bash profiles -printf "\e[96msetting up profile for all users...\e[0m\n" -sudo .assets/provision/setup_profile_allusers.sh $user -printf "\e[96msetting up profile for current user...\e[0m\n" -.assets/provision/setup_profile_user.sh -# install powershell modules -if [ -f /usr/bin/pwsh ]; then - cmnd="Import-Module (Resolve-Path './modules/InstallUtils'); Invoke-GhRepoClone -OrgRepo 'szymonos/ps-modules'" - cloned=$(pwsh -nop -c "$cmnd") - if [ $cloned -gt 0 ]; then - printf "\e[96minstalling ps-modules...\e[0m\n" - # install do-common module for all users - printf "\e[3;32mAllUsers\e[23m : do-common\e[0m\n" - sudo ../ps-modules/module_manage.ps1 'do-common' -CleanUp - - # determine current user scope modules to install - modules=('do-linux') - grep -qw 'az' <<<$scope && modules+=(do-az) || true - [ -f /usr/bin/git ] && modules+=(aliases-git) || true - [ -f /usr/bin/kubectl ] && modules+=(aliases-kubectl) || true - # Convert the modules array to a comma-separated string with quoted elements - printf "\e[3;32mCurrentUser\e[23m : ${modules[*]}\e[0m\n" - mods='' - for element in "${modules[@]}"; do - mods="$mods'$element'," - done - pwsh -nop -c "@(${mods%,}) | ../ps-modules/module_manage.ps1 -CleanUp" - else - printf '\e[33mps-modules repository cloning failed\e[0m.\n' fi + # common post-install setup (copilot, zsh plugins, ps-modules) + common_args=() + [ "$update_modules" = true ] && common_args+=(--update-modules) + .assets/setup/setup_common.sh "${common_args[@]}" "${scope_arr[@]}" fi +_ir_phase="complete" # restore working directory popd >/dev/null diff --git a/.assets/scripts/modules_update.ps1 b/.assets/scripts/modules_update.ps1 index e3b0ca21..6684a267 100755 --- a/.assets/scripts/modules_update.ps1 +++ b/.assets/scripts/modules_update.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh #Requires -PSEdition Core -Version 7.3 <# .SYNOPSIS diff --git a/.assets/scripts/vg_certs_add.ps1 b/.assets/scripts/vg_certs_add.ps1 index f2d99ab7..eb0d2c9f 100755 --- a/.assets/scripts/vg_certs_add.ps1 +++ b/.assets/scripts/vg_certs_add.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh #Requires -PSEdition Core <# .SYNOPSIS diff --git a/.assets/provision/autoexec.sh b/.assets/setup/autoexec.sh similarity index 100% rename from .assets/provision/autoexec.sh rename to .assets/setup/autoexec.sh diff --git a/.assets/provision/set_authorized_keys.sh b/.assets/setup/set_authorized_keys.sh similarity index 100% rename from .assets/provision/set_authorized_keys.sh rename to .assets/setup/set_authorized_keys.sh diff --git a/.assets/provision/set_ulimits.sh b/.assets/setup/set_ulimits.sh similarity index 92% rename from .assets/provision/set_ulimits.sh rename to .assets/setup/set_ulimits.sh index 03ac5761..dcd12b9b 100755 --- a/.assets/provision/set_ulimits.sh +++ b/.assets/setup/set_ulimits.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -sudo .assets/provision/set_ulimits.sh +sudo .assets/setup/set_ulimits.sh ' set -euo pipefail diff --git a/.assets/setup/setup_common.sh b/.assets/setup/setup_common.sh new file mode 100755 index 00000000..ffcdefde --- /dev/null +++ b/.assets/setup/setup_common.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +: ' +# common post-install setup (called by nix/setup.sh and linux_setup.sh) +.assets/setup/setup_common.sh shell zsh az k8s_base pwsh +# with module updates +.assets/setup/setup_common.sh --update-modules shell zsh pwsh +' +set -euo pipefail + +if [[ $EUID -eq 0 ]]; then + printf '\e[31;1mDo not run the script as root.\e[0m\n' >&2 + exit 1 +fi + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } + +update_modules="false" +if [[ "${1:-}" == "--update-modules" ]]; then + update_modules="true" + shift +fi +scopes=("$@") + +has_scope() { + local s="$1" + for sc in "${scopes[@]}"; do + [[ "$sc" == "$s" ]] && return 0 + done + return 1 +} + +# -- Copilot CLI (shell scope, skip in CI) ------------------------------------ +if has_scope shell && [ -z "${CI:-}" ]; then + "$SCRIPT_ROOT/.assets/provision/install_copilot.sh" +fi + +# -- Zsh plugins (zsh scope) -------------------------------------------------- +if has_scope zsh && command -v zsh &>/dev/null; then + info "setting up zsh profile for current user..." + "$SCRIPT_ROOT/.assets/setup/setup_profile_user.zsh" +fi + +# -- PowerShell user profile + modules (pwsh scope) --------------------------- +if command -v pwsh &>/dev/null; then + # setup PowerShell user profile + info "setting up PowerShell profile for current user..." + if [[ "$update_modules" == "true" ]]; then + pwsh -nop "$SCRIPT_ROOT/.assets/setup/setup_profile_user.ps1" -UpdateModules + else + pwsh -nop "$SCRIPT_ROOT/.assets/setup/setup_profile_user.ps1" + fi + + # clone/refresh ps-modules and install user-scope modules + pushd "$SCRIPT_ROOT" >/dev/null + cmnd="Import-Module (Resolve-Path './modules/InstallUtils'); Invoke-GhRepoClone -OrgRepo 'szymonos/ps-modules'" + cloned=$(pwsh -nop -c "$cmnd") + if [[ $cloned -gt 0 ]]; then + info "installing ps-modules..." + # determine current user scope modules to install + modules=('do-linux') + has_scope az && modules+=(do-az) || true + command -v git &>/dev/null && modules+=(aliases-git) || true + command -v kubectl &>/dev/null && modules+=(aliases-kubectl) || true + printf "\e[3;32mCurrentUser\e[23m : %s\e[0m\n" "${modules[*]}" + mods='' + for element in "${modules[@]}"; do + mods="$mods'$element'," + done + pwsh -nop -c "@(${mods%,}) | ../ps-modules/module_manage.ps1 -CleanUp" + else + warn "ps-modules repository cloning failed" + fi + popd >/dev/null + + # install PowerShell Az modules from PSGallery + if has_scope az; then + cmnd='if (-not (Get-Module -ListAvailable "Az")) { + Write-Host "installing Az..." + Install-PSResource Az -WarningAction SilentlyContinue -ErrorAction Stop +} +if (-not (Get-Module -ListAvailable "Az.ResourceGraph")) { + Write-Host "installing Az.ResourceGraph..." + Install-PSResource Az.ResourceGraph -ErrorAction Stop +}' + pwsh -nop -c "$cmnd" + fi +fi diff --git a/.assets/provision/setup_docker_mount.sh b/.assets/setup/setup_docker_mount.sh similarity index 100% rename from .assets/provision/setup_docker_mount.sh rename to .assets/setup/setup_docker_mount.sh diff --git a/.assets/provision/setup_gh_https.sh b/.assets/setup/setup_gh_https.sh similarity index 92% rename from .assets/provision/setup_gh_https.sh rename to .assets/setup/setup_gh_https.sh index 9757407e..6a6002cd 100755 --- a/.assets/provision/setup_gh_https.sh +++ b/.assets/setup/setup_gh_https.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash : ' # set up GitHub CLI https authentication for the specified user -sudo .assets/provision/setup_gh_https.sh -u "$(id -un)" +sudo .assets/setup/setup_gh_https.sh -u "$(id -un)" # set up GitHub CLI https authentication with admin:public_key scope -sudo .assets/provision/setup_gh_https.sh -u "$(id -un)" -k +sudo .assets/setup/setup_gh_https.sh -u "$(id -un)" -k # set up GitHub CLI https authentication with the provided token -sudo .assets/provision/setup_gh_https.sh -u "$(id -un)" -c "$gh_auth" +sudo .assets/setup/setup_gh_https.sh -u "$(id -un)" -c "$gh_auth" # set up GitHub CLI https authentication with admin:public_key scope and the provided token -sudo .assets/provision/setup_gh_https.sh -u "$(id -un)" -c "$gh_auth" -k +sudo .assets/setup/setup_gh_https.sh -u "$(id -un)" -c "$gh_auth" -k ' set -euo pipefail diff --git a/.assets/provision/setup_gh_repos.ps1 b/.assets/setup/setup_gh_repos.ps1 similarity index 88% rename from .assets/provision/setup_gh_repos.ps1 rename to .assets/setup/setup_gh_repos.ps1 index 56aad86b..ee470663 100755 --- a/.assets/provision/setup_gh_repos.ps1 +++ b/.assets/setup/setup_gh_repos.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh <# .SYNOPSIS Clone specified GitHub repositories into ~/source/repos folder. @@ -13,16 +13,16 @@ Windows user name to copy ssh keys from. .EXAMPLE $Repos = 'szymonos/linux-setup-scripts szymonos/ps-modules' $User = 'szymo' -.assets/provision/setup_gh_repos.ps1 $Repos -u $User -.assets/provision/setup_gh_repos.ps1 $Repos -u $User -WorkspaceSuffix 'scripts' +.assets/setup/setup_gh_repos.ps1 $Repos -u $User +.assets/setup/setup_gh_repos.ps1 $Repos -u $User -WorkspaceSuffix 'scripts' .NOTES # :save script example -./scripts_egsave.ps1 .assets/provision/setup_gh_repos.ps1 +./scripts_egsave.ps1 .assets/setup/setup_gh_repos.ps1 # :override the existing script example if exists -./scripts_egsave.ps1 .assets/provision/setup_gh_repos.ps1 -Force +./scripts_egsave.ps1 .assets/setup/setup_gh_repos.ps1 -Force # :open the example script in VSCode -code -r (./scripts_egsave.ps1 .assets/provision/setup_gh_repos.ps1 -WriteOutput) +code -r (./scripts_egsave.ps1 .assets/setup/setup_gh_repos.ps1 -WriteOutput) #> [CmdletBinding()] param ( diff --git a/.assets/provision/setup_gh_repos.sh b/.assets/setup/setup_gh_repos.sh similarity index 86% rename from .assets/provision/setup_gh_repos.sh rename to .assets/setup/setup_gh_repos.sh index 5ecce813..bd6264f8 100755 --- a/.assets/provision/setup_gh_repos.sh +++ b/.assets/setup/setup_gh_repos.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash : ' -.assets/provision/setup_gh_repos.sh --repos "szymonos/linux-setup-scripts szymonos/ps-modules" -.assets/provision/setup_gh_repos.sh --repos "szymonos/linux-setup-scripts szymonos/ps-modules" --ws_suffix "scripts" +.assets/setup/setup_gh_repos.sh --repos "szymonos/linux-setup-scripts szymonos/ps-modules" +.assets/setup/setup_gh_repos.sh --repos "szymonos/linux-setup-scripts szymonos/ps-modules" --ws_suffix "scripts" ' set -euo pipefail diff --git a/.assets/provision/setup_gh_ssh.sh b/.assets/setup/setup_gh_ssh.sh similarity index 98% rename from .assets/provision/setup_gh_ssh.sh rename to .assets/setup/setup_gh_ssh.sh index 4e56f873..b91e7046 100755 --- a/.assets/provision/setup_gh_ssh.sh +++ b/.assets/setup/setup_gh_ssh.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -.assets/provision/setup_gh_ssh.sh +.assets/setup/setup_gh_ssh.sh ' set -euo pipefail diff --git a/.assets/provision/setup_gnome.sh b/.assets/setup/setup_gnome.sh similarity index 97% rename from .assets/provision/setup_gnome.sh rename to .assets/setup/setup_gnome.sh index 80661ca0..5e6d4552 100755 --- a/.assets/provision/setup_gnome.sh +++ b/.assets/setup/setup_gnome.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -.assets/provision/setup_gnome.sh +.assets/setup/setup_gnome.sh ' set -euo pipefail diff --git a/.assets/provision/setup_omp.sh b/.assets/setup/setup_omp.sh similarity index 85% rename from .assets/provision/setup_omp.sh rename to .assets/setup/setup_omp.sh index 9fa23182..ad682a4c 100755 --- a/.assets/provision/setup_omp.sh +++ b/.assets/setup/setup_omp.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash : ' # :setup oh-my-posh theme using default base fonts -sudo .assets/provision/setup_omp.sh --user $(id -un) +sudo .assets/setup/setup_omp.sh --user $(id -un) # :setup oh-my-posh theme using powerline fonts -sudo .assets/provision/setup_omp.sh --user $(id -un) --theme powerline +sudo .assets/setup/setup_omp.sh --user $(id -un) --theme powerline # :setup oh-my-posh theme using nerd fonts -sudo .assets/provision/setup_omp.sh --user $(id -un) --theme nerd +sudo .assets/setup/setup_omp.sh --user $(id -un) --theme nerd # :you can specify any themes from https://ohmyposh.dev/docs/themes/ (e.g. atomic) -sudo .assets/provision/setup_omp.sh --user $(id -un) --theme atomic +sudo .assets/setup/setup_omp.sh --user $(id -un) --theme atomic ' set -euo pipefail diff --git a/.assets/provision/setup_profile_allusers.ps1 b/.assets/setup/setup_profile_allusers.ps1 similarity index 86% rename from .assets/provision/setup_profile_allusers.ps1 rename to .assets/setup/setup_profile_allusers.ps1 index ce50121b..df74e8c5 100755 --- a/.assets/provision/setup_profile_allusers.ps1 +++ b/.assets/setup/setup_profile_allusers.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh <# .SYNOPSIS Setting up PowerShell for the all users. @@ -6,12 +6,19 @@ Setting up PowerShell for the all users. .PARAMETER UserName Default user name to run the script in context of. +.PARAMETER UpdateModules +Run update_psresources.ps1 to update all installed modules. + .EXAMPLE -sudo .assets/provision/setup_profile_allusers.ps1 -UserName $(id -un) +sudo .assets/setup/setup_profile_allusers.ps1 -UserName $(id -un) +# :update modules +sudo .assets/setup/setup_profile_allusers.ps1 -UserName $(id -un) -UpdateModules #> param ( [Parameter(Position = 0)] - [string]$UserName + [string]$UserName, + + [switch]$UpdateModules ) begin { @@ -89,8 +96,8 @@ process { Install-PSResource -Name posh-git -Scope AllUsers } # update existing modules - if (Test-Path .assets/provision/update_psresources.ps1 -PathType Leaf) { - .assets/provision/update_psresources.ps1 + if ($PSBoundParameters.UpdateModules -and (Test-Path .assets/setup/update_psresources.ps1 -PathType Leaf)) { + .assets/setup/update_psresources.ps1 } } } diff --git a/.assets/provision/setup_profile_allusers.sh b/.assets/setup/setup_profile_allusers.sh similarity index 98% rename from .assets/provision/setup_profile_allusers.sh rename to .assets/setup/setup_profile_allusers.sh index be4b5c34..dba3a1d7 100755 --- a/.assets/provision/setup_profile_allusers.sh +++ b/.assets/setup/setup_profile_allusers.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -sudo .assets/provision/setup_profile_allusers.sh $(id -un) +sudo .assets/setup/setup_profile_allusers.sh $(id -un) ' set -euo pipefail diff --git a/.assets/provision/setup_profile_user.ps1 b/.assets/setup/setup_profile_user.ps1 similarity index 55% rename from .assets/provision/setup_profile_user.ps1 rename to .assets/setup/setup_profile_user.ps1 index e8f3b9a3..0ff717c7 100755 --- a/.assets/provision/setup_profile_user.ps1 +++ b/.assets/setup/setup_profile_user.ps1 @@ -1,11 +1,19 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh <# .SYNOPSIS Setting up PowerShell for the current user. +.PARAMETER UpdateModules +Run update_psresources.ps1 to update all installed modules. + .EXAMPLE -.assets/provision/setup_profile_user.ps1 +.assets/setup/setup_profile_user.ps1 +# :update modules +.assets/setup/setup_profile_user.ps1 -UpdateModules #> +param ( + [switch]$UpdateModules +) $ErrorActionPreference = 'SilentlyContinue' $WarningPreference = 'Ignore' @@ -20,13 +28,16 @@ if (Get-Module -Name Microsoft.PowerShell.PSResourceGet -ListAvailable) { if (-not (Get-PSResourceRepository -Name PSGallery).Trusted) { Write-Host 'setting PSGallery trusted...' Set-PSResourceRepository -Name PSGallery -Trusted + # update help, assuming this is the initial setup - Write-Host 'updating help...' - Update-Help -UICulture en-US + if (Test-Connection 'aka.ms' -TcpPort 443 -TimeoutSeconds 1) { + Write-Host 'updating help...' + Update-Help -UICulture en-US + } } # update existing modules - if (Test-Path .assets/provision/update_psresources.ps1 -PathType Leaf) { - .assets/provision/update_psresources.ps1 + if ($PSBoundParameters.UpdateModules -and (Test-Path .assets/setup/update_psresources.ps1 -PathType Leaf)) { + .assets/setup/update_psresources.ps1 } } # install PSReadLine @@ -102,7 +113,7 @@ if (Test-Path "$HOME/$condaCli" -PathType Leaf) { $isProfileModified = $true } # hide conda env in shell prompt if oh-my-posh is installed - if (Test-Path /usr/bin/oh-my-posh -PathType Leaf) { + if (Get-Command oh-my-posh -CommandType Application -ErrorAction SilentlyContinue) { $changeps1 = & "$HOME/$condaCli" config --show | Select-String 'changeps1: False' -SimpleMatch -Quiet if (-not $changeps1) { & "$HOME/$condaCli" config --set changeps1 false @@ -113,13 +124,13 @@ if (Test-Path "$HOME/$condaCli" -PathType Leaf) { # set up uv $uvCli = '.local/bin/uv' if (Test-Path "$HOME/$uvCli" -PathType Leaf) { - if (-not ($profileContent | Select-String 'UV_NATIVE_TLS' -SimpleMatch -Quiet)) { + if (-not ($profileContent | Select-String 'UV_SYSTEM_CERTS' -SimpleMatch -Quiet)) { Write-Verbose 'adding uv autocompletion...' $profileContent.AddRange( [string[]]@( "`n#region uv" '# use system certificates' - '[System.Environment]::SetEnvironmentVariable("UV_NATIVE_TLS", $true)' + '[System.Environment]::SetEnvironmentVariable("UV_SYSTEM_CERTS", $true)' ) ) $isProfileModified = $true @@ -157,30 +168,6 @@ if (Get-Command $completerFunction -Module 'do-linux' -CommandType Function -Err } } -# set up pixi -$pixiCli = '.pixi/bin/pixi' -if (Test-Path "$HOME/$pixiCli" -PathType Leaf) { - if (-not ($profileContent | Select-String $pixiCli -SimpleMatch -Quiet)) { - Write-Verbose 'adding pixi autocompletion...' - $profileContent.AddRange( - [string[]]@( - "`n#region pixi" - '# autocompletion' - "try { (& `"`$HOME/$pixiCli`" completion --shell powershell) | Out-String | Invoke-Expression } catch { Out-Null }" - '#endregion' - ) - ) - $isProfileModified = $true - } - # hide pixi env in shell prompt if oh-my-posh is installed - if (Test-Path /usr/bin/oh-my-posh -PathType Leaf) { - $changeps1 = & "$HOME/$pixiCli" config list | Select-String 'change-ps1 = false' -SimpleMatch -Quiet - if (-not $changeps1) { - & "$HOME/$pixiCli" config set --global shell.change-ps1 false - } - } -} - # set up opencode $openCodePath = '.opencode/bin' if (Test-Path "$HOME/$openCodePath/opencode" -PathType Leaf) { @@ -199,6 +186,100 @@ if (Test-Path "$HOME/$openCodePath/opencode" -PathType Leaf) { } } +# set up local-path (~/.local/bin) +$localBin = [IO.Path]::Combine($HOME, '.local', 'bin') +if (-not ($profileContent | Select-String 'local-path' -SimpleMatch -Quiet)) { + Write-Verbose 'adding local-path to PATH...' + $profileContent.AddRange([string[]]@( + "`n#region local-path" + '$localBin = [IO.Path]::Combine([Environment]::GetFolderPath(''UserProfile''), ''.local/bin'')' + 'if ([IO.Directory]::Exists($localBin) -and $localBin -notin $env:PATH.Split([IO.Path]::PathSeparator)) {' + ' [Environment]::SetEnvironmentVariable(''PATH'', [string]::Join([IO.Path]::PathSeparator, $localBin, $env:PATH))' + '}' + '#endregion' + ) + ) + $isProfileModified = $true +} + +# set up devenv function (install provenance viewer) +if (-not ($profileContent | Select-String 'function devenv' -SimpleMatch -Quiet)) { + Write-Verbose 'adding devenv function...' + $profileContent.AddRange([string[]]@( + "`n#region devenv" + 'function devenv {' + ' $f = [IO.Path]::Combine([Environment]::GetFolderPath(''UserProfile''), ''.config/dev-env/install.json'')' + ' if (-not [IO.File]::Exists($f)) { Write-Host "`e[33mNo install record found.`e[0m"; return }' + ' $r = Get-Content $f -Raw | ConvertFrom-Json' + ' $ref = if ($r.source_ref) { $r.source_ref.Substring(0, [Math]::Min(12, $r.source_ref.Length)) } else { ''n/a'' }' + ' $statusStr = if ($r.status -eq ''success'') { "`e[32m$($r.status)`e[0m" } else { "`e[31m$($r.status)`e[0m (phase: $($r.phase))" }' + ' $prop = [ordered]@{' + ' Version = "`e[96m$($r.version)`e[0m"' + ' Entry = $r.entry_point' + ' Source = "$($r.source) ($ref)"' + ' Platform = "$($r.platform)/$($r.arch)"' + ' Mode = $r.mode' + ' Status = $statusStr' + ' }' + ' if ($r.status -ne ''success'' -and $r.error) { $prop[''Error''] = "`e[31m$($r.error)`e[0m" }' + ' $prop[''Installed''] = $r.installed_at' + ' if ($r.nix_version) { $prop[''Nix''] = $r.nix_version }' + ' $prop[''Scopes''] = if ($r.scopes) { $r.scopes -join '', '' } else { '''' }' + ' return [PSCustomObject]$prop' + '}' + '#endregion' + ) + ) + $isProfileModified = $true +} + +# set up custom CA certs environment variables for MITM proxy certificates +$certCustom = [IO.Path]::Combine($HOME, '.config', 'certs', 'ca-custom.crt') +$certBundle = [IO.Path]::Combine($HOME, '.config', 'certs', 'ca-bundle.crt') +if (Test-Path $certCustom -PathType Leaf) { + if (-not ($profileContent | Select-String 'NODE_EXTRA_CA_CERTS' -SimpleMatch -Quiet)) { + Write-Verbose 'adding NODE_EXTRA_CA_CERTS env var...' + $profileContent.AddRange([string[]]@( + "`n#region certs" + "if (Test-Path `"$certCustom`" -PathType Leaf) {" + " [Environment]::SetEnvironmentVariable('NODE_EXTRA_CA_CERTS', `"$certCustom`")" + '}' + '#endregion' + ) + ) + $isProfileModified = $true + } +} +if (Test-Path $certBundle -PathType Leaf) { + if (-not ($profileContent | Select-String 'REQUESTS_CA_BUNDLE' -SimpleMatch -Quiet)) { + Write-Verbose 'adding REQUESTS_CA_BUNDLE and SSL_CERT_FILE env vars...' + $profileContent.AddRange([string[]]@( + "`n#region ca-bundle" + "if (Test-Path `"$certBundle`" -PathType Leaf) {" + " [Environment]::SetEnvironmentVariable('REQUESTS_CA_BUNDLE', `"$certBundle`")" + " [Environment]::SetEnvironmentVariable('SSL_CERT_FILE', `"$certBundle`")" + '}' + '#endregion' + ) + ) + $isProfileModified = $true + } + if (-not ($profileContent | Select-String 'CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE' -SimpleMatch -Quiet)) { + if ((Test-Path /usr/bin/gcloud -PathType Leaf) -or (Test-Path "$HOME/.nix-profile/bin/gcloud" -PathType Leaf)) { + Write-Verbose 'adding CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE env var...' + $profileContent.AddRange([string[]]@( + "`n#region gcloud-certs" + "if (Test-Path `"$certBundle`" -PathType Leaf) {" + " [Environment]::SetEnvironmentVariable('CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE', `"$certBundle`")" + '}' + '#endregion' + ) + ) + $isProfileModified = $true + } + } +} + # save profile if modified if ($isProfileModified) { [System.IO.File]::WriteAllText( diff --git a/.assets/provision/setup_profile_user.sh b/.assets/setup/setup_profile_user.sh similarity index 73% rename from .assets/provision/setup_profile_user.sh rename to .assets/setup/setup_profile_user.sh index 3410f573..81a3ecdb 100755 --- a/.assets/provision/setup_profile_user.sh +++ b/.assets/setup/setup_profile_user.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -.assets/provision/setup_profile_user.sh +.assets/setup/setup_profile_user.sh ' set -euo pipefail @@ -8,11 +8,19 @@ set -euo pipefail PROFILE_PATH='/etc/profile.d' OMP_PATH='/usr/local/share/oh-my-posh' +# *deploy functions.sh to user-scope if system-wide not available +if [ ! -f "$PROFILE_PATH/functions.sh" ] && [ -f .assets/config/bash_cfg/functions.sh ]; then + mkdir -p "$HOME/.config/bash" + install -m 0644 .assets/config/bash_cfg/functions.sh "$HOME/.config/bash/" +fi + # *add custom functions grep -qw 'd/functions.sh' $HOME/.bashrc 2>/dev/null || cat <>$HOME/.bashrc # custom functions if [ -f "$PROFILE_PATH/functions.sh" ]; then source "$PROFILE_PATH/functions.sh" +elif [ -f "\$HOME/.config/bash/functions.sh" ]; then + source "\$HOME/.config/bash/functions.sh" fi EOF @@ -65,7 +73,7 @@ if ! grep -qw "$COMPLETION_CMD" $HOME/.bashrc 2>/dev/null && [ -x "$HOME/$UV_PAT # initialize uv autocompletion if [ -x "\$HOME/$UV_PATH/uv" ]; then - export UV_NATIVE_TLS=true + export UV_SYSTEM_CERTS=true eval "\$(\$HOME/$UV_PATH/$COMPLETION_CMD)" fi EOF @@ -80,18 +88,21 @@ complete -W "\`if [ -f Makefile ]; then grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' Make EOF fi -# *set up pixi -COMPLETION_CMD='pixi completion --shell bash' -PIXI_PATH=".pixi/bin" -if ! grep -qw "$COMPLETION_CMD" $HOME/.bashrc 2>/dev/null && [ -x "$HOME/$PIXI_PATH/pixi" ]; then - cat <>$HOME/.bashrc - -# initialize pixi autocompletion -if [ -x "\$HOME/$PIXI_PATH/pixi" ]; then - eval "\$(\$HOME/$PIXI_PATH/$COMPLETION_CMD)" -fi -EOF -fi +# *set up managed env block (local path + MITM proxy cert env vars) +_setup_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)" +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +# shellcheck source=../lib/profile_block.sh +source "$_setup_lib/profile_block.sh" +# shellcheck source=../lib/env_block.sh +source "$_setup_lib/env_block.sh" +# shellcheck source=../lib/certs.sh +source "$_setup_lib/certs.sh" +build_ca_bundle +setup_vscode_certs +_env_tmp="$(mktemp)" +render_env_block >"$_env_tmp" +manage_block "$HOME/.bashrc" "$ENV_BLOCK_MARKER" upsert "$_env_tmp" +rm -f "$_env_tmp" # *add oh-my-posh invocation if ! grep -qw 'oh-my-posh' $HOME/.bashrc 2>/dev/null && type oh-my-posh &>/dev/null; then diff --git a/.assets/setup/setup_profile_user.zsh b/.assets/setup/setup_profile_user.zsh new file mode 100755 index 00000000..20a17a80 --- /dev/null +++ b/.assets/setup/setup_profile_user.zsh @@ -0,0 +1,120 @@ +#!/usr/bin/env zsh +: ' +.assets/setup/setup_profile_user.zsh +' +# path variables +PROFILE_PATH='/etc/profile.d' +OMP_PATH='/usr/local/share/oh-my-posh' + +# -- zsh plugins ------------------------------------------------------------- +ZSH_PLUGIN_DIR="$HOME/.zsh" +mkdir -p "$ZSH_PLUGIN_DIR" + +for plugin url file in \ + 'zsh-autocomplete' 'https://github.com/marlonrichert/zsh-autocomplete.git' 'zsh-autocomplete.plugin.zsh' \ + 'zsh-make-complete' 'https://github.com/22peacemaker/zsh-make-complete.git' 'zsh-make-complete.plugin.zsh' \ + 'zsh-autosuggestions' 'https://github.com/zsh-users/zsh-autosuggestions.git' 'zsh-autosuggestions.zsh' \ + 'zsh-syntax-highlighting' 'https://github.com/zsh-users/zsh-syntax-highlighting.git' 'zsh-syntax-highlighting.zsh' +do + if [[ -d "$ZSH_PLUGIN_DIR/$plugin" ]]; then + git -C "$ZSH_PLUGIN_DIR/$plugin" pull --quiet 2>/dev/null || true + else + git clone --depth 1 "$url" "$ZSH_PLUGIN_DIR/$plugin" + fi + if ! grep -q "$file" "$HOME/.zshrc" 2>/dev/null; then + [[ -s "$HOME/.zshrc" ]] && echo "" >>"$HOME/.zshrc" + if [[ "$plugin" == 'zsh-autocomplete' ]]; then + echo '# *plugins' >>"$HOME/.zshrc" + fi + echo "source \"\$HOME/.zsh/$plugin/$file\"" >>"$HOME/.zshrc" + fi +done + +if ! grep -q '^bindkey .* autosuggest-accept' "$HOME/.zshrc"; then + echo "bindkey '^ ' autosuggest-accept" >>"$HOME/.zshrc" +fi + +# -- deploy functions.sh to user-scope if system-wide not available ---------- +if [[ ! -f "$PROFILE_PATH/functions.sh" ]] && [[ -f .assets/config/bash_cfg/functions.sh ]]; then + mkdir -p "$HOME/.config/bash" + install -m 0644 .assets/config/bash_cfg/functions.sh "$HOME/.config/bash/" +fi + +# -- custom functions -------------------------------------------------------- +if ! grep -qw 'd/functions.sh' "$HOME/.zshrc" 2>/dev/null; then + cat <>"$HOME/.zshrc" +# custom functions +if [ -f "$PROFILE_PATH/functions.sh" ]; then + source "$PROFILE_PATH/functions.sh" +elif [ -f "\$HOME/.config/bash/functions.sh" ]; then + source "\$HOME/.config/bash/functions.sh" +fi +EOF +fi + +# -- aliases ----------------------------------------------------------------- +for guard grep_key source_file label in \ + 'true' 'd/aliases.sh' "$PROFILE_PATH/aliases.sh" 'common aliases' \ + 'type git' 'd/aliases_git.sh' "$PROFILE_PATH/aliases_git.sh" 'git aliases' \ + 'type -f kubectl' 'kubectl' "$PROFILE_PATH/aliases_kubectl.sh" 'kubectl aliases' +do + eval "$guard" &>/dev/null 2>&1 || continue + grep -qw "$grep_key" "$HOME/.zshrc" 2>/dev/null && continue + if [[ "$label" == 'kubectl aliases' ]]; then + cat <>"$HOME/.zshrc" +# kubectl autocompletion and aliases +if type -f kubectl &>/dev/null; then + if [ -f "$source_file" ]; then + source "$source_file" + fi +fi +EOF + else + cat <>"$HOME/.zshrc" +# $label +if [ -f "$source_file" ]; then + source "$source_file" +fi +EOF + fi +done + +# -- conda initialization --------------------------------------------------- +if ! grep -qw '__conda_setup' "$HOME/.zshrc" 2>/dev/null && [[ -f $HOME/miniforge3/bin/conda ]]; then + $HOME/miniforge3/bin/conda init zsh >/dev/null +fi + +# -- uv completion ----------------------------------------------------------- +COMPLETION_CMD='uv generate-shell-completion zsh' +UV_PATH=".local/bin" +if ! grep -qw "$COMPLETION_CMD" "$HOME/.zshrc" 2>/dev/null && [[ -x "$HOME/$UV_PATH/uv" ]]; then + cat <>"$HOME/.zshrc" + +# initialize uv autocompletion +if [ -x "\$HOME/$UV_PATH/uv" ]; then + export UV_SYSTEM_CERTS=true + eval "\$(\$HOME/$UV_PATH/$COMPLETION_CMD)" +fi +EOF +fi + +# -- managed env block (local path + MITM proxy cert env vars) -------------- +source "${0:A:h}/../lib/profile_block.sh" +source "${0:A:h}/../lib/env_block.sh" +_env_tmp="$(mktemp)" +render_env_block >"$_env_tmp" +manage_block "$HOME/.zshrc" "$ENV_BLOCK_MARKER" upsert "$_env_tmp" +rm -f "$_env_tmp" + +# -- oh-my-posh prompt ------------------------------------------------------- +if ! grep -qw 'oh-my-posh' "$HOME/.zshrc" 2>/dev/null && type oh-my-posh &>/dev/null; then + cat <>"$HOME/.zshrc" +# initialize oh-my-posh prompt +if [ -f "$OMP_PATH/theme.omp.json" ] && type oh-my-posh &>/dev/null; then + eval "\$(oh-my-posh init zsh --config "$OMP_PATH/theme.omp.json")" +fi +EOF +elif grep -qw 'oh-my-posh --init' "$HOME/.zshrc" 2>/dev/null; then + # convert oh-my-posh initialization to the new API + sed -i 's/oh-my-posh --init --shell zsh/oh-my-posh init zsh/' "$HOME/.zshrc" +fi diff --git a/.assets/provision/setup_python.sh b/.assets/setup/setup_python.sh similarity index 97% rename from .assets/provision/setup_python.sh rename to .assets/setup/setup_python.sh index e0829a79..0afb43de 100755 --- a/.assets/provision/setup_python.sh +++ b/.assets/setup/setup_python.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash : ' -sudo .assets/provision/setup_python.sh +sudo .assets/setup/setup_python.sh ' set -euo pipefail diff --git a/.assets/provision/setup_ssh.sh b/.assets/setup/setup_ssh.sh similarity index 97% rename from .assets/provision/setup_ssh.sh rename to .assets/setup/setup_ssh.sh index 9db490ba..b3a70d19 100755 --- a/.assets/provision/setup_ssh.sh +++ b/.assets/setup/setup_ssh.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash : ' # :generate new SSH key if missing -.assets/provision/setup_ssh.sh +.assets/setup/setup_ssh.sh # :generate SSH key and print the public one ./setup_ssh.sh print_pub ' diff --git a/.assets/provision/update_psresources.ps1 b/.assets/setup/update_psresources.ps1 similarity index 95% rename from .assets/provision/update_psresources.ps1 rename to .assets/setup/update_psresources.ps1 index 68b9611d..8f12572d 100755 --- a/.assets/provision/update_psresources.ps1 +++ b/.assets/setup/update_psresources.ps1 @@ -1,11 +1,11 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh #Requires -Module Microsoft.PowerShell.PSResourceGet <# .SYNOPSIS Script for updating PowerShell modules and cleaning-up old versions. .EXAMPLE -.assets/provision/update_psresources.ps1 +.assets/setup/update_psresources.ps1 #> $ErrorActionPreference = 'SilentlyContinue' diff --git a/.assets/tools/cmd_bench.ps1 b/.assets/tools/cmd_bench.ps1 index 47d20b93..41254d23 100755 --- a/.assets/tools/cmd_bench.ps1 +++ b/.assets/tools/cmd_bench.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh #Requires -PSEdition Core <# .SYNOPSIS diff --git a/.assets/tools/cmd_bench_compare.ps1 b/.assets/tools/cmd_bench_compare.ps1 index ff2387db..4448c9df 100755 --- a/.assets/tools/cmd_bench_compare.ps1 +++ b/.assets/tools/cmd_bench_compare.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh #Requires -PSEdition Core <# .SYNOPSIS diff --git a/.assets/tools/term_bench.ps1 b/.assets/tools/term_bench.ps1 index 7517ac17..6e3f16ac 100755 --- a/.assets/tools/term_bench.ps1 +++ b/.assets/tools/term_bench.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh <# .SYNOPSIS Script synopsis. diff --git a/.assets/trigger/delete_ssh_config.ps1 b/.assets/trigger/delete_ssh_config.ps1 index b67961be..cd9d0dd1 100755 --- a/.assets/trigger/delete_ssh_config.ps1 +++ b/.assets/trigger/delete_ssh_config.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh <# .SYNOPSIS Clean up ssh config and remove entries from known_hosts on destroy. diff --git a/.assets/trigger/set_ssh_config.ps1 b/.assets/trigger/set_ssh_config.ps1 index 5570942a..34a8c453 100755 --- a/.assets/trigger/set_ssh_config.ps1 +++ b/.assets/trigger/set_ssh_config.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh <# .SYNOPSIS Update ssh config and known_hosts files. diff --git a/.claude/enterprise_notes.md b/.claude/enterprise_notes.md new file mode 100644 index 00000000..9644645d --- /dev/null +++ b/.claude/enterprise_notes.md @@ -0,0 +1,134 @@ +# Enterprise integration notes + +Reference document for future enterprise/IDP integration. **Nothing here +is implemented in this repo.** This repo stays generic and IDP-agnostic. +Enterprise-specific work belongs in a company organization fork. + +## Three-tier config composition + +Inspired by Kubernetes Kustomize, Nix overlays, and systemd drop-ins. + +```text +Base (this repo, immutable) + + Org overlay (distributed by IT, read-only to user) + + User overlay (writable, ~/.config/nix-env/local/) +``` + +The base repo provides the overlay skeleton (directory discovery, scope +copy with `local_` prefix, hook directories). The org tier and its +distribution/signing infrastructure are enterprise-specific. + +### Extension points (already working for base + user tiers) + +| Extension point | Base location | Overlay location | +| --------------- | -------------------------- | ------------------------------- | +| Nix scopes | `nix/scopes/*.nix` | `/scopes/*.nix` | +| Shell aliases | `.assets/config/bash_cfg/` | `/bash_cfg/` | +| Post-install | `nix/configure/*.sh` | `/hooks/post-setup.d/` | + +### What the org tier adds (not implemented) + +- Signed overlay tarballs distributed via artifact store +- `overlay.yaml` metadata (name, version, min-core-version) +- `policy.yaml` (ban/require scopes) +- `scopes.json` deep-merge (arrays unioned, dependency_rules appended) +- Multi-tier hook ordering (base -> org -> user, lexical within tier) + +## IDP consumption model + +The tool modeled as a first-class catalog entity in the IDP: + +- **Discovery** via catalog search +- **Onboarding** via self-service template emitting a personalized, + pinned bootstrap command +- **Day-two** via `nx upgrade` or IDP-emitted upgrade commands +- **Fleet visibility** via aggregated opt-in telemetry from `nx doctor` + +The IDP is a **control plane**, not a runtime. The laptop must never +hard-depend on IDP reachability to install, upgrade, or self-heal. + +### Consumption tiers + +| Tier | Who | How | +| --------------- | ------------------------- | --------------------------------------- | +| Individual | Personal macOS/Linux | `git clone` or release tarball | +| Corporate fleet | Coder, WSL, managed macOS | Artifact store + org overlay + env vars | +| Contributor | Repo maintainer | `git clone` + `make test` | + +## Reserved environment variables + +These are **not wired up** in the upstream repo. Semantics depend on +enterprise decisions. + +| Variable | Purpose | Decided by | +| ------------------------------ | ---------------------------- | ---------- | +| `NIX_ENV_OVERLAY_URL` | Signed overlay fetch URL | IDP team | +| `NIX_ENV_OVERLAY_PUBKEY` | Overlay signature public key | IDP team | +| `NIX_ENV_TELEMETRY_URL` | Opt-in telemetry endpoint | IDP team | +| `NIX_ENV_TELEMETRY` | `off` (default) / `on` | IDP team | +| `NIX_ENV_DISABLE_USER_OVERLAY` | Disable user-scope overlay | IT policy | +| `NIX_ENV_CACHE_DIR` | Override `~/.cache/nix-env` | user | + +## Decision checklist for IDP/platform team + +Before implementing enterprise integration in the fork: + +1. **Catalog schema** -- which IDP (Backstage, Port, other)? Determines + `metadata.yaml` / `catalog-info.yaml` shape. +2. **Overlay distribution** -- artifact store URL? OCI registry? Git + submodule? Determines `NIX_ENV_OVERLAY_URL` semantics. +3. **Signing infrastructure** -- minisign, Sigstore, or corp CA? + Determines `NIX_ENV_OVERLAY_PUBKEY` format. +4. **Telemetry contract** -- what data, where it goes, privacy + guarantees, mandatory vs. opt-in. Determines telemetry variables. +5. **Policy enforcement** -- can org overlays ban/require scopes? What + is the override model? Determines `policy.yaml` schema. + +## Implementation items (all in company fork) + +| Item | Blocked on | Notes | +| ------------------------- | --------------- | ---------------------------------- | +| `metadata.yaml` | Decision 1 | Shape depends on IDP choice | +| Reserved env var warnings | All 5 decisions | Can't warn on undefined semantics | +| `dist/fetch_overlay.sh` | Decisions 2 + 3 | Download + verify org overlay | +| `nx overlay fetch` | Decisions 2 + 3 | CLI command to refresh org overlay | +| Telemetry reporting | Decision 4 | `nx` commands emit events | +| Policy enforcement | Decision 5 | `policy.yaml` in org overlay | +| `scopes.json` deep-merge | Decision 2 | jq-based merge of base + org | +| Multi-tier hook ordering | Decision 2 | Base -> org -> user hook execution | + +## What the upstream repo already provides + +These are the building blocks the enterprise fork will consume: + +- **Version identity** -- git tags, `NIX_ENV_VERSION`, VERSION file in + release tarballs +- **Health checks** -- `nx doctor --json` usable by any monitoring +- **Hook directories** -- org customization without forking +- **Overlay skeleton** -- `NIX_ENV_OVERLAY_DIR` for org scope/config + injection +- **Install provenance** -- `install.json` for audit trails +- **Release tarballs** -- versioned artifacts for any artifact store +- **Managed env var namespace** -- `NIX_ENV_*` reserved for future use + +## Scenarios + +### A: Small team pilot + +Catalog entry + TechDocs + static bootstrap pointing at artifact store. +Hours of work. No custom plugin, no telemetry. + +### B: Enterprise rollout + +Custom IDP surface, mandatory telemetry on company-owned devices, fleet +dashboard, staged rollouts via release labels. + +### C: No IDP + +The tool works fine standalone. Catalog + docs + scaffolder is enough for +discoverability. Everything above is optional. + +### D: Another org adopts without Backstage + +Plain `install.sh` + artifact mirror. The upstream repo must stay +IDP-agnostic; all IDP integration lives downstream. diff --git a/.claude/implementation_plan.md b/.claude/implementation_plan.md new file mode 100644 index 00000000..a712fb4d --- /dev/null +++ b/.claude/implementation_plan.md @@ -0,0 +1,128 @@ +# Implementation plan + +Remaining work for the nix-env setup tool. Organized by independence from +enterprise infrastructure. Each phase is self-contained; phases can be +done in any order but are listed by priority. + +## Completed + +All standalone tool improvements are implemented and tested. + +| Area | What was done | +| ---------------- | ----------------------------------------------------------------------- | +| Version identity | CHANGELOG.md, `NIX_ENV_VERSION`/`NIX_ENV_SCOPES` exports, VERSION file | +| Upgrade control | `--upgrade` flag, `should_update_flake()`, `nx upgrade` | +| Provenance | `install.json` EXIT trap across all entry points, `devenv`/`nx version` | +| Diagnostics | `nx doctor` (8 checks, `--json`), `# bins:` as binary source of truth | +| Hook system | `pre-setup.d/` and `post-setup.d/` hook directories in `setup.sh` | +| Overlay system | Overlay directory discovery, scope copy with `local_` prefix | +| Overlay CLI | `nx overlay list`, `nx overlay status`, `nx scope add` | +| Shell profiles | Managed block pattern, `nx profile doctor/migrate/uninstall` | +| CI | Linux (daemon + no-daemon matrix), macOS (Determinate installer) | +| Testing | bats (5 test files, 60+ tests), Pester, pre-commit hooks | +| Validation | `validate_scopes.py` (scopes + bins), `check_bash32.py` (bash 3.2 lint) | +| Docs | ARCHITECTURE.md, SUPPORT.md, CHANGELOG.md | + +--- + +## Phase 1: Distribution + +**Goal:** Versioned release artifacts so the tool can be consumed without +`git clone`. Makes the tool shareable, forkable, and artifact-store-ready. + +### 1a. Release tarball builder + +`scripts/build_release.sh` stamps a `VERSION` file from the git tag into +a minimal archive. Only runtime files are included (nix/, .assets/lib/, +.assets/config/, setup_common.sh, install_copilot.sh). Excludes tests, +Docker, Vagrant, legacy scripts, docs. Generates `CHECKSUMS.sha256`. + +```bash +scripts/build_release.sh # version from git tag +VERSION=1.0.0 scripts/build_release.sh # explicit override +``` + +**Files:** + +- `scripts/build_release.sh` (new) +- `Makefile` (add `release` target) +- `.gitignore` (add `lss-v*.tar.gz`) + +### 1b. Release CI workflow + +`.github/workflows/release.yml` triggered on `v*` tags: + +1. Run test matrix (Linux + macOS). +2. Build tarball via `build_release.sh`. +3. Create GitHub Release with attached artifact + checksums. +4. (Optional, env-gated) Sign with `minisign` if `MINISIGN_KEY` secret + is configured. Off by default upstream; forks enable it. + +**Files:** + +- `.github/workflows/release.yml` (new) + +**Effort:** ~1.5 days + +--- + +## Phase 2: Documentation + +**Goal:** User-facing docs for day-1 onboarding and ops reference. + +### 2a. Documentation structure + +- `docs/user/` - quickstart ("I'm a new hire on macOS, what do I run?"), + scope catalog, troubleshooting guide. +- `docs/ops/` - overlay authoring guide, air-gapped install, corporate + proxy setup (move existing `docs/corporate_proxy.md` here). +- `docs/contrib/` - architecture reference, testing guide, release + process (complement to ARCHITECTURE.md). + +### 2b. README update + +Restructure README to lead with the Nix path. Point to `docs/` for +details. De-emphasize legacy Linux scripts. + +**Files:** + +- `docs/` directory (new) +- `README.md` (update) + +**Effort:** ~1 day + +--- + +## Phase 3: Enterprise integration (reference only) + +**Not implemented in this repo.** This repo stays generic and +IDP-agnostic. Enterprise-specific integration (IDP catalog, signed +overlay fetch, telemetry, policy enforcement) is deferred to a company +organization fork after legacy path cleanup. + +See `enterprise_notes.md` for the full design and the decision checklist +for the platform/IDP team. + +**Building blocks already in place** (no enterprise code, but the seams +exist): + +| Building block | Enterprise use case | +| ------------------ | -------------------------------------------- | +| Hook directories | Org hooks without forking | +| Overlay skeleton | `NIX_ENV_OVERLAY_DIR` for org customization | +| `nx doctor --json` | Machine-readable health for fleet monitoring | +| Install provenance | `install.json` for audit / compliance | +| Version identity | `NIX_ENV_VERSION` for fleet version tracking | +| Release tarball | Distributable artifact for artifact stores | +| Managed env vars | `NIX_ENV_*` namespace reserved | + +--- + +## Effort summary + +| Phase | What | Effort | Status | +| ----- | ----------------- | ------ | --------- | +| -- | Standalone (w1-4) | 4.5 d | DONE | +| 1 | Distribution | 1.5 d | -- | +| 2 | Documentation | 1 d | -- | +| 3 | Enterprise / IDP | TBD | reference | diff --git a/.claude/nix_path_review.md b/.claude/nix_path_review.md new file mode 100644 index 00000000..d9ee22a0 --- /dev/null +++ b/.claude/nix_path_review.md @@ -0,0 +1,196 @@ +# Nix Path Architectural Review + +**Author:** Review of the Nix setup path for cross-platform (WSL, macOS, Coder) machine provisioning. +**Date:** April 2026 +**Scope:** `nix/setup.sh`, flake architecture, scope system, and integration strategy for enterprise distribution. + +--- + +## Executive Summary + +**Verdict: Strong engineering. Enterprise distribution layer is the remaining gap.** + +Code quality is **9/10** - above-average for ops tooling with proper error handling, test coverage, explicit design trade-offs in `ARCHITECTURE.md`, deliberate multi-language schema design (`scopes.json` consumed natively by bash/PowerShell/Python), and a slim phase-based orchestrator (`nix/setup.sh` ~120 lines sourcing `nix/lib/phases/`) that isolates side effects behind testable `_io_*` stubs. Ceiling is ~9.5/10 after adding an end-to-end integration test spanning all three platforms. + +Enterprise fit is **7/10**. Coder is validated (no-daemon CI matrix), macOS is validated (Determinate installer workflow), `--unattended` mode and configurable TLS probe URL (`NIX_ENV_TLS_PROBE_URL`) are in place. The remaining in-repo gap is defaulting to a pinned nixpkgs revision; downstream items (MDM integration, telemetry contract, fleet IDP) are deferred to the enterprise fork by design. Ceiling is ~8/10 for the base repo; 9-10 lives in the enterprise fork. + +--- + +## Strengths + +### Architecture & Documentation + +- **`ARCHITECTURE.md` is exceptional.** File ownership is classified by runtime constraint (bash 3.2, bash 4+, nix-only), call tree is documented, runtime paths cataloged, design trade-offs with rejected alternatives (`pip-system-certs` vs `fixcertpy`, `NODE_OPTIONS` vs `NODE_EXTRA_CA_CERTS`). This is rare-most internal tooling lacks this rigor. The codebase is maintainable by someone other than the author. +- **Layering is clean.** Orchestration (`setup.sh`) → declarative package definition (`flake.nix`) → scope lists (`.nix` files) → tool-specific setup (`configure/*.sh`). Adding a new tool is low friction: one `.nix` file + optional `configure/tool.sh`. +- **Idempotent managed-block pattern** (`profile_block.sh`) is the correct solution vs. `grep -q && echo >>` append-spam. `uninstall.sh` actually works because configuration is regenerated, not deleted. +- **JSON as shared schema across three language runtimes.** `scopes.json` is consumed natively by bash (via `jq`), PowerShell (`ConvertFrom-Json`), and Python (stdlib `json`). This is the correct choice for the multi-language reality: a `.nix` source of truth would force PS and Python consumers to shell out to Nix or re-implement attrset parsing. The bootstrap cost of installing `jq` before the first scope resolution is bounded to ~13 lines in `base_init.nix` and documented in `ARCHITECTURE.md`. + +### Operations & Observability + +- **Provenance via EXIT trap** (`install_record.sh`) writes structured `install.json` with entry point, scopes, phase, and status. Enables fleet debugging that most internal tools lack. +- **Health surface with classification** (`nx doctor`): 8 checks with FAIL/WARN distinction. `# bins:` comments are the single source of truth-validated by pre-commit hook `validate_scopes.py`. Proper observability design. +- **Extension points are well-placed**: `$NIX_ENV_OVERLAY_DIR` for org customization, `pre-setup.d/` and `post-setup.d/` hooks for tool-specific setup, `pinned_rev` for fleet cohort pinning. The `enterprise_notes.md` roadmap is realistic and doesn't over-claim. + +### Quality & Discipline + +- **Bash 3.2 constraint enforced via pre-commit** (`check_bash32.py`). Most projects claim "portable bash" and drift silently. This one actually stays true. +- **Test coverage exists** (13 bats test files, 9 Pester suites). Uncommon for ops tooling. CI validates daemon + no-daemon on Linux, Determinate installer on macOS. +- **Coder compatibility via the `no-daemon` CI matrix** (`.github/workflows/test_linux.yml`). The rootless single-user Nix install is exactly the Coder/devcontainer scenario (no systemd, no root in the runtime image), so passing that job is the compatibility guarantee. No dedicated Coder runner needed. +- **Slim phase-based orchestrator.** `nix/setup.sh` (~120 lines) reads as a phase-by-phase narrative; each phase lives in `nix/lib/phases/` and is independently sourceable by bats. Side-effecting operations (`nix`, `curl`, external scripts) are routed through `nix/lib/io.sh` wrappers (`_io_nix`, `_io_nix_eval`, `_io_curl_probe`, `_io_run`) that tests override by function redefinition - no PATH tricks. +- **Explicit design rationale**: `set -eo pipefail` without `-u` (bash 3.2 array handling), dual prompt engines (oh-my-posh for latency-insensitive macOS/WSL, starship for Coder), dual Python managers (conda for binary packages, uv for venv workflows), corporate proxy MITM detection with tool-specific env vars. + +### Enterprise Readiness + +- **MITM proxy handling** (`phase_nix_profile_mitm_probe`, `docs/corporate_proxy.md`) shows real corporate network experience. Intercepts certificates, sets `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `UV_SYSTEM_CERTS`, and VS Code Server `~/.vscode-server/server-env-setup`. Probe URL configurable via `NIX_ENV_TLS_PROBE_URL`; default rationale documented in `ARCHITECTURE.md`. +- **`--unattended` mode** is a first-class flag. MDM/Ansible/Terraform rollouts work without TTY (`phase_configure_gh`/`phase_configure_git` receive the flag and skip interactive paths). +- **Version identity** (`NIX_ENV_VERSION` from git tags → VERSION file in tarballs → `devenv` function) enables fleet tracking. +- **Structured planning** (`implementation_plan.md`) breaks remaining work (Distribution + Docs) into sized phases. Phase 3 (Enterprise/IDP) defers to downstream fork without over-engineering the base. + +--- + +## Weaknesses + +### Enterprise Distribution + +- **`nixpkgs-unstable` by default.** The rationale in `ARCHITECTURE.md` is honest ("target audience values current tooling") but conflicts with "aimed for big company." Enterprise needs SBOMs, reproducibility, supply-chain attestation, staged upgrade cadences. The `pinned_rev` opt-in per-user is not a fleet-level control. **Recommendation:** Default to a pinned revision shipped in the repo; make `unstable` opt-in via `NIX_ENV_UNPIN=1`. Flip the current default. +- **No fleet-level pinning enforcement.** Each user can pin independently (`pinned_rev`), but there's no org-tier pinning. Enterprise rollouts need a "all machines on nixpkgs revision X until signed by IT" contract. +- **No fleet telemetry scaffold.** `install.json` is per-machine. For corporate scale, document a standard `post-setup.d/99-report.sh` pattern that POSTs to an internal endpoint. Don't build telemetry, but make the contract explicit. + +### Operations + +No open items. Previous concerns (flake-update failure on first run, env-dir mutation) resolved - see "Resolved since initial review." + +### Coverage & Clarity + +- **Platform matrix (WSL/macOS/Coder) + Language matrix (bash/PowerShell/Nix) + Constraint matrix (bash 3.2/4+/5+) is high cognitive load.** `ARCHITECTURE.md` mitigates this, but the matrix itself is the real risk. Simplifying would help (e.g., "bash 5+ everywhere except macOS system sh," "PowerShell only on WSL host"). +- **Legacy path still shipped.** Coexistence increases surface. The `entry_point` taxonomy (`legacy/nix`, `wsl/legacy`) signals it will live longer than "until migrated." Every month it lives doubles the test matrix and maintenance burden. + +### Testing + +- **No "happy path" integration test spanning all 3 platforms.** bats unit tests validate functions, but the trap/phase/profile-block interactions are integration-level. CI should run `nix/setup.sh --all` end-to-end on macOS-latest, ubuntu-latest, and one WSL image. +- **PowerShell tests (9 Pester suites) but unclear coverage of `profiles.ps1` + profile block injection.** Does it test the `Update-ProfileRegion` idempotence across re-runs? + +--- + +## Risks for Enterprise Distribution + +### Risk 1: Nix Adoption (Highest Impact) + +**If Nix is not approvable in your organization, this entire path is moot.** This is not a code risk; it's a strategic risk. The determinate-systems installer URL alone may be blocked by firewalls. Security teams may reject Nix for supply-chain concerns (Nix builds are hermetic but not auditable at the package level; nixpkgs has 80k+ packages with minimal CNA coverage). + +**Mitigation:** Validate Nix approval with InfoSec/Platform first. If rejected, pivot to `mise`, `devbox`, or static tarballs before further investment. + +### Risk 2: macOS Security & MDM + +Nix on managed macOS (Jamf/Kandji) requires special handling: SIP (System Integrity Protection) restrictions, Gatekeeper notarization, daemon vs. single-user install trade-offs. Not shown in this repo; every org I've seen needed custom PKG wrapping. + +**Mitigation:** Early PoC on your MDM. Contact determinate-systems for MDM guidance. + +### Risk 3: Upstream Drift + +`nixpkgs-unstable` + post-install scripts that call `gh auth`, `git config`, `az login`, etc. Any upstream CLI option change breaks you. No pinning on tool behavior. + +**Mitigation:** Vendor a snapshot of critical tool configs into the overlay. Pre-commit hooks to validate gh/git/az --help output doesn't change in ways we depend on. + +### Risk 4: WSL Distro Diversity + +Supporting Fedora/Debian/Ubuntu/Arch/OpenSUSE/Alpine is generous but pays a maintenance tax for optionality most corporate WSL fleets won't use. WSL community officially supports Ubuntu and Debian. + +**Mitigation:** Pick one primary distro for corporate rollout. Keep code generality but test only 1-2 distros in CI. + +### Risk 5: No Rollback Story + +`nx rollback` only rolls back nix profiles. Configuration changes (profile blocks, certs, VS Code server env) aren't snapshotted. A failed setup run can leave the shell in an inconsistent state. + +**Mitigation:** Document the recovery procedure (which phase failed, restore from install.json, re-run). Consider versioning the profile block format. + +### Risk 6: Docs Gap + +`customization.md` exists but doesn't answer "how do I distribute a pre-configured overlay via our MDM?" Developers deploying to 500 machines need an answer to that. + +**Mitigation:** Prioritize `docs/ops/` tier in Phase 2. Include MDM examples (Jamf, Kandji, Intune) even if they're off-repo. + +--- + +## Specific Recommendations + +### Must-do (blocks enterprise use) + +1. **Validate Nix assumption.** Confirm with InfoSec/Platform that Nix is acceptable and `install.determinate.systems` is reachable. Nothing else matters if this fails. +2. **Default to pinned nixpkgs.** Add `nix/default_rev` (or bake the SHA into `flake.nix`). Make unstable opt-in via flag. Flip the current default. + +### Should-do (unblocks enterprise patterns) + +1. **Add fleet telemetry scaffold.** Document `post-setup.d/99-report.sh` pattern. Don't implement, but standardize the contract. +2. **End-to-end integration test.** CI should run `nix/setup.sh --all` across macOS, Linux, and WSL. + +### Nice-to-have (quality) + +1. **Simplify the platform/language matrix.** Document which combinations are actually tested: macOS via Determinate installer, Linux daemon mode, Linux no-daemon mode (which covers Coder / rootless containers). PowerShell only on WSL host. Kill unnecessary branches. +2. **Migrate legacy path to separate repo or tag it for deletion.** Every month it lives doubles maintenance. + +--- + +## What's Already Right + +Do not change these: + +- **Managed-block pattern.** It's correct. Don't revert to append-style. +- **Explicit EXIT trap for provenance.** This is good observability. +- **Overlay skeleton + hook directories.** These are the right extension points. +- **Platform-specific prompt engines.** Both oh-my-posh and starship serve real use cases. +- **`nx doctor` health checks.** The FAIL/WARN classification and `# bins:` as source of truth are solid. +- **Bash 3.2 discipline with pre-commit enforcement.** Rare and valuable. + +--- + +## Resolved since initial review + +Items from the first-pass review that have since been addressed in the repo: + +- **`setup.sh` phase extraction.** Orchestrator is now ~120 lines sourcing `nix/lib/phases/{bootstrap,platform,scopes,nix_profile,configure,profiles,post_install,summary}.sh`, with `nix/lib/io.sh` providing stubbable side-effect wrappers. Pattern documented in `ARCHITECTURE.md`. +- **`--unattended` flag.** Replaces the earlier `--skip-gh-auth` / `--skip-gh-ssh-key` / `--skip-git-config` trio with a single discoverable flag suitable for MDM/Ansible/Terraform. +- **Configurable TLS probe URL.** `NIX_ENV_TLS_PROBE_URL` (default `https://www.google.com`, rationale in `ARCHITECTURE.md`). Override to use an internal endpoint on air-gapped networks. +- **WSL-from-Windows CI gap.** Documented as a scope boundary in `ARCHITECTURE.md`: all Nix-path components are covered by the `test_linux.yml` matrix; the orchestrator layer (`wsl_setup.ps1`) remains uncovered by design. +- **Silent flake-update on first run.** Removed the redundant `nix flake update` from first run - `nix profile add` creates `flake.lock` implicitly. `should_update_flake` now only triggers on `--upgrade`. +- **Bootstrapper model documented.** `~/.config/nix-env/` as self-contained durable state is now an explicit design decision in `ARCHITECTURE.md`, not an undocumented side effect. Repo clone is optional after first run. + +--- + +## Implementation Roadmap + +Based on `implementation_plan.md`, the path to enterprise readiness is: + +| Phase | Work | Effort | Blocker? | +| -------- | ------------------------------------------------------------ | ------ | -------------------------------- | +| Pre-work | Validate Nix approval | 0.5 d | YES | +| 1 | Release tarball + CI | 1.5 d | No (but needed for distribution) | +| 2 | User/ops docs + README | 1 d | No (helps adoption) | +| 3 | Enterprise fork work (IDP, overlay fetch, telemetry, policy) | TBD | No (deferred to downstream) | +| Gaps | Pinned nixpkgs default, telemetry scaffold, e2e test | ~1.5 d | No (but improves enterprise UX) | + +**Recommended order:** + +1. Nix approval validation (decision-gate) +2. Gaps above (enterprise UX improvements, ~1.5 days) +3. Phase 1 (distribution, ~1.5 days) +4. Phase 2 (docs, ~1 day) +5. Enterprise fork starts (reference implementation in `enterprise_notes.md`) + +Total: ~4.5 days of work if Nix is approved. + +--- + +## Bottom Line + +**The engineering is good. The architecture is thoughtful. The documentation is exemplary.** + +The success of this tool is **not** determined by code quality-it's determined by whether your organization approves Nix and invests in the enterprise integration layer (fleet pinning, overlay distribution, telemetry, unattended deployment). The base repo doesn't need major refactoring, but it does need: + +- **Decision gates** (Nix approval, MDM PoC) +- **Enterprise defaults** (pinned nixpkgs, unattended mode, fleet telemetry) +- **Distribution infrastructure** (Phase 1-2, then downstream fork) + +If Nix is approved: **Invest in the gaps above, build Phase 1-2, then fork.** This is worth the effort. + +If Nix is not approved: **Evaluate `mise`, `devbox`, or static tarball approaches instead.** No refactor to this code saves you if the foundational assumption fails. diff --git a/.claude/nix_path_review_old.md b/.claude/nix_path_review_old.md new file mode 100644 index 00000000..a64967a9 --- /dev/null +++ b/.claude/nix_path_review_old.md @@ -0,0 +1,161 @@ +# Nix-path solution review + +Cross-platform dev environment setup (WSL, macOS, Coder) - brutally honest evaluation. + +## TL;DR verdict + +**Solid personal/team tool, enterprise-ready for standalone use.** Architecture is sound and above average for shell-based dotfiles automation. All critical weaknesses resolved: managed block profile injection, explicit upgrade semantics, hidden sudo removed, bash 3.2 lint enforcement, error handling, macOS + Linux CI, config overlay system, `nx doctor` diagnostics. Remaining gaps: release distribution (tarball + CI), user-facing documentation, external dep pinning. ~92/100. + +--- + +## Strengths (genuinely good) + +1. **Right core choice.** Nix + a generated `flake.nix` + `buildEnv` per scope is the correct backbone for cross-platform (macOS/Linux/WSL/Coder) reproducibility. Most "company setup scripts" reach for Homebrew + apt + chocolatey wrappers; you skipped that swamp. +2. **Clean separation of durable state vs. transient repo** (`~/.config/nix-env/` survives repo deletion). This is the single best architectural decision in the repo. +3. **Scope abstraction with JSON-defined deps + install order** (`scopes.json` + `scopes.sh`) is a nice declarative kernel inside an otherwise imperative codebase. Bash 3.2 / BSD compat discipline is documented and enforced - genuinely rare. +4. **Deliberate `set -eo pipefail` (no `-u`) in nix-path scripts.** Bash 3.2 treats empty arrays as unset under `set -u`, causing spurious failures. ShellCheck catches uninitialized variables at lint time - a stronger guard. Documented as a design decision in `ARCHITECTURE.md`. +5. **MITM/corporate proxy story is unusually thoughtful.** `cert_intercept`, `fixcertpy`, the `NODE_EXTRA_CA_CERTS` / `REQUESTS_CA_BUNDLE` / `CLOUDSDK_*` matrix, auto-detection on connection failure, Linux symlink vs. macOS merged-bundle - this is the part most enterprise scripts get badly wrong, and you got it right. +6. **No-root by default** for the Nix path. Critical for managed corporate machines and Coder workspaces. +7. **`nx` CLI** (apt-like wrapper around `nix profile` + `packages.nix`) is a real productivity win and a smart way to give users an escape hatch without breaking the declarative model. +8. **Tests exist** (bats + Pester + Docker smoke for both legacy and nix paths). Above-average for this category of repo. +9. **Idempotent, additive scope merging** with explicit `--remove`. Most setup scripts are write-once disasters. + +--- + +## Weaknesses (the brutal part) + +### 1. ~~The bash configurators are a footgun at scale~~ - RESOLVED + +Managed block pattern implemented. See `ARCHITECTURE.md` "Managed block pattern". +Files rewritten: `profiles.sh`, `profiles.zsh`, `profiles.ps1`. Library: +`.assets/lib/profile_block.sh`. CLI: `nx profile doctor/migrate/uninstall`. +Tests: `test_profile_block.bats` (23), `test_profile_migration.bats` (14). + +### 2. Three competing prompts and two competing python managers in the same scope graph + +`oh_my_posh` vs `starship`, `conda` vs `uv`, optional pwsh vs bash vs zsh, plus `aliases_kubectl.sh` is 52 KB. This is a personal dotfiles taste tree, not a company baseline. For "big company distributable" you need: + +- A **base profile** that's enforced and minimal. +- Optional **personality scopes** that users can opt into. +- Right now base + opinions are tangled. + +### 3. Personal-repo coupling + +`setup_common.sh` clones `szymonos/ps-modules` from GitHub at install time. For a company tool this is unacceptable: pinning, auditing, mirroring, and offline installs all break. Same for the live `https://search.nixos.org/backend/...` call hard-coded in `nx search` (will break the day they version-bump the index). + +### 4. ~~Bash 3.2 + BSD constraint applied unevenly~~ - RESOLVED + +Pre-commit hook `tests/hooks/check_bash32.py` now statically enforces bash 3.2 / +BSD sed rules on all nix-path files **and bats tests** (which also run on macOS CI). +Includes BSD sed grouped-command rule (`sed { cmd }` on one line). All `.ps1` shebangs +fixed to `#!/usr/bin/env pwsh` for macOS portability; bash callers use `pwsh -nop` +explicitly. Wired in `.pre-commit-config.yaml`. + +### 5. ~~Nix daemon bootstrapping with `sudo setsid` (setup.sh:124-125)~~ - RESOLVED + +Removed entirely. The script now fails with a diagnostic message if `nix store info` +is unreachable. WSL has systemd, macOS uses Determinate installer (launchd), Coder +uses `--no-daemon`. No scenario requires runtime daemon start from the setup script. + +### 6. ~~Error handling is inconsistent~~ - PARTIALLY RESOLVED + +`|| true` instances audited. Four problematic cases (silent `nix flake update` failure, +`fixcertpy` failure, `conda init` failure) replaced with `|| warn "..."` to surface errors +without killing the script. Remaining `|| true` instances are legitimate `set -e` guards +(e.g., `[ -x ] && alias || true`, `grep -c` returning 1 on no match). +Installation provenance (`install.json`) now captures failure state, phase, and error via +EXIT trap across all entry points. Full `nx doctor` still TODO. + +### 7. ~~No explicit upgrade control or rollback path~~ - RESOLVED + +`setup.sh` no longer runs `nix flake update` implicitly. New `--upgrade` flag and +`should_update_flake()` function gate updates on first run or explicit request. +`nx upgrade` available as shell alias. Per-user `flake.lock` in `$ENV_DIR`. +Tested in `test_nix_setup.bats` (4 tests). Documented in `ARCHITECTURE.md` +under "Design decisions". + +### 8. Distribution model is unclear + +How does Acme Corp consume this? Fork the repo? Vendor it? `curl | bash` an installer? Right now the answer is "git clone and run `nix/setup.sh`". For an enterprise tool you need: + +- A versioned release artifact (tag + explicit upgrade semantics). +- A one-line bootstrap (`curl ... | sh` that clones a pinned tag). +- An override mechanism for org-specific scopes/configs without forking. + +### 9. ~~Config layering missing~~ - RESOLVED + +Overlay system implemented: `NIX_ENV_OVERLAY_DIR` or `~/.config/nix-env/local/` +for scope and shell config overlays. Overlay scopes copied with `local_` prefix +to avoid collisions. Hook directories (`pre-setup.d/`, `post-setup.d/`) for +customization without forking. CLI: `nx overlay list`, `nx overlay status`, +`nx scope add`. 18 bats tests. Full org-tier signing/fetch deferred to +enterprise fork. + +### 10. ~~Tests don't actually test setup~~ - RESOLVED + +**macOS CI** (`test_macos.yml`) passing green. Triggers: `workflow_dispatch`, PR label +`test:macos`, push to labeled PRs (`synchronize`). E2E: Determinate Nix installer, +`setup.sh --shell --python`, core + scope binary verification, managed block check, +idempotency (second run), bats tests, install provenance, uninstaller (`--env-only`). + +**Linux CI** (`test_linux.yml`) passing green. Matrix: `ubuntu-latest` (Determinate +daemon) + `ubuntu-slim` (upstream `--no-daemon`). Same E2E pipeline as macOS. +Both workflows have `concurrency` with `cancel-in-progress: true`. Copilot CLI +install skipped in CI (`$CI` env var check) to avoid GitHub API rate limits. + +### 11. Documentation is internal-developer-oriented + +`ARCHITECTURE.md` and `AGENTS.md` are excellent for contributors. There is **no user-facing "Day 1" doc**: "I'm a new hire on macOS, what do I run?" The README still describes the legacy Linux scripts. + +--- + +## Risks for the "big company" use case + +| Risk | Severity | Notes | +| ------------------------------------------------ | ---------- | -------------------------------------------------- | +| ~~Implicit `flake update` on every run~~ | ~~Medium~~ | RESOLVED - explicit `--upgrade` flag | +| ~~Hidden `sudo` in no-root script~~ | ~~High~~ | RESOLVED - removed, fail with diagnostic | +| External GitHub fetches mid-install | High | Air-gapped/proxied envs, supply chain | +| ~~No macOS CI~~ | ~~High~~ | RESOLVED - `test_macos.yml` passing green | +| ~~No Linux CI~~ | ~~High~~ | RESOLVED - `test_linux.yml` passing green (matrix) | +| ~~Profile injection cannot be cleanly reverted~~ | ~~Medium~~ | RESOLVED - managed block + `nx profile uninstall` | +| ~~No org-level config overlay~~ | ~~Medium~~ | RESOLVED - overlay skeleton + CLI + hooks | +| ~~`nx search` hits hard-coded NixOS API URL~~ | ~~Low~~ | RESOLVED - uses `nix search nixpkgs` (offline) | +| 52 KB kubectl aliases | Low | Cosmetic, but signals "personal dotfiles" | + +--- + +## Recommendations (prioritized) + +**Must-do before calling it "company-grade":** + +1. ~~**Explicit upgrade semantics.**~~ DONE - `--upgrade` flag, `should_update_flake()`, `nx upgrade`. +2. ~~**Replace shell-rc append-pattern with a single managed block.**~~ DONE - managed block pattern. +3. ~~**Eliminate hidden `sudo`.**~~ DONE - removed, fail with diagnostic. +4. ~~**macOS CI**~~ DONE - `test_macos.yml` passing green. Triggers on `workflow_dispatch`, PR label `test:macos`, and push to labeled PRs. Concurrency cancels stale runs. E2E: nix install, setup.sh, binary verification, managed block check, idempotency, bats tests. +5. **Vendor or pin** `ps-modules` and any other live git fetches; provide an offline mode. +6. ~~**Add a config overlay mechanism.**~~ DONE - overlay skeleton (`NIX_ENV_OVERLAY_DIR` / `local/`), scope copy with `local_` prefix, hook directories, `nx overlay list/status`, `nx scope add`. 18 bats tests. +7. ~~**Add a `nx doctor` / `verify` command.**~~ DONE - 8 health checks, `--json` output, `# bins:` as binary source of truth, 13 bats tests. +8. **Separate "base" from "opinions"**: kubectl aliases / oh-my-posh / zsh plugins move to optional scopes; default install is minimal. +9. **Document a distribution model**: tagged releases + a 1-line bootstrap. Decide whether downstream orgs fork or overlay. + +**Should-do:** + +1. ~~Static enforcement of bash-3.2 rules.~~ DONE - `check-bash32` pre-commit hook. +2. ~~Replace marker-grep idempotency with deterministic state files.~~ DONE - managed block. +3. Reduce surface: drop unused legacy scripts from contributor cognitive load now, don't wait for "after migration." +4. ~~Telemetry-free, but **structured logs** (JSON option).~~ DONE - `install.json` provenance + `nx doctor --json`. + +**Nice-to-have:** + +Replace bash configurators with `home-manager` (Nix-native) for the shell-config layer. You're 80% reinventing it. This is the **one decision worth seriously reconsidering**: home-manager would eliminate weaknesses #1 and #6 partially, and give you proper rollback / generations. Cost: zsh/bash dotfile users see a Nix-store path in their `~/.bashrc` source line, which is fine. + +--- + +## Is it the wrong tool? + +**No.** Nix is the right tool for the cross-platform package layer. Bash is the right tool for the bootstrap (you can't assume anything else exists). The mismatch is in the **middle layer** (shell-rc / profile configuration) where you're hand-rolling what `home-manager`, `chezmoi`, or even a templated systemd-style drop-in dir would do better. That's a refactor, not a rewrite. + +## One-line summary + +Architecturally above average, operationally enterprise-ready for standalone use; all critical issues resolved (profile injection, upgrade control, hidden sudo, bash 3.2 lint, error handling, CI, config overlays, diagnostics); remaining work is distribution (release tarball + CI), documentation, and external dep pinning. diff --git a/.claude/setup_phase_extraction.md b/.claude/setup_phase_extraction.md new file mode 100644 index 00000000..205959b0 --- /dev/null +++ b/.claude/setup_phase_extraction.md @@ -0,0 +1,385 @@ +# setup.sh Phase Extraction Plan + +**Target:** `nix/setup.sh` (590 lines) → slim orchestrator (~120 lines) + phase libraries. +**Primary driver:** Unit testability with `bats`. Current tests use `sed -n '/^fn()/,/^}/p'` to extract functions from `setup.sh` for testing - brittle and limits what can be tested. +**Secondary driver:** Maintainability once the script crosses ~600 lines. +**Constraint:** Bash 3.2 compatible (same as `scopes.sh`). No `declare -A`, no `mapfile`, no namerefs. + +--- + +## Goals + +1. Every phase function is sourceable in isolation by `bats` without executing `setup.sh`. +2. Side-effecting operations (`nix profile`, `cp`, `curl`) are isolated behind thin wrappers so tests can stub them. +3. The EXIT trap and `_ir_phase`/`_ir_error` provenance contract is preserved - phases remain in the same shell process. +4. No behavior change. This is a pure refactor; bats and CI matrix must pass unchanged before and after. + +## Non-goals + +- Porting to Go/Rust (separate decision). +- Removing globals entirely. Bash does not reward that; pragmatic shared state is fine if documented. +- Changing the public flag surface or exit codes. + +--- + +## Current structure (line map) + +| Lines | Block | Phase label | +| ------- | ------------------------------------------------------- | --------------------------- | +| 34-91 | Guards, path resolution, helpers, EXIT trap | `bootstrap` (pre-phase) | +| 93-130 | `usage()` | - | +| 132-278 | Arg parsing, jq bootstrap, sync to `ENV_DIR` | `bootstrap` | +| 283-316 | Platform detect, pre-setup hooks, overlay discovery | (pre-phase) | +| 318-378 | Load existing scopes, merge, remove, prompt exclusivity | `scope-resolve` | +| 380-425 | Resolve deps, sort, write `config.nix` | `scope-resolve` | +| 427-479 | Flake update logic, `nix profile upgrade`, MITM probe | `nix-profile` | +| 481-519 | `gh.sh`, `git.sh`, per-scope configure scripts | `configure` | +| 521-549 | Shell profiles, post-setup hooks, `setup_common.sh` | `profiles` + `post-install` | +| 551-589 | Mode detection, GC, summary | `complete` | + +Each phase label already matches `_ir_phase` values - the script is logically already divided. The refactor just externalizes the division. + +--- + +## Target layout + +```text +nix/ + setup.sh # orchestrator (~120 lines) + lib/ + phases/ + bootstrap.sh # nix/jq detection, ENV_DIR sync, arg parsing + platform.sh # OS detect, overlay discovery, hooks + scopes.sh # merge + resolve + write config.nix + nix_profile.sh # flake update + profile upgrade + MITM probe + configure.sh # gh/git/per-scope dispatchers + profiles.sh # bash/zsh/pwsh profile setup + post_install.sh # setup_common.sh + GC + summary.sh # mode detection + final print + io.sh # thin side-effect wrappers (run_nix, run_curl, ...) +``` + +### Why `nix/lib/phases/` (not `.assets/lib/phases/`) + +The existing `.assets/lib/` contains cross-entry-point libraries (`scopes.sh`, `profile_block.sh`, `install_record.sh`) used by both legacy and nix paths. Phase files are Nix-path-specific. Keeping them under `nix/lib/` preserves the existing separation and avoids polluting `.assets/lib/` with files only one entry point uses. + +--- + +## Shared state contract + +To keep phases sourceable + independently testable, declare the shared variables explicitly at the top of `setup.sh` and document ownership: + +| Variable | Owner | Readers | Notes | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ---------------------------------------- | --------------------------------------- | +| `SCRIPT_ROOT`, `NIX_SRC`, `CONFIGURE_DIR`, `ENV_DIR`, `CONFIG_NIX` | `setup.sh` | all phases | Constants, set once | +| `NIX_ENV_VERSION`, `NIX_ENV_PLATFORM`, `NIX_ENV_PHASE`, `NIX_ENV_SCOPES` | `setup.sh`/platform/scopes | all phases + hooks | Exported for hooks | +| `_scope_set`, `sorted_scopes` | `scopes.sh` (lib) | scopes, configure, post_install, summary | Bash 3.2 space-delimited string + array | +| `_ir_phase`, `_ir_error`, `_ir_skip` | setup.sh | all phases + EXIT trap | Phase tracking | +| `omp_theme`, `starship_theme`, `skip_gh_auth`, `skip_gh_ssh_key`, `skip_git_config`, `update_modules`, `upgrade_packages`, `quiet_summary`, `remove_scopes[]`, `any_scope` | bootstrap (arg parser) | scopes, nix_profile, configure, summary | CLI flag results | +| `PINNED_REV`, `OVERLAY_DIR` | nix_profile, platform | various | Derived state | +| `_mode`, `platform` | summary, platform | summary, trap | Mode/OS label | + +Rule: each phase function documents its globals in a header comment. Example: + +```bash +# phase_scopes_resolve +# Reads: CONFIG_NIX, any_scope, remove_scopes, omp_theme, starship_theme +# Writes: _scope_set, sorted_scopes, NIX_ENV_SCOPES, _ir_phase +# Calls: scope_add, scope_del, scope_has, resolve_scope_deps, sort_scopes +phase_scopes_resolve() { + ... +} +``` + +--- + +## Per-phase extraction + +### `lib/phases/bootstrap.sh` + +Functions: + +- `phase_bootstrap_check_root` - EUID guard. +- `phase_bootstrap_resolve_paths` - sets `SCRIPT_ROOT`, `NIX_ENV_VERSION`, `NIX_SRC`, `CONFIGURE_DIR`, `ENV_DIR`, `CONFIG_NIX`. Accepts `${BASH_SOURCE[0]}` via arg so tests can pass a fixture path. +- `phase_bootstrap_detect_nix` - sources `nix-daemon.sh` or `nix.sh`, fallback PATH add. Returns 0 if nix present, 1 otherwise (setter of `_ir_error`). +- `phase_bootstrap_verify_store` - `nix store info` probe. +- `phase_bootstrap_sync_env_dir` - `mkdir -p`, `cp` flake/scopes/nx_doctor. +- `phase_bootstrap_install_jq` - writes minimal `config.nix` with `isInit=true; scopes=[]`, runs `nix profile add` + `upgrade`. Only if `! command -v jq`. +- `phase_bootstrap_parse_args "$@"` - the big `while` loop. Sets all the flag globals. **Testable:** call with fixture args, inspect globals. + +### `lib/phases/platform.sh` + +- `phase_platform_detect` - sets `platform`, exports `NIX_ENV_PLATFORM`. +- `phase_platform_discover_overlay` - determines `OVERLAY_DIR`, copies overlay scopes. +- `phase_platform_run_hooks ` - move `_run_hooks` here (tiny but cohesive). + +### `lib/phases/scopes.sh` (orchestrator phase, not the library) + +Distinct from `.assets/lib/scopes.sh` (the shared scope-set library). This wraps the library for the Nix path. + +- `phase_scopes_load_existing` - `nix eval` on existing `config.nix`, populates `_scope_set`. Falls back to system detection when no config. **Testable:** inject `_run_nix_eval` stub. +- `phase_scopes_apply_removes` - iterates `remove_scopes[]`. +- `phase_scopes_enforce_prompt_exclusivity` - omp vs starship logic. +- `phase_scopes_resolve_and_sort` - wrapper around lib functions; exports `NIX_ENV_SCOPES`. +- `phase_scopes_write_config` - writes `config.nix` with resolved scopes + `isInit` detection. **Testable:** already has bats coverage via `generate_config_nix` helper; this just moves the logic into the script where tests can call it directly instead of duplicating it. +- `phase_scopes_detect_init` - extracts `has_system_cmd` + `is_init=true/false` logic. + +### `lib/phases/nix_profile.sh` + +- `should_update_flake ` - already a function, move as-is. bats already tests this via sed extraction; after this refactor it sources cleanly. +- `phase_nix_profile_load_pinned_rev` - reads `pinned_rev`. +- `phase_nix_profile_print_mode` - the info/warn messages before the update. +- `phase_nix_profile_update_flake` - calls `nix flake update` or `nix flake lock --override-input` based on `PINNED_REV`. +- `phase_nix_profile_apply` - `nix profile add` + `upgrade`. +- `phase_nix_profile_mitm_probe` - the `curl google.com` check + `cert_intercept` call. Extract the probe URL as `NIX_ENV_TLS_PROBE_URL` (ties into the earlier review recommendation; zero extra work if done here). + +### `lib/phases/configure.sh` + +- `phase_configure_gh ` - invokes `gh.sh`, exports `GITHUB_TOKEN`. +- `phase_configure_git ` - conditional `git.sh`. +- `phase_configure_per_scope` - the `case` dispatch loop over `sorted_scopes[]`. + +### `lib/phases/profiles.sh` + +- `phase_profiles_bash` - `profiles.sh`. +- `phase_profiles_zsh` - conditional on `command -v zsh`. +- `phase_profiles_pwsh` - conditional on `command -v pwsh`. + +### `lib/phases/post_install.sh` + +- `phase_post_install_common ` - invokes `setup_common.sh` with or without `--update-modules`. +- `phase_post_install_gc` - `wipe-history` + `store gc`. + +### `lib/phases/summary.sh` + +- `phase_summary_detect_mode` - sets `_mode` from flag state. +- `phase_summary_print` - the final coloured output + shell-specific restart hint. + +### `lib/io.sh` (side-effect wrappers) + +Thin shims that tests can override by redefining the function before sourcing the phase: + +```bash +_io_nix() { nix "$@"; } +_io_nix_eval() { nix eval --impure --raw --expr "$1"; } +_io_curl_probe() { curl -sS "$1" >/dev/null 2>&1; } +_io_run() { "$@"; } # for configure/*.sh invocations +``` + +Phases call `_io_nix profile upgrade nix-env` instead of `nix profile upgrade nix-env`. Tests define `_io_nix() { echo "nix $*" >>"$TEST_LOG"; }` before sourcing to assert the right commands are issued without executing them. + +This replaces the current bats approach (stubbing via PATH) with a cleaner in-process hook. + +--- + +## Slim `setup.sh` (target) + +```bash +#!/usr/bin/env bash +# (runnable examples block unchanged) +set -eo pipefail + +# ---- resolve paths ---- +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB_DIR="$SCRIPT_ROOT/nix/lib" + +# ---- source libraries ---- +# shellcheck source=nix/lib/io.sh +source "$LIB_DIR/io.sh" +for p in bootstrap platform scopes nix_profile configure profiles post_install summary; do + # shellcheck source=/dev/null + source "$LIB_DIR/phases/$p.sh" +done +# shellcheck source=.assets/lib/install_record.sh +source "$SCRIPT_ROOT/.assets/lib/install_record.sh" +# shellcheck source=.assets/lib/scopes.sh +source "$SCRIPT_ROOT/.assets/lib/scopes.sh" + +# ---- trap + provenance ---- +_IR_ENTRY_POINT="nix" +_IR_SCRIPT_ROOT="$SCRIPT_ROOT" +_ir_phase="bootstrap" +_ir_skip=false +trap _on_exit EXIT # _on_exit stays in setup.sh (needs all globals) + +# ---- run phases ---- +phase_bootstrap_check_root +phase_bootstrap_resolve_paths +phase_bootstrap_parse_args "$@" # handles --help by setting _ir_skip + exit +phase_bootstrap_detect_nix +phase_bootstrap_verify_store +phase_bootstrap_sync_env_dir +phase_bootstrap_install_jq + +phase_platform_detect +_ir_phase="pre-setup" +phase_platform_run_hooks "$ENV_DIR/hooks/pre-setup.d" +phase_platform_discover_overlay + +_ir_phase="scope-resolve" +phase_scopes_load_existing +phase_scopes_apply_removes +phase_scopes_enforce_prompt_exclusivity +phase_scopes_resolve_and_sort +phase_scopes_detect_init +phase_scopes_write_config + +_ir_phase="nix-profile" +phase_nix_profile_load_pinned_rev +phase_nix_profile_print_mode +phase_nix_profile_update_flake +phase_nix_profile_apply +phase_nix_profile_mitm_probe + +_ir_phase="configure" +phase_configure_gh "$skip_gh_auth" "$skip_gh_ssh_key" +phase_configure_git "$skip_git_config" +phase_configure_per_scope + +_ir_phase="profiles" +phase_profiles_bash +phase_profiles_zsh +phase_profiles_pwsh +_ir_phase="post-setup" +phase_platform_run_hooks "$ENV_DIR/hooks/post-setup.d" + +_ir_phase="post-install" +phase_post_install_common "$update_modules" "${sorted_scopes[@]}" + +_ir_phase="complete" +phase_post_install_gc +phase_summary_detect_mode +phase_summary_print +``` + +Result: ~120 lines, reads as a phase-by-phase narrative. Each line is one unit. + +--- + +## Unit testability + +### Before (current state) + +```bash +_load_should_update_flake() { + eval "$(sed -n '/^should_update_flake()/,/^}/p' "$REPO_ROOT/nix/setup.sh")" +} +``` + +This only works for self-contained functions with no dependencies. `phase_scopes_write_config`, `phase_bootstrap_parse_args`, etc. cannot be extracted this way because they reference other functions defined elsewhere in the file. + +### After + +```bash +setup() { + export REPO_ROOT="$BATS_TEST_DIRNAME/../.." + export ENV_DIR="$(mktemp -d)" + export CONFIG_NIX="$ENV_DIR/config.nix" + + # stub side effects + _io_nix() { echo "nix $*" >>"$BATS_TEST_TMPDIR/nix.log"; } + _io_nix_eval() { cat "$BATS_TEST_TMPDIR/nix_eval_fixture"; } + + # shellcheck source=../../nix/lib/io.sh + source "$REPO_ROOT/nix/lib/io.sh" + source "$REPO_ROOT/.assets/lib/scopes.sh" + source "$REPO_ROOT/nix/lib/phases/scopes.sh" +} + +@test "phase_scopes_write_config: produces isInit=true when jq not system-installed" { + _scope_set=" shell " + sorted_scopes=(shell) + # fake: no system jq + has_system_cmd() { return 1; } + phase_scopes_detect_init + phase_scopes_write_config + grep -q 'isInit = true' "$CONFIG_NIX" +} + +@test "phase_nix_profile_apply: runs profile add + upgrade" { + phase_nix_profile_apply + grep -q 'nix profile add path:' "$BATS_TEST_TMPDIR/nix.log" + grep -q 'nix profile upgrade nix-env' "$BATS_TEST_TMPDIR/nix.log" +} +``` + +### New tests unlocked + +| Test | Not possible today | Possible after | +| ---------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------ | +| Arg parser handles `--remove a b c` without swallowing next flag | No (requires full setup.sh run) | Yes (call `phase_bootstrap_parse_args --remove a b c --pwsh`, inspect globals) | +| `phase_scopes_load_existing` falls back to system detection when `nix eval` fails | No | Yes (stub `_io_nix_eval` to return error) | +| `phase_nix_profile_update_flake` uses `--override-input` when `pinned_rev` present | No | Yes (create `pinned_rev` file, assert log) | +| `phase_nix_profile_mitm_probe` skips when probe succeeds | No | Yes (stub `_io_curl_probe` to return 0) | +| `phase_summary_print` emits correct restart hint per shell | No | Yes (stub `$PPID`/`ps`, inspect stdout) | +| `phase_configure_per_scope` invokes `omp.sh` only when `oh_my_posh` in scopes | No | Yes (stub `_io_run`, inspect log) | +| `phase_bootstrap_install_jq` no-ops when `jq` present | No | Yes | + +### Tests preserved + +All existing `test_nix_setup.bats` tests keep working. The `_load_should_update_flake` helper becomes a trivial `source "$REPO_ROOT/nix/lib/phases/nix_profile.sh"`. + +--- + +## Migration steps (in order, each a separate commit) + +1. **Create `nix/lib/io.sh`** with the side-effect wrappers. No callers yet. (zero risk) +2. **Create `nix/lib/phases/` directory** with empty stub files. Wire them into `setup.sh` via `source` but keep all logic inline for now. Verify CI passes. (zero risk) +3. **Extract `should_update_flake` + `has_system_cmd`** into `nix_profile.sh` / `scopes.sh`. Update `test_nix_setup.bats` to source the file instead of `sed`-extracting. Verify. (low risk - already tested) +4. **Extract `phase_bootstrap_parse_args`**. Add bats tests for every flag. High value: arg parsing has zero tests today. (medium risk, highest test payoff) +5. **Extract `phase_scopes_*` group.** The `generate_config_nix` helper in bats becomes a direct call to `phase_scopes_write_config`. (medium risk) +6. **Extract `phase_nix_profile_*` group.** Introduce `_io_nix`/`_io_nix_eval`/`_io_curl_probe` calls. This is the phase with the most side effects; test via stubbed io.sh. (medium risk) +7. **Extract `phase_configure_*`, `phase_profiles_*`, `phase_post_install_*`, `phase_summary_*`.** Smaller cohesive chunks; bulk of line-count reduction. (low risk per step) +8. **Move `phase_platform_*` and the pre/post-hook runners.** (low risk) +9. **Final pass:** verify `setup.sh` is ~120 lines, run full CI matrix (linux daemon, linux no-daemon, macOS, WSL where applicable), confirm `install.json` output unchanged on a real run. +10. **Update `ARCHITECTURE.md`**: add phase library to runtime layout section, document the `_io_*` stub convention for tests. + +Each step is individually revertable. No step requires simultaneous changes to `setup.sh` + tests + CI. + +--- + +## Risks & mitigations + +| Risk | Mitigation | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| EXIT trap loses variables when phases `return` non-zero | Keep `set -eo pipefail` (no `-u`), keep trap in `setup.sh`. Phases that expect failure must explicitly ` | +| Sourcing order matters (later phases depend on earlier globals) | Document in header comment of each phase file. Add a `check_deps` hook that asserts required globals are set (can be noop in production, validated in test). | +| Bash 3.2: `local -n` namerefs unavailable for passing arrays | Phases that need arrays read `sorted_scopes` global directly (already the pattern). Document as part of the state contract. | +| Cross-platform `source` path differences | Use `${BASH_SOURCE[0]}` resolution consistently. `scopes.sh` already does this. | +| Hidden dependency on function ordering in `setup.sh` | Run full bats suite after each migration step. CI already covers daemon/no-daemon/macOS. | +| Increased file count hurts discoverability | `ARCHITECTURE.md` runtime layout table lists every phase file with purpose. Grep for `phase_` finds all entry points. | + +--- + +## What this does NOT do + +- Does not change behavior, flags, or exit codes. +- Does not add `--unattended` mode. Separate work item from the review's Must-do list. +- Does not pin nixpkgs by default. Separate work item. +- Does not add fleet telemetry. Separate work item. +- Does not port to Go/Rust. If the answer becomes "rewrite," the phase boundaries drawn here become the module boundaries there. Either way, this refactor is not wasted. + +--- + +## Effort estimate + +| Step | Effort | +| ------------------------------------------- | --------------------- | +| 1-2 (scaffolding) | 0.5 h | +| 3 (nix_profile extraction + test cleanup) | 1 h | +| 4 (arg parser + new tests) | 3 h | +| 5 (scopes phase) | 2 h | +| 6 (nix_profile phase + io stubs) | 2 h | +| 7 (configure/profiles/post_install/summary) | 2 h | +| 8 (platform + hooks) | 1 h | +| 9 (CI validation, fix fallout) | 1.5 h | +| 10 (ARCHITECTURE.md update) | 0.5 h | +| **Total** | **~13.5 h (~2 days)** | + +Net new test surface: ~20-30 bats tests covering paths that today are completely untested (arg parser, config loading with corrupt config.nix, MITM probe behavior, per-scope dispatch correctness). + +--- + +## Recommendation + +Execute this before the `--unattended` and pinned-nixpkgs work items. Reason: those items will add flag-handling and state branches. Refactoring first means those features land in a structure that supports test coverage from day one, rather than being added to a 700+ line script and retrofitted with tests later. diff --git a/.cspell.json b/.cspell.json new file mode 100644 index 00000000..9a939f04 --- /dev/null +++ b/.cspell.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": [ + "project-words" + ], + "dictionaryDefinitions": [ + { + "addWords": true, + "name": "project-words", + "path": "./project-words.txt" + } + ], + "ignorePaths": [ + "docs/assets", + "docs/legacy/images", + "project-words.txt", + "./mkdocs.yml" + ], + "language": "en", + "version": "0.2" +} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..97ef4930 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,43 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + + - name: Build site + run: uv run --extra docs mkdocs build + + - uses: actions/upload-pages-artifact@v3 + with: + path: site/ + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/repo_checks.yml b/.github/workflows/repo_checks.yml index fa811b7d..86b36fa5 100644 --- a/.github/workflows/repo_checks.yml +++ b/.github/workflows/repo_checks.yml @@ -15,6 +15,31 @@ jobs: - name: Fetch base branch run: git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.11.6" + enable-cache: "true" + cache-dependency-glob: "uv.lock" + + - name: Sync uv virtual environment + run: | + uv sync --frozen --extra docs --compile-bytecode + + - name: Install Pester module + shell: pwsh + run: | + Install-Module -Name Pester -RequiredVersion 5.6.0 -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Build mkdocs documentation + run: | + uv run --frozen --extra docs mkdocs build + - name: Run pre-commit hooks uses: j178/prek-action@v2 with: diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml new file mode 100644 index 00000000..64154963 --- /dev/null +++ b/.github/workflows/test_linux.yml @@ -0,0 +1,264 @@ +name: Linux Integration Test + +# Triggers: +# 1. Manual: Actions tab -> "Run workflow" button (pick mode + scopes) +# 2. PR label: add "test:linux" label to a PR (runs with --shell only) +# 3. Push to PR: runs on push if the PR already has the "test:linux" label +# Use manual dispatch for full scope coverage; label triggers are fast smoke tests. + +on: + workflow_dispatch: + inputs: + scopes: + description: "Scope flags for setup.sh (space-separated)" + type: string + default: "--shell --python --pwsh --k8s-dev --terraform --omp-theme base" + + pull_request: + types: [labeled, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event.label.name || 'push' }} + cancel-in-progress: true + +jobs: + test-linux: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'test:linux') || + (github.event_name == 'pull_request' && github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'test:linux')) + + # Matrix covers the two Nix install modes that map to real deployment targets: + # - daemon : multi-user install. Scenario: WSL, bare-metal Linux, + # managed macOS via equivalent path. + # - no-daemon : single-user rootless install. Scenario: Coder / + # devcontainer (no systemd, no root at runtime). + # Passing this job is the Coder compatibility guarantee. + # See ARCHITECTURE.md "CI pipelines and validated targets" for the full map. + strategy: + fail-fast: false + matrix: + include: + - name: daemon # WSL / bare-metal Linux / managed macOS scenario + nix-method: determinate + default-scopes: "--shell --omp-theme base" + - name: no-daemon # Coder / rootless container scenario + nix-method: no-daemon + default-scopes: "--shell --starship-theme base" + + name: "nix/setup.sh (${{ matrix.name }})" + runs-on: ubuntu-slim + timeout-minutes: 30 + env: + NIX_CONFIG: "access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}" + + steps: + # ---- critical steps (fail-fast) ---------------------------------------- + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install Nix (Determinate) + if: matrix.nix-method == 'determinate' + uses: DeterminateSystems/nix-installer-action@main + + - name: Install Nix (no-daemon) + if: matrix.nix-method == 'no-daemon' + run: | + curl -sL https://nixos.org/nix/install | sh -s -- --no-daemon + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + mkdir -p "$HOME/.config/nix" + printf 'experimental-features = nix-command flakes\n' > "$HOME/.config/nix/nix.conf" + + - name: Verify nix is working + run: | + nix --version + nix store info + + - name: Run setup.sh + run: | + nix/setup.sh \ + ${{ github.event.inputs.scopes || matrix.default-scopes }} \ + --unattended --quiet-summary + + # ---- verification steps (continue-on-error, summary at end) ------------ + - name: Verify core binaries on PATH + id: core-bins + continue-on-error: true + run: | + echo "--- Verifying binaries ---" + failed=0 + for cmd in git gh jq curl openssl; do + if command -v "$cmd" &>/dev/null; then + printf "\e[32m%-20s %s\e[0m\n" "$cmd" "$(command -v "$cmd")" + else + printf "\e[31m%-20s MISSING\e[0m\n" "$cmd" + failed=1 + fi + done + [ "$failed" -eq 0 ] || exit 1 + + - name: Verify scope binaries on PATH + id: scope-bins + continue-on-error: true + run: | + echo "--- Verifying scope binaries ---" + failed=0 + scopes="${{ github.event.inputs.scopes || matrix.default-scopes }}" + bins="" + [[ "$scopes" == *"--shell"* ]] && bins="$bins fzf eza bat rg yq" + [[ "$scopes" == *"--python"* ]] && bins="$bins uv" + [[ "$scopes" == *"--pwsh"* ]] && bins="$bins pwsh" + [[ "$scopes" == *"--nodejs"* ]] && bins="$bins node" + [[ "$scopes" == *"--k8s-base"* ]] && bins="$bins kubectl k9s" + [[ "$scopes" == *"--k8s-dev"* ]] && bins="$bins helm kustomize trivy" + [[ "$scopes" == *"--terraform"* ]] && bins="$bins terraform" + [[ "$scopes" == *"--bun"* ]] && bins="$bins bun" + for cmd in $bins; do + if command -v "$cmd" &>/dev/null; then + printf "\e[32m%-20s %s\e[0m\n" "$cmd" "$(command -v "$cmd")" + else + printf "\e[31m%-20s MISSING\e[0m\n" "$cmd" + failed=1 + fi + done + [ "$failed" -eq 0 ] || exit 1 + + - name: Run nx doctor + id: nx-doctor + continue-on-error: true + run: bash "$HOME/.config/nix-env/nx_doctor.sh" --strict + + - name: Verify managed block in shell profile + id: managed-block + continue-on-error: true + run: | + echo "--- Checking managed block in ~/.bashrc ---" + if grep -c '# >>> nix-env managed >>>' ~/.bashrc; then + echo "managed block present" + else + echo "ERROR: managed block not found in ~/.bashrc" + exit 1 + fi + + - name: Verify idempotency (second run) + id: idempotency + continue-on-error: true + run: | + echo "--- Running setup.sh a second time (idempotency check) ---" + nix/setup.sh --unattended --quiet-summary + count=$(grep -c '# >>> nix-env managed >>>' ~/.bashrc) + if [ "$count" -ne 1 ]; then + echo "ERROR: expected 1 managed block, found $count" + exit 1 + fi + echo "idempotency check passed" + + - name: Verify install provenance record + id: provenance + continue-on-error: true + run: | + echo "--- Checking install.json ---" + test -f "$HOME/.config/dev-env/install.json" + jq -e '.status == "success"' "$HOME/.config/dev-env/install.json" + echo "install.json check passed" + + - name: Run bats unit tests + id: bats + continue-on-error: true + run: | + echo "--- Running bats tests ---" + bats tests/bats/test_nix_setup.bats + + - name: Verify uninstaller (env-only) + id: uninstaller + continue-on-error: true + run: | + echo "--- Running nix/uninstall.sh --env-only ---" + nix/uninstall.sh --env-only + + echo "--- Checking nix-env managed block removed ---" + if grep -qF '# >>> nix-env managed >>>' ~/.bashrc; then + echo "ERROR: nix-env managed block still present" + exit 1 + fi + + echo "--- Checking managed env block preserved ---" + if ! grep -qF '# >>> managed env >>>' ~/.bashrc; then + echo "ERROR: managed env block missing" + exit 1 + fi + + echo "--- Checking nix-env config removed ---" + if [ -d "$HOME/.config/nix-env" ]; then + echo "ERROR: ~/.config/nix-env still exists" + exit 1 + fi + + echo "--- Checking nix-specific aliases removed ---" + if [ -f "$HOME/.config/bash/aliases_nix.sh" ]; then + echo "ERROR: aliases_nix.sh still exists" + exit 1 + fi + + echo "--- Checking generic config preserved ---" + test -f "$HOME/.config/bash/functions.sh" + + echo "--- Checking Nix still installed ---" + test -d /nix/store + echo "uninstall checks passed" + + # ---- summary ----------------------------------------------------------- + - name: Show verification summary + if: always() + run: | + set -euo pipefail + + gh_to_emoji() { + case "$1" in + success) echo "✅" ;; + failure) echo "❌" ;; + skipped) echo "⏭️" ;; + *) echo "❓" ;; + esac + } + + echo "" + echo "Verification results:" + any_failed=false + + while IFS= read -r entry; do + entry="${entry#"${entry%%[![:space:]]*}"}" + [ -z "$entry" ] && continue + id="${entry%%:*}" + label="${entry#*:}" + outcome="" + case "$id" in + core-bins) outcome="${{ steps.core-bins.outcome }}" ;; + scope-bins) outcome="${{ steps.scope-bins.outcome }}" ;; + nx-doctor) outcome="${{ steps.nx-doctor.outcome }}" ;; + managed-block) outcome="${{ steps.managed-block.outcome }}" ;; + idempotency) outcome="${{ steps.idempotency.outcome }}" ;; + provenance) outcome="${{ steps.provenance.outcome }}" ;; + bats) outcome="${{ steps.bats.outcome }}" ;; + uninstaller) outcome="${{ steps.uninstaller.outcome }}" ;; + esac + echo "$(gh_to_emoji "$outcome") $label : $outcome" + [ "$outcome" = "failure" ] && any_failed=true + done <<'STEPS' + core-bins:Core binaries + scope-bins:Scope binaries + nx-doctor:nx doctor + managed-block:Managed block + idempotency:Idempotency + provenance:Install provenance + bats:Bats unit tests + uninstaller:Uninstaller + STEPS + + echo "" + if [ "$any_failed" = "true" ]; then + echo "❌ Some verification steps failed" + exit 1 + else + echo "✅ All verification steps passed" + fi diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml new file mode 100644 index 00000000..c5110c65 --- /dev/null +++ b/.github/workflows/test_macos.yml @@ -0,0 +1,285 @@ +name: macOS Integration Test + +# Triggers: +# 1. Manual: Actions tab -> "Run workflow" button (pick runner + scopes) +# 2. PR label: add "test:macos" label to a PR (runs with --shell only) +# 3. Push to PR: runs on push if the PR already has the "test:macos" label +# Use manual dispatch for full scope coverage; label triggers are fast smoke tests. + +on: + workflow_dispatch: + inputs: + runner: + description: "macOS runner" + type: choice + default: macos-15 + options: + - macos-15 + - macos-26 + scopes: + description: "Scope flags for setup.sh (space-separated)" + type: string + default: "--shell --python --pwsh --k8s-dev --terraform --omp-theme base" + + pull_request: + types: [labeled, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event.label.name || 'push' }} + cancel-in-progress: true + +jobs: + test-macos: + # Scenario: Apple Silicon macOS via Determinate installer. + # Validates the nix-path under its tightest constraints: bash 3.2 (system + # default on macOS), BSD sed, single-user Nix install. Passing this job is + # the macOS compatibility guarantee. + # + # Runner choice: + # - macos-15 (default) - Sequoia. Current broadly-deployed corporate + # baseline; tests what most fleets actually run. + # - macos-26 - Tahoe (released Sept 2025, year-based versioning). + # Forward-signal runner - opt-in via + # workflow_dispatch. Flip to default when org + # enforcement date is <=3 months out. + # Re-evaluate default every ~6 months or on macOS enforcement changes. + # See ARCHITECTURE.md "CI pipelines and validated targets" for the full map. + # + # Run on label trigger only when the label is "test:macos" + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'test:macos') || + (github.event_name == 'pull_request' && github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'test:macos')) + name: "nix/setup.sh on ${{ github.event.inputs.runner || 'macos-15' }}" + runs-on: ${{ github.event.inputs.runner || 'macos-15' }} + timeout-minutes: 30 + env: + NIX_CONFIG: "access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}" + + steps: + # ---- critical steps (fail-fast) ---------------------------------------- + - name: Checkout code + uses: actions/checkout@v6 + + - name: Reserve GID 350 (simulate conflict) + run: | + sudo dscl . -create /Groups/_test_reserved + sudo dscl . -create /Groups/_test_reserved PrimaryGroupID 350 + echo "Reserved GID 350 to test auto-install GID detection" + dscl . -list /Groups PrimaryGroupID | awk '$NF == 350' + + - name: Run setup.sh (auto-installs Nix) + run: | + nix/setup.sh \ + ${{ github.event.inputs.scopes || '--shell --omp-theme base' }} \ + --unattended --quiet-summary + + - name: Source nix profile for subsequent steps + run: | + for p in "$HOME/.nix-profile/etc/profile.d/nix.sh" \ + /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; do + if [ -f "$p" ]; then + # shellcheck source=/dev/null + . "$p" + break + fi + done + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + printenv PATH | tr ':' '\n' | grep nix >> "$GITHUB_PATH" || true + + - name: Verify nix is working + run: | + nix --version + nix store info + # verify GID 350 was skipped + nix_gid=$(dscl . -read /Groups/nixbld PrimaryGroupID 2>/dev/null | awk '{print $2}') + echo "nixbld GID: $nix_gid" + if [ "$nix_gid" = "350" ]; then + echo "ERROR: nixbld should not use GID 350 (reserved)" + exit 1 + fi + + # ---- verification steps (continue-on-error, summary at end) ------------ + - name: Verify core binaries on PATH + id: core-bins + continue-on-error: true + run: | + echo "--- Verifying binaries ---" + failed=0 + for cmd in git gh jq curl openssl; do + if command -v "$cmd" &>/dev/null; then + printf "\e[32m%-20s %s\e[0m\n" "$cmd" "$(command -v "$cmd")" + else + printf "\e[31m%-20s MISSING\e[0m\n" "$cmd" + failed=1 + fi + done + [ "$failed" -eq 0 ] || exit 1 + + - name: Verify scope binaries on PATH + id: scope-bins + continue-on-error: true + run: | + echo "--- Verifying scope binaries ---" + failed=0 + scopes="${{ github.event.inputs.scopes || '--shell --omp-theme base' }}" + bins="" + # map scopes to expected binaries + [[ "$scopes" == *"--shell"* ]] && bins="$bins fzf eza bat rg yq" + [[ "$scopes" == *"--python"* ]] && bins="$bins uv" + [[ "$scopes" == *"--pwsh"* ]] && bins="$bins pwsh" + [[ "$scopes" == *"--nodejs"* ]] && bins="$bins node" + [[ "$scopes" == *"--k8s-base"* ]] && bins="$bins kubectl k9s" + [[ "$scopes" == *"--k8s-dev"* ]] && bins="$bins helm kustomize trivy" + [[ "$scopes" == *"--terraform"* ]] && bins="$bins terraform" + [[ "$scopes" == *"--bun"* ]] && bins="$bins bun" + for cmd in $bins; do + if command -v "$cmd" &>/dev/null; then + printf "\e[32m%-20s %s\e[0m\n" "$cmd" "$(command -v "$cmd")" + else + printf "\e[31m%-20s MISSING\e[0m\n" "$cmd" + failed=1 + fi + done + [ "$failed" -eq 0 ] || exit 1 + + - name: Run nx doctor + id: nx-doctor + continue-on-error: true + run: bash "$HOME/.config/nix-env/nx_doctor.sh" --strict + + - name: Verify managed block in shell profile + id: managed-block + continue-on-error: true + run: | + echo "--- Checking managed block in ~/.bashrc ---" + if grep -c '# >>> nix-env managed >>>' ~/.bashrc; then + echo "managed block present" + else + echo "ERROR: managed block not found in ~/.bashrc" + exit 1 + fi + + - name: Verify idempotency (second run) + id: idempotency + continue-on-error: true + run: | + echo "--- Running setup.sh a second time (idempotency check) ---" + nix/setup.sh --unattended --quiet-summary + # managed block should appear exactly once + count=$(grep -c '# >>> nix-env managed >>>' ~/.bashrc) + if [ "$count" -ne 1 ]; then + echo "ERROR: expected 1 managed block, found $count" + exit 1 + fi + echo "idempotency check passed" + + - name: Verify install provenance record + id: provenance + continue-on-error: true + run: | + echo "--- Checking install.json ---" + test -f "$HOME/.config/dev-env/install.json" + jq -e '.status == "success"' "$HOME/.config/dev-env/install.json" + echo "install.json check passed" + + - name: Run bats unit tests + id: bats + continue-on-error: true + run: | + echo "--- Running bats tests ---" + bats tests/bats/test_nix_setup.bats + + - name: Verify uninstaller (env-only) + id: uninstaller + continue-on-error: true + run: | + echo "--- Running nix/uninstall.sh --env-only ---" + nix/uninstall.sh --env-only + + echo "--- Checking nix-env managed block removed ---" + if grep -qF '# >>> nix-env managed >>>' ~/.bashrc; then + echo "ERROR: nix-env managed block still present" + exit 1 + fi + + echo "--- Checking managed env block preserved ---" + if ! grep -qF '# >>> managed env >>>' ~/.bashrc; then + echo "ERROR: managed env block missing" + exit 1 + fi + + echo "--- Checking nix-env config removed ---" + if [ -d "$HOME/.config/nix-env" ]; then + echo "ERROR: ~/.config/nix-env still exists" + exit 1 + fi + + echo "--- Checking nix-specific aliases removed ---" + if [ -f "$HOME/.config/bash/aliases_nix.sh" ]; then + echo "ERROR: aliases_nix.sh still exists" + exit 1 + fi + + echo "--- Checking generic config preserved ---" + test -f "$HOME/.config/bash/functions.sh" + + echo "--- Checking Nix still installed ---" + test -d /nix/store + echo "uninstall checks passed" + + # ---- summary ----------------------------------------------------------- + - name: Show verification summary + if: always() + run: | + set -euo pipefail + + gh_to_emoji() { + case "$1" in + success) echo "✅" ;; + failure) echo "❌" ;; + skipped) echo "⏭️" ;; + *) echo "❓" ;; + esac + } + + echo "" + echo "Verification results:" + any_failed=false + + while IFS= read -r entry; do + entry="${entry#"${entry%%[![:space:]]*}"}" + [ -z "$entry" ] && continue + id="${entry%%:*}" + label="${entry#*:}" + outcome="" + case "$id" in + core-bins) outcome="${{ steps.core-bins.outcome }}" ;; + scope-bins) outcome="${{ steps.scope-bins.outcome }}" ;; + nx-doctor) outcome="${{ steps.nx-doctor.outcome }}" ;; + managed-block) outcome="${{ steps.managed-block.outcome }}" ;; + idempotency) outcome="${{ steps.idempotency.outcome }}" ;; + provenance) outcome="${{ steps.provenance.outcome }}" ;; + bats) outcome="${{ steps.bats.outcome }}" ;; + uninstaller) outcome="${{ steps.uninstaller.outcome }}" ;; + esac + echo "$(gh_to_emoji "$outcome") $label : $outcome" + [ "$outcome" = "failure" ] && any_failed=true + done <<'STEPS' + core-bins:Core binaries + scope-bins:Scope binaries + nx-doctor:nx doctor + managed-block:Managed block + idempotency:Idempotency + provenance:Install provenance + bats:Bats unit tests + uninstaller:Uninstaller + STEPS + + echo "" + if [ "$any_failed" = "true" ]; then + echo "❌ Some verification steps failed" + exit 1 + else + echo "✅ All verification steps passed" + fi diff --git a/.gitignore b/.gitignore index c44c3ee2..4521b86b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,19 @@ /tmp.*/ /logs/*.log +# Intercepted MITM root certificates +.assets/certs/ + +# Nix (user-specific, generated at runtime) +nix/config.nix +nix/flake.lock + +# mkdocs build output +site/ + +# uv +.venv/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66630c40..06068669 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,42 @@ repos: entry: python3 -m tests.hooks.gremlins language: system types: [text] + - id: validate-docs-words + name: validate docs words + entry: python3 -m tests.hooks.validate_docs_words + language: system + pass_filenames: false + files: (README|docs/.+)\.md$ + - id: align-tables + name: align markdown tables + entry: python3 -m tests.hooks.align_tables + language: system + files: \.md$ + exclude: /copilot-instructions\.md + - id: validate-scopes + name: Validate scope definitions consistency + entry: python3 -m tests.hooks.validate_scopes + language: system + files: (\.assets/lib/scopes\.json|nix/scopes/.*\.nix) + pass_filenames: false + - id: check-bash32 + name: Check nix-path scripts for bash 3.2 / BSD compatibility + entry: python3 -m tests.hooks.check_bash32 + language: system + files: (nix/.*\.sh$|\.assets/lib/(scopes|profile_block|nx_doctor)\.sh$|\.assets/config/bash_cfg/(aliases_nix|aliases_git|aliases_kubectl|functions)\.sh$|\.assets/setup/setup_common\.sh$|\.assets/provision/install_copilot\.sh$) + types: [file] + - id: bats-tests + name: Run bats unit tests + entry: python3 -m tests.hooks.run_bats + language: system + types: [file] + pass_filenames: true + - id: pester-tests + name: Run Pester unit tests + entry: python3 -m tests.hooks.run_pester + language: system + types: [file] + pass_filenames: true - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: @@ -15,12 +51,34 @@ repos: - id: mixed-line-ending - id: trailing-whitespace exclude: \.md$ + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.11 + hooks: + - id: ruff-check + types_or: [python, pyi] + args: [--fix] + files: ^tests/ + - id: ruff-format + types_or: [python, pyi] + files: ^tests/ - repo: https://github.com/DavidAnson/markdownlint-cli2 rev: v0.22.0 hooks: - id: markdownlint-cli2 files: \.md$ exclude: /copilot-instructions\.md + - repo: https://github.com/streetsidesoftware/cspell-cli + rev: v10.0.0 + hooks: + - id: cspell + files: (README|docs/.+)\.md$ + - id: cspell + name: check commit message spelling + args: + - --no-must-find-files + - --no-progress + - --no-summary + stages: [commit-msg] - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 hooks: diff --git a/AGENTS.md b/AGENTS.md index c5f30144..24cf229b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,25 @@ Automation scripts for provisioning Linux systems, primarily **WSL** (Windows Su - **Languages**: Bash 5.0+ (`.sh`), PowerShell 7.4+ (`.ps1`) - **Supported distros**: Fedora/RHEL, Debian/Ubuntu, Arch, OpenSUSE, Alpine - **Targets**: WSL (primary), VMs/bare-metal/distroboxes (secondary) +- **Architecture**: See `ARCHITECTURE.md` for file classification, dependency tree, and runtime layout + +## Bash Portability + +Scripts in the **Nix setup path** (`nix/setup.sh`, `.assets/lib/scopes.sh`, `.assets/config/bash_cfg/`) must be compatible with **bash 3.2** (macOS system default). This means: + +- **No `mapfile`/`readarray`** - use `while IFS= read -r line; do arr+=("$line"); done < <(...)` instead +- **No `declare -A`** (associative arrays) - use space-delimited strings with helper functions (`scope_has`, `scope_add`, `scope_del` in `scopes.sh`) +- **No `${var,,}`/`${var^^}`** (case modification) - use `tr '[:upper:]' '[:lower:]'` instead +- **No `declare -n`** (namerefs), **no negative array indices** (`${arr[-1]}`) + +Linux-only scripts (`.assets/scripts/linux_setup.sh`, `.assets/check/`, `.assets/provision/`, WSL scripts) may use bash 4+ features since they run on Linux where bash 5.x is standard. ## Key Entry Points - `wsl/wsl_setup.ps1` - main orchestration script, runs on Windows host, calls `.assets/provision/` scripts inside WSL - `.assets/scripts/linux_setup.sh` - provisioning from Linux host (bare-metal, VMs, WSL) - `.assets/provision/install_*.sh` - individual tool installers (most require root) -- `.assets/provision/setup_*.sh` - configuration scripts (typically run as user) +- `.assets/setup/setup_*.sh` - configuration scripts (typically run as user) - `.assets/provision/source.sh` - shared functions (dotsourced by other scripts) ## Tooling @@ -22,7 +34,12 @@ Automation scripts for provisioning Linux systems, primarily **WSL** (Windows Su ## Before Committing -Run `make lint` and fix any failures. Pre-commit hooks are configured in `.pre-commit-config.yaml`. +**IMPORTANT**: Always run `make lint` before every commit and fix any failures. Do not skip this step. Pre-commit hooks are configured in `.pre-commit-config.yaml`. + +## Writing Style + +- Never use em-dashes (U+2014) or double dashes (`--`) as punctuation; use a single dash (`-`) instead. +- The gremlins pre-commit hook rejects Unicode characters like em-dashes. ## Bash Style (`.sh`) @@ -36,6 +53,25 @@ Run `make lint` and fix any failures. Pre-commit hooks are configured in `.pre-c - PowerShell local variables use `camelCase` (see below) - Color output: `\e[31;1m` red/error, `\e[32m` green, `\e[92m` bright green, `\e[96m` cyan/info +### Runnable examples block + +Every executable `.sh` and `.zsh` script must have a `: '...'` block immediately after the shebang. This lets the user run any example with the IDE "run current line" shortcut. Rules: + +- Use `# comment` lines to describe what the next example does +- The following line must be the bare runnable command - no `Usage:`, `Example:`, or any other prefix +- Never put prose descriptions or text with embedded single quotes inside the block (single quotes cannot be escaped inside `'...'`; move such text to `#` comments before the block) + +```bash +#!/usr/bin/env bash +: ' +# run as current user +.assets/setup/setup_foo.sh +# run with a specific option +.assets/setup/setup_foo.sh --option value +' +set -euo pipefail +``` + ### Common Bash Patterns ```bash diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..1b9e6180 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,616 @@ +# Architecture + +Concise reference for repo structure, file ownership, and runtime layout. Read this before making global or +cross-cutting changes. + +## Setup paths + +| Path | Entry point | Platforms | Requirements | +| ------ | -------------------------------- | ------------------------ | ------------------------------ | +| Nix | `nix/setup.sh` | macOS, Linux, WSL, Coder | User-scope, bash 3.2 + BSD sed | +| Legacy | `.assets/scripts/linux_setup.sh` | Linux | Root, bash 4+ | +| WSL | `wsl/wsl_setup.ps1` | Windows host | Admin, PowerShell 7.4+ | + +## File classification + +### nix-path (bash 3.2 + BSD compatible required) + +Everything sourced or called by `nix/setup.sh`, plus shell config files that get sourced at login on macOS. + +| File | Role | +| -------------------------------------------- | -------------------------------------------------------------- | +| `nix/setup.sh` | Main entry point (slim orchestrator, sources phase libraries) | +| `nix/lib/io.sh` | Side-effect wrappers + output helpers (tests override these) | +| `nix/lib/phases/bootstrap.sh` | Root guard, path resolution, nix/jq detection, arg parsing | +| `nix/lib/phases/platform.sh` | OS detection, overlay discovery, hook runner | +| `nix/lib/phases/scopes.sh` | Load/merge scopes, resolve deps, write config.nix | +| `nix/lib/phases/nix_profile.sh` | Flake update, nix profile upgrade, MITM probe | +| `nix/lib/phases/configure.sh` | GitHub CLI auth, git config, per-scope configure dispatch | +| `nix/lib/phases/profiles.sh` | Bash/zsh/PowerShell shell profile setup | +| `nix/lib/phases/post_install.sh` | Common post-install setup and nix garbage collection | +| `nix/lib/phases/summary.sh` | Mode detection and final status output | +| `.assets/lib/scopes.sh` | Scope helpers (sourced by `nix/setup.sh` and `linux_setup.sh`) | +| `.assets/lib/scopes.json` | Scope definitions (read by `scopes.sh` via jq) | +| `.assets/lib/install_record.sh` | Install provenance writer (sourced by all entry points) | +| `nix/configure/az.sh` | Configure azure-cli | +| `nix/configure/conda.sh` | Configure conda | +| `nix/configure/docker.sh` | Configure docker | +| `nix/configure/gh.sh` | Configure GitHub CLI | +| `nix/configure/git.sh` | Configure git | +| `nix/configure/omp.sh` | Configure oh-my-posh | +| `nix/configure/profiles.sh` | Copy bash configs to `~/.config/bash/` | +| `nix/configure/profiles.zsh` | Copy zsh configs (zsh, not bash, but runs on macOS) | +| `nix/configure/profiles.ps1` | Copy PowerShell configs to `~/.config/powershell/` | +| `nix/configure/starship.sh` | Configure starship prompt | +| `.assets/lib/profile_block.sh` | Managed block library (sourced by profiles.sh/.zsh, nx) | +| `.assets/lib/env_block.sh` | Generic env block (sourced by profiles, setup_profile_user) | +| `.assets/lib/certs.sh` | CA bundle builder + VS Code Server cert setup | +| `.assets/lib/nx_doctor.sh` | Health check script (`nx doctor`) | +| `.assets/config/bash_cfg/aliases_nix.sh` | Shell config - nix aliases (copied to `~/.config/bash/`) | +| `.assets/config/bash_cfg/aliases_git.sh` | Shell config - git aliases | +| `.assets/config/bash_cfg/aliases_kubectl.sh` | Shell config - kubectl aliases | +| `.assets/config/bash_cfg/functions.sh` | Shell config - shared functions (includes `devenv`) | +| `.assets/setup/setup_common.sh` | Post-install setup (called via `nix/setup.sh`) | +| `.assets/setup/setup_profile_user.ps1` | PowerShell user profile (devenv, certs, local-path, etc.) | +| `.assets/provision/install_copilot.sh` | Post-install - GitHub Copilot CLI | +| `nix/uninstall.sh` | Removes nix-env environment, optionally Nix itself | + +### linux-only (bash 4+ OK) + +Scripts that only run on Linux where bash 5.x is the standard. + +| File / Pattern | Role | +| ----------------------------------------- | ---------------------------------------------- | +| `.assets/scripts/linux_setup.sh` | Legacy entry point | +| `.assets/provision/source.sh` | Shared functions for provision scripts | +| `.assets/provision/install_*.sh` | Individual tool installers (60+ scripts, root) | +| `.assets/provision/upgrade_system.sh` | System upgrade | +| `.assets/setup/setup_profile_allusers.sh` | Copies configs to `/etc/profile.d/` | +| `.assets/setup/setup_profile_user.sh` | User bash profile setup | +| `.assets/setup/setup_profile_user.zsh` | User zsh profile setup | +| `.assets/setup/setup_*.sh` | Other setup scripts | +| `.assets/check/*.sh` | System checks | +| `.assets/fix/*.sh` | One-off fixes | +| `.assets/config/bash_cfg/aliases.sh` | Legacy aliases (copied to `/etc/profile.d/`) | + +### powershell + +| File / Pattern | Role | +| ------------------------------------------ | ----------------------------- | +| `wsl/*.ps1` | WSL management (Windows host) | +| `.assets/config/pwsh_cfg/_aliases_nix.ps1` | PowerShell nix aliases | +| `.assets/config/pwsh_cfg/profile_nix.ps1` | Base profile template | +| `modules/InstallUtils/` | Install utility module | +| `modules/SetupUtils/` | Setup utility module | + +### nix declarations (not bash) + +| File / Pattern | Role | +| ------------------ | ---------------------- | +| `nix/flake.nix` | buildEnv flake | +| `nix/scopes/*.nix` | 19 scope package lists | + +### tests + +| File / Pattern | Role | +| ------------------------------ | -------------------------------------------- | +| `tests/bats/*.bats` | bats-core unit tests | +| `tests/pester/*.Tests.ps1` | Pester unit tests | +| `tests/hooks/*.py` | Pre-commit hook scripts | +| `.github/workflows/test_*.yml` | Integration tests (see "CI pipelines" below) | + +## Nix-path call tree + +Entry point: `nix/setup.sh` (orchestrator, ~110 lines). + +**Phase libraries** (sourced at startup, executed in order): + +| File | Role | +| -------------------------------- | ------------------------------------------------ | +| `nix/lib/io.sh` | Output helpers + side-effect wrappers | +| `nix/lib/phases/bootstrap.sh` | Root guard, paths, nix detect/install, arg parse | +| `nix/lib/phases/platform.sh` | OS detect, overlay, hooks | +| `nix/lib/phases/scopes.sh` | Load/merge/resolve scopes, write `config.nix` | +| `nix/lib/phases/nix_profile.sh` | Flake update, profile upgrade, MITM probe | +| `nix/lib/phases/configure.sh` | gh/git/per-scope configure dispatch | +| `nix/lib/phases/profiles.sh` | bash/zsh/pwsh profile setup | +| `nix/lib/phases/post_install.sh` | `setup_common.sh` + nix GC | +| `nix/lib/phases/summary.sh` | Mode detect + final output | +| `.assets/lib/install_record.sh` | EXIT trap provenance | +| `.assets/lib/scopes.sh` | Scope helpers (reads `.assets/lib/scopes.json`) | + +**Configure scripts** (dispatched by `configure.sh` via [`_io_run`](nix/lib/io.sh)): + +| File | Condition | Dependencies | +| ---------------------------- | ----------------- | -------------------------------------------------------------------------- | +| `nix/configure/gh.sh` | always | - | +| `nix/configure/git.sh` | always | - | +| `nix/configure/docker.sh` | scope: docker | - | +| `nix/configure/conda.sh` | scope: conda | sources `functions.sh` | +| `nix/configure/az.sh` | scope: az | calls `install_azurecli_uv.sh` | +| `nix/configure/omp.sh` | scope: oh_my_posh | reads `.assets/config/omp_cfg/` | +| `nix/configure/starship.sh` | scope: starship | reads `.assets/config/starship_cfg/` | +| `nix/configure/profiles.sh` | always | sources `profile_block.sh`, `env_block.sh`, `certs.sh`; copies `bash_cfg/` | +| `nix/configure/profiles.zsh` | scope: zsh | sources `profile_block.sh`, `env_block.sh`, `certs.sh`; copies `bash_cfg/` | +| `nix/configure/profiles.ps1` | scope: pwsh | copies `pwsh_cfg/`; writes `nix:base`, `nix:path`, `nix:certs` regions | + +**Post-install dispatch** (dispatched by `post_install.sh` via [`_io_run`](nix/lib/io.sh)): + +| File | Condition | Purpose | +| -------------------------------------- | --------------- | -------------------------------- | +| `.assets/setup/setup_common.sh` | always | Copilot, zsh plugins, PS modules | +| `.assets/provision/install_copilot.sh` | called by above | GitHub Copilot CLI | +| `.assets/setup/setup_profile_user.zsh` | scope: zsh | Zsh profile setup | +| `.assets/setup/setup_profile_user.ps1` | pwsh available | devenv + certs + local-path | + +## Runtime file locations + +### User-scope durable state (`~/.config/nix-env/`) + +Persists after the repo is removed. This is the user's nix environment. + +| Runtime file | Source | Created by | +| -------------------- | ----------------------------------------- | ---------------------------- | +| `flake.nix` | `nix/flake.nix` | `nix/setup.sh` | +| `scopes/*.nix` | `nix/scopes/*.nix` | `nix/setup.sh` | +| `config.nix` | generated | `nix/setup.sh` or `nx scope` | +| `packages.nix` | generated | `nx install` / `nx remove` | +| `omp/theme.omp.json` | `.assets/config/omp_cfg/` | `nix/configure/omp.sh` | +| `profile_base.ps1` | `.assets/config/pwsh_cfg/profile_nix.ps1` | `nix/configure/profiles.ps1` | +| `nx_doctor.sh` | `.assets/lib/nx_doctor.sh` | `nix/setup.sh` | + +### Hook directories (`~/.config/nix-env/hooks/`) + +Not created automatically. Users create these directories when they have hooks to run. Hook files (`*.sh`) are +sourced in lexical order. + +| Directory | When | Variables available | +| --------------- | ----------------------- | ------------------------------------------------ | +| `pre-setup.d/` | Before scope resolution | `NIX_ENV_VERSION`, `NIX_ENV_PLATFORM`, `ENV_DIR` | +| `post-setup.d/` | After profile config | All above + `NIX_ENV_SCOPES` | + +`NIX_ENV_PHASE` is exported as `pre-setup` or `post-setup` so hooks can verify which phase they're running in. + +### Overlay directory (`~/.config/nix-env/local/` or `$NIX_ENV_OVERLAY_DIR`) + +Local customization layer. Discovery order: `$NIX_ENV_OVERLAY_DIR` (if set and exists), then +`~/.config/nix-env/local/`. Not created automatically. + +| Path | Purpose | +| --------------- | ------------------------------------------------ | +| `scopes/*.nix` | Extra nix packages (copied as `local_*.nix`) | +| `bash_cfg/*.sh` | Extra shell config (copied to `~/.config/bash/`) | + +Overlay scope files are prefixed with `local_` when copied to `~/.config/nix-env/scopes/` to avoid collisions with +base scope names. The flake reads all `*.nix` from the scopes directory, so overlay packages are included +automatically. + +**CLI commands:** + +- `nx overlay list` -- show active overlay directory and its contents (scopes, shell config, hooks). +- `nx overlay status` -- show sync status of overlay files (synced, modified, source missing) by comparing overlay + source with installed copies. +- `nx scope add ` -- create a stub `.nix` file in the overlay directory, copy it to + `scopes/local_.nix`, and register it in `config.nix`. + +### Shell config (`~/.config/bash/`) + +Sourced by `~/.bashrc` and `~/.zshrc` on all platforms including macOS. + +| Runtime file | Source | +| -------------------- | -------------------------------------------- | +| `aliases_nix.sh` | `.assets/config/bash_cfg/aliases_nix.sh` | +| `aliases_git.sh` | `.assets/config/bash_cfg/aliases_git.sh` | +| `aliases_kubectl.sh` | `.assets/config/bash_cfg/aliases_kubectl.sh` | +| `functions.sh` | `.assets/config/bash_cfg/functions.sh` | + +### PowerShell config (`~/.config/powershell/`) + +| Runtime file | Source | +| -------------------------- | ------------------------------------------ | +| `Scripts/_aliases_nix.ps1` | `.assets/config/pwsh_cfg/_aliases_nix.ps1` | + +### Legacy system-scope (`/etc/profile.d/`, root required) + +Used only by the legacy path (`setup_profile_allusers.sh`). Not used on macOS. + +| Runtime file | Source | +| -------------------- | -------------------------------------------- | +| `aliases.sh` | `.assets/config/bash_cfg/aliases.sh` | +| `aliases_git.sh` | `.assets/config/bash_cfg/aliases_git.sh` | +| `aliases_kubectl.sh` | `.assets/config/bash_cfg/aliases_kubectl.sh` | +| `functions.sh` | `.assets/config/bash_cfg/functions.sh` | + +### Installation provenance (`~/.config/dev-env/`) + +Written on every setup run (success or failure) by an EXIT trap (bash) or `clean` block (PowerShell). Records entry +point, scopes, status, and phase. + +| Runtime file | Created by | +| -------------- | ------------------------------------------------------------------- | +| `install.json` | EXIT trap in `nix/setup.sh`, `linux_setup.sh`, or WSL `clean` block | + +The `entry_point` field distinguishes how setup was invoked: + +| Value | Meaning | +| ------------ | -------------------------------------------- | +| `nix` | `nix/setup.sh` run directly | +| `legacy` | `linux_setup.sh` with traditional installers | +| `legacy/nix` | `linux_setup.sh` delegating to nix | +| `wsl/nix` | `wsl_setup.ps1` using nix path | +| `wsl/legacy` | `wsl_setup.ps1` using traditional path | + +### Certificate store (`~/.config/certs/`) + +Created by the corporate proxy certificate interception flow. See `docs/corporate_proxy.md` for operational +details. + +| Runtime file | Created by | Purpose | +| --------------- | ------------------------------- | -------------------------------- | +| `ca-custom.crt` | `cert_intercept` (functions.sh) | Intercepted proxy certs only | +| `ca-bundle.crt` | `build_ca_bundle` (certs.sh) | Full CA bundle (system + custom) | + +## Environment variables + +Variables exported by `nix/setup.sh` for use by hooks, downstream scripts, and diagnostic tools (`nx doctor`). + +| Variable | Set by | When | Purpose | +| ----------------------- | ---------- | -------------------------------- | -------------------------------------------- | +| `NIX_ENV_VERSION` | `setup.sh` | After script root resolution | Tool version (git tag/tarball) | +| `NIX_ENV_SCOPES` | `setup.sh` | After scope resolution | Space-separated resolved scopes | +| `NIX_SSL_CERT_FILE` | profiles | Shell startup (if bundle exists) | Merged CA bundle for all nix-built tools | +| `NIX_ENV_TLS_PROBE_URL` | `certs.sh` | On source | TLS probe URL for MITM detection (see below) | + +`NIX_ENV_VERSION` uses a three-step fallback: `git describe --tags --dirty`, then a `VERSION` file (present in +release tarballs, absent in the repo), then `"unknown"`. The same chain is used by `install_record.sh` for +provenance. + +## Nixpkgs pinning (`nx pin`) + +The file `~/.config/nix-env/pinned_rev` controls whether upgrades resolve the latest `nixpkgs-unstable` or lock to +a specific commit. Managed via `nx pin set `, `nx pin remove`, `nx pin show`. + +When the file exists and contains a commit SHA, `setup.sh --upgrade` and `nx upgrade` use +`nix flake lock --override-input` to pin nixpkgs to that revision. When absent, upgrades resolve the latest +unstable HEAD as before. + +Useful for reproducible builds or fleet-wide cohort pinning without shipping a repo-level `flake.lock`. + +## Diagnostics (`nx doctor`) + +`nx doctor` runs read-only health checks against the nix-env managed environment. Implemented in +`.assets/lib/nx_doctor.sh`, copied to `~/.config/nix-env/nx_doctor.sh` during setup so it remains available after +the repo is removed. By default only FAILs produce a non-zero exit; `--strict` treats warnings as failures too (used +in CI). + +**Checks:** + +| Check | Pass | Fail/Warn | +| ---------------- | --------------------------------------------- | -------------------------------- | +| `nix_available` | `nix` in PATH | FAIL: nix not found | +| `flake_lock` | `flake.lock` exists, nixpkgs node valid | FAIL: missing; WARN: unreadable | +| `install_record` | `install.json` exists, status=success | WARN: missing or last run failed | +| `scope_binaries` | All `# bins:` binaries from scope files found | WARN: lists missing binaries | +| `shell_profile` | Exactly 1 managed block per rc file | FAIL: zero or duplicates | +| `cert_bundle` | Custom CA bundle + VS Code env OK | FAIL: bundle or env missing | +| `nix_profile` | `nix-env` in `nix profile list` | FAIL: not found | +| `overlay_dir` | `NIX_ENV_OVERLAY_DIR` readable (if set) | FAIL: not a readable directory | + +**Binary names** are declared in each scope's `.nix` file via a `# bins:` comment (e.g. `# bins: rg yq fzf`). This +is the single source of truth - `validate_scopes.py` enforces that every scope file has one. + +## CI pipelines and validated targets + +GitHub Actions workflows under `.github/workflows/` encode which deployment targets are actually tested. Each +matrix entry maps to a real-world install scenario; passing the job is the compatibility guarantee for that +scenario. + +| Workflow | Runner / Matrix | Scenario it validates | +| ------------------ | -------------------------- | ----------------------------------------------------------------------------------------------- | +| `test_linux.yml` | `ubuntu-slim`, `daemon` | Multi-user Nix install (WSL, bare-metal Linux, managed macOS via equivalent path). | +| `test_linux.yml` | `ubuntu-slim`, `no-daemon` | Single-user rootless Nix install. Covers Coder / devcontainer (no systemd, no root at runtime). | +| `test_macos.yml` | `macos-15` (default), `26` | Apple Silicon macOS via Determinate installer. Validates bash 3.2 + BSD sed constraints. | +| `repo_checks.yml` | pre-commit hooks | `check_bash32`, `validate_scopes`, ShellCheck, lint. | +| `build_docker.yml` | container build | Docker image for legacy path (not nix). | + +**Test-per-run assertions** (both integration workflows): + +- `setup.sh` completes with requested scope flags. Label-triggered runs use a fast smoke test (`--shell` plus a + prompt theme); `workflow_dispatch` defaults to the full scope set (`--shell --python --pwsh --k8s-dev --terraform` + plus a prompt engine) with an input override for custom scopes. +- Core binaries (`git`, `gh`, `jq`, `curl`, `openssl`) resolve on PATH. +- Scope-specific binaries resolve (mapped from scope flags). +- `nx doctor --strict` passes (warnings and failures both break the build). +- `# >>> nix-env managed >>>` block exists in `~/.bashrc` exactly once. +- Second `setup.sh` invocation produces exactly one managed block (idempotency). +- `install.json` written with `status = "success"`. +- `bats tests/bats/test_nix_setup.bats` passes. +- `nix/uninstall.sh --env-only` removes nix-env state, preserves generic + `managed env` block, leaves `/nix/store` intact. + +**Triggers:** manual (`workflow_dispatch` with scope override), PR label (`test:linux` / `test:macos`), or push to +an already-labeled PR. + +**WSL end-to-end testing (intentionally omitted):** `wsl_setup.ps1` orchestration is not tested in CI. GitHub-hosted Windows runners only support WSL1 (no nested virtualization for WSL2), which lacks systemd and behaves differently from production WSL2 environments. A self-hosted Windows runner with WSL2 would work but cannot be ephemeral, making the maintenance cost disproportionate to the coverage gained. The orchestration logic is already validated by Pester unit tests (`tests/pester/WslSetup.Tests.ps1`) that mock `wsl.exe` and verify script dispatch for all scope/mode combinations. + +## Design decisions + +### Bootstrapper model (repo is disposable) + +The tool is a bootstrapper, not a daemon or agent. A single `nix/setup.sh` run provisions a fully self-contained environment in `~/.config/nix-env/` - flake declarations, scope definitions, shell configs, health checks, and the uninstaller. After setup, the repo clone is disposable: + +- `nx upgrade` / `nx scope` / `nx install` operate on `~/.config/nix-env/` directly (no repo needed). +- `nx doctor` runs health checks from the copied `nx_doctor.sh`. +- `nix/uninstall.sh` is self-contained (removes nix-env state, preserves generic config). +- `devenv` / `nx version` reads `~/.config/dev-env/install.json` for provenance. + +Keeping the repo clone is a user choice, not a requirement. It simplifies adding new scopes (`setup.sh --k8s-base`), upgrading declarations to newer versions, and re-running the full setup after OS reinstalls. Users who don't need that can delete the clone - the environment continues to work and is fully manageable via `nx` commands. + +### nixpkgs-unstable with explicit upgrade semantics + +The flake input is `nixpkgs-unstable` - intentional, not an oversight. The target audience values "reasonably +current" tooling over point-in-time reproducibility. The binary cache GCs old revisions, so a pinned lock older +than ~6 weeks risks cold source builds anyway. + +To avoid silent breakage, `setup.sh` does **not** implicitly run `nix flake update` on scope-only changes. The +upgrade path is explicit: + +- `nix/setup.sh` (first run) - `nix profile add` resolves `unstable` HEAD and writes `$ENV_DIR/flake.lock` implicitly. +- `nix/setup.sh` (subsequent) - re-uses existing lock, applies scope changes only. +- `nix/setup.sh --upgrade` - explicit: runs `nix flake update` to refresh `flake.lock`, then upgrades. +- `nx upgrade` - same as above but from the shell alias (runs `nix flake update` + `nix profile upgrade`). +- `nx rollback` - wraps `nix profile rollback`. + +No repo-level `flake.lock` is shipped. The per-user lock in `$ENV_DIR` gives run-to-run reproducibility on one +machine. Enterprise pinning via `NIX_ENV_PINNED_REV` env var when/if an SBOM requirement appears. + +### Dual prompt engines (oh-my-posh + starship) + +Both are kept intentionally - they serve different environments: + +- **oh-my-posh** (Go, more mature, richer themes) - default for macOS and WSL where startup latency is less + critical. +- **starship** (Rust, faster cold-start) - preferred on Coder where container startup time matters and resource + budgets are tighter. + +The scopes are mutually exclusive at runtime (`--omp-theme` removes starship and vice versa) but both remain +available as opt-in choices. + +### Dual Python managers (conda + uv) + +Both are kept - conda is required by many data science and ML teams (binary package ecosystem, environment +isolation), while uv is the modern replacement for pip/venv workflows. They coexist without conflict: conda manages +its own environments, uv manages `$HOME/.local/bin` and venvs. + +### `set -eo pipefail` without `-u` (nix-path files) + +Nix-path scripts use `set -eo pipefail` deliberately omitting `-u` (nounset). Bash 3.2 (macOS system default) +treats `arr=()` as unset when `-u` is active, so `${#arr[@]}` on an empty array causes an "unbound variable" error. +This forced ugly counter-variable workarounds throughout the codebase. + +Dropping `-u` is the right trade-off because: + +- ShellCheck (run via pre-commit) already catches uninitialized variable references at lint time - a stronger guard + than runtime `-u`. +- The counter-variable pattern actively harmed readability and introduced its own bug surface. +- `set -e` still catches most real errors from commands receiving empty args. + +Linux-only scripts may use `set -euo pipefail` since they run bash 5.x where empty arrays are handled correctly. + +### Corporate proxy and SSL inspection + +Many enterprise environments use MITM TLS inspection proxies that replace upstream certificates. The solution +intercepts proxy certificates at setup time and configures tools via environment variables (`NIX_SSL_CERT_FILE`, +`NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `UV_SYSTEM_CERTS`). See `docs/corporate_proxy.md` for +operational details. + +**Why nix tools need special handling:** Nix-installed binaries (git, curl, etc.) are built against nix's own OpenSSL +and ship with an isolated Mozilla CA bundle. They do not use the macOS Keychain or the Linux system CA store. This +means a MITM proxy cert trusted by the OS is invisible to nix tools. `NIX_SSL_CERT_FILE` overrides the default CA +bundle for all nix-built tools with a merged bundle (nix CAs + intercepted proxy certs). It is set during setup +(`phase_nix_profile_mitm_probe`) and persisted in shell profiles (bash/zsh managed block, PowerShell `nix:certs` +region). + +**TLS probe URL** (`NIX_ENV_TLS_PROBE_URL`, default `https://www.google.com`): used by MITM detection +(`phase_nix_profile_mitm_probe`), SSL connectivity checks (`check_ssl.sh`), and certificate interception +(`cert_intercept`). The default is defined in `.assets/lib/certs.sh` as the single source of truth. Override via +environment variable to use an internal endpoint. The default was chosen because `www.google.com` is (a) reachable +from virtually every network that has internet access, (b) almost universally subject to MITM TLS inspection when a +proxy is present, and (c) does not depend on any project-specific infrastructure (e.g. `nixos.org` could be +allowlisted or unreachable on air-gapped networks that still have filtered internet). + +Alternatives considered and why they were not adopted: + +- **`pip-system-certs`** (auto-patches certifi at import time) - rejected. Must be installed in every virtualenv, + breaks isolation guarantees, and interacts poorly with uv-managed environments where the package may not be + present. The `fixcertpy` shell function patches certifi on demand instead - explicit, idempotent, and works + across any Python installation. +- **`NODE_OPTIONS='--use-system-ca'`** - not adopted yet. Requires Node.js 22+ (experimental in earlier versions) + and is not yet stable across all Node consumers (bundlers, test runners). `NODE_EXTRA_CA_CERTS` works reliably + across all supported Node versions. Will revisit when Node 22 becomes the minimum supported LTS. +- **`UV_SYSTEM_CERTS`** - adopted (replaced deprecated `UV_NATIVE_TLS`). Tells uv/uvx to use the platform's native + certificate store, avoiding the need to point at a specific PEM bundle. +- **System CA store installation** - handled by `wsl/wsl_certs_add.ps1` (WSL) and `cert_intercept` + manual + `update-ca-certificates` (Linux). Not automated in the nix path because it requires root; the env-var approach + covers user-scope tools without privilege escalation. + +### Bootstrap dependency (base_init.nix) + +`scopes.json` is the single source of truth for scope metadata (valid names, install order, dependency rules). It +is consumed by three runtimes: + +| Consumer | Parser | Native? | +| ---------- | -------------------------------------------------- | ------- | +| bash | `jq` via `.assets/lib/scopes.sh` | No | +| PowerShell | `ConvertFrom-Json` (`modules/SetupUtils/`, `wsl/`) | Yes | +| Python | `json` stdlib (`tests/hooks/validate_scopes.py`) | Yes | + +JSON was chosen because it is the only format all three parse without a custom parser. Alternatives +(bash-sourceable data, TSV, INI) would force either a fragile parallel parser in PowerShell/Python or a +source-of-truth split between bash-data and JSON-data. + +The only cost is that bash 3.2 on bare macOS has no JSON parser, so jq must be bootstrapped before scope resolution +can run. This is handled by: + +1. `nix/scopes/base_init.nix` - minimal package list (jq, curl) included only during bootstrap. +2. `isInit` flag in `config.nix` - set to `true` on first run when the system has no jq/curl outside Nix. +3. `flake.nix` - conditionally includes `base_init.nix` packages when `cfg.isInit` is true. +4. `nix/setup.sh:183-197` - on first run (jq not found), writes a bootstrap `config.nix` with `isInit = true` and + empty `scopes`, then runs `nix profile add` + `nix profile upgrade` to install jq. Subsequent runs find jq and + skip the bootstrap block entirely. + +The bootstrap is ~13 lines of `setup.sh`, one `.nix` file, and one config flag. It runs once per machine (seconds), +after which jq is an ordinary Nix package and `isInit` flips to `false`. + +Alternatives considered: + +- **Vendored jq binary** - supply-chain concerns, per-arch packaging (macOS ARM/x86, Linux ARM/x86), not acceptable + for corporate repos. +- **Pure-bash JSON parser** - maintenance burden, fragile on edge cases, more code than the data it would parse. +- **Different data format** (TSV, bash-sourceable, YAML) - would force the PowerShell and Python consumers to use + non-native parsers, or split the source of truth. + +### VS Code Server certificate environment + +VS Code Server (remote-SSH, WSL) does not source `~/.bashrc` on startup, so shell-profile environment variables +like `NODE_EXTRA_CA_CERTS` are invisible to extensions. This causes `SELF_SIGNED_CERT_IN_CHAIN` errors in +extensions that call HTTPS APIs (GitHub Actions, GitHub Pull Requests, etc.). + +The fix is `~/.vscode-server/server-env-setup` - a file VS Code Server sources before launching. +`setup_vscode_certs` in `.assets/lib/certs.sh` writes `NODE_EXTRA_CA_CERTS` there, creating the directory and file +if they don't exist. This handles the bootstrapping problem where setup runs before the first VS Code remote +session creates `~/.vscode-server/`. + +This is tool-specific but VS Code is the standard editor in corporate environments where MITM proxies are common. +The same pattern applies to both WSL and remote-SSH connections. + +## Phase library and test stubs (`nix/lib/`) + +`nix/setup.sh` is a slim orchestrator (~110 lines) that sources phase libraries from `nix/lib/phases/`. Each phase +file exports functions prefixed with `phase__` (e.g. `phase_bootstrap_parse_args`, +`phase_scopes_write_config`). + +Side-effecting operations (`nix`, `curl`, external script invocations) are called through thin wrappers defined in +`nix/lib/io.sh`: + +| Wrapper | Wraps | +| ---------------- | -------------------------------- | +| `_io_nix` | `nix` | +| `_io_nix_eval` | `nix eval --impure --raw --expr` | +| `_io_curl_probe` | `curl -sS ` | +| `_io_run` | Direct command execution | + +Tests override these before sourcing phase files to assert commands without executing them: + +```bash +setup() { + _io_nix() { echo "nix $*" >>"$BATS_TEST_TMPDIR/nix.log"; } + source "$REPO_ROOT/nix/lib/io.sh" + source "$REPO_ROOT/nix/lib/phases/nix_profile.sh" +} +``` + +Shared state between phases is documented in each phase file's header comment (`# Reads:` / `# Writes:` lines). + +## Bash 3.2 / BSD sed constraints (nix-path files) + +All nix-path `.sh` files must avoid: + +- `mapfile` / `readarray` - use `while IFS= read -r; do arr+=(); done < <(...)` +- `declare -A` (assoc arrays) - use space-delimited strings with helpers +- `${var,,}` / `${var^^}` - use `tr '[:upper:]' '[:lower:]'` +- `declare -n` (namerefs) - pass variable name as string +- Negative array index `${arr[-1]}` - use `${arr[$((${#arr[@]}-1))]}` +- `sed \s` - use `[[:space:]]` +- `sed` BRE `\+` or alternation - use `sed -E` with bare `+` or alternation +- `sed -i ''` - write to temp file + `mv` +- `sed -r` - use `sed -E` +- `grep -P` (PCRE) - use `grep -E` or `sed` +- `grep \S` / `\w` / `\d` - use `[^[:space:]]` / `[a-zA-Z0-9_]` / `[0-9]` + +These constraints are enforced by the `check-bash32` pre-commit hook (`tests/hooks/check_bash32.py`). + +## Managed block pattern + +Shell profile injection (`~/.bashrc`, `~/.zshrc`, PowerShell `$PROFILE`) uses a **managed block** pattern instead +of `grep -q && echo >>` append. This gives idempotent, fully-regenerated, removable config injection. + +**Bash/Zsh** (`profile_block.sh` - `manage_block` function): + +Two blocks are written to each rc file: + +- `nix-env managed` - nix-specific: PATH, nix aliases, completions, prompt init. Removed by `nix/uninstall.sh`. +- `managed env` - generic env (local PATH, cert env vars, generic aliases/functions). Not nix-specific, survives + uninstall. + +Alias files are assigned to the correct block based on how the tool was installed: + +- `functions.sh` - always in `managed env` (purely generic, includes `devenv`). +- `aliases_git.sh` - in `nix-env managed` if git is from nix (`~/.nix-profile/bin/git` exists), otherwise in + `managed env`. +- `aliases_kubectl.sh` - same logic: nix block if kubectl is from nix, otherwise generic. +- `aliases_nix.sh` - always in `nix-env managed` (nix-specific by definition). + +```bash +# >>> nix-env managed >>> +# :path +. $HOME/.nix-profile/etc/profile.d/nix.sh +export PATH="$HOME/.nix-profile/bin:$PATH" +export NIX_SSL_CERT_FILE="$HOME/.config/certs/ca-bundle.crt" +# :aliases +. "$HOME/.config/bash/aliases_nix.sh" +# :oh-my-posh +[ -x "$HOME/.nix-profile/bin/oh-my-posh" ] && eval "$(oh-my-posh init bash ...)" +# <<< nix-env managed <<< + +# >>> managed env >>> +# :local path +if [ -d "$HOME/.local/bin" ]; then + export PATH="$HOME/.local/bin:$PATH" +fi +# :certs +export NODE_EXTRA_CA_CERTS="$HOME/.config/certs/ca-custom.crt" +# <<< managed env <<< +``` + +**PowerShell** (`profiles.ps1` - `Update-ProfileRegion` function): + +Nix-managed regions use the `nix:` prefix. Generic regions (certs, conda, make completer) use unprefixed names and +are written by `setup_profile_user.ps1`. The uninstaller only removes `nix:`-prefixed regions. + +```powershell +#region nix:base +. "$HOME/.config/nix-env/profile_base.ps1" +#endregion + +#region nix:path +... +#endregion + +#region nix:certs +$caBundlePath = ... +if ([IO.File]::Exists($caBundlePath)) { $env:NIX_SSL_CERT_FILE = $caBundlePath } +#endregion + +#region certs +... +#endregion +``` + +Key properties: + +- Block is **fully regenerated** each run (content is rendered fresh, not appended). +- `manage_block upsert` / `Update-ProfileRegion` replaces old content atomically. +- `manage_block remove` / `nx profile uninstall` cleanly removes the block. +- `nx profile doctor` detects duplicate blocks and legacy (pre-managed-block) lines. +- `nx profile migrate` converts legacy append-style injections to managed blocks. +- `nix/uninstall.sh` removes only nix-specific blocks/regions, preserving generic config. + +### `devenv` command + +Shows installation provenance from `~/.config/dev-env/install.json`. Available in all shells, independent of nix: + +- **bash/zsh**: `devenv` function in `functions.sh` (bash 3.2 compatible, jq with `cat` fallback). Also accessible + as `nx version`. +- **PowerShell**: `devenv` function written to `$PROFILE.CurrentUserAllHosts` by `setup_profile_user.ps1`. Returns + a `PSCustomObject` with ANSI-colored values. Also accessible as `nx version`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..49ddeb25 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] + +### Added + +- Nix-based cross-platform setup with scope system (`nix/setup.sh`) +- Declarative `nx` CLI for bash and PowerShell (`aliases_nix.sh`, `_aliases_nix.ps1`) +- `devenv` command for bash, zsh, and PowerShell +- Installation provenance record (`~/.config/dev-env/install.json`) +- Managed block pattern for shell profile injection (`manage_block`) +- Managed env block for cert env vars and local PATH (`env_block.sh`) +- Shared CA bundle builder and VS Code Server cert setup (`certs.sh`) +- Explicit upgrade semantics (`--upgrade` flag, `nx upgrade`) +- Scope dependency resolution and validation (`scopes.sh`, `scopes.json`) +- Oh-my-posh and starship prompt integration with mutual exclusivity +- Linux CI workflow (daemon + no-daemon matrix) +- macOS CI workflow (Determinate installer) +- Uninstaller with env-only mode (`nix/uninstall.sh`) +- BATS and Pester unit testing with pre-commit hooks +- BSD sed lint enforcement in pre-commit hook (`check_bash32`) +- `NIX_ENV_VERSION` and `NIX_ENV_SCOPES` environment variable exports +- `VERSION` file fallback for tarball installs (no `.git` directory) +- ARCHITECTURE.md with file classification, call tree, and design decisions +- Corporate proxy documentation (`docs/corporate_proxy.md`) +- `nx doctor` health checks (8 checks, `--json` output) +- `# bins:` comments in scope `.nix` files as single source of truth for expected binaries +- Pre-setup and post-setup hook directories (`~/.config/nix-env/hooks/`) +- Overlay directory for local customization (`~/.config/nix-env/local/` or `$NIX_ENV_OVERLAY_DIR`) +- `nx overlay list` and `nx overlay status` commands +- `nx scope add ` for creating custom overlay scopes +- SUPPORT.md with platform support matrix + +### Fixed + +- BSD sed grouped-command violations across all nix-path scripts +- Bash 3.2 compatibility (no mapfile, no associative arrays, no namerefs) +- Uninstaller cleanup for env-only and full removal modes diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..42ec7c16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Universal, cross-platform system configuration and developer environment setup. Installs and configures base system tools (ripgrep, eza, bat, fzf), development toolchains (Python/uv, Node.js/Bun, shell scripting), shell prompt customization (oh-my-posh), and common aliases across shells. + +**Platforms:** + +- **macOS** - via `nix/setup.sh` (primary path) +- **Linux** (Debian/Ubuntu, Fedora/RHEL, OpenSUSE; Arch/Alpine have reduced support) - via `nix/setup.sh` or `linux_setup.sh` +- **WSL** (Windows Subsystem for Linux) - via `wsl/wsl_setup.ps1` on the Windows host +- **Coder / rootless environments** - `nix/setup.sh` works without root access + +**Languages**: Bash 5.0+ (`.sh`), PowerShell 7.4+ (`.ps1`) + +## Bash Portability + +Scripts in the **Nix setup path** (`nix/setup.sh`, `.assets/lib/scopes.sh`, `.assets/config/bash_cfg/`) must be compatible with **bash 3.2** (macOS system default). This means: + +- **No `mapfile`/`readarray`** - use `while IFS= read -r line; do arr+=("$line"); done < <(...)` instead +- **No `declare -A`** (associative arrays) - use space-delimited strings with helper functions (`scope_has`, `scope_add`, `scope_del` in `scopes.sh`) +- **No `${var,,}`/`${var^^}`** (case modification) - use `tr '[:upper:]' '[:lower:]'` instead +- **No `declare -n`** (namerefs), **no negative array indices** (`${arr[-1]}`) + +Linux-only scripts (`.assets/scripts/linux_setup.sh`, `.assets/check/`, `.assets/provision/`, WSL scripts) may use bash 4+ features since they run on Linux where bash 5.x is standard. + +## Setup Paths + +### Primary: Nix (`nix/setup.sh`) + +The preferred path for all platforms. Uses a Nix buildEnv flake for **user-scope, rootless, idempotent** package management. Nix itself must be pre-installed once (requires root); everything after runs as the user. + +- Durable config lives in `~/.config/nix-env/` - persists after the repo is removed +- Run without scope flags to upgrade existing packages; add flags to install new scopes +- Scope definitions: `nix/scopes/*.nix`; flake: `nix/flake.nix` +- Post-install configuration scripts: `nix/configure/` + +```bash +nix/setup.sh # upgrade existing +nix/setup.sh --shell --python --pwsh --oh-my-posh # install scopes +nix/setup.sh --all # install everything +nix/setup.sh --help # list all scopes/options +``` + +### Legacy: Traditional scripts (`linux_setup.sh`) + +Per-distro installers under `.assets/provision/install_*.sh` (most require root). Distro is detected at runtime from `os-release`. Used for bare-metal Linux and VM provisioning. + +### WSL: Windows host orchestration (`wsl/wsl_setup.ps1`) + +Runs on the Windows host; creates/configures WSL distros and calls either the Nix or traditional path inside WSL. + +## Architecture + +See `ARCHITECTURE.md` for file classification (nix-path vs linux-only), call tree, runtime file locations, and bash 3.2/BSD sed constraints. Read it before making global or cross-cutting changes. + +**Scope system**: users select feature sets (e.g., `shell`, `python`, `k8s_base`, `pwsh`). Scope logic and dependency resolution are in `.assets/lib/scopes.sh`; canonical definitions in `.assets/lib/scopes.json`. The Nix path uses the same scope names with package lists in `nix/scopes/*.nix`. + +**Key shared files:** + +- `.assets/lib/scopes.sh` - scope parsing, dependency resolution, `resolve_scope_deps` / `sort_scopes` +- `.assets/provision/source.sh` - shared Bash functions, dot-sourced by provision scripts +- `.assets/setup/setup_common.sh` - post-install setup (copilot, zsh plugins, PS modules) + +**Testing**: Docker-based smoke tests in `.assets/docker/` run a full provisioning pass and verify key binaries exist in `$PATH`. End-to-end only - no unit tests. + +## Key Entry Points + +- `nix/setup.sh` - primary entry point (all platforms, user-scope, no root after Nix install) +- `wsl/wsl_setup.ps1` - WSL orchestration, runs on Windows host +- `.assets/scripts/linux_setup.sh` - legacy Linux provisioning (requires root) +- `.assets/provision/install_*.sh` - individual tool installers +- `.assets/setup/setup_*.sh` - user-level configuration scripts + +## Common Commands + +**IMPORTANT**: Always run `make lint` before every commit and fix any failures. + +```bash +make lint # Run pre-commit hooks on changed files (use before committing) +make lint-all # Run pre-commit hooks on all files +make test # Run all Docker smoke tests (legacy + nix) +make test-legacy # Test .assets/scripts/linux_setup.sh in Docker +make test-nix # Test nix/setup.sh in Docker +make help # List all available make targets +``` + +**Tooling notes:** + +- Pre-commit runner is `prek` (not `pre-commit`) +- Use `pwsh` for PowerShell 7.4+ (not `powershell`) +- Use `gh` CLI for GitHub operations + +## Bash Style (`.sh`) + +- Shebang: `#!/usr/bin/env bash` +- Indentation: **2 spaces**; line length: **120 chars max** +- Error handling: `set -euo pipefail` +- Command substitution: `$(...)`, never backticks +- Functions: `snake_case`, private: `_prefixed`; prefer `local` for function-scoped variables +- Variables: `snake_case` locals, `UPPERCASE` constants/env +- Color output: `\e[31;1m` red/error, `\e[32m` green, `\e[92m` bright green, `\e[96m` cyan/info + +### Runnable examples block + +Every executable `.sh` and `.zsh` script must have a `: '...'` block immediately after the shebang with copy-pasteable examples. See `CONTRIBUTING.md` "Runnable examples block" for the format and rules. + +### Common Bash Patterns + +```bash +# Distro detection +SYS_ID="$(sed -En '/^ID.*(alpine|arch|fedora|debian|ubuntu|opensuse).*/{s//\1/;p;q}' /etc/os-release)" + +# Root check +if [ $EUID -ne 0 ]; then + printf '\e[31;1mRun the script as root.\e[0m\n' >&2 + exit 1 +fi +``` + +## PowerShell Style (`.ps1`) + +- Indentation: **4 spaces** +- Brace style: **OTBS** - opening `{` on same line as statement, closing `}` on its own line, block body always on separate lines +- Functions: `Verb-Noun` PascalCase (approved verbs only); use parameter splatting for >3 parameters +- Parameters: `PascalCase`; local variables: `camelCase` +- Public functions require comment-based help: `.SYNOPSIS`, `.PARAMETER`, `.EXAMPLE` +- `wsl_setup.ps1` uses `$Script:rel_*` variables to cache release versions across distro loops +- For conditional/loop statements with multiple conditions, all conditions and the opening `{` must be on the same line diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1af507e2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,193 @@ +# Contributing + +Development workflow, tooling, and conventions for working on this repo. For architecture and runtime layout, see +`ARCHITECTURE.md`. + +## Prerequisites + +| Tool | Version | Purpose | +| ------------ | ------- | ----------------------------------------- | +| `bash` | 3.2+ | Scripts (macOS system default is 3.2) | +| `prek` | latest | Pre-commit hook runner (not `pre-commit`) | +| `bats` | 1.5+ | Bash unit tests | +| `pwsh` | 7.4+ | PowerShell scripts and Pester tests | +| `jq` | any | Scope resolution (`scopes.sh`) | +| `python3` | 3.10+ | Pre-commit hook scripts | +| `shellcheck` | 0.9+ | Shell linting | +| `docker` | any | Smoke tests (optional) | + +## Quick start + +```bash +make install # install pre-commit hooks via prek +make lint # run hooks on changed files (do this before every commit) +make test-unit # run bats + Pester unit tests (fast, no Docker) +make test # run all tests including Docker smoke tests +make help # list all targets +``` + +## Development loop + +1. Make changes. +2. Run `make lint`. This stages all changes and runs pre-commit hooks. +3. Fix any failures. Re-run `make lint` until clean. +4. Commit. + +`make lint` runs `git add --all && prek run`, so it always checks the current working tree. Use `make lint-all` to +check every file in the repo, or `make lint-diff` to check only files changed since `main`. + +## Pre-commit hooks + +Configured in `.pre-commit-config.yaml`, run via `prek`. + +### Local hooks (`tests/hooks/`) + +| Hook | Script | What it checks | +| ----------------- | -------------------- | ------------------------------------------------------------- | +| `gremlins-check` | `gremlins.py` | Unwanted Unicode characters (zero-width spaces, smart quotes) | +| `align-tables` | `align_tables.py` | Auto-aligns markdown tables on save | +| `validate-scopes` | `validate_scopes.py` | `scopes.json` and `nix/scopes/*.nix` are consistent | +| `check-bash32` | `check_bash32.py` | Nix-path `.sh` files avoid bash 4+ constructs | +| `bats-tests` | `run_bats.py` | Runs bats unit tests when relevant files change | +| `pester-tests` | `run_pester.py` | Runs Pester unit tests when relevant files change | + +### External hooks + +| Hook | What it checks | +| -------------------------------------- | ------------------------------------------ | +| `check-executables-have-shebangs` | Executable files have a shebang line | +| `check-shebang-scripts-are-executable` | Files with shebangs are `chmod +x` | +| `end-of-file-fixer` | Files end with exactly one newline | +| `mixed-line-ending` | No mixed LF/CRLF | +| `trailing-whitespace` | No trailing whitespace (except `.md`) | +| `ruff-check` / `ruff-format` | Python lint + format (`tests/` only) | +| `markdownlint-cli2` | Markdown lint | +| `shellcheck` | Shell static analysis (severity: warning+) | + +ShellCheck global excludes: `SC1090` (non-constant source), `SC2139` (expand at define time), `SC2148` (missing +shebang on sourced files), `SC2155` (declare and assign separately), `SC2174` (mkdir mode). + +## File rules + +### Which bash version? + +| Files matching | Bash version | `set` flags | +| ------------------------------------------------------------------------------------------------------ | ------------ | ------------------- | +| `nix/**/*.sh`, `.assets/lib/{scopes,profile_block,nx_doctor,certs}.sh`, `.assets/config/bash_cfg/*.sh` | 3.2 | `set -eo pipefail` | +| `.assets/provision/*.sh`, `.assets/scripts/*.sh`, `.assets/check/*.sh` | 5.x (Linux) | `set -euo pipefail` | + +Nix-path files must avoid: `mapfile`, `declare -A`, `declare -n`, `${var,,}`, negative array indices, `sed -i ''`, +`sed -r`, `grep -P`. Full list in `ARCHITECTURE.md` under "Bash 3.2 / BSD sed constraints". Enforced by the +`check-bash32` pre-commit hook. + +### Sourced libraries vs executable scripts + +Sourced library files (e.g. `nix/lib/io.sh`, `nix/lib/phases/*.sh`, `.assets/lib/*.sh`) must **not** have a shebang +and must **not** be executable. They are loaded via `source` by the calling script. + +Executable scripts must have `#!/usr/bin/env bash` and be `chmod +x`. The `check-executables-have-shebangs` and +`check-shebang-scripts-are-executable` hooks enforce this. + +### Runnable examples block + +Every executable `.sh` and `.zsh` script must have a `: '...'` block immediately after the shebang. This lets the user run any example with the IDE "run current line" shortcut. Rules: + +- Use `# comment` lines to describe what the next example does +- The following line must be the bare runnable command - no `Usage:`, `Example:`, or any other prefix +- Never put prose descriptions or text with embedded single quotes inside the block (single quotes cannot be escaped inside `'...'`; move such text to `#` comments before the block) + +```bash +#!/usr/bin/env bash +: ' +# run as current user +.assets/setup/setup_foo.sh +# run with a specific option +.assets/setup/setup_foo.sh --option value +' +set -euo pipefail +``` + +## Testing + +### Unit tests (bats) + +Test files live in `tests/bats/*.bats`. Run with `bats tests/bats/` or `make test-unit`. + +Phase functions from `nix/lib/phases/` are tested by sourcing them directly and overriding `_io_*` wrappers from +`nix/lib/io.sh`: + +```bash +setup() { + # source libraries + source "$REPO_ROOT/nix/lib/io.sh" + source "$REPO_ROOT/nix/lib/phases/nix_profile.sh" + source "$REPO_ROOT/.assets/lib/scopes.sh" + + # override side effects AFTER sourcing + _io_nix() { echo "nix $*" >>"$BATS_TEST_TMPDIR/nix.log"; } + _io_run() { echo "run $*" >>"$BATS_TEST_TMPDIR/run.log"; } +} + +@test "nix_profile: apply runs profile add and upgrade" { + phase_nix_profile_apply + grep -q 'nix profile add' "$BATS_TEST_TMPDIR/nix.log" + grep -q 'nix profile upgrade nix-env' "$BATS_TEST_TMPDIR/nix.log" +} +``` + +The `_io_*` convention: phases call `_io_nix`, `_io_nix_eval`, `_io_curl_probe`, `_io_run` instead of the raw +commands. Tests redefine these to capture calls without executing them. Define stubs **after** sourcing `io.sh` +(sourcing redefines the defaults). + +### Unit tests (Pester) + +Test files live in `tests/pester/*.Tests.ps1`. Run with `make test-unit` or invoke Pester directly: + +```powershell +$pesterCfg = @{ + Run = @{ Path = 'tests/pester/'; Exit = $true } + Output = @{ Verbosity = 'Detailed' } +} +Invoke-Pester -Configuration @pesterCfg +``` + +### Smoke tests (Docker) + +`make test-nix` and `make test-legacy` build throwaway Docker images that run a full provisioning pass and verify +key binaries. Slower but catches integration issues that unit tests miss. + +### CI workflows + +| Workflow | What it tests | +| ----------------- | ---------------------------------------------------- | +| `test_linux.yml` | `setup.sh` on Linux: daemon + no-daemon (Coder) mode | +| `test_macos.yml` | `setup.sh` on macOS 14/15 (bash 3.2 + BSD sed) | +| `repo_checks.yml` | Pre-commit hooks (same as `make lint-diff`) | + +Trigger CI via PR labels `test:linux` / `test:macos`, or `workflow_dispatch` for manual runs. + +## Adding a new scope + +1. Create `nix/scopes/.nix` with the package list and a `# bins:` comment. +2. Add the scope to `.assets/lib/scopes.json` (`valid_scopes`, `install_order`, and `dependency_rules` if needed). +3. Add a `--` case to `phase_bootstrap_parse_args` in `nix/lib/phases/bootstrap.sh`. +4. If the scope needs post-install configuration, add `nix/configure/.sh` and a `case` entry in + `phase_configure_per_scope` in `nix/lib/phases/configure.sh`. +5. Run `make lint` (triggers `validate-scopes` + `bats-tests`). + +## Adding a new phase function + +1. Add the function to the appropriate file in `nix/lib/phases/`. +2. Document globals in the header comment (`# Reads:` / `# Writes:`). +3. Call it from `nix/setup.sh` at the right point in the phase sequence. +4. Use `_io_*` wrappers for any external commands (`nix`, `curl`, script invocations). +5. Add bats tests that stub `_io_*` and verify behavior. + +## Style reference + +Bash and PowerShell style guides are in `CLAUDE.md`. Key points: + +- **Bash**: 2-space indent, 120-char lines, `snake_case` functions, `UPPERCASE` constants, `local` for function + variables. +- **PowerShell**: 4-space indent, OTBS braces, `Verb-Noun` functions, `PascalCase` parameters, `camelCase` locals. +- Prefer no comments. Add one only when the *why* is non-obvious. diff --git a/Makefile b/Makefile index 7bd52384..a3a09ba1 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,28 @@ SHELL := /bin/bash # Default target .DEFAULT_GOAL := help +# MITM proxy support: use native TLS (OpenSSL) in prek to trust system CA certificates +export PREK_NATIVE_TLS := 1 +# tell Node.js/npm to trust custom MITM proxy certificates (for prek-managed node hooks) +CA_CUSTOM := $(wildcard $(HOME)/.config/certs/ca-custom.crt) +ifdef CA_CUSTOM +export NODE_EXTRA_CA_CERTS := $(CA_CUSTOM) +endif +# root certificate interception +define ROOT_CERT_CMD +command -v openssl >/dev/null 2>&1 || { printf '\e[31;1mopenssl not found, aborting.\e[0m\n' >&2; exit 1; }; \ +openssl s_client -showcerts -connect google.com:443 /dev/null \ + | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{ if(/BEGIN/){pem=""} pem=pem $$0 "\n" } END{printf "%s", pem}' +endef + +define ENSURE_ROOT_CERT +set -e; \ +ROOT_PEM=$$($(ROOT_CERT_CMD)); \ +[ -n "$$ROOT_PEM" ] || { printf '\e[31;1mFailed to retrieve root certificate.\e[0m\n' >&2; exit 1; }; \ +mkdir -p .assets/certs && printf '%s' "$$ROOT_PEM" >.assets/certs/ca-cert-root.crt +endef +CLEANUP_ROOT_CERT = rm -f .assets/certs/ca-cert-root.crt + .PHONY: help help: ## Show this help message @printf 'Usage: make [target]\n\n' @@ -15,16 +37,51 @@ install: ## Install pre-commit hooks prek install --overwrite upgrade: ## Upgrade prek and hooks versions @printf "\n✅ All dependencies upgraded\n\n" - prek self update prek auto-update +.PHONY: test test-unit test-legacy test-nix +test: test-unit test-legacy test-nix ## Run all tests (unit + Docker smoke) + +test-unit: ## Run bats unit tests (fast, no Docker) + @printf "\n\033[95;1m== Running unit tests ==\033[0m\n\n" + @bats tests/bats/ + @pwsh -c '$$cfg = New-PesterConfiguration; $$cfg.Run.Path = "tests/pester/"; $$cfg.Run.Exit = $$true; $$cfg.Output.Verbosity = "Detailed"; Invoke-Pester -Configuration $$cfg' + +test-legacy: ## Run Docker smoke test for legacy path + @printf "\n\033[95;1m== Testing legacy path (linux_setup.sh) ==\033[0m\n\n" + @$(ENSURE_ROOT_CERT) && \ + docker build --no-cache \ + -f .assets/docker/Dockerfile.test-legacy \ + --output type=image,name=lss-test-legacy,unpack=false . \ + && printf "\n\033[32;1m>> Legacy test PASSED\033[0m\n\n" \ + && docker rmi lss-test-legacy >/dev/null 2>&1; \ + $(CLEANUP_ROOT_CERT) + +test-nix: ## Run Docker smoke test for nix path + @printf "\n\033[95;1m== Testing nix path (nix/setup.sh) ==\033[0m\n\n" + @$(ENSURE_ROOT_CERT) && \ + docker build --no-cache \ + -f .assets/docker/Dockerfile.test-nix \ + --output type=image,name=lss-test-nix,unpack=false . \ + && printf "\n\033[32;1m>> Nix test PASSED\033[0m\n\n" \ + && docker rmi lss-test-nix >/dev/null 2>&1; \ + $(CLEANUP_ROOT_CERT) + +.PHONY: mkdocs-serve +mkdocs-serve: ## Serve mkdocs documentation with live reload + uv run --extra docs mkdocs serve --livereload + .PHONY: lint lint-diff lint-all lint: ## Run pre-commit hooks for changed files @printf "🧭 Running pre-commit hooks for changed files...\n\n" git add --all && prek run lint-diff: ## Run pre-commit hooks for files changed in this diff @printf "🧭 Running pre-commit hooks for files changed in this diff...\n\n" - @[ "$$(git branch --show-current)" = "main" ] && printf "⚠️ You are on the main branch. Skipping lint-diff.\n" || prek run --from-ref main --to-ref HEAD + @if [ "$$(git branch --show-current)" = "main" ]; then \ + printf "⚠️ You are on the main branch. Skipping lint-diff.\n"; \ + else \ + git add --all && prek run --from-ref main --to-ref HEAD; \ + fi lint-all: ## Run pre-commit hooks for all files @printf "🧭 Running pre-commit hooks for all files...\n\n" prek run --all-files diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..a9666f0c --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,35 @@ +# Support matrix + +## Platforms + +| Platform | Setup path | Status | +| ----------------------------- | -------------- | ----------- | +| macOS (Apple Silicon + Intel) | `nix/setup.sh` | Supported | +| Ubuntu / Debian | `nix/setup.sh` | Supported | +| Fedora / RHEL | `nix/setup.sh` | Supported | +| OpenSUSE | `nix/setup.sh` | Supported | +| WSL (any distro) | `nix/setup.sh` | Supported | +| Arch Linux | `nix/setup.sh` | Best-effort | +| Alpine Linux | `nix/setup.sh` | Best-effort | + +## Shell versions + +| Shell | Minimum version | Notes | +| ---------- | --------------- | --------------------------------------------------- | +| bash | 3.2 | macOS system default; no mapfile/associative arrays | +| zsh | 5.0 | macOS system default | +| PowerShell | 7.4 | Installed via `--pwsh` scope | + +## Nix + +| Requirement | Value | +| --------------- | -------------------------------------------------------------------------- | +| Minimum version | 2.18 | +| Recommended | Latest from [Determinate Systems](https://install.determinate.systems/nix) | +| Flakes | Required (enabled by default with Determinate installer) | +| Daemon mode | Supported (recommended) | +| No-daemon mode | Supported (rootless containers) | + +## Reporting issues + +Run `nx doctor --json` and include the output when reporting problems. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..3c4fb8ee --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,177 @@ +# Architecture + +This page provides a condensed view of the system's design - how the phases connect, where state lives, and how the extensibility model works. For the full implementation reference, see [ARCHITECTURE.md](https://github.com/szymonos/linux-setup-scripts/blob/main/ARCHITECTURE.md) in the repository. + +## Setup paths + +| Path | Entry point | Platforms | Requirements | +| ------------- | -------------------------------- | ------------------------ | ------------------------------- | +| Nix (primary) | `nix/setup.sh` | macOS, Linux, WSL, Coder | User-scope, bash 3.2 compatible | +| Legacy | `.assets/scripts/linux_setup.sh` | Linux | Root, bash 4+ | +| WSL | `wsl/wsl_setup.ps1` | Windows host | Admin, PowerShell 7.4+ | + +The Nix path is the recommended entry point for all platforms. It runs entirely in user scope after the one-time Nix installation and works identically on macOS (including bash 3.2 and BSD sed), Linux, WSL, and rootless container environments. + +## Phase pipeline + +`nix/setup.sh` is a slim orchestrator (~110 lines) that sources phase libraries and executes them in sequence. Each phase is a separate file with documented inputs and outputs. + +```mermaid +flowchart TD + B["Bootstrap + Root guard, Nix detect/install, + jq bootstrap, arg parsing"] --> PL["Platform + OS detect, overlay discovery, + pre-setup hooks"] + PL --> SC["Scopes + Load existing, apply removes, + resolve deps, write config.nix"] + SC --> NP["Nix Profile + Flake update, profile upgrade, + MITM probe + CA bundle"] + NP --> CF["Configure + gh auth, git config, + per-scope configure scripts"] + CF --> PR["Profiles + bash, zsh, PowerShell + managed block rendering"] + PR --> PI["Post-install + setup_common, GC, + summary output"] +``` + +Side-effecting operations (nix commands, git clones, curl probes) are called through thin wrappers defined in `io.sh`. Tests override these wrappers to assert behaviour without executing real side effects - no mocking frameworks needed. + +## Package composition + +Packages are assembled from four layers, evaluated bottom-up by the Nix flake. Each layer serves a different purpose and audience: + +```mermaid +graph TD + subgraph "Nix buildEnv (single profile entry)" + L4["Layer 4: Extra packages + (packages.nix - nx install/remove)"] + L3["Layer 3: Overlay scopes + (local_*.nix - team/org overlays)"] + L2["Layer 2: Repo scopes + (shell.nix, python.nix - nix/setup.sh flags)"] + L1["Layer 1: Base + (base.nix - always installed)"] + end + + L4 --> L3 --> L2 --> L1 +``` + +All four layers merge into a single `buildEnv` - one nix profile entry, one `nix profile upgrade` to apply. No layer can shadow or break another. + +## Durable state + +After setup, all state lives in the user's home directory. The repository clone is disposable. + +| Location | Purpose | Managed by | +| -------------------------------- | ---------------------------------- | ----------------------------------- | +| `~/.config/nix-env/` | Flake, scopes, config, theme files | `nix/setup.sh`, `nx` CLI | +| `~/.config/bash/` | Shell aliases and functions | `nix/configure/profiles.sh` | +| `~/.config/certs/` | CA bundles for proxy environments | `build_ca_bundle`, `cert_intercept` | +| `~/.config/dev-env/install.json` | Install provenance record | EXIT trap in setup scripts | + +The `nx` CLI operates entirely on `~/.config/nix-env/` - no network access, no server dependency, no repository clone required for day-to-day operations. + +## Overlay and hook system + +The overlay mechanism supports customization at multiple organizational levels without forking: + +```mermaid +flowchart LR + subgraph "Source" + OR["Org repo + (NIX_ENV_OVERLAY_DIR)"] + TR["Team hooks + (pre-setup.d/)"] + UR["User local + (~/.config/nix-env/local/)"] + end + + subgraph "Runtime (~/.config/nix-env/)" + SC["scopes/local_*.nix"] + BC["~/.config/bash/*.sh"] + HK["hooks/pre-setup.d/ + hooks/post-setup.d/"] + end + + OR --> SC + OR --> BC + TR --> HK + UR --> SC +``` + +- **Scopes** in the overlay directory are copied with a `local_` prefix, preventing name collisions with base scopes. +- **Shell configs** are sourced alongside standard configs at login. +- **Hooks** run at defined phases (`pre-setup`, `post-setup`) with access to environment variables like `NIX_ENV_SCOPES` and `NIX_ENV_PLATFORM`. + +## Health checks + +`nx doctor` runs read-only diagnostic checks against the managed environment. It is copied to `~/.config/nix-env/` during setup so it remains available after the repository is removed. + +| Check | What it verifies | +| ---------------- | ------------------------------------- | +| `nix_available` | Nix is in PATH | +| `flake_lock` | Lock file exists and is valid | +| `install_record` | Last setup completed successfully | +| `scope_binaries` | All declared binaries are on PATH | +| `shell_profile` | Exactly one managed block per rc file | +| `cert_bundle` | CA bundle and VS Code env configured | +| `nix_profile` | Profile entry exists | + +The `--strict` flag treats warnings as failures, suitable for CI validation. The `--json` flag produces machine-readable output for fleet monitoring. + +## Shell profile management + +Shell profiles (`.bashrc`, `.zshrc`, PowerShell `$PROFILE`) use a **managed block** pattern - a delimited section that is fully regenerated on each setup run. This replaces the fragile `grep -q && echo >>` append pattern. + +Two blocks are written to each file: + +- **`nix-env managed`** - nix-specific configuration (PATH, aliases, completions, prompt). Removed by `nix/uninstall.sh`. +- **`managed env`** - generic environment (local PATH, cert env vars, shared functions). Survives uninstall. + +`nx profile doctor` detects issues (missing blocks, duplicates, legacy injections). `nx profile migrate` converts legacy append-style configurations to managed blocks. + +## Uninstaller + +`nix/uninstall.sh` provides a complete, two-phase removal that restores the system to its pre-setup state. + +**Phase 1 (environment-only)** removes everything this tool created while preserving what it did not: + +- Nix-specific managed blocks removed from `.bashrc`, `.zshrc` - generic blocks (certs, local PATH) preserved +- Nix-prefixed `#region` blocks removed from PowerShell profiles - generic regions preserved +- `~/.config/nix-env/` directory, nix-specific aliases, zsh plugins, miniforge - all removed +- Nix profile entry removed (after file operations, so tools remain available during cleanup) +- Legacy prompt init lines and conda init blocks cleaned up +- Trailing blank lines normalized using bash builtins only (external tools may already be gone at this point) + +**Phase 2 (optional)** removes Nix itself, detecting whether the Determinate Systems installer or upstream single-user install was used and calling the appropriate removal method. + +Three modes are available: interactive (prompts before each phase), `--env-only` (scripted, keeps Nix), and `--all` (scripted, removes everything). A `--dry-run` flag previews all changes without touching anything. + +Every CI run validates the uninstaller: after `nix/uninstall.sh --env-only`, the pipeline asserts that the nix-env managed block is gone, the managed-env block is preserved, `~/.config/nix-env/` is removed, nix-specific aliases are removed, generic config files are preserved, and Nix itself is still installed. + +## CI validation matrix + +| Workflow | Runner | Scenario | +| ----------------- | ------------------ | ------------------------------------ | +| `test_linux.yml` | Ubuntu (daemon) | Multi-user Nix install | +| `test_linux.yml` | Ubuntu (no-daemon) | Rootless Nix (Coder/devcontainer) | +| `test_macos.yml` | macOS 15 | Apple Silicon, bash 3.2 + BSD sed | +| `repo_checks.yml` | Ubuntu | Pre-commit, ShellCheck, bats, Pester | + +Each test run validates: setup completion with requested scopes, binary resolution, `nx doctor --strict`, managed block idempotency, install provenance, and clean uninstall. + +## Design principles + +**Bootstrapper, not agent** - the tool runs once, provisions a self-contained environment, and exits. No daemon, no background process, no runtime dependency on infrastructure. + +**Explicit upgrades** - `nix/setup.sh` without `--upgrade` re-applies configuration using existing package versions. Package updates require explicit `--upgrade` or `nx upgrade`. No silent breakage from upstream changes. + +**Additive scopes** - adding a scope never removes existing tools. Scope dependencies are resolved automatically (e.g., `k8s_dev` pulls in `k8s_base`). Removing a scope is an explicit `--remove` action. + +**Tested constraints** - bash 3.2 and BSD sed compatibility is enforced by a pre-commit hook, not by convention. ShellCheck runs on every change. Scope definitions are validated by a Python script that ensures every scope declares its expected binaries. diff --git a/docs/assets/javascripts/diagram-zoom.js b/docs/assets/javascripts/diagram-zoom.js new file mode 100644 index 00000000..43d1ea9d --- /dev/null +++ b/docs/assets/javascripts/diagram-zoom.js @@ -0,0 +1,135 @@ +/** + * Lightbox / zoom enhancement for diagrams. + * Adds click-to-zoom, pan & wheel-zoom behaviour to mermaid diagrams. + */ +(function() { + const createLightbox = (svg) => { + if (document.querySelector('.mermaid-lightbox-overlay')) { + return; + } + + const overlay = document.createElement('div'); + overlay.className = 'mermaid-lightbox-overlay'; + + const clonedSvg = svg.cloneNode(true); + clonedSvg.className = 'mermaid-lightbox-svg'; + clonedSvg.style.maxWidth = '90%'; + clonedSvg.style.maxHeight = '90%'; + clonedSvg.style.cursor = 'grab'; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'mermaid-lightbox-close'; + closeBtn.innerHTML = '×'; + + overlay.appendChild(clonedSvg); + overlay.appendChild(closeBtn); + document.body.appendChild(overlay); + + addInteraction(overlay, clonedSvg); + + const closeLightbox = () => { + if (document.body.contains(overlay)) { + document.body.removeChild(overlay); + } + document.removeEventListener('keydown', onKeydown); + }; + + const onKeydown = (e) => { + if (e.key === 'Escape') closeLightbox(); + }; + + overlay.addEventListener('click', closeLightbox); + closeBtn.addEventListener('click', closeLightbox); + clonedSvg.addEventListener('click', e => e.stopPropagation()); + document.addEventListener('keydown', onKeydown); + }; + + const addInteraction = (container, svg) => { + let scale = 1, pointX = 0, pointY = 0; + let isDragging = false, startPos = { x: 0, y: 0 }; + + svg.style.transformOrigin = 'center'; + svg.style.transition = 'transform 0.1s ease-out'; + + const setTransform = () => { + svg.style.transform = `translate(${pointX}px, ${pointY}px) scale(${scale})`; + }; + + svg.addEventListener('mousedown', e => { + e.stopPropagation(); + isDragging = true; + startPos = { x: e.clientX - pointX, y: e.clientY - pointY }; + svg.style.cursor = 'grabbing'; + }); + + window.addEventListener('mousemove', e => { + if (!isDragging) return; + pointX = e.clientX - startPos.x; + pointY = e.clientY - startPos.y; + setTransform(); + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + svg.style.cursor = 'grab'; + }); + + container.addEventListener('wheel', e => { + e.preventDefault(); + const delta = e.deltaY < 0 ? 0.1 : -0.1; + scale = Math.max(0.2, Math.min(10, scale + delta)); + setTransform(); + }, { passive: false }); + }; + + const enhanceDiagram = (diagramDiv) => { + if (diagramDiv.dataset.enhanced) return; + diagramDiv.dataset.enhanced = 'true'; + diagramDiv.style.cursor = 'zoom-in'; + + diagramDiv.addEventListener('click', (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + + const svg = diagramDiv.querySelector('svg'); + if (svg) { + createLightbox(svg); + } + }, true); + }; + + const scanAndEnhance = () => { + const containers = document.querySelectorAll('.mermaid'); + containers.forEach(container => { + const svg = container.querySelector('svg'); + if (svg) { + enhanceDiagram(container); + } + }); + }; + + const waitForRenderedSVGs = () => { + const maxAttempts = 40; + let attempts = 0; + + const check = () => { + attempts++; + const svgs = document.querySelectorAll('.mermaid svg'); + if (svgs.length > 0) { + scanAndEnhance(); + } + + if (attempts < maxAttempts) { + setTimeout(check, 150); + } + }; + + check(); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', waitForRenderedSVGs); + } else { + waitForRenderedSVGs(); + } +})(); diff --git a/docs/assets/stylesheets/layout-wide-screen.css b/docs/assets/stylesheets/layout-wide-screen.css new file mode 100644 index 00000000..5c9cee2b --- /dev/null +++ b/docs/assets/stylesheets/layout-wide-screen.css @@ -0,0 +1,70 @@ +/* + Wide-screen layout adjustments for Material for MkDocs. + + Material constrains the page using `.md-grid` (in `rem`) and increases the root + font size on very wide screens (>= 100em and >= 125em). + + The rules below: + - widen the grid container on large screens + - prevent breakpoint-based font-size scaling +*/ + +html { + /* Prevent Material's breakpoint-based root font resizing */ + font-size: 125% !important; + + /* Prevent browser text autosizing (notably Safari/iOS) */ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +@media screen and (min-width: 76.25em) { + .md-grid { + max-width: 1600px; + } + + .md-sidebar--primary { + left: 32px; + } + + .md-sidebar--secondary { + right: 0; + margin-left: 32px; + -webkit-transform: none; + transform: none; + } +} + +@media screen and (min-width: 100em) { + .md-grid { + max-width: 1600px; + } + + .md-sidebar--primary { + left: 32px; + } + + .md-sidebar--secondary { + right: 0; + margin-left: 32px; + -webkit-transform: none; + transform: none; + } +} + +@media screen and (min-width: 125em) { + .md-grid { + max-width: min(1600px, calc(100vw - 64px)); + } + + .md-sidebar--primary { + left: 32px; + } + + .md-sidebar--secondary { + right: 0; + margin-left: 32px; + -webkit-transform: none; + transform: none; + } +} diff --git a/docs/assets/stylesheets/mermaid-lightbox.css b/docs/assets/stylesheets/mermaid-lightbox.css new file mode 100644 index 00000000..d6f9bf10 --- /dev/null +++ b/docs/assets/stylesheets/mermaid-lightbox.css @@ -0,0 +1,69 @@ +/** + * Lightbox and zoom styles for mermaid diagrams. + */ + +/* -- Inline diagrams -- */ +.mermaid { + cursor: zoom-in; +} + +/* -- Lightbox overlay -- */ +.mermaid-lightbox-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(30, 30, 30, 0.95); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +/* SVG inside the lightbox */ +.mermaid-lightbox-svg { + max-width: 90%; + max-height: 90%; + cursor: grab; + transform-origin: center; + transition: transform 0.1s ease-out; +} + +.mermaid-lightbox-svg.is-dragging { + cursor: grabbing; +} + +.mermaid-lightbox-overlay svg { + max-width: none !important; + max-height: none !important; + width: 90vw; + height: 90vh; + object-fit: contain; +} + +/* -- Close button -- */ +.mermaid-lightbox-close { + position: absolute; + top: 1rem; + right: 1.5rem; + width: 3rem; + height: 3rem; + background-color: rgba(255, 255, 255, 0.1); + color: #e0e0e0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 50%; + font-size: 2rem; + line-height: 1; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.mermaid-lightbox-close:hover { + background-color: rgba(255, 255, 255, 0.2); + color: #ffffff; + transform: rotate(90deg); +} diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 00000000..56389a4b --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,192 @@ +# Customization + +The tool is designed for customization at three levels - from individual developers adding a package to organizations distributing standardized environments - without forking the base repository. + +## Package layers + +Packages are assembled from four layers, evaluated bottom-up by the Nix flake. All layers merge into a single `buildEnv` - one nix profile entry, one `nix profile upgrade` to apply. No layer can shadow or break another. + +```mermaid +graph TD + subgraph "Nix buildEnv (single profile entry)" + L4["Layer 4: Extra packages + packages.nix - nx install/remove"] + L3["Layer 3: Overlay scopes + local_*.nix - team/org overlays"] + L2["Layer 2: Repo scopes + shell.nix, python.nix - nix/setup.sh flags"] + L1["Layer 1: Base + base.nix - always installed"] + end + + L4 --> L3 --> L2 --> L1 +``` + +| Layer | What | How | Who | +| ------------------ | ---------------------------------------------- | ----------------------------------- | ---------- | +| **Base** | Core tools (git, jq, curl, coreutils) | Always included, cannot be disabled | Base repo | +| **Repo scopes** | Curated groups (shell, python, k8s, terraform) | `nix/setup.sh --shell --python` | Base repo | +| **Overlay scopes** | Custom groups (team CLIs, org utilities) | `nx scope add` or overlay directory | Team / org | +| **Extra packages** | Individual tools | `nx install httpie` | Individual | + +## Adding packages (layer 4) + +The simplest way - no scopes, no files to edit: + +```bash +nx install httpie jq # validates against nixpkgs, installs immediately +nx remove httpie # remove a package +nx list # see everything installed, annotated by layer +``` + +Package names are validated against nixpkgs before being added. Typos and non-existent packages are caught immediately. Changes apply without needing `nx upgrade`. + +Use this for one-off tools. If you find yourself adding many related packages, consider creating a scope instead. + +## Creating custom scopes (layer 3) + +Scopes group related packages with a meaningful name: + +```bash +# Create a scope and add packages in one step +nx scope add devtools httpie jq bat + +# Add more packages to an existing scope +nx scope add devtools fd + +# Open a scope in your editor +nx scope edit devtools + +# Apply changes after manual edits +nx upgrade +``` + +The scope file is standard Nix: + +```nix +{ pkgs }: with pkgs; [ + bat + fd + httpie + jq +] +``` + +Scopes created via `nx scope add` live in the overlay directory and are copied into `~/.config/nix-env/scopes/` with a `local_` prefix - preventing name collisions with base repo scopes. + +## Overlay directory + +The overlay directory is where custom scopes, shell configs, and hooks live. It supports three usage patterns: + +| Pattern | Overlay location | Use case | +| -------------- | ------------------------------------------ | ------------------------------- | +| Solo developer | `~/.config/nix-env/local/` (default) | Personal tools and aliases | +| Team | Shared git repo via `NIX_ENV_OVERLAY_DIR` | Team-specific scopes and config | +| Organization | Org-managed repo via `NIX_ENV_OVERLAY_DIR` | Org-wide standards and hooks | + +### Structure + +```text +overlay-dir/ +├── scopes/ # custom scope files (copied as local_*.nix) +│ └── devtools.nix +├── bash_cfg/ # extra shell config (sourced on login) +│ └── aliases_custom.sh +└── hooks/ + ├── pre-setup.d/ # run before scope resolution + │ └── check_vpn.sh + └── post-setup.d/ # run after setup completes + └── notify_slack.sh +``` + +- **Scopes** are copied with `local_` prefix during `nix/setup.sh` or `nx scope add` +- **Shell configs** in `bash_cfg/` are sourced alongside standard configs at login +- **Hooks** run during `nix/setup.sh` at the indicated phase, with access to `NIX_ENV_PHASE`, `NIX_ENV_SCOPES`, and `NIX_ENV_PLATFORM` + +### Sharing with a team + +Point everyone at a shared overlay directory: + +```bash +# In ~/.bashrc or ~/.zshenv, before the managed block +export NIX_ENV_OVERLAY_DIR="$HOME/src/team-nix-overlay" +``` + +The shared repo can contain team-specific scopes, shell aliases, and setup hooks. Individual users can still use `nx install` for personal additions on top. + +### Managing overlays + +```bash +nx overlay list # show overlay directory contents +nx overlay status # show sync status (synced / modified / source missing) +``` + +## Hooks + +Hook scripts (`*.sh`) run at defined phases during `nix/setup.sh`, with access to environment variables: + +| Phase | Directory | Variables available | +| ---------- | --------------------- | ------------------------------------------------ | +| Pre-setup | `hooks/pre-setup.d/` | `NIX_ENV_VERSION`, `NIX_ENV_PLATFORM`, `ENV_DIR` | +| Post-setup | `hooks/post-setup.d/` | All above + `NIX_ENV_SCOPES` | + +Example use cases: + +- **VPN check** - verify VPN is connected before setup downloads packages +- **Fleet telemetry** - POST `install.json` to a monitoring endpoint after setup +- **Team pinning** - write `pinned_rev` to lock package versions across a team +- **Compliance** - verify required scopes are present, block setup if missing + +## Pinning package versions + +By default, `nx upgrade` resolves the latest `nixpkgs-unstable` - a deterministic snapshot of 100k+ packages at specific versions. Each commit is a reproducible state, not a rolling release. + +For teams that need coordinated versions: + +```bash +nx upgrade # upgrade and verify everything works +nx pin set # pin the current (tested) revision +nx pin show # show current pin +nx pin remove # go back to latest unstable +``` + +The pin is stored in `~/.config/nix-env/pinned_rev`. When present, `nx upgrade` locks to that commit instead of resolving the latest. Distribute the pin via a pre-setup hook: + +```bash +# overlay-dir/hooks/pre-setup.d/pin_nixpkgs.sh +echo "abc123..." > "$HOME/.config/nix-env/pinned_rev" +``` + +When IT validates a new nixpkgs revision, they update the hook - everyone gets the tested baseline on next upgrade. + +## Upgrades and rollback + +```bash +nx upgrade # upgrade all packages to latest (or pinned) versions +nx rollback # revert to the previous package set +nix profile diff-closures # see exactly what changed +nx gc # clean up old generations +``` + +Upgrades are atomic - all packages upgrade together through a single `buildEnv`. Rollback reverts the entire environment to the previous state, not individual packages. This eliminates partial upgrade states. + +## Quick reference + +| Task | Command | +| ---------------------------- | ------------------------------------ | +| Add a package | `nx install ` | +| Remove a package | `nx remove ` | +| List all packages | `nx list` | +| Search nixpkgs | `nx search ` | +| Create a scope with packages | `nx scope add [pkg...]` | +| Edit a scope file | `nx scope edit ` | +| Show scope tree | `nx scope tree` | +| List overlay contents | `nx overlay list` | +| Check overlay sync status | `nx overlay status` | +| Upgrade packages | `nx upgrade` | +| Pin current revision | `nx pin set` | +| Show / remove pin | `nx pin show` / `nx pin remove` | +| Roll back last upgrade | `nx rollback` | +| See what changed | `nix profile diff-closures` | +| Clean up old generations | `nx gc` | +| Run health checks | `nx doctor` | diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 00000000..d0084ceb --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,189 @@ +# Design Decisions + +Every tool makes architectural choices that shape what it can and cannot do. This page explains the reasoning behind the key decisions in this project - not just what was chosen, but why the obvious alternatives were rejected. + +## Architecture decisions + +### Why Nix, not Homebrew + +**The objection:** "Homebrew works fine, everyone knows it, and it's already installed on most Macs." + +Homebrew is an excellent macOS package manager. It is not a cross-platform environment provisioning tool. The differences matter at scale: + +| Capability | Homebrew | Nix | +| ------------------------ | -------------------------------- | ----------------------------------------- | +| Atomic rollback | No | Yes (`nix profile rollback`) | +| Reproducible pins | No lock file, no version pinning | `flake.lock` + `nx pin set ` | +| Cross-platform | macOS-first, Linux second-class | macOS, Linux, WSL, containers - identical | +| User-scope after install | Requires sudo for updates | No root after initial install | +| Package composition | Flat list, no grouping | `buildEnv` merges scopes atomically | +| Binary cache | Bottles (limited arch coverage) | 100k+ cached packages, multi-arch | + +Beyond the feature comparison, Nix provides a structural advantage: **store-based isolation**. Every package is installed into a content-addressed path (`/nix/store/--/`), so multiple versions of the same tool coexist without conflict and upgrades never leave the system in a half-updated state. Homebrew mutates shared prefixes in-place - an interrupted `brew upgrade` can leave broken symlinks that require manual cleanup. Nix's immutable store makes rollback a pointer swap, not a repair job. + +The runtime characteristics reflect the architectural gap. Nix's core is C++ with a purpose-built functional evaluation language - every invocation resolves a dependency graph declaratively and applies the result atomically. Homebrew is a cohesive Ruby codebase, but every `brew` invocation pays Ruby interpreter startup cost, formula evaluation is interpreted, and `brew upgrade` processes packages sequentially with per-package overhead. At scale - dozens of packages across scopes - the difference compounds. + +Homebrew installs packages. Nix provisions environments - declaratively, atomically, and reproducibly. + +**The enterprise off-ramp:** Nix adoption carries organizational risk. [Determinate Systems](https://determinate.systems/nix/macos/mdm/) provides a commercially supported Nix installer with MDM integration (Jamf, Intune), enterprise support contracts, and managed fleet deployment. If the organization decides to formalize Nix adoption, the transition from the open-source installer to the enterprise offering is a configuration change, not a rewrite. The tool is already built on the Determinate Systems installer as its recommended installation method. + +### Why not golden images + +**The objection:** "Just bake a VM image or container with everything pre-installed, push it to developers." + +Golden images are the default enterprise answer to environment standardization. They fail in developer workstation contexts for several reasons: + +**WSL golden images require disproportionate effort.** Building a custom WSL disk image is technically possible, but it demands a custom build pipeline, manual distribution, and ongoing maintenance - significantly more effort than a bootstrapper that provisions WSL in minutes from a single PowerShell command. WSL is the most popular development environment on enterprise Windows; any solution that cannot provision it easily is incomplete in practice. + +**Images go stale immediately.** A golden image is a snapshot. The day after distribution, packages are outdated. Every update requires a new build-test-distribute cycle through the MDM pipeline. Developers either wait for the next image refresh or install tools manually on top - re-creating the inconsistency the image was supposed to prevent. + +**Images solve certificates at the wrong layer.** A golden image can ship with corporate CA certificates pre-installed, covering the OS trust store and some frameworks. But certificates expire - when they do, every deployed image needs a rebuild or a separate automation to rotate certs, which is exactly the tooling this solution already provides. More fundamentally, golden images resolve certificate trust at the system level and for some framework-level paths, but not all execution paths. An application not launched via bash will not see environment variables set in a bash profile. Different frameworks consult different certificate stores. This tool resolves MITM certificate issues at runtime, independently of how each framework is launched, covering execution paths that images cannot reach. + +**Images cannot handle diverse network environments.** Vendors and contractors typically cannot receive golden images - they work on their own hardware. This solution is far more accessible: clone the repo, run the setup. Additionally, contractors connecting through external networks often encounter different MITM certificate chains than internal employees. A golden image baked against the internal proxy will fail on a contractor's network. Runtime certificate interception handles both scenarios transparently. + +**Images are all-or-nothing.** A data scientist and a platform engineer need different toolchains. Golden images either ship everything (bloated, slow to distribute) or require multiple image variants (multiplied maintenance). Scopes solve this: `--shell --python` for the data scientist, `--shell --k8s-dev --terraform` for the platform engineer, from the same base. + +This tool takes the opposite approach: a lightweight bootstrapper that runs on the developer's actual machine, detects the actual network environment, installs exactly what's needed, and stays current via `nx upgrade`. It works on every platform - including WSL - because it provisions rather than snapshots. + +### Why a bootstrapper, not a configuration management agent + +**The objection:** "Use Ansible, Chef, or Puppet - that's what configuration management tools are for." + +Configuration management tools are designed for servers: homogeneous fleets, root access, persistent agents, central control planes. Developer workstations are the opposite: + +| Server fleet | Developer workstation | +| ----------------------- | -------------------------------- | +| Homogeneous OS | macOS + WSL + Linux + Coder | +| Root access guaranteed | Managed machines restrict root | +| Agent runs continuously | No daemon, no background process | +| Central server required | Works offline after setup | +| IT-managed | Developer-managed | + +Ansible requires Python and SSH. Chef requires a server and a Ruby agent. Puppet requires a daemon and a control plane. All three assume root access and target a single OS distribution per playbook/recipe/manifest. None handle the cross-platform, user-scope, rootless requirements of developer workstations. + +This tool is a **bootstrapper**: it runs once, provisions a self-contained environment in `~/.config/nix-env/`, and exits. No daemon, no server, no runtime dependency. After setup: + +- `nx upgrade` updates packages - no central server needed +- `nx rollback` reverts if something breaks - no IT ticket needed +- `nx doctor` runs health checks - no monitoring agent needed +- The repository clone is disposable - all state is local + +The bootstrapper model means zero operational overhead: no agent to monitor, no server to maintain, no network dependency for day-to-day use. The tool provisions the environment and gets out of the way. From there, developers can continue using the repo individually to manage their environment, or teams and organizations can distribute overlays to extend and customize capabilities - without contributing to the upstream repository. + +## Implementation decisions + +### Why nixpkgs-unstable, not a stable channel + +**The objection:** "The word 'unstable' is right there in the name. Use a stable release for production tooling." + +The name is misleading. `nixpkgs-unstable` is not raw `main` - every commit is validated by Hydra (NixOS's CI system) through build tests before being promoted to the channel. It is a rolling release with quality gates, not a bleeding-edge feed. Compared to Arch Linux (daily builds, minimal testing) or Fedora Rawhide (nightly composes), nixpkgs-unstable is more conservative - updates land days after upstream release, not hours. + +Stable nixpkgs channels (e.g., `nixos-24.11`) exist but serve a different purpose: they hold back major version updates and apply only security and critical bug fixes. For developer workstations, this creates a maintenance problem. Developers expect reasonably current versions of kubectl, terraform, ripgrep, and Node.js - not versions frozen six months ago. A stable channel means either accepting outdated tools or manually overriding package versions, which defeats the purpose of a curated package set. + +Using `nixpkgs-unstable` eliminates this version chasing entirely. The channel tracks upstream releases through a validated pipeline of 120,000+ packages, so `nx upgrade` pulls current, CI-tested versions without per-package version management. The trade-off is explicit: upgrades are never automatic. `nix/setup.sh` without `--upgrade` re-applies configuration using existing package versions. `nx upgrade` is the deliberate action that pulls new versions, and `nx rollback` reverts if something breaks. + +For teams that need coordinated versions, `nx pin set` locks the entire nixpkgs input to a specific commit SHA. Everyone on the team resolves the same package versions until the pin is updated. This provides reproducibility without the staleness of a stable channel - the team controls when to advance, and the pin is a single value rather than per-package version overrides. + +### Why bash 3.2 compatibility + +**The objection:** "It's 2026. Just require bash 5 and use modern features." + +macOS ships bash 3.2 as the system default. Apple will not update it due to GPLv3 licensing. This creates a bootstrapping paradox: **the tool that sets up your environment cannot require you to already have a setup environment.** + +If the setup script required bash 5, users would need to install it first - via Homebrew, Nix, or manual compilation. That prerequisite defeats the purpose of a one-command setup tool. The script must work with what the operating system provides out of the box. + +The constraint is real and affects daily development: + +- No `mapfile` or `readarray` - use `while IFS= read -r` loops +- No associative arrays (`declare -A`) - use space-delimited strings with helper functions +- No case modification (`${var,,}`) - use `tr` +- No namerefs (`declare -n`) - pass variable names as strings +- BSD `sed` and `grep` - no GNU extensions (`\s`, `\w`, `-P`, `-r`) + +This is not enforced by convention. A custom pre-commit hook (`check_bash32.py`) scans every nix-path file for bash 4+ constructs and blocks the commit if any are found. The macOS CI workflow validates the constraint on every pull request by running the full setup on a macOS runner with the system bash. + +Linux-only scripts (provisioning, system checks) use bash 5 features freely - the constraint applies only to files that run on macOS. + +### Why oh-my-posh and starship, not oh-my-zsh + +**The objection:** "Oh-my-zsh works great for me - it has hundreds of themes and plugins." + +Oh-my-zsh is a zsh framework. That is exactly the problem: + +| Capability | oh-my-zsh | oh-my-posh / starship | +| ------------------- | ------------------ | ---------------------------------------- | +| Shell support | zsh only | bash, zsh, PowerShell, fish, cmd, nu | +| Platform parity | Requires zsh setup | Works on any shell the platform ships | +| Startup performance | Plugin-dependent | Single binary, sub-50ms prompt render | +| Configuration | ~/.zshrc framework | Standalone config file, no shell lock-in | + +A cross-platform tool that standardizes the developer experience cannot anchor its prompt to a single shell. Developers on this tool use bash on Coder, zsh on macOS, and PowerShell on Windows - often all three in the same week. Oh-my-posh and starship render an identical prompt across all of them from a single theme file. + +Both engines are offered as opt-in scopes rather than forcing one choice: + +- **oh-my-posh** (Go, mature ecosystem, rich themes) - default recommendation for macOS and WSL where startup latency is less critical +- **starship** (Rust, faster cold-start) - preferred on Coder where container startup time matters and resource budgets are tighter + +The scopes are mutually exclusive at runtime (`--omp-theme` removes starship and vice versa) but both remain available. This lets teams standardize on a prompt engine while respecting environment-specific trade-offs. + +### Why managed blocks, not append-style profile injection + +**The objection:** "Just append a line to `.bashrc` - it's simpler." + +The `grep -q 'pattern' || echo 'line' >> ~/.bashrc` pattern is the most common approach to shell profile configuration. It is also the most fragile: + +- Running setup twice appends duplicate entries unless the grep is perfectly maintained +- Removing configuration requires manual editing or fragile `sed` deletion +- Uninstallation leaves orphaned lines that can cause errors after the tool is removed +- There is no way to update configuration in place - only append more + +This tool uses a **managed block** pattern instead. Configuration is written between sentinel markers (`# >>> nix-env managed >>>` / `# <<< nix-env managed <<<`) and fully regenerated on each run: + +- **Idempotent** - running setup any number of times produces identical results, validated by CI on every PR +- **Updatable** - the block is replaced atomically, not appended to +- **Removable** - `nix/uninstall.sh` deletes the block cleanly, leaving the rest of the profile intact +- **Diagnosable** - `nx doctor` detects duplicate or missing blocks + +The same pattern is implemented for PowerShell via `#region`/`#endregion` markers and `Update-ProfileRegion`. Two block types separate nix-specific config (removed on uninstall) from generic config (certs, local PATH - preserved after uninstall). + +### Why phase-based orchestration with side-effect stubs + +**The objection:** "It's a setup script - just write it top to bottom." + +A 600-line bash script written top-to-bottom is untestable by definition. Functions cannot be sourced in isolation, side effects execute on import, and tests resort to brittle `sed` extraction to test individual functions. + +This tool uses a **phase library** architecture: `nix/setup.sh` is a slim ~110-line orchestrator that sources independent phase files from `nix/lib/phases/`. Each phase exports functions with documented inputs and outputs (`# Reads:` / `# Writes:` header comments). Side-effecting operations (nix commands, curl probes, external script invocations) are routed through thin wrappers in `nix/lib/io.sh`: + +```bash +_io_nix() { nix "$@"; } +_io_curl_probe() { curl -sS "$1" >/dev/null 2>&1; } +_io_run() { "$@"; } +``` + +Tests override these wrappers by function redefinition before sourcing the phase under test - three lines per test, zero framework overhead: + +```bash +setup() { + _io_nix() { echo "nix $*" >>"$BATS_TEST_TMPDIR/nix.log"; } + source "$REPO_ROOT/nix/lib/io.sh" + source "$REPO_ROOT/nix/lib/phases/nix_profile.sh" +} +``` + +This pattern makes bash scripts testable at a level normally associated with compiled languages - without mocking frameworks, without PATH manipulation, without subprocess overhead. It is the reason this project has 412 test cases across 22 test files for what is, at its core, a shell script. + +### Why JSON as the shared schema format + +**The objection:** "Bash scripts should use bash-native data formats." + +Scope metadata (valid names, install order, dependency rules) lives in a single `scopes.json` consumed by three runtimes: + +| Consumer | Parser | +| ---------- | ------------------ | +| bash | `jq` | +| PowerShell | `ConvertFrom-Json` | +| Python | `json` stdlib | + +JSON is the only format all three parse natively without a custom parser. Alternatives (bash-sourceable data, TSV, INI) would force either a fragile parallel parser in PowerShell/Python or a source-of-truth split between bash-data and JSON-data. A single source of truth means scope definitions are always consistent across `nix/setup.sh`, `wsl/wsl_setup.ps1`, and the `validate_scopes.py` pre-commit hook. + +The only cost is that bash 3.2 on a bare macOS has no JSON parser, so `jq` must be bootstrapped before scope resolution can run. This is handled by a minimal `base_init.nix` scope (~13 lines) that installs `jq` on first run and is skipped on all subsequent runs - a bounded, one-time cost for a permanent architectural benefit. diff --git a/docs/enterprise.md b/docs/enterprise.md new file mode 100644 index 00000000..7a632dd1 --- /dev/null +++ b/docs/enterprise.md @@ -0,0 +1,161 @@ +# Enterprise Readiness + +An honest assessment of where this tool stands today, what it provides out of the box, and what would need investment - on both the tool side and the enterprise infrastructure side - for full organizational adoption. + +## Maturity summary + +| Dimension | Rating | Detail | +| ------------------ | -------------------- | ---------------------------------------------------------------------------- | +| Code quality | Strong | 412 test cases, 7 custom pre-commit hooks, ShellCheck, multi-platform CI | +| Architecture | Strong | Phase-separated orchestrator, testable without mocking, documented call tree | +| Cross-platform | Strong | macOS (bash 3.2 + BSD), Linux, WSL, Coder - all CI-validated | +| Documentation | Strong | ARCHITECTURE.md, CONTRIBUTING.md, SUPPORT.md, inline design rationale | +| Corporate proxy | Strong | Automatic MITM detection, CA bundle merging, per-tool env var configuration | +| Extensibility | Strong | Three-tier overlay model, hook system, custom scopes without forking | +| Upgrade/rollback | Strong | Atomic upgrades via Nix, `nx rollback`, `nx pin` for fleet coordination | +| Fleet telemetry | Scaffold only | `install.json` provenance + `nx doctor --json` - consumer not included | +| MDM integration | Ready (not included) | Unattended mode works; Determinate Systems provides the MDM installer | +| Policy enforcement | Extension point | Hook directories exist; enforcement logic is a downstream concern | + +## What's production-ready today + +### Self-contained environment lifecycle + +The complete install → configure → upgrade → rollback → uninstall lifecycle works without external dependencies: + +- `nix/setup.sh` provisions the environment (one command, idempotent) +- `nx upgrade` / `nx rollback` manage package versions +- `nx doctor --strict` validates environment health +- `nix/uninstall.sh` cleanly removes everything (two-phase: environment-only or full Nix removal, with `--dry-run` preview). CI-validated on every PR - assertions verify that nix-specific config is removed while generic config is preserved + +### Organizational customization without forking + +The overlay system supports three levels of customization: + +- **Base layer** - curated scopes shipped with this repository +- **Overlay layer** - organization or team scopes, shell config, and hooks distributed via `NIX_ENV_OVERLAY_DIR` +- **User layer** - individual packages via `nx install` + +An organization can maintain its own overlay repository with custom scopes (internal CLI tools, team-specific packages), shell aliases, and setup hooks - without modifying the base repository. Base updates are pulled independently of overlay changes. + +### IDP integration surface + +The tool provides building blocks that an Internal Developer Platform can consume: + +| Building block | What it provides | How an IDP consumes it | +| ------------------ | --------------------------------------------------------------- | -------------------------------------------------- | +| Version identity | `NIX_ENV_VERSION`, git tags, `VERSION` file in release tarballs | Catalog entity metadata | +| Health checks | `nx doctor --json` | Monitoring endpoint, fleet health dashboard | +| Install provenance | `install.json` (version, scopes, timestamp, status) | Audit trail, compliance reporting | +| Hook directories | `pre-setup.d/`, `post-setup.d/` | Org policy injection without forking | +| Overlay mechanism | `NIX_ENV_OVERLAY_DIR` | Scope and config distribution | +| Env var namespace | `NIX_ENV_*` reserved for extensions | Enterprise-specific configuration | +| Unattended mode | `--unattended` flag | MDM deployment, Ansible playbooks, Coder templates | + +### Certificate and proxy handling + +Automatic MITM proxy detection and certificate configuration is production-ready and tested. See [Corporate Proxy](proxy.md) for the full technical flow. This alone saves hours per developer in environments with TLS inspection. + +## What needs enterprise investment + +### Nix approval (strategic, highest impact) + +Nix is the foundational dependency. Before organizational adoption, InfoSec and platform teams need to evaluate: + +- **Supply chain:** packages come from `nixpkgs-unstable`, a community-maintained repository. Nix provides reproducible builds and content-addressable storage, but the upstream is not vendor-managed. +- **Network requirements:** Nix downloads from `cache.nixos.org` (binary cache). Air-gapped environments need a local cache or binary mirror. +- **MDM compatibility:** [Determinate Systems](https://determinate.systems/nix/macos/mdm/) provides a commercially supported installer with Jamf/Intune integration - the tool already uses their installer as the recommended method. + +**Mitigation:** `nx pin set ` locks all packages to a specific nixpkgs commit SHA. Distributed via overlay hooks, this ensures every developer runs identical, audited package versions. The pin mechanism is already implemented and CI-tested. + +### Fleet telemetry + +The building blocks exist (`install.json` for provenance, `nx doctor --json` for health), but the consumer - the system that collects, aggregates, and dashboards this data - is an enterprise infrastructure concern. Options: + +- **Lightweight:** Post-setup hook that `curl`s provenance to an internal endpoint +- **Full:** Scheduled `nx doctor --json` output to a monitoring system (Datadog, Grafana) + +The hook system (`post-setup.d/`) provides the injection point. The telemetry endpoint and data contract are decisions for the platform team. + +### Policy enforcement + +The overlay hook system provides the mechanism (code runs at defined phases with access to environment variables). Example policies an organization might enforce: + +- Minimum tool versions +- Required scopes for specific teams +- Mandatory proxy certificate configuration +- Package allowlists or blocklists + +The enforcement logic itself is downstream - it belongs in the organization's overlay repository, not in the base tool. + +### Distribution + +The tool is currently distributed as a git clone. For enterprise deployment, additional distribution channels may be needed: + +- **Release tarballs** - versioned archives for artifact stores (Artifactory, Nexus) +- **MDM scripts** - wrapper scripts for Jamf/Intune that handle the initial Nix install + setup invocation +- **Coder templates** - pre-configured dev container definitions that run setup on workspace creation + +The GitHub Actions workflow for release automation (tag → test → build → publish) is designed but not yet implemented. + +## Strengths for enterprise adoption + +### Solves the hardest problems first + +Most environment setup tools handle the easy case (installing packages on a single OS) and ignore the hard cases: + +- **MITM proxy certificates** - detected automatically, configured for every tool, persisted across shell sessions. This is the number one developer productivity drain in corporate environments and the problem most tools don't even attempt. +- **WSL environments** - WSL is the most popular development environment on enterprise Windows. It cannot be golden-imaged, its certificate trust store is separate from Windows, and most setup tools don't support it. This tool handles WSL end-to-end, including certificate propagation from the Windows host. +- **Rootless containers** - Coder and devcontainer environments have no root access and no systemd. The tool works without either. + +### Engineering discipline as a feature + +The tool's own development practices demonstrate the standards it enables: + +- 412 test cases covering bash, PowerShell, and integration scenarios +- Custom pre-commit hooks that enforce bash 3.2 compatibility, scope consistency, and documentation quality +- Multi-platform CI that validates every change on macOS and Linux +- Idempotency verified on every pull request + +This is not typical for infrastructure tooling. It signals that the tool is maintained to application-grade standards and can be relied upon for production use. + +### Clean separation of concerns + +The tool does not try to be an IDP, a fleet manager, or a policy engine. It provides: + +- A provisioning mechanism (setup) +- A management interface (nx CLI) +- A health check system (nx doctor) +- Extension points (hooks, overlays, env vars) + +The enterprise layer - telemetry, policy, distribution, fleet management - is built *on top of* these primitives, not baked into the tool. This separation means the base tool remains simple, testable, and upgradeable, while the enterprise layer can evolve independently. + +## Risks and mitigations + +| Risk | Severity | Mitigation | +| ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| Nix not approved by InfoSec | High | Determinate Systems commercial support; `nx pin` for supply chain control; content-addressable store for auditability | +| nixpkgs-unstable drift | Medium | `nx pin set ` locks versions; explicit `--upgrade` required; no silent updates | +| Cognitive load (macOS + WSL + Coder + bash 3.2/5) | Medium | Phase-separated architecture; each phase independently testable; comprehensive ARCHITECTURE.md | +| External GitHub fetch at install time | Medium | Release tarballs for air-gapped use; overlay for internal mirrors | +| macOS MDM conflicts with Nix | Low | Determinate Systems MDM installer designed for managed fleets | + +## Adoption path + +```mermaid +flowchart LR + E["Evaluate + Run setup on a test machine, + review nx doctor output"] --> P["Pilot + Small team, overlay repo + with team-specific scopes"] + P --> S["Standardize + Org overlay, MDM deployment, + fleet telemetry hooks"] + S --> SC["Scale + IDP catalog entry, + policy enforcement, + compliance reporting"] +``` + +Each stage is independently valuable. A single developer benefits from the setup automation. A team benefits from shared scopes and overlays. The organization benefits from fleet visibility and policy enforcement. The tool does not require full organizational commitment to deliver value at each stage. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..83130a09 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,181 @@ +# Dev Environment Setup + +Your development environment works today. It will break the day you switch to a new machine, connect to a different network, rotate a certificate, or onboard a teammate who needs the same setup you spent a week assembling from memory. The tools are installed - but are they reproducible? Can you roll them back? Can you prove to anyone what version of what is running where? + +This tool turns a developer workstation into managed infrastructure. One command provisions a complete, standards-compliant environment across macOS, Linux, WSL, and Coder. Everything is declarative, versioned, upgradeable, and cleanly uninstallable - with rollback when upgrades break. Corporate proxy certificates are detected and resolved automatically - across Nix, Python, Node.js, and every other framework that has its own trust store. + +```bash +nix/setup.sh --shell --python --pwsh --k8s-base +``` + +After setup, the repository clone is disposable. All state lives in `~/.config/nix-env/`, managed by the built-in `nx` CLI: + +```bash +nx install httpie # add a package +nx upgrade # upgrade all packages +nx rollback # revert if something breaks +nx doctor # run health checks +``` + +## Why this exists + +Large engineering organizations without a standard approach to developer workstation setup experience a predictable set of problems: + +- **Inconsistent tooling** - developers install tools manually, from different sources, at different versions. "Works on my machine" is a daily conversation. Linting tools, pre-commit hooks, and Makefiles - the building blocks of code quality - remain a rarity because there is no baseline that includes them. + +- **SSL/TLS certificate failures** - corporate MITM inspection proxies replace upstream certificates. Every tool that makes HTTPS requests (git, curl, pip, npm, az, terraform) breaks with cryptic SSL errors. Developers lose hours on workarounds that are fragile and tool-specific. See [Corporate Proxy](proxy.md) for how this tool solves it. + +- **Platform fragmentation** - some teams use macOS, others run Linux in WSL, cloud environments add a third variant. Each platform has its own package manager, shell configuration conventions, and trust store. Supporting all three is expensive and rarely attempted. + +- **No reproducibility** - onboarding a new developer takes hours of manual setup. Rebuilding after a hardware failure repeats the same effort. There is no way to audit what is installed, no rollback path when an upgrade breaks something, and no mechanism to coordinate package versions across a team. + +## What it provides + +### Standards out of the box + +Every installation includes a curated baseline: git with sane defaults, shell aliases, pre-commit tooling, Makefile completion, and consistent shell configuration across bash, zsh, and PowerShell. Teams that adopt this tool inherit a shared vocabulary of commands, aliases, and workflows without additional effort. + +### Transparent proxy and certificate handling + +Corporate proxy issues are detected and resolved automatically during setup. The tool intercepts MITM proxy certificates, builds a merged CA bundle, and configures every tool that needs it - git, curl, pip, npm, az, terraform, and nix-built binaries - through the correct environment variables. On macOS, certificates are exported directly from the Keychain. See [Corporate Proxy](proxy.md) for the full flow. + +### Cross-platform consistency + +The same tool, the same scopes, and the same `nx` commands work identically across all supported platforms. Developers switch platforms without learning a new setup process. Teams standardize on a shared configuration regardless of hardware preferences. + +| Platform | Entry point | Root required | Shell support | +| ---------------------------------------------- | ------------------- | ------------------------ | --------------------- | +| macOS (Apple Silicon, Intel) | `nix/setup.sh` | One-time for Nix install | bash, zsh, PowerShell | +| Linux (Debian, Ubuntu, Fedora, RHEL, openSUSE) | `nix/setup.sh` | One-time for Nix install | bash, zsh, PowerShell | +| WSL (Windows Subsystem for Linux) | `wsl/wsl_setup.ps1` | Windows admin | bash, zsh, PowerShell | +| Coder / devcontainers | `nix/setup.sh` | None (rootless) | bash, zsh, PowerShell | + +### Declarative and reproducible + +The entire environment is defined in Nix scope files - plain text that can be version-controlled, code-reviewed, and audited. `nx pin` coordinates package versions across a team by locking to a specific nixpkgs commit. Every installation writes provenance metadata to `install.json`, enabling fleet-wide visibility into what is deployed where. + +### Safe upgrades, rollback, and clean uninstall + +`nx upgrade` pulls the latest packages. `nx rollback` reverts to the previous generation if something breaks. `nix profile diff-closures` shows exactly what changed. When the tool is no longer needed, `nix/uninstall.sh` cleanly removes everything it created - nix-specific shell config, aliases, plugins, state directories - while preserving generic configuration (certificates, local PATH) that other tools may depend on. A `--dry-run` flag previews all changes before committing. The entire lifecycle - install, upgrade, rollback, uninstall - is explicit, auditable, and reversible. + +## Scope system + +Packages are organized into **scopes** - curated groups that can be composed to match a team's technology stack. Scopes are additive: adding a new scope never removes existing tools. Dependencies are resolved automatically (e.g., `k8s-dev` pulls in `k8s-base`). + +| Scope | What it provides | +| ----------- | ------------------------------------------- | +| `shell` | fzf, eza, bat, ripgrep, yq | +| `python` | uv, prek | +| `pwsh` | PowerShell 7 | +| `k8s-base` | kubectl, kubelogin, k9s, kubecolor, kubectx | +| `k8s-dev` | helm, flux, kustomize, trivy, argo, cilium | +| `az` | Azure CLI, azcopy | +| `terraform` | terraform, tflint | +| `nodejs` | Node.js | +| `conda` | Miniforge | +| `docker` | Docker post-install configuration | + +Prompt engines (oh-my-posh, starship) and additional scopes (gcloud, bun, rice, zsh) are also available. Run `nix/setup.sh --help` for the full list. + +## Extensibility + +The tool supports customization at three levels without forking: + +```mermaid +graph TD + A["Base (this repo)"] --> B["Overlay (team or org)"] + B --> C["User packages (nx install)"] + A -- "nix/scopes/*.nix" --> D["Curated scopes"] + B -- "overlay/scopes/*.nix" --> E["Custom scopes"] + B -- "overlay/bash_cfg/*.sh" --> F["Shell config"] + B -- "overlay/hooks/*.sh" --> G["Setup hooks"] + C -- "packages.nix" --> H["Ad-hoc packages"] +``` + +- **Base layer** - curated scopes shipped with this repository +- **Overlay layer** - team or org customization via `NIX_ENV_OVERLAY_DIR`, survives base upgrades +- **User layer** - individual packages via `nx install` + +See [Customization](customization.md) for the full guide. + +## Engineering quality + +This is not a shell script collection. It is infrastructure that developers depend on to work - and it is tested accordingly. + +| Metric | Value | +| ----------------------- | ------------------------------------------------------------------- | +| Unit test cases | 412 (13 bats + 9 Pester test files) | +| Custom pre-commit hooks | 7 (bash 3.2 enforcer, scope validator, smart test runner, and more) | +| CI matrix | macOS Sequoia + Tahoe, Ubuntu daemon + rootless | +| Idempotency | Verified on every PR - second run produces identical results | +| Install provenance | Every run writes `install.json` with version, scopes, status | +| Uninstall | Two-phase cleanup with dry-run mode | + +See [Quality & Testing](standards.md) for the full breakdown. + +## Architecture at a glance + +```mermaid +flowchart LR + subgraph "nix/setup.sh (orchestrator)" + B[Bootstrap] --> P[Platform] + P --> S[Scopes] + S --> N[Nix Profile] + N --> CF[Configure] + CF --> PR[Profiles] + PR --> PI[Post-install] + end + + subgraph "Durable state (~/.config/nix-env/)" + FL[flake.nix + scopes/] + CX[config.nix] + PK[packages.nix] + end + + subgraph "Shell integration" + RC[".bashrc / .zshrc / $PROFILE"] + NX["nx CLI (aliases_nix.sh)"] + end + + PI --> FL + PI --> RC + RC --> NX +``` + +The orchestrator is a slim ~110-line bash script that sources phase libraries in sequence. Each phase is independently testable - side-effecting operations are called through thin wrappers that tests override, no mocking frameworks required. + +See [Architecture](architecture.md) for the full reference and [Design Decisions](decisions.md) for the reasoning behind key choices. + +## Getting started + +=== "WSL (Windows)" + + Run from **PowerShell on the Windows host**. The script installs the WSL distro if needed, sets up Nix inside it, and provisions the environment end-to-end - including certificate propagation from Windows: + + ```powershell + git clone https://github.com/szymonos/linux-setup-scripts.git + cd linux-setup-scripts + wsl/wsl_setup.ps1 'Ubuntu' -Nix -s @('shell', 'python', 'pwsh') + ``` + +=== "macOS" + + Nix is installed automatically by `nix/setup.sh` via the [Determinate Systems](https://determinate.systems/) installer if not already present - no manual pre-installation needed: + + ```bash + git clone https://github.com/szymonos/linux-setup-scripts.git + cd linux-setup-scripts + nix/setup.sh --shell --python --pwsh + ``` + +=== "Coder / containers" + + For rootless environments where Nix is pre-installed (no daemon, no root). The same `nix/setup.sh` works without any special flags using the upstream installer with `--no-daemon` flag: + + ```bash + git clone https://github.com/szymonos/linux-setup-scripts.git + cd linux-setup-scripts + nix/setup.sh --shell --python --pwsh + ``` + +After setup, the repository clone can be removed - the environment is fully self-contained in `~/.config/nix-env/`. diff --git a/docs/legacy/corporate_proxy.md b/docs/legacy/corporate_proxy.md new file mode 100644 index 00000000..e0e697d0 --- /dev/null +++ b/docs/legacy/corporate_proxy.md @@ -0,0 +1,151 @@ +# Corporate Proxy and Certificate Handling + +Many enterprise environments use MITM (man-in-the-middle) TLS inspection proxies that replace upstream SSL certificates with ones signed by a corporate CA. This breaks TLS verification for most developer tools (curl, git, pip, npm, az, etc.) unless the proxy's root certificate is trusted by the system and individual toolchains. + +This repository handles the problem automatically in most cases and provides manual tools for the rest. + +## How it works + +### Automatic detection (nix path) + +When `nix/setup.sh` runs, it probes `https://www.google.com` with curl. If the connection fails due to SSL verification, it assumes a MITM proxy is present and runs `cert_intercept` automatically: + +```text +nix/setup.sh + -> curl -sS https://www.google.com fails + -> sources .assets/config/bash_cfg/functions.sh + -> calls cert_intercept + -> extracts intermediate/root certs from TLS chain + -> saves to ~/.config/certs/ca-custom.crt + -> calls build_ca_bundle + -> merges nix CAs + custom certs into ~/.config/certs/ca-bundle.crt + -> exports NIX_SSL_CERT_FILE=~/.config/certs/ca-bundle.crt (immediate, covers remaining setup steps) +``` + +After interception, the profile setup scripts (`nix/configure/profiles.sh`, `profiles.zsh`, `profiles.ps1`) automatically: + +1. Create `~/.config/certs/ca-bundle.crt` - a full CA bundle (symlink to system bundle on Linux, merged nix CAs + custom certs on macOS) +2. Set `NIX_SSL_CERT_FILE` to the merged CA bundle so all nix-built tools (git, curl, etc.) trust proxy certificates +3. Add environment variable exports to shell profiles so downstream tools trust the custom certificates + +**Why nix tools need `NIX_SSL_CERT_FILE`:** Nix-installed binaries are built against nix's own OpenSSL and ship with an isolated Mozilla CA bundle. They do not consult the macOS Keychain or the Linux system CA store. A MITM proxy cert trusted by the OS is invisible to nix tools unless `NIX_SSL_CERT_FILE` points to a bundle that includes both the standard CAs and the intercepted proxy certs. This is the root cause of "unable to get local issuer certificate" errors from nix-installed git, curl, etc. even when the system-provided versions of those tools work fine. + +### WSL path + +When provisioning WSL via PowerShell, pass `-AddCertificate` to intercept and install proxy certificates system-wide: + +```powershell +wsl/wsl_setup.ps1 'Ubuntu' -AddCertificate +``` + +This installs the intercepted root cert into the distro's system CA store (`/usr/local/share/ca-certificates/` on Debian/Ubuntu, `/etc/pki/ca-trust/source/anchors/` on Fedora). + +### CI / Docker builds + +The `Makefile` handles cert interception for Docker-based tests automatically. It extracts the root cert from the TLS chain and injects it into the build context: + +```bash +make test-nix # auto-intercepts root cert, injects into Docker build +make test-legacy # same +``` + +## Certificate storage + +| Location | Purpose | Created by | +| ---------------------------------------- | -------------------------------------------------------------------- | ---------------------------- | +| `~/.config/certs/ca-custom.crt` | User-scope PEM bundle of intercepted proxy certs only | `cert_intercept` | +| `~/.config/certs/ca-bundle.crt` | Full CA bundle (system CAs + custom certs) for tools needing a chain | `build_ca_bundle` (certs.sh) | +| `/usr/local/share/ca-certificates/*.crt` | System CA store (Debian/Ubuntu) | WSL setup or manual | +| `/etc/pki/ca-trust/source/anchors/*.crt` | System CA store (Fedora/RHEL) | WSL setup or manual | + +On Linux, `ca-bundle.crt` is a symlink to the system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`), which already includes any custom certs added via `update-ca-certificates`. On macOS, it is a merged file combining the nix-provided CA bundle with the intercepted proxy certs. + +## Shell functions + +After setup, two functions are available in your shell for ongoing cert management: + +### `cert_intercept` + +Connects to one or more hosts, extracts intermediate and root certificates from the TLS chain (skipping the leaf cert), deduplicates by serial number, and appends new certs to `~/.config/certs/ca-custom.crt`. + +```bash +# intercept from default host (www.google.com) +cert_intercept + +# intercept from specific hosts (useful when different proxies serve different chains) +cert_intercept login.microsoftonline.com pypi.org +``` + +### `fixcertpy` + +Patches Python's `certifi` CA bundle(s) with certs from `~/.config/certs/ca-custom.crt`. This is needed because Python (pip, requests, azure-cli, etc.) uses its own CA bundle and ignores the system store. + +```bash +# auto-discover and patch all certifi bundles (venv + system pip) +fixcertpy + +# patch a specific cacert.pem +fixcertpy /path/to/venv/lib/python3.12/site-packages/certifi/cacert.pem +``` + +Idempotent - running it twice does not duplicate certificates. + +## Environment variables + +The profile setup scripts automatically export environment variables into bash, zsh, and PowerShell profiles so that tools which bypass the system CA store can still verify TLS connections through a MITM proxy. Each variable is added independently based on which cert files exist at setup time: + +| Variable | Used by | Cert file | Added when | Shells | +| ------------------------------------ | -------------------- | --------------- | --------------------------------- | ------------------------- | +| `NIX_SSL_CERT_FILE` | All nix-built tools | `ca-bundle.crt` | `ca-bundle.crt` exists | bash, zsh, PowerShell | +| `NODE_EXTRA_CA_CERTS` | Node.js, npm | `ca-custom.crt` | `ca-custom.crt` exists | bash, zsh, PowerShell | +| `REQUESTS_CA_BUNDLE` | Python requests, pip | `ca-bundle.crt` | `ca-bundle.crt` exists | bash, zsh, PowerShell | +| `SSL_CERT_FILE` | OpenSSL-based tools | `ca-bundle.crt` | `ca-bundle.crt` exists | bash, zsh, PowerShell | +| `UV_SYSTEM_CERTS` | uv, uvx | n/a | uv installed | bash, zsh, PowerShell | +| `CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE` | Google Cloud CLI | `ca-bundle.crt` | `ca-bundle.crt` exists + `gcloud` | bash, zsh, PowerShell | +| `PREK_NATIVE_TLS` | prek (pre-commit) | n/a | always | Makefile only (not shell) | + +`NIX_SSL_CERT_FILE` points at `ca-bundle.crt` (the full bundle) because nix-built tools use nix's isolated OpenSSL and ignore the OS CA store entirely - they need a complete replacement bundle. `NODE_EXTRA_CA_CERTS` points at `ca-custom.crt` (proxy certs only) because Node.js already trusts system CAs and only needs the additional proxy certs. `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` point at `ca-bundle.crt` (the full bundle) because Python and OpenSSL-based tools replace - rather than extend - their default trust store with the value of these variables. `UV_SYSTEM_CERTS` tells uv/uvx to load TLS certificates from the platform's native certificate store, so it automatically trusts any certificates in the system CA store. + +All exports are guarded at runtime: the variables are only set if the cert file still exists when the shell starts. + +If for some reason you need to set these manually, add to `~/.bashrc` or `~/.zshrc`: + +```bash +export NODE_EXTRA_CA_CERTS="$HOME/.config/certs/ca-custom.crt" +export REQUESTS_CA_BUNDLE="$HOME/.config/certs/ca-bundle.crt" +export SSL_CERT_FILE="$HOME/.config/certs/ca-bundle.crt" +``` + +## Troubleshooting + +**"SSL certificate problem: unable to get local issuer certificate"** (curl/git) + +The proxy cert is not in the system CA store. Run `cert_intercept` and check that the cert was added. For system-wide trust (requires root): + +```bash +# Debian/Ubuntu +sudo cp ~/.config/certs/ca-custom.crt /usr/local/share/ca-certificates/proxy-ca.crt +sudo update-ca-certificates + +# Fedora/RHEL +sudo cp ~/.config/certs/ca-custom.crt /etc/pki/ca-trust/source/anchors/proxy-ca.crt +sudo update-ca-trust +``` + +**"CERTIFICATE_VERIFY_FAILED"** (Python/pip/az) + +Python ignores the system CA store. Run `fixcertpy` or check that `REQUESTS_CA_BUNDLE` is set: + +```bash +# check if the variable is set +echo $REQUESTS_CA_BUNDLE + +# if not set, re-run setup or set manually +fixcertpy +# or +export REQUESTS_CA_BUNDLE="$HOME/.config/certs/ca-bundle.crt" +``` + +### Pre-commit hooks fail with SSL errors + +The `Makefile` sets `PREK_NATIVE_TLS=1` and `NODE_EXTRA_CA_CERTS` automatically. If running hooks outside of `make`, set these variables yourself. diff --git a/docs/legacy/customization.md b/docs/legacy/customization.md new file mode 100644 index 00000000..0be630cf --- /dev/null +++ b/docs/legacy/customization.md @@ -0,0 +1,297 @@ +# Customization guide + +How packages are organized, how to add your own, and how to manage +upgrades and rollbacks. + +## Package layers + +Packages are assembled from four layers, evaluated bottom-up by the +Nix flake. Each layer has a different purpose and audience: + +```text +┌─────────────────────────────────────────────────┐ +│ 4. Extra packages (packages.nix) │ nx install / nx remove +│ Ad-hoc packages added by the user. │ Quickest way to add a package. +├─────────────────────────────────────────────────┤ +│ 3. Overlay scopes (local_*.nix) │ nx scope add / overlay dir +│ Custom scope groups from an overlay dir. │ For grouping packages or +│ Can be personal, team, or org-maintained │ distributing to a team. +│ depending on where the overlay dir points. │ +├─────────────────────────────────────────────────┤ +│ 2. Repo scopes (shell.nix, python.nix, ...) │ nix/setup.sh --shell --python +│ Curated scope groups shipped with the repo. │ The standard dev environment. +├─────────────────────────────────────────────────┤ +│ 1. Base (base.nix) │ Always included. +│ Core tools: git, jq, curl, coreutils, ... │ Cannot be disabled. +└─────────────────────────────────────────────────┘ +``` + +All four layers merge into a single `buildEnv` - one nix profile entry, +one `nix profile upgrade` to apply. No layer can shadow or break another. + +Layer 3 serves different roles depending on who maintains the overlay +directory: + +- **Solo user** - defaults to `~/.config/nix-env/local/`, personal tools +- **Team** - `NIX_ENV_OVERLAY_DIR` points to a shared git repo with + team-specific scopes, aliases, and hooks +- **Organization** - same mechanism, org-maintained repo distributed + via MDM or onboarding scripts + +Currently one overlay directory is active at a time. Multi-tier stacking +(org + team + personal overlays simultaneously) is possible through the +hooks mechanism - an org-level pre-setup hook can copy org scopes before +the team overlay is processed - but first-class multi-tier support is +planned for the enterprise integration phase. + +### Layer 1: Base (always installed) + +`nix/scopes/base.nix` - core utilities (git, jq, curl, coreutils, etc.) +included in every installation. You don't interact with this layer. + +### Layer 2: Repo scopes (the standard environment) + +The built-in scopes shipped with the repo. Selected via `nix/setup.sh` +flags: + +```bash +nix/setup.sh --shell --python --pwsh # add scopes +nix/setup.sh --remove python # remove a scope +nix/setup.sh # re-apply existing scopes +``` + +Each scope is a `.nix` file in `nix/scopes/` - e.g., `shell.nix` provides +fzf, eza, bat, ripgrep; `python.nix` provides uv. Scopes can depend on +each other (e.g., `k8s_dev` pulls in `k8s_base`). + +See all available scopes with `nix/setup.sh --help`. + +### Layer 3: Overlay scopes (custom groups) + +For packages that don't belong in the upstream repo - personal tools, +team-specific CLIs, org-wide utilities. Overlay scopes live in a separate +directory and are copied into `~/.config/nix-env/scopes/` with a `local_` +prefix to avoid name collisions with repo scopes. + +```bash +# Create a scope and add packages in one step (validates against nixpkgs) +nx scope add devtools httpie jq + +# Add more packages to an existing scope +nx scope add devtools bat + +# Open a scope in your editor for manual editing +nx scope edit devtools +``` + +Package names are validated against nixpkgs before being added - typos +and non-existent packages are caught immediately. + +The scope file is standard Nix: + +```nix +{ pkgs }: with pkgs; [ + bat + httpie + jq +] +``` + +After manual edits via `nx scope edit`, run `nx upgrade` to apply. + +**Why a separate overlay directory?** The overlay dir is a *source* that +survives repo updates. When `nix/setup.sh` runs, it copies the repo's +scope files into `~/.config/nix-env/scopes/`, overwriting anything there. +Overlay scopes live outside the repo's scope directory (in `local/`) so +they are never overwritten, and the `local_` prefix ensures they never +collide with a repo scope of the same name. + +### Layer 4: Extra packages (ad-hoc) + +The simplest way to add a package - no scopes, no files to edit: + +```bash +nx install httpie jq # validates + adds packages +nx remove httpie # remove a package +nx list # see everything installed, by layer +``` + +Package names are validated against nixpkgs before being added. +Packages are stored in `~/.config/nix-env/packages.nix` as a flat list. +Changes are applied immediately (no `nx upgrade` needed). + +Use this for one-off tools. If you find yourself adding many related +packages, consider creating a scope instead (layer 3). + +## Overlay directory + +The overlay directory is where custom scopes, shell configs, and hooks +live. It is resolved in this order: + +1. `NIX_ENV_OVERLAY_DIR` environment variable (if set and exists) +2. `~/.config/nix-env/local/` (default fallback) + +### Structure + +```text +~/.config/nix-env/local/ # or $NIX_ENV_OVERLAY_DIR +├── scopes/ # custom scope files (→ local_*.nix) +│ └── devtools.nix +├── bash_cfg/ # extra bash config (sourced on login) +│ └── aliases_custom.sh +└── hooks/ + ├── pre-setup.d/ # run before nix/setup.sh main logic + │ └── check_vpn.sh + └── post-setup.d/ # run after nix/setup.sh completes + └── notify_slack.sh +``` + +- **Scopes** are copied with `local_` prefix during `nix/setup.sh` or + `nx scope add`. +- **Shell configs** in `bash_cfg/` are sourced alongside the standard + configs. +- **Hooks** run during `nix/setup.sh` at the indicated phase. They receive + `NIX_ENV_PHASE`, `NIX_ENV_SCOPES`, and `NIX_ENV_PLATFORM` as environment + variables. + +### Managing overlays + +```bash +nx overlay list # show overlay directory contents +nx overlay status # show sync status (synced / modified / source missing) +``` + +### Sharing with a team + +Point everyone at a shared overlay directory (e.g., a separate git repo): + +```bash +# In ~/.bashrc or ~/.zshenv, before the managed block +export NIX_ENV_OVERLAY_DIR="$HOME/src/team-nix-overlay" +``` + +The shared repo can contain team-specific scopes, shell aliases, and +setup hooks. Each user can still use `nx install` (layer 4) for personal +additions on top. + +For multi-tier distribution (org + team + personal), the overlay hooks +mechanism can chain: an org-level hook can copy org scopes, then the +team overlay adds team scopes, and the user's `packages.nix` adds +individual packages. + +## Pinning nixpkgs - coordinated package versions + +### Why pin? + +By default, `nx upgrade` pulls the latest `nixpkgs-unstable`. Despite the +name, `nixpkgs-unstable` is not random - it is a branch where every commit +is a deterministic snapshot of ~100k packages at specific versions. When your +`flake.lock` points to commit `abc123`, you get exactly `ripgrep 14.1.0`, +`python 3.12.3`, `bat 0.24.0` etc. - every time, on every machine. + +Without a pin, each `nx upgrade` resolves to whatever the latest commit +happens to be at that moment. This is fine for solo use, but in a team it +causes "works on my machine" issues when people upgrade on different days. + +**Pinning** locks `nx upgrade` to a specific nixpkgs commit. Everyone who +shares the same pin gets identical package versions, regardless of when +they upgrade. + +### Typical workflow + +```bash +# 1. Upgrade and verify everything works +nx upgrade + +# 2. Pin the current (tested) revision +nx pin set + +# 3. Future upgrades will stay on this revision until the pin is removed +nx upgrade # uses the pinned rev, not latest + +# 4. Remove the pin when ready to move forward +nx pin remove +``` + +### Commands + +```bash +nx pin set # Pin to the current flake.lock revision +nx pin set # Pin to a specific nixpkgs commit SHA +nx pin show # Show current pin (or "no pin set") +nx pin remove # Remove the pin, go back to latest unstable +``` + +The pin is stored in `~/.config/nix-env/pinned_rev` - a single file +containing the commit SHA. No shell profile changes needed. + +### How it works + +When `~/.config/nix-env/pinned_rev` exists: + +- `nx upgrade` and `nix/setup.sh --upgrade` use + `nix flake lock --override-input` to lock nixpkgs to that exact commit + instead of resolving the latest. +- Regular runs without `--upgrade` are unaffected - they use the existing + `flake.lock` as-is. + +When the file does **not** exist, upgrades resolve the latest +`nixpkgs-unstable` as before. + +### Team-wide pinning + +For a team, distribute the pin via a pre-setup hook in a shared overlay: + +```bash +# overlay-dir/hooks/pre-setup.d/pin_nixpkgs.sh +# Write the team-wide pin before setup runs +echo "abc123..." > "$HOME/.config/nix-env/pinned_rev" +``` + +Or include `pinned_rev` directly in your shared overlay directory and +copy it during setup. When IT validates a new nixpkgs revision, they +update the hook - everyone gets the tested baseline on next upgrade. + +## Rolling back + +If an upgrade breaks something, roll back to the previous generation: + +```bash +nx rollback +``` + +This wraps `nix profile rollback` and reverts to the previous set of +packages. Restart your shell after rolling back. + +To inspect what changed between generations: + +```bash +nix profile diff-closures +``` + +To clean up old generations after confirming the new one works: + +```bash +nx gc +``` + +## Quick reference + +| Task | Command | +| -------------------------- | ------------------------------------ | +| Add a package | `nx install ` | +| Remove a package | `nx remove ` | +| List all packages | `nx list` | +| Search nixpkgs | `nx search ` | +| Create a scope with pkgs | `nx scope add [pkg...]` | +| Add packages to a scope | `nx scope add [pkg...]` | +| Edit a scope file | `nx scope edit ` | +| List overlay contents | `nx overlay list` | +| Check overlay sync status | `nx overlay status` | +| Upgrade to latest packages | `nx upgrade` | +| Pin current revision | `nx pin set` | +| Show / remove pin | `nx pin show` / `nx pin remove` | +| Roll back last upgrade | `nx rollback` | +| See what changed | `nix profile diff-closures` | +| Clean up old generations | `nx gc` | +| Run health checks | `nx doctor` | diff --git a/docs/images/omp_base.png b/docs/legacy/images/omp_base.png similarity index 100% rename from docs/images/omp_base.png rename to docs/legacy/images/omp_base.png diff --git a/docs/images/omp_nerd.png b/docs/legacy/images/omp_nerd.png similarity index 100% rename from docs/images/omp_nerd.png rename to docs/legacy/images/omp_nerd.png diff --git a/docs/images/omp_powerline.png b/docs/legacy/images/omp_powerline.png similarity index 100% rename from docs/images/omp_powerline.png rename to docs/legacy/images/omp_powerline.png diff --git a/docs/images/setup_omp.png b/docs/legacy/images/setup_omp.png similarity index 100% rename from docs/images/setup_omp.png rename to docs/legacy/images/setup_omp.png diff --git a/docs/vagrant.md b/docs/legacy/vagrant.md similarity index 97% rename from docs/vagrant.md rename to docs/legacy/vagrant.md index 2159a06f..25c51627 100644 --- a/docs/vagrant.md +++ b/docs/legacy/vagrant.md @@ -93,7 +93,7 @@ vagrant plugin install vagrant-reload ## Vagrant home location The Vagrant home directory is where things such as boxes are stored, so it can actually become quite large on disk. -To change it, set the `VAGRANT_HOME` enviroment variable to some other location: +To change it, set the `VAGRANT_HOME` environment variable to some other location: ```PowerShell [Environment]::SetEnvironmentVariable('VAGRANT_HOME', 'F:/Virtual Machines/.vagrant.d', 'Machine') diff --git a/docs/wsl_scripts.md b/docs/legacy/wsl_scripts.md similarity index 98% rename from docs/wsl_scripts.md rename to docs/legacy/wsl_scripts.md index 269599f3..cc0e133d 100644 --- a/docs/wsl_scripts.md +++ b/docs/legacy/wsl_scripts.md @@ -18,7 +18,7 @@ wsl/wsl_certs_add.ps1 'Ubuntu' -Uri 'www.powershellgallery.com' ## [wsl_distro_move](../wsl/wsl_distro_move.ps1) Script allows moving WSL distro from the default location e.g. to another disk. If you specify `-NewName` parameter, it will also rename the distribution - it allows to conveniently *multiply* existing distros. -Imagine, that you have Ubuntu distro installed, but you want to have another, fresh one. You can use the script to move distro to existing location with the new name, and then you can type `ubuntu.exe` in therminal and it will setup new, fresh Ubuntu distro. +Imagine, that you have Ubuntu distro installed, but you want to have another, fresh one. You can use the script to move distro to existing location with the new name, and then you can type `ubuntu.exe` in terminal and it will setup new, fresh Ubuntu distro. ``` powershell # Copy existing WSL distro to new location diff --git a/docs/wsl_setup.md b/docs/legacy/wsl_setup.md similarity index 85% rename from docs/wsl_setup.md rename to docs/legacy/wsl_setup.md index 4164eb4b..8d6653b0 100644 --- a/docs/wsl_setup.md +++ b/docs/legacy/wsl_setup.md @@ -23,7 +23,7 @@ wsl/pwsh_setup.ps1 # Git distributed version control system winget install --id Git.Git -# Other usefull tools/applications +# Other useful tools/applications winget install --id Microsoft.WindowsTerminal winget install --id Microsoft.VisualStudioCode winget install --id gerardog.gsudo @@ -90,17 +90,17 @@ There are three themes included in the repository: You can also specify any other theme name from [Themes | Oh My Posh](https://ohmyposh.dev/docs/themes) - it will be downloaded and installed automatically during the provisioning. -It is also possible to easily change the oh-my-posh theme using the `.assets/provision/setup_omp.sh` script: +It is also possible to easily change the oh-my-posh theme using the `.assets/setup/setup_omp.sh` script: ``` shell # setup oh-my-posh theme using default base fonts -sudo .assets/provision/setup_omp.sh +sudo .assets/setup/setup_omp.sh # setup oh-my-posh theme using powerline fonts -sudo .assets/provision/setup_omp.sh --theme powerline +sudo .assets/setup/setup_omp.sh --theme powerline # setup oh-my-posh theme using nerd fonts -sudo .assets/provision/setup_omp.sh --theme nerd +sudo .assets/setup/setup_omp.sh --theme nerd # specify any theme from https://ohmyposh.dev/docs/themes/ (e.g. atomic) -sudo .assets/provision/setup_omp.sh --theme atomic +sudo .assets/setup/setup_omp.sh --theme atomic ``` ![omp_base.png](images/setup_omp.png) @@ -115,6 +115,22 @@ You can specify the `-AddCertificate` parameter to the **wsl_setup** script to i wsl/wsl_setup.ps1 'Ubuntu' -AddCertificate ``` +For detailed information about certificate handling, troubleshooting, and the shell helper functions (`cert_intercept`, `fixcertpy`), see [Corporate Proxy and Certificate Handling](corporate_proxy.md). + +### Using Nix package manager + +Instead of the traditional per-tool install scripts, you can use the [Nix](https://nixos.org/) package manager for a faster, reproducible setup. Pass the `-Nix` flag on first install - subsequent updates auto-detect Nix and use it automatically: + +``` powershell +# first install: pass -Nix to install Nix and packages via nix profile +wsl/wsl_setup.ps1 'Ubuntu' -Nix -Scope @('shell', 'pwsh') + +# updates: Nix is auto-detected, no need to pass -Nix again +wsl/wsl_setup.ps1 +``` + +Scopes not available in Nix (`bun`, `distrobox`, `docker`) are installed using traditional scripts automatically. + ### Using other packages scopes Depending on the use case you can install many other package `scopes` to further customize the system. diff --git a/docs/proxy.md b/docs/proxy.md new file mode 100644 index 00000000..ce833140 --- /dev/null +++ b/docs/proxy.md @@ -0,0 +1,147 @@ +# Corporate Proxy & Certificates + +Corporate MITM (man-in-the-middle) TLS inspection proxies are the single biggest source of developer friction in enterprise environments. Every tool that makes HTTPS requests - git, curl, pip, npm, az, terraform - breaks with cryptic SSL errors. Developers lose hours searching for tool-specific workarounds, and the solutions they find are fragile and incomplete. + +This tool detects and resolves MITM proxy issues automatically during setup. No manual `openssl s_client` debugging required. + +## The problem in detail + +TLS inspection proxies replace upstream SSL certificates with ones signed by a corporate CA. This works transparently for browsers (which trust the system CA store), but breaks developer tools in three ways: + +1. **Nix-installed binaries** are built against Nix's own OpenSSL and ship with an isolated Mozilla CA bundle. They do not consult the macOS Keychain or the Linux system CA store. A proxy cert trusted by the OS is invisible to nix-installed git, curl, and every other tool. + +2. **Python tools** (pip, requests, azure-cli) use `certifi`, a vendored CA bundle that ignores the system store. Each virtualenv gets its own copy. + +3. **Node.js tools** (npm, pre-commit hooks) use Node's built-in CA bundle by default, separate from the system store. + +The result: the same certificate must be configured in multiple places, through different mechanisms, for different tools - and the configuration must survive shell restarts, virtualenv creation, and environment upgrades. + +## How it works + +### Automatic detection + +When `nix/setup.sh` runs, it probes a TLS endpoint (default: `https://www.google.com`). If the connection fails due to SSL verification, a MITM proxy is assumed and the certificate interception flow runs automatically: + +```mermaid +flowchart TD + P["TLS probe fails"] --> I["cert_intercept + Extract intermediate + root certs + from TLS chain, deduplicate"] + I --> S["Save to + ~/.config/certs/ca-custom.crt"] + S --> B["build_ca_bundle + Merge nix CAs + custom certs"] + B --> F["Write + ~/.config/certs/ca-bundle.crt"] + F --> E["Export NIX_SSL_CERT_FILE + (immediate, covers remaining setup)"] + E --> PR["Profile setup + Persist env vars to shell profiles"] +``` + +On macOS, certificates are exported directly from the Keychain - capturing any corporate CA certificates that IT has already deployed via MDM. + +### What gets configured + +The profile setup scripts automatically export environment variables into bash, zsh, and PowerShell profiles. Each variable targets a specific tool ecosystem: + +| Variable | Used by | Points to | Why this file | +| ------------------------------------ | -------------------- | --------------- | --------------------------------------------------------------------- | +| `NIX_SSL_CERT_FILE` | All nix-built tools | `ca-bundle.crt` | Nix tools ignore the OS store; need a complete replacement bundle | +| `NODE_EXTRA_CA_CERTS` | Node.js, npm | `ca-custom.crt` | Node already trusts system CAs; only needs the additional proxy certs | +| `REQUESTS_CA_BUNDLE` | Python requests, pip | `ca-bundle.crt` | Python replaces (not extends) its default store with this variable | +| `SSL_CERT_FILE` | OpenSSL-based tools | `ca-bundle.crt` | Same replacement behavior as Python | +| `UV_SYSTEM_CERTS` | uv, uvx | n/a (flag) | Tells uv to use the platform's native certificate store | +| `CLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE` | Google Cloud CLI | `ca-bundle.crt` | gcloud has its own cert configuration | + +All exports are guarded at runtime - variables are only set if the cert file still exists when the shell starts. + +### Certificate storage + +| Location | Purpose | Created by | +| ------------------------------- | ------------------------------------------ | ----------------- | +| `~/.config/certs/ca-custom.crt` | Intercepted proxy certs only | `cert_intercept` | +| `~/.config/certs/ca-bundle.crt` | Full CA bundle (system CAs + custom certs) | `build_ca_bundle` | + +On Linux, `ca-bundle.crt` symlinks to the system bundle (which includes any custom certs added via `update-ca-certificates`). On macOS, it is a merged file combining the nix-provided CA bundle with Keychain-exported and intercepted proxy certs. + +### VS Code Server + +VS Code Server (remote-SSH, WSL) does not source `~/.bashrc` on startup, so shell-profile environment variables are invisible to extensions. This causes `SELF_SIGNED_CERT_IN_CHAIN` errors in extensions that call HTTPS APIs (GitHub Actions, GitHub Pull Requests, etc.). + +The setup automatically writes `NODE_EXTRA_CA_CERTS` to `~/.vscode-server/server-env-setup` - a file VS Code Server sources before launching. This handles the bootstrapping problem where setup runs before the first VS Code remote session. + +## Shell functions for ongoing management + +After setup, two functions are available for ongoing certificate management: + +### `cert_intercept` + +Connects to hosts, extracts intermediate and root certificates from the TLS chain (skipping the leaf cert), deduplicates by serial number, and appends new certs to `~/.config/certs/ca-custom.crt`. + +```bash +cert_intercept # default host (www.google.com) +cert_intercept login.microsoftonline.com pypi.org # specific hosts +``` + +Useful when different proxies serve different certificate chains (e.g., VPN vs. office network). + +### `fixcertpy` + +Patches Python's `certifi` CA bundle(s) with certs from `~/.config/certs/ca-custom.crt`. Needed because each Python virtualenv gets its own copy of certifi's CA bundle. + +```bash +fixcertpy # auto-discover and patch all certifi bundles +fixcertpy /path/to/cacert.pem # patch a specific bundle +``` + +Idempotent - running it twice does not duplicate certificates. + +## WSL integration + +When provisioning WSL via PowerShell, the `-AddCertificate` flag intercepts proxy certificates and installs them into the distro's system CA store: + +```powershell +wsl/wsl_setup.ps1 'Ubuntu' -AddCertificate +``` + +This installs the intercepted root cert system-wide (`/usr/local/share/ca-certificates/` on Debian/Ubuntu, `/etc/pki/ca-trust/source/anchors/` on Fedora), so both system tools and nix tools trust it. + +## Build system support + +The `Makefile` handles MITM proxies for development workflows: + +- `PREK_NATIVE_TLS=1` - tells the pre-commit runner to use the system's OpenSSL (which trusts the proxy cert) +- `NODE_EXTRA_CA_CERTS` - automatically set for Node.js-based hooks (markdownlint, cspell) when a custom CA certificate is present +- Docker test targets (`make test-nix`, `make test-legacy`) - auto-intercept the root cert and inject it into the build context + +Developers behind corporate proxies don't need special configuration - the build system handles it. + +## Troubleshooting + +**"unable to get local issuer certificate"** (curl, git) + +Run `cert_intercept` and verify the cert was added. For system-wide trust (requires root): + +```bash +# Debian/Ubuntu +sudo cp ~/.config/certs/ca-custom.crt /usr/local/share/ca-certificates/proxy-ca.crt +sudo update-ca-certificates + +# Fedora/RHEL +sudo cp ~/.config/certs/ca-custom.crt /etc/pki/ca-trust/source/anchors/proxy-ca.crt +sudo update-ca-trust +``` + +**"CERTIFICATE_VERIFY_FAILED"** (Python, pip, az) + +Run `fixcertpy` or verify `REQUESTS_CA_BUNDLE` is set: + +```bash +echo $REQUESTS_CA_BUNDLE # should point to ~/.config/certs/ca-bundle.crt +fixcertpy # auto-patch all certifi bundles +``` + +### Pre-commit hooks fail with SSL errors + +Run hooks via `make lint` - the Makefile sets the required environment variables automatically. If running hooks outside of `make`, set `PREK_NATIVE_TLS=1` and `NODE_EXTRA_CA_CERTS` manually. diff --git a/docs/standards.md b/docs/standards.md new file mode 100644 index 00000000..7a91578f --- /dev/null +++ b/docs/standards.md @@ -0,0 +1,134 @@ +# Quality & Testing + +This tool provisions developer environments - if it breaks, developers cannot work. The engineering standards applied to this repository reflect that criticality. This page documents how the codebase enforces its own quality, not as convention, but as automated, CI-validated constraints. + +## By the numbers + +| Metric | Value | +| ---------------------------- | ------------------------------------------------------------ | +| Unit test files | 22 (13 bats + 9 Pester) | +| Individual test cases | 412 | +| Test code | 5,400+ lines | +| Custom pre-commit hooks | 7 Python scripts | +| CI matrix axes | 4 (Linux daemon, Linux rootless, macOS Sequoia, macOS Tahoe) | +| Platforms validated per PR | macOS (bash 3.2 + BSD), Ubuntu (bash 5 + GNU) | +| Pre-commit checks per commit | 18 hooks | + +## Test infrastructure + +### Unit tests - bash (bats) + +13 bats test files cover the core logic: scope dependency resolution, `nx` CLI commands (pin, rollback, scope, install, remove), managed block injection and removal, profile migration, overlay system, health checks, and certificate handling. + +Phase functions from `nix/lib/phases/` are tested by sourcing them directly and overriding side-effect wrappers: + +```bash +setup() { + source "$REPO_ROOT/nix/lib/io.sh" + source "$REPO_ROOT/nix/lib/phases/nix_profile.sh" + # override side effects AFTER sourcing + _io_nix() { echo "nix $*" >>"$BATS_TEST_TMPDIR/nix.log"; } +} + +@test "nix_profile: apply runs profile add and upgrade" { + phase_nix_profile_apply + grep -q 'nix profile add' "$BATS_TEST_TMPDIR/nix.log" +} +``` + +No mocking framework, no external dependencies. Functions call `_io_nix`, `_io_run`, `_io_curl_probe` instead of raw commands. Tests redefine these - three lines per test, zero framework overhead. The pattern is self-documenting and works identically on bash 3.2 and bash 5. + +### Unit tests - PowerShell (Pester) + +9 Pester test files mirror the bash coverage for PowerShell components: WSL orchestration, scope parsing, certificate conversion, configuration helpers, and `nx` CLI PowerShell equivalents. + +### Integration tests - CI + +GitHub Actions workflows run the full setup end-to-end on real operating systems: + +```mermaid +flowchart LR + subgraph "Linux CI" + LD["Ubuntu + daemon mode"] + LN["Ubuntu + rootless (Coder)"] + end + + subgraph "macOS CI" + MS["macOS Sequoia + bash 3.2 + BSD"] + MT["macOS Tahoe + forward-compat signal"] + end + + LD & LN & MS & MT --> V["Verify"] + + V --> V1["Scope binaries on PATH"] + V --> V2["nx doctor --strict"] + V --> V3["Managed block idempotency"] + V --> V4["install.json provenance"] + V --> V5["Uninstall --env-only cleanup"] +``` + +Each CI run validates: + +- Setup completes with requested scope flags +- Core binaries (`git`, `gh`, `jq`, `curl`, `openssl`) resolve on PATH +- Scope-specific binaries resolve (mapped from scope flags) +- `nx doctor --strict` passes (warnings and failures both break the build) +- Second run produces exactly one managed block (idempotency) +- `install.json` records `status: success` +- Uninstaller removes nix-env state while preserving generic config + +### Docker smoke tests + +`make test-nix` and `make test-legacy` build throwaway Docker images that run a full provisioning pass, verify the `nx` CLI, validate `install.json`, and test the uninstaller - all in an isolated container. Slower than unit tests, but catches integration issues that mocking cannot. + +## Pre-commit hooks + +Every commit passes through 18 hooks. 7 are custom Python scripts purpose-built for this codebase: + +| Hook | What it enforces | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `gremlins-check` | No unwanted Unicode characters (zero-width spaces, smart quotes, en-dashes) - auto-fixes common substitutions | +| `validate-docs-words` | Custom dictionary (`project-words.txt`) contains only words that appear in docs - removes stale entries automatically | +| `align-tables` | Markdown tables are column-aligned (auto-fixes on save) | +| `validate-scopes` | `scopes.json` and `nix/scopes/*.nix` are consistent - every scope has a matching `.nix` file with a `# bins:` declaration | +| `check-bash32` | Nix-path scripts contain no bash 4+ constructs - 14 rules covering mapfile, associative arrays, namerefs, GNU sed/grep extensions | +| `bats-tests` / `pester-tests` | Smart test runners that parse `source` directives to map changed files to their tests - only runs relevant tests, not the full suite | + +Documentation build validation (`mkdocs build --strict`) runs in the CI pipeline rather than as a pre-commit hook, since it requires the full docs dependency set. + +External hooks add standard checks: ShellCheck (shell static analysis), markdownlint, cspell (spell checking on docs and commit messages), ruff (Python lint and format), executable/shebang validation, line ending normalization. + +## Enforced constraints + +### Bash 3.2 compatibility + +Not documented as a guideline - enforced by `check_bash32.py` at commit time and validated by macOS CI at merge time. The hook scans for 14 categories of bash 4+ constructs and blocks the commit with line numbers and explanations. Developers get immediate, actionable feedback before the code leaves their machine. + +### Scope consistency + +`validate_scopes.py` ensures that adding a new scope is a complete operation: the JSON definition, the Nix package list, the dependency rules, and the binary declarations must all be consistent. A partial addition is caught at commit time, not at runtime on a developer's machine. + +### Idempotency + +CI explicitly validates that running `nix/setup.sh` twice produces identical results - no duplicate managed blocks, no accumulated profile entries, no leftover state. This is verified on every pull request, not assumed. + +### Install provenance + +Every setup run (success or failure) writes `install.json` with version, scopes, timestamp, and exit status. CI validates this file exists and contains the expected values. This enables fleet-wide auditing: which developers have which versions, when they last ran setup, and whether it succeeded. + +## Development workflow + +```bash +make install # install pre-commit hooks (one-time) +make lint # run hooks on changed files (before every commit) +make test-unit # bats + Pester unit tests (fast, no Docker) +make test # all tests including Docker smoke tests +make lint-diff # hooks on files changed since main +make mkdocs-serve # live-reload documentation preview +``` + +The `Makefile` handles MITM proxy support automatically: it sets `PREK_NATIVE_TLS` for the pre-commit runner and `NODE_EXTRA_CA_CERTS` for Node.js-based hooks (markdownlint, cspell) when a custom CA certificate is present. Developers behind corporate proxies don't need special configuration - the build system handles it. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..83870adf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,86 @@ +site_name: Dev Environment Setup +site_url: https://szymonos.github.io/linux-setup-scripts/ +site_description: Universal, cross-platform developer environment provisioning with Nix +repo_url: https://github.com/szymonos/linux-setup-scripts +repo_name: szymonos/linux-setup-scripts + +docs_dir: "docs/" + +plugins: + - search + - mermaid2: + arguments: + startOnLoad: true + theme: "dark" + securityLevel: "loose" + themeVariables: + clusterBkg: "#263238" + darkMode: true + edgeLabelBackground: "#263238" + fontFamily: "Roboto, sans-serif" + lineColor: "#90a4ae" + mainBkg: "#2c3e50" + nodeBorder: "#5c6bc0" + secondBkg: "#34495e" + tertiaryColor: "#455a64" + useMaxWidth: true + htmlLabels: true + curve: basis + sequence: + useMaxWidth: true + gantt: + useMaxWidth: true + flowchart: + useMaxWidth: true + htmlLabels: true + curve: basis + +theme: + name: material + icon: + repo: fontawesome/brands/github + palette: + scheme: slate + primary: indigo + accent: blue + features: + - navigation.indexes + - navigation.sections + - content.code.copy + - content.tabs.link + +extra_css: + - assets/stylesheets/layout-wide-screen.css + - assets/stylesheets/mermaid-lightbox.css + +extra_javascript: + - assets/javascripts/diagram-zoom.js + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.emoji + - pymdownx.highlight + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_div_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.magiclink + - pymdownx.tasklist + - toc: + permalink: "¤" + - meta + - attr_list + - md_in_html + +nav: + - Home: index.md + - Architecture: architecture.md + - Design Decisions: decisions.md + - Quality & Testing: standards.md + - Enterprise Readiness: enterprise.md + - Corporate Proxy: proxy.md + - Customization: customization.md diff --git a/modules/InstallUtils/Functions/common.ps1 b/modules/InstallUtils/Functions/common.ps1 index 5d0ce565..0551b91b 100644 --- a/modules/InstallUtils/Functions/common.ps1 +++ b/modules/InstallUtils/Functions/common.ps1 @@ -45,7 +45,7 @@ function Invoke-CommandRetry { } function Join-Str { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'None')] [OutputType([string])] param ( [Parameter(Mandatory, ValueFromPipeline = $true)] diff --git a/modules/SetupUtils/Functions/common.ps1 b/modules/SetupUtils/Functions/common.ps1 index bd53017c..57abef0a 100644 --- a/modules/SetupUtils/Functions/common.ps1 +++ b/modules/SetupUtils/Functions/common.ps1 @@ -111,7 +111,7 @@ function ConvertTo-Cfg { [CmdletBinding()] param ( [Parameter(Mandatory, Position = 0, ValueFromPipeline)] - [System.Collections.Specialized.OrderedDictionary]$OrderedDict, + [System.Collections.IDictionary]$OrderedDict, [string]$Path, diff --git a/modules/SetupUtils/Functions/logs.ps1 b/modules/SetupUtils/Functions/logs.ps1 index 0e6d34c2..b45ed3a7 100644 --- a/modules/SetupUtils/Functions/logs.ps1 +++ b/modules/SetupUtils/Functions/logs.ps1 @@ -113,19 +113,19 @@ function Get-LogLine { # build the log line to show [string]::Join('|', - "`e[36m$($ctx.TimeStamp.ToString('yyyy-MM-dd HH:mm:ss'))`e[0m", + "`e[36m$($LogContext.TimeStamp.ToString('yyyy-MM-dd HH:mm:ss'))`e[0m", "${lvlColor}${Level}`e[0m", - "`e[90m$($ctx.Invocation)`e[0m", - "`e[90m$($ctx.Function)`e[0m: $Message" + "`e[90m$($LogContext.Invocation)`e[0m", + "`e[90m$($LogContext.Function)`e[0m: $Message" ) } Write { # build the log line to write [string]::Join('|', - $ctx.TimeStamp.ToString('yyyy-MM-dd HH:mm:ss.fff'), + $LogContext.TimeStamp.ToString('yyyy-MM-dd HH:mm:ss.fff'), $Level, - $ctx.Invocation, - $ctx.Function, + $LogContext.Invocation, + $LogContext.Function, $Message ) } diff --git a/modules/SetupUtils/Functions/scopes.ps1 b/modules/SetupUtils/Functions/scopes.ps1 new file mode 100644 index 00000000..b58d63af --- /dev/null +++ b/modules/SetupUtils/Functions/scopes.ps1 @@ -0,0 +1,45 @@ +function Resolve-ScopeDeps { + <# + .SYNOPSIS + Expands implicit scope dependencies in a HashSet using shared rules from scopes.json. + .PARAMETER ScopeSet + A HashSet[string] of enabled scopes - modified in-place. + .PARAMETER OmpTheme + If non-empty, implies oh_my_posh scope. + #> + param( + [Parameter(Mandatory)] + [System.Collections.Generic.HashSet[string]]$ScopeSet, + + [string]$OmpTheme + ) + + if ($OmpTheme) { + $ScopeSet.Add('oh_my_posh') | Out-Null + } + + foreach ($rule in $Script:ScopeDependencyRules) { + if ($ScopeSet.Contains($rule.if)) { + $rule.add.ForEach({ $ScopeSet.Add($_) | Out-Null }) + } + } +} + +function Get-SortedScopes { + <# + .SYNOPSIS + Returns scopes sorted by install order from scopes.json. + .PARAMETER ScopeSet + A HashSet[string] or string array of enabled scopes. + #> + param( + [Parameter(Mandatory)] + [System.Collections.Generic.HashSet[string]]$ScopeSet + ) + + [string[]]$sorted = $ScopeSet | Sort-Object -Unique { + $idx = [array]::IndexOf($Script:InstallOrder, $_) + if ($idx -ge 0) { $idx } else { 999 } + } + return $sorted +} diff --git a/modules/SetupUtils/Functions/wsl.ps1 b/modules/SetupUtils/Functions/wsl.ps1 index 2cb3c4e4..a6c40ddf 100644 --- a/modules/SetupUtils/Functions/wsl.ps1 +++ b/modules/SetupUtils/Functions/wsl.ps1 @@ -142,7 +142,7 @@ https://learn.microsoft.com/en-us/windows/wsl/wsl-config#wslconf .PARAMETER Distro Name of the WSL distro to set wsl.conf. .PARAMETER ConfDict -Input ordered dictionary consisting configuration to be saved into wsl.conf. +Input dictionary consisting configuration to be saved into wsl.conf. .PARAMETER ShowConf Print current wsl.conf after setting the configuration. #> @@ -152,7 +152,7 @@ function Set-WslConf { [Parameter(Mandatory, Position = 0)] [string]$Distro, - [System.Collections.Specialized.OrderedDictionary]$ConfDict, + [System.Collections.IDictionary]$ConfDict, [switch]$ShowConf ) diff --git a/modules/SetupUtils/SetupUtils.psd1 b/modules/SetupUtils/SetupUtils.psd1 index faae0f29..e40f075f 100644 --- a/modules/SetupUtils/SetupUtils.psd1 +++ b/modules/SetupUtils/SetupUtils.psd1 @@ -82,6 +82,9 @@ 'Invoke-ExampleScriptSave' # logs 'Show-LogContext' + # scopes + 'Resolve-ScopeDeps' + 'Get-SortedScopes' # wsl 'Get-WslDistro' 'Set-WslConf' diff --git a/modules/SetupUtils/SetupUtils.psm1 b/modules/SetupUtils/SetupUtils.psm1 index e9bbb2ba..0979a999 100644 --- a/modules/SetupUtils/SetupUtils.psm1 +++ b/modules/SetupUtils/SetupUtils.psm1 @@ -3,7 +3,13 @@ $ErrorActionPreference = 'Stop' . $PSScriptRoot/Functions/certs.ps1 . $PSScriptRoot/Functions/common.ps1 . $PSScriptRoot/Functions/logs.ps1 +. $PSScriptRoot/Functions/scopes.ps1 . $PSScriptRoot/Functions/wsl.ps1 +# load shared scope definitions from JSON +$scopesData = [System.IO.File]::ReadAllText("$PSScriptRoot/../../.assets/lib/scopes.json") | ConvertFrom-Json +[string[]]$Script:ValidScopes = $scopesData.valid_scopes +[string[]]$Script:InstallOrder = $scopesData.install_order +$Script:ScopeDependencyRules = $scopesData.dependency_rules $exportModuleMemberParams = @{ Function = @( @@ -19,6 +25,9 @@ $exportModuleMemberParams = @{ 'Invoke-ExampleScriptSave' # logs 'Show-LogContext' + # scopes + 'Resolve-ScopeDeps' + 'Get-SortedScopes' # wsl 'Get-WslDistro' 'Set-WslConf' diff --git a/nix/configure/az.sh b/nix/configure/az.sh new file mode 100755 index 00000000..420decd3 --- /dev/null +++ b/nix/configure/az.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Post-install Azure CLI configuration (cross-platform, Nix variant) +# Azure CLI is installed via uv (not Nix) for better cross-platform compatibility. +: ' +nix/configure/az.sh +' +# macOS uses native TLS (system keychain) - no certifi patching needed. +set -eo pipefail + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_ROOT/../.." && pwd)" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } + +info "installing azure-cli via uv..." +install_args=() +# on Linux/WSL, patch the certifi bundle with system CA certificates so az +# commands work behind a MITM proxy; macOS keychain integration is not supported +if [ "$(uname -s)" = "Linux" ]; then + install_args+=(--fix_certify true) +fi +"$REPO_ROOT/.assets/provision/install_azurecli_uv.sh" "${install_args[@]}" diff --git a/nix/configure/conda.sh b/nix/configure/conda.sh new file mode 100755 index 00000000..44e661f0 --- /dev/null +++ b/nix/configure/conda.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Post-install conda/miniforge configuration (cross-platform, Nix variant) +# Nix does not package miniforge, so we install it via the official installer. +: ' +nix/configure/conda.sh +' +set -eo pipefail + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +. "$SCRIPT_ROOT/.assets/config/bash_cfg/functions.sh" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } + +find_conda() { + local candidates=( + "$HOME/miniforge3/bin/conda" + "$HOME/miniforge3/condabin/conda" + ) + for c in "${candidates[@]}"; do + if [[ -x "$c" ]]; then + echo "$c" + return 0 + fi + done + if command -v conda &>/dev/null; then + command -v conda + return 0 + fi + return 1 +} + +# install miniforge if not present +if ! find_conda &>/dev/null; then + info "installing Miniforge..." + OS_NAME="$(uname -s)" + ARCH="$(uname -m)" + case "$OS_NAME" in + Linux) os_label="Linux" ;; + Darwin) os_label="MacOSX" ;; + *) err "Unsupported OS: $OS_NAME"; exit 1 ;; + esac + MINIFORGE_URL="https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-${os_label}-${ARCH}.sh" + curl -fsSL "$MINIFORGE_URL" -o /tmp/miniforge.sh + bash /tmp/miniforge.sh -b -p "$HOME/miniforge3" + rm -f /tmp/miniforge.sh +fi + +# miniforge post-install +conda_bin="$(find_conda || true)" +if [[ -n "$conda_bin" ]]; then + # fix certifi certificates before update (handles MITM proxy certs) + fixcertpy || warn "certifi certificate fix failed - pip/conda may have SSL issues behind proxy" + info "updating conda..." + "$conda_bin" update --name base --channel conda-forge conda --yes --update-all 2>/dev/null || warn "conda update failed" + # fix certifi certificates after update (update may replace cacert.pem) + fixcertpy || warn "certifi certificate fix failed - pip/conda may have SSL issues behind proxy" + "$conda_bin" clean --yes --all 2>/dev/null || true + # initialize shell integration and disable auto-activate + "$conda_bin" init bash zsh 2>/dev/null || warn "conda shell init failed - run 'conda init bash zsh' manually" + "$conda_bin" config --set auto_activate_base false + ok "conda configured" +else + warn "conda binary not found after miniforge install" +fi diff --git a/nix/configure/docker.sh b/nix/configure/docker.sh new file mode 100755 index 00000000..0f6359fb --- /dev/null +++ b/nix/configure/docker.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Post-install Docker configuration check (no root required) +: ' +nix/configure/docker.sh +' +set -eo pipefail + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } + +if [[ "$(uname -s)" == "Darwin" ]]; then + ok "Docker Desktop should be installed separately on macOS." + exit 0 +fi + +# Linux: check docker is available and user is in docker group +if command -v docker &>/dev/null; then + if groups | grep -qw docker; then + ok "docker is available and user is in docker group" + else + warn "docker is installed but $(whoami) is not in the docker group." + warn "Run: sudo usermod -aG docker $(whoami)" + fi +else + warn "docker is not installed. Install it separately (requires root)." +fi diff --git a/nix/configure/gh.sh b/nix/configure/gh.sh new file mode 100755 index 00000000..ac99e0bb --- /dev/null +++ b/nix/configure/gh.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Configure GitHub CLI authentication and SSH key (cross-platform) +: ' +nix/configure/gh.sh +# skip all interactive steps (unattended mode) +nix/configure/gh.sh true +' +set -eo pipefail + +unattended="${1:-false}" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } + +if [[ "$unattended" == "true" ]]; then + info "skipping GitHub authentication setup (unattended)." + exit 0 +fi + +if ! command -v gh &>/dev/null; then + warn "gh CLI not found - skipping GitHub authentication setup." + exit 0 +fi + +# authenticate (request admin:public_key upfront for SSH key registration) +info "setting up GitHub authentication..." +if gh auth status -h github.com &>/dev/null; then + ok "already authenticated to GitHub" +elif gh auth token -h github.com &>/dev/null; then + ok "GitHub device already authorized" +else + gh auth login --scopes admin:public_key +fi + +# register gh as git credential helper (idempotent) +gh auth setup-git + +# SSH key +SSH_KEY="$HOME/.ssh/id_ed25519" +if [[ ! -f "$SSH_KEY" ]]; then + info "generating SSH key..." + mkdir -p "$HOME/.ssh" + ssh-keygen -t ed25519 -f "$SSH_KEY" -N "" -q +fi + +# determine hostname label +if [[ "$(uname -s)" == "Darwin" ]]; then + host_label="macOS $(hostname -s)" +else + host_label="$(hostname -s)" +fi + +# skip when auth comes from an external token (e.g. CI, containers) - can't control scopes +if [[ -n "${GITHUB_TOKEN:-}" ]]; then + info "skipping SSH key registration (using external GITHUB_TOKEN)." + exit 0 +fi + +# add SSH key to GitHub if not already registered +pub_key_fp=$(awk '{print $2}' "$SSH_KEY.pub") +if ! gh ssh-key list 2>/dev/null | grep -q "$pub_key_fp"; then + info "adding SSH key to GitHub..." + if ! gh ssh-key add "$SSH_KEY.pub" --title "$host_label $(date +%Y-%m-%d)"; then + # existing token may lack admin:public_key scope (pre-scoped auth) + warn "SSH key add failed; upgrading token scope..." + if gh auth refresh -h github.com -s admin:public_key; then + gh ssh-key add "$SSH_KEY.pub" --title "$host_label $(date +%Y-%m-%d)" || warn "could not add SSH key after refresh" + else + warn "could not refresh admin:public_key scope" + fi + fi +else + ok "SSH key already registered on GitHub" +fi diff --git a/nix/configure/git.sh b/nix/configure/git.sh new file mode 100755 index 00000000..44288f49 --- /dev/null +++ b/nix/configure/git.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Configure git defaults (cross-platform) +: ' +nix/configure/git.sh +' +set -eo pipefail + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } + +info "configuring git..." + +if ! git config --global --get user.name >/dev/null 2>&1; then + git_user="" + if command -v gh &>/dev/null && gh auth status -h github.com &>/dev/null; then + git_user="$(gh api user --jq '.name // empty' 2>/dev/null)" + fi + while [[ -z "$git_user" ]]; do + read -rp "provide git user name: " git_user + done + git config --global user.name "$git_user" +fi +if ! git config --global --get user.email >/dev/null 2>&1; then + git_email="" + if command -v gh &>/dev/null && gh auth status -h github.com &>/dev/null; then + git_email="$(gh api user --jq '.email // empty' 2>/dev/null)" + fi + while [[ -z "$git_email" ]]; do + read -rp "provide git user email: " git_email + done + git config --global user.email "$git_email" +fi + +git config --global core.eol lf +git config --global core.autocrlf input +git config --global core.longpaths true +git config --global push.autoSetupRemote true + +ca_bundle="$HOME/.config/certs/ca-bundle.crt" +if [ -f "$ca_bundle" ]; then + git config --global http.sslCAInfo "$ca_bundle" + ok "git http.sslCAInfo set to $ca_bundle" +fi + +ok "git configured" diff --git a/nix/configure/omp.sh b/nix/configure/omp.sh new file mode 100755 index 00000000..91001441 --- /dev/null +++ b/nix/configure/omp.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Post-install oh-my-posh theme configuration (cross-platform, Nix variant) +# The theme is always at a fixed path (~/.config/nix-env/omp/theme.omp.json). +: ' +# reconfigure with the existing theme +nix/configure/omp.sh +# set a specific theme +nix/configure/omp.sh base +nix/configure/omp.sh nerd +' +# Shell rc files point to this path once; theme changes just replace the file. +set -eo pipefail + +omp_theme="${1:-}" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } + +if ! command -v oh-my-posh &>/dev/null; then + exit 0 +fi + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# -- Fixed theme path -------------------------------------------------------- +OMP_CFG_DIR="$HOME/.config/nix-env/omp" +OMP_THEME="$OMP_CFG_DIR/theme.omp.json" +mkdir -p "$OMP_CFG_DIR" + +# -- Install/update theme file ----------------------------------------------- +if [[ -n "$omp_theme" ]]; then + info "setting oh-my-posh theme to '$omp_theme'..." + # 1. check repo's custom themes first + # 2. fall back to nix store built-in themes + theme_src="" + if [[ -f "$SCRIPT_ROOT/.assets/config/omp_cfg/${omp_theme}.omp.json" ]]; then + theme_src="$SCRIPT_ROOT/.assets/config/omp_cfg/${omp_theme}.omp.json" + else + omp_bin="$(command -v oh-my-posh)" + omp_store_path="$(dirname "$(dirname "$(readlink -f "$omp_bin")")")" + nix_theme_dir="$omp_store_path/share/oh-my-posh/themes" + if [[ -f "$nix_theme_dir/${omp_theme}.omp.json" ]]; then + theme_src="$nix_theme_dir/${omp_theme}.omp.json" + fi + fi + if [[ -z "$theme_src" ]]; then + warn "theme '$omp_theme' not found in repo or nix store" + exit 1 + fi + cp -f "$theme_src" "$OMP_THEME" + ok "installed theme '$omp_theme' to $OMP_THEME" +elif [[ ! -f "$OMP_THEME" ]]; then + # no theme specified and no existing theme - install default (base) + if [[ -f "$SCRIPT_ROOT/.assets/config/omp_cfg/base.omp.json" ]]; then + cp -f "$SCRIPT_ROOT/.assets/config/omp_cfg/base.omp.json" "$OMP_THEME" + ok "installed default theme (base) to $OMP_THEME" + fi +fi diff --git a/nix/configure/profiles.ps1 b/nix/configure/profiles.ps1 new file mode 100755 index 00000000..b22365ab --- /dev/null +++ b/nix/configure/profiles.ps1 @@ -0,0 +1,277 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS +Setting up PowerShell profile for Nix package manager. + +.EXAMPLE +nix/configure/profiles.ps1 +#> +$ErrorActionPreference = 'SilentlyContinue' +$WarningPreference = 'Ignore' + +# resolve paths +$scriptRoot = $PSScriptRoot +$repoRoot = (Resolve-Path "$scriptRoot/../..").Path +$pwshCfg = [IO.Path]::Combine($repoRoot, '.assets/config/pwsh_cfg') + +# ============================================================================ +# Helpers +# ============================================================================ + +# Upsert a #region block into a List[string] of profile lines. +# Replaces existing region content if present and outdated, inserts if absent. +# Returns $true if a change was made. +function Update-ProfileRegion { + param( + [System.Collections.Generic.List[string]]$Lines, + [string]$RegionName, + [string[]]$Content + ) + $startTag = "#region $RegionName" + $endTag = '#endregion' + $startIdx = ($Lines | Select-String $startTag -SimpleMatch).LineNumber + if ($startIdx) { + $endIdx = ($Lines | Select-String $endTag -SimpleMatch | + Where-Object LineNumber -GE $startIdx | Select-Object -First 1).LineNumber + if ($endIdx) { + $existing = $Lines[($startIdx - 1)..($endIdx - 1)] -join "`n" + if ($existing -eq ($Content -join "`n")) { + return $false + } + $removeFrom = $startIdx - 1 + while ($removeFrom -gt 0 -and [string]::IsNullOrWhiteSpace($Lines[$removeFrom - 1])) { + $removeFrom-- + } + $Lines.RemoveRange($removeFrom, $endIdx - $removeFrom) + $Lines.Add('') + $Lines.AddRange([string[]]$Content) + return $true + } + } + $Lines.Add('') + $Lines.AddRange([string[]]$Content) + return $true +} + +# Remove a #region block by exact name. Returns $true if found and removed. +function Remove-ProfileRegion { + param( + [System.Collections.Generic.List[string]]$Lines, + [string]$RegionName + ) + $startTag = "#region $RegionName" + $endTag = '#endregion' + $startIdx = $null + for ($i = 0; $i -lt $Lines.Count; $i++) { + if ($Lines[$i].TrimEnd() -eq $startTag) { $startIdx = $i; break } + } + if ($null -eq $startIdx) { return $false } + $endIdx = $null + for ($i = $startIdx + 1; $i -lt $Lines.Count; $i++) { + if ($Lines[$i].TrimEnd() -eq $endTag) { $endIdx = $i; break } + } + if ($null -eq $endIdx) { return $false } + $removeFrom = $startIdx + while ($removeFrom -gt 0 -and [string]::IsNullOrWhiteSpace($Lines[$removeFrom - 1])) { + $removeFrom-- + } + $Lines.RemoveRange($removeFrom, $endIdx - $removeFrom + 1) + return $true +} + +# ============================================================================ +# Install user-scope alias files +# ============================================================================ +$userScriptsPath = [IO.Path]::Combine( + [Environment]::GetFolderPath('UserProfile'), '.config/powershell/Scripts') +if (-not [IO.Directory]::Exists($userScriptsPath)) { + [IO.Directory]::CreateDirectory($userScriptsPath) | Out-Null +} +$aliasFile = '_aliases_nix.ps1' +$src = [IO.Path]::Combine($pwshCfg, $aliasFile) +$dst = [IO.Path]::Combine($userScriptsPath, $aliasFile) +if ([IO.File]::Exists($src)) { + $needsCopy = -not [IO.File]::Exists($dst) -or + [IO.File]::ReadAllText($src) -ne [IO.File]::ReadAllText($dst) + if ($needsCopy) { + [IO.File]::Copy($src, $dst, $true) + Write-Host "`e[32minstalled $aliasFile for PowerShell`e[0m" + } +} + +# ============================================================================ +# Install base profile to durable config +# ============================================================================ +$envDir = [IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.config/nix-env') +$baseProfileSrc = [IO.Path]::Combine($pwshCfg, 'profile_nix.ps1') +$baseProfileDst = [IO.Path]::Combine($envDir, 'profile_base.ps1') +if ([IO.File]::Exists($baseProfileSrc)) { + $needsCopy = -not [IO.File]::Exists($baseProfileDst) -or + [IO.File]::ReadAllText($baseProfileSrc) -ne [IO.File]::ReadAllText($baseProfileDst) + if ($needsCopy) { + [IO.File]::Copy($baseProfileSrc, $baseProfileDst, $true) + Write-Host "`e[32minstalled base profile for PowerShell`e[0m" + } +} + +# ============================================================================ +# Build profile content - CurrentUserAllHosts ($PROFILE.CurrentUserAllHosts) +# ============================================================================ +$nixBin = [IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.nix-profile/bin') +$profilePath = $PROFILE.CurrentUserAllHosts +$profileContent = [System.Collections.Generic.List[string]]::new() +if ([IO.File]::Exists($profilePath)) { + $profileContent.AddRange([IO.File]::ReadAllLines($profilePath)) +} + +# -- Migration: remove old region names (before nix: prefix convention) ------ +foreach ($oldRegion in @('base', 'nix', 'oh-my-posh', 'starship', 'uv')) { + if (Remove-ProfileRegion -Lines $profileContent -RegionName $oldRegion) { + Write-Host "`e[33mmigrated old region '$oldRegion' from PowerShell profile`e[0m" + } +} + +# -- nix:base - profile dot-source ------------------------------------------- +$baseRegion = [string[]]@( + '#region nix:base' + "if (Test-Path `"$baseProfileDst`" -PathType Leaf) { . `"$baseProfileDst`" }" + '#endregion' +) +if (Update-ProfileRegion -Lines $profileContent -RegionName 'nix:base' -Content $baseRegion) { + Write-Host "`e[32mupdated nix:base in PowerShell profile`e[0m" +} + +# -- nix:path - nix PATH ----------------------------------------------------- +$nixRegion = [string[]]@( + '#region nix:path' + 'foreach ($nixPath in @(''/nix/var/nix/profiles/default/bin'', [IO.Path]::Combine([Environment]::GetFolderPath(''UserProfile''), ''.nix-profile/bin''))) {' + ' if ([IO.Directory]::Exists($nixPath) -and $nixPath -notin $env:PATH.Split([IO.Path]::PathSeparator)) {' + ' [Environment]::SetEnvironmentVariable(''PATH'', [string]::Join([IO.Path]::PathSeparator, $nixPath, $env:PATH))' + ' }' + '}' + '#endregion' +) +if (Update-ProfileRegion -Lines $profileContent -RegionName 'nix:path' -Content $nixRegion) { + Write-Host "`e[32mupdated nix:path in PowerShell profile`e[0m" +} + +# -- nix:certs - override NIX_SSL_CERT_FILE with merged CA bundle ----------- +$caBundlePath = [IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.config/certs/ca-bundle.crt') +if ([IO.File]::Exists($caBundlePath)) { + $certsRegion = [string[]]@( + '#region nix:certs' + '$caBundlePath = [IO.Path]::Combine([Environment]::GetFolderPath(''UserProfile''), ''.config/certs/ca-bundle.crt'')' + 'if ([IO.File]::Exists($caBundlePath)) { $env:NIX_SSL_CERT_FILE = $caBundlePath }' + '#endregion' + ) + if (Update-ProfileRegion -Lines $profileContent -RegionName 'nix:certs' -Content $certsRegion) { + Write-Host "`e[32mupdated nix:certs in PowerShell profile`e[0m" + } +} + +# -- nix:starship - starship prompt ------------------------------------------ +$nixBinStarship = [IO.Path]::Combine($nixBin, 'starship') +if ([IO.File]::Exists($nixBinStarship)) { + $starshipRegion = [string[]]@( + '#region nix:starship' + '# starship prompt' + "if (Test-Path `"$nixBinStarship`" -PathType Leaf) { (& `"$nixBinStarship`" init powershell) | Out-String | Invoke-Expression }" + '#endregion' + ) + if (Update-ProfileRegion -Lines $profileContent -RegionName 'nix:starship' -Content $starshipRegion) { + Write-Host "`e[32mupdated nix:starship in PowerShell profile`e[0m" + } +} + +# -- nix:oh-my-posh - oh-my-posh prompt ------------------------------------- +$nixBinOmp = [IO.Path]::Combine($nixBin, 'oh-my-posh') +$ompTheme = [IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.config/nix-env/omp/theme.omp.json') +if ([IO.File]::Exists($nixBinOmp) -and [IO.File]::Exists($ompTheme)) { + $ompRegion = [string[]]@( + '#region nix:oh-my-posh' + '# oh-my-posh prompt' + "if (Test-Path `"$nixBinOmp`" -PathType Leaf) {" + " (& `"$nixBinOmp`" init pwsh --config `"$ompTheme`") | Out-String | Invoke-Expression" + ' [Environment]::SetEnvironmentVariable(''VIRTUAL_ENV_DISABLE_PROMPT'', $true)' + '}' + '#endregion' + ) + if (Update-ProfileRegion -Lines $profileContent -RegionName 'nix:oh-my-posh' -Content $ompRegion) { + Write-Host "`e[32mupdated nix:oh-my-posh in PowerShell profile`e[0m" + } +} + +# -- nix:uv - uv / uvx completion ------------------------------------------- +$nixBinUv = [IO.Path]::Combine($nixBin, 'uv') +if ([IO.File]::Exists($nixBinUv)) { + $uvRegion = [string[]]@( + '#region nix:uv' + "if (Test-Path `"$nixBinUv`" -PathType Leaf) {" + ' $env:UV_SYSTEM_CERTS = ''true''' + " (& `"$nixBinUv`" generate-shell-completion powershell) | Out-String | Invoke-Expression" + ' (& uvx --generate-shell-completion powershell) | Out-String | Invoke-Expression' + '}' + '#endregion' + ) + if (Update-ProfileRegion -Lines $profileContent -RegionName 'nix:uv' -Content $uvRegion) { + Write-Host "`e[32mupdated nix:uv in PowerShell profile`e[0m" + } +} + +# -- local-path - ~/.local/bin (generic, survives nix uninstall) ------------ +$localBin = [IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.local/bin') +$localPathRegion = [string[]]@( + '#region local-path' + '$localBin = [IO.Path]::Combine([Environment]::GetFolderPath(''UserProfile''), ''.local/bin'')' + 'if ([IO.Directory]::Exists($localBin) -and $localBin -notin $env:PATH.Split([IO.Path]::PathSeparator)) {' + ' [Environment]::SetEnvironmentVariable(''PATH'', [string]::Join([IO.Path]::PathSeparator, $localBin, $env:PATH))' + '}' + '#endregion' +) +if (Update-ProfileRegion -Lines $profileContent -RegionName 'local-path' -Content $localPathRegion) { + Write-Host "`e[32mupdated local-path in PowerShell profile`e[0m" +} + +# save CurrentUserAllHosts profile +[IO.File]::WriteAllText( + $profilePath, + "$(($profileContent -join "`n").Trim())`n" +) + +# ============================================================================ +# kubectl completion - CurrentUserCurrentHost ($PROFILE.CurrentUserCurrentHost) +# ============================================================================ +$kubectlBin = [IO.Path]::Combine($nixBin, 'kubectl') +if ([IO.File]::Exists($kubectlBin)) { + $kubectlProfilePath = $PROFILE.CurrentUserCurrentHost + $kubectlContent = [System.Collections.Generic.List[string]]::new() + if ([IO.File]::Exists($kubectlProfilePath)) { + $kubectlContent.AddRange([IO.File]::ReadAllLines($kubectlProfilePath)) + } + # migration: remove old region name + if (Remove-ProfileRegion -Lines $kubectlContent -RegionName 'kubectl completer') { + Write-Host "`e[33mmigrated old region 'kubectl completer' from PowerShell profile`e[0m" + } + $kubectlRegion = [string[]]@( + '#region nix:kubectl' + (& $kubectlBin completion powershell) -join "`n" + '' + '# setup autocompletion for the k alias' + 'Set-Alias -Name k -Value kubectl' + "Register-ArgumentCompleter -CommandName 'k' -ScriptBlock `${__kubectlCompleterBlock}" + '' + '# setup autocompletion for kubecolor' + 'if (Test-Path "$HOME/.nix-profile/bin/kubecolor" -PathType Leaf) {' + ' Set-Alias -Name kubectl -Value kubecolor' + " Register-ArgumentCompleter -CommandName 'kubecolor' -ScriptBlock `${__kubectlCompleterBlock}" + '}' + '#endregion' + ) + if (Update-ProfileRegion -Lines $kubectlContent -RegionName 'nix:kubectl' -Content $kubectlRegion) { + Write-Host "`e[32mupdated nix:kubectl in PowerShell profile`e[0m" + [IO.File]::WriteAllText( + $kubectlProfilePath, + "$(($kubectlContent -join "`n").Trim())`n" + ) + } +} diff --git a/nix/configure/profiles.sh b/nix/configure/profiles.sh new file mode 100755 index 00000000..2efff4cb --- /dev/null +++ b/nix/configure/profiles.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Post-install bash profile setup (cross-platform, Nix variant) +# Sets up: nix PATH, aliases, fzf integration, completions, +: ' +nix/configure/profiles.sh +' +# uv completions, make completions, kubectl completions +set -eo pipefail + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_ROOT/../.." && pwd)" +LIB="$REPO_ROOT/.assets/lib" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } + +# shellcheck source=../../.assets/lib/profile_block.sh +source "$LIB/profile_block.sh" +# shellcheck source=../../.assets/lib/env_block.sh +source "$LIB/env_block.sh" +# shellcheck source=../../.assets/lib/certs.sh +source "$LIB/certs.sh" + +BLOCK_MARKER="nix-env managed" +BASH_CFG="$REPO_ROOT/.assets/config/bash_cfg" + +info "configuring bash profile..." + +# create .bashrc if missing +[ -f "$HOME/.bashrc" ] || touch "$HOME/.bashrc" + +# --------------------------------------------------------------------------- +# Copy alias/function files to durable location +# --------------------------------------------------------------------------- +_install_cfg_file() { + local src="$1" dst="$2" + [ -f "$src" ] || return 0 + if ! cmp -s "$src" "$dst" 2>/dev/null; then + mkdir -p "$(dirname "$dst")" + cp -f "$src" "$dst" + fi +} + +_install_cfg_file "$BASH_CFG/aliases_nix.sh" "$HOME/.config/bash/aliases_nix.sh" +_install_cfg_file "$BASH_CFG/aliases_git.sh" "$HOME/.config/bash/aliases_git.sh" +_install_cfg_file "$BASH_CFG/aliases_kubectl.sh" "$HOME/.config/bash/aliases_kubectl.sh" +_install_cfg_file "$BASH_CFG/functions.sh" "$HOME/.config/bash/functions.sh" + +# --------------------------------------------------------------------------- +# Copy overlay shell config files (if overlay directory is active) +# --------------------------------------------------------------------------- +if [ -n "${OVERLAY_DIR:-}" ] && [ -d "$OVERLAY_DIR/bash_cfg" ]; then + for _overlay_cfg in "$OVERLAY_DIR/bash_cfg"/*.sh; do + [ -f "$_overlay_cfg" ] || continue + _install_cfg_file "$_overlay_cfg" "$HOME/.config/bash/$(basename "$_overlay_cfg")" + done +fi + +# --------------------------------------------------------------------------- +# Build the CA bundle and configure VS Code Server certs +# --------------------------------------------------------------------------- +build_ca_bundle +setup_vscode_certs + +# --------------------------------------------------------------------------- +# Render the managed block content +# --------------------------------------------------------------------------- +_render_bash_block() { + printf '# :path\n' + + # 1. Nix profile script (sets NIX_SSL_CERT_FILE, XDG_DATA_DIRS, adds default bin) + for nix_profile in \ + "$HOME/.nix-profile/etc/profile.d/nix.sh" \ + /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; do + if [ -f "$nix_profile" ]; then + printf '. %s\n' "$nix_profile" + break + fi + done + + # 2. Nix profile PATH (prepend over nix-daemon defaults) + if [ -d "$HOME/.nix-profile/bin" ]; then + printf 'export PATH="$HOME/.nix-profile/bin:$PATH"\n' + fi + + # 3. Override NIX_SSL_CERT_FILE with merged CA bundle (MITM proxy support) + if [ -f "$HOME/.config/certs/ca-bundle.crt" ]; then + printf 'export NIX_SSL_CERT_FILE="$HOME/.config/certs/ca-bundle.crt"\n' + fi + + # 4. Nix aliases (generic aliases moved to managed env block) + if [ -f "$HOME/.config/bash/aliases_nix.sh" ] && command -v nix &>/dev/null; then + printf '\n# :aliases\n' + printf '. "$HOME/.config/bash/aliases_nix.sh"\n' + fi + if [ -f "$HOME/.config/bash/aliases_git.sh" ] && [ -x "$HOME/.nix-profile/bin/git" ]; then + printf '[ -f "$HOME/.config/bash/aliases_git.sh" ] && . "$HOME/.config/bash/aliases_git.sh"\n' + fi + if [ -f "$HOME/.config/bash/aliases_kubectl.sh" ] && [ -x "$HOME/.nix-profile/bin/kubectl" ]; then + printf '[ -f "$HOME/.config/bash/aliases_kubectl.sh" ] && . "$HOME/.config/bash/aliases_kubectl.sh"\n' + fi + + # 5. fzf integration + if [ -x "$HOME/.nix-profile/bin/fzf" ]; then + printf '\n# :fzf\n' + printf '[ -x "$HOME/.nix-profile/bin/fzf" ] && eval "$(fzf --bash)"\n' + fi + + # 6. uv / uvx completion + if [ -x "$HOME/.nix-profile/bin/uv" ]; then + printf '\n# :uv\n' + printf 'if [ -x "$HOME/.nix-profile/bin/uv" ]; then\n' + printf ' export UV_SYSTEM_CERTS=true\n' + printf ' eval "$(uv generate-shell-completion bash)"\n' + printf ' eval "$(uvx --generate-shell-completion bash)"\n' + printf 'fi\n' + fi + + # 7. kubectl completion + if [ -x "$HOME/.nix-profile/bin/kubectl" ]; then + printf '\n# :kubectl\n' + printf 'if [ -x "$HOME/.nix-profile/bin/kubectl" ]; then\n' + printf ' source <(kubectl completion bash)\n' + printf ' complete -o default -F __start_kubectl k\n' + printf 'fi\n' + fi + + # 8. make completion + # shellcheck disable=SC2016 + printf '\n# :make\n' + printf 'complete -W "$(if [ -f Makefile ]; then grep -oE '\''^[a-zA-Z0-9_-]+:([^=]|$)'\'' Makefile | sed '\''s/[^a-zA-Z0-9_-]*$//'\'' +elif [ -f makefile ]; then grep -oE '\''^[a-zA-Z0-9_-]+:([^=]|$)'\'' makefile | sed '\''s/[^a-zA-Z0-9_-]*$//'\'' +fi)" make\n' + + # 9. oh-my-posh prompt + if [ -x "$HOME/.nix-profile/bin/oh-my-posh" ] && [ -f "$HOME/.config/nix-env/omp/theme.omp.json" ]; then + printf '\n# :oh-my-posh\n' + # shellcheck disable=SC2016 + printf '[ -x "$HOME/.nix-profile/bin/oh-my-posh" ] && eval "$(oh-my-posh init bash --config $HOME/.config/nix-env/omp/theme.omp.json)"\n' + fi + + # 10. starship prompt + if [ -x "$HOME/.nix-profile/bin/starship" ]; then + printf '\n# :starship\n' + # shellcheck disable=SC2016 + printf '[ -x "$HOME/.nix-profile/bin/starship" ] && eval "$(starship init bash)"\n' + fi +} + +# --------------------------------------------------------------------------- +# Generic env block (local path + cert env vars) from env_block.sh. +# Not nix-specific - survives nix-env uninstall. +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Clean up legacy prompt init lines (now rendered inside managed block) +# --------------------------------------------------------------------------- +_cleanup_legacy_prompt() { + local rc="$1" + [ -f "$rc" ] || return 0 + if grep -qE 'oh-my-posh init|starship init' "$rc" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk ' + /^# (oh-my-posh|starship) prompt$/ { next } + /oh-my-posh init/ { next } + /starship init/ { next } + { print } + ' "$rc" >"$tmp" + mv -f "$tmp" "$rc" + fi +} + +# --------------------------------------------------------------------------- +# Write the managed blocks (managed env first - generic/persistent, +# nix-env managed second - added/removed with nix) +# --------------------------------------------------------------------------- +BLOCK_TMP="$(mktemp)" +ENV_TMP="$(mktemp)" +trap 'rm -f "$BLOCK_TMP" "$ENV_TMP"' EXIT + +_cleanup_legacy_prompt "$HOME/.bashrc" + +# migrate old cert-only markers +manage_block "$HOME/.bashrc" "nix-env certs" remove +manage_block "$HOME/.bashrc" "managed certs" remove + +# remove both blocks to enforce ordering on re-append +manage_block "$HOME/.bashrc" "$BLOCK_MARKER" remove +manage_block "$HOME/.bashrc" "$ENV_BLOCK_MARKER" remove + +render_env_block >"$ENV_TMP" +manage_block "$HOME/.bashrc" "$ENV_BLOCK_MARKER" upsert "$ENV_TMP" + +_render_bash_block >"$BLOCK_TMP" +manage_block "$HOME/.bashrc" "$BLOCK_MARKER" upsert "$BLOCK_TMP" + +ok "bash profile configured" diff --git a/nix/configure/profiles.zsh b/nix/configure/profiles.zsh new file mode 100755 index 00000000..c82daa42 --- /dev/null +++ b/nix/configure/profiles.zsh @@ -0,0 +1,238 @@ +#!/usr/bin/env zsh +# Post-install zsh profile setup (cross-platform, Nix variant) +# Sets up: nix PATH, ~/.local/bin, aliases, fzf integration, +# uv completions, kubectl completions, and zsh plugins +: ' +nix/configure/profiles.zsh +' +set -euo pipefail + +SCRIPT_ROOT="${0:A:h}" +REPO_ROOT="${SCRIPT_ROOT:h:h}" +LIB="$REPO_ROOT/.assets/lib" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } + +# shellcheck source=../../.assets/lib/profile_block.sh +source "$LIB/profile_block.sh" +# shellcheck source=../../.assets/lib/env_block.sh +source "$LIB/env_block.sh" +# shellcheck source=../../.assets/lib/certs.sh +source "$LIB/certs.sh" + +BLOCK_MARKER="nix-env managed" +BASH_CFG="$REPO_ROOT/.assets/config/bash_cfg" + +info "configuring zsh profile..." + +# create .zshrc if missing +[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc" + +# --------------------------------------------------------------------------- +# Copy alias/function files to durable location +# --------------------------------------------------------------------------- +_install_cfg_file() { + local src="$1" dst="$2" + [[ -f "$src" ]] || return 0 + if ! cmp -s "$src" "$dst" 2>/dev/null; then + mkdir -p "${dst:h}" + cp -f "$src" "$dst" + fi +} + +_install_cfg_file "$BASH_CFG/aliases_nix.sh" "$HOME/.config/bash/aliases_nix.sh" +_install_cfg_file "$BASH_CFG/aliases_git.sh" "$HOME/.config/bash/aliases_git.sh" +_install_cfg_file "$BASH_CFG/aliases_kubectl.sh" "$HOME/.config/bash/aliases_kubectl.sh" +_install_cfg_file "$BASH_CFG/functions.sh" "$HOME/.config/bash/functions.sh" + +# --------------------------------------------------------------------------- +# Copy overlay shell config files (if overlay directory is active) +# --------------------------------------------------------------------------- +if [[ -n "${OVERLAY_DIR:-}" ]] && [[ -d "$OVERLAY_DIR/bash_cfg" ]]; then + for _overlay_cfg in "$OVERLAY_DIR/bash_cfg"/*.sh; do + [[ -f "$_overlay_cfg" ]] || continue + _install_cfg_file "$_overlay_cfg" "$HOME/.config/bash/${_overlay_cfg:t}" + done +fi + +# --------------------------------------------------------------------------- +# Install zsh plugins (git clone / pull, not injected into managed block; +# the source lines go into the block below) +# --------------------------------------------------------------------------- +ZSH_PLUGIN_DIR="$HOME/.zsh" +mkdir -p "$ZSH_PLUGIN_DIR" + +typeset -A ZSH_PLUGINS +ZSH_PLUGINS=( + zsh-autocomplete 'https://github.com/marlonrichert/zsh-autocomplete.git' + zsh-make-complete 'https://github.com/22peacemaker/zsh-make-complete.git' + zsh-autosuggestions 'https://github.com/zsh-users/zsh-autosuggestions.git' + zsh-syntax-highlighting 'https://github.com/zsh-users/zsh-syntax-highlighting.git' +) + +typeset -A ZSH_PLUGIN_FILES +ZSH_PLUGIN_FILES=( + zsh-autocomplete 'zsh-autocomplete.plugin.zsh' + zsh-make-complete 'zsh-make-complete.plugin.zsh' + zsh-autosuggestions 'zsh-autosuggestions.zsh' + zsh-syntax-highlighting 'zsh-syntax-highlighting.zsh' +) + +# ordered list for deterministic block output +ZSH_PLUGIN_ORDER=(zsh-autocomplete zsh-make-complete zsh-autosuggestions zsh-syntax-highlighting) + +for plugin in "${ZSH_PLUGIN_ORDER[@]}"; do + local url="${ZSH_PLUGINS[$plugin]}" + if [[ -d "$ZSH_PLUGIN_DIR/$plugin" ]]; then + git -C "$ZSH_PLUGIN_DIR/$plugin" pull --quiet 2>/dev/null || true + else + git clone --depth 1 "$url" "$ZSH_PLUGIN_DIR/$plugin" + ok "installed zsh plugin: $plugin" + fi +done + +# --------------------------------------------------------------------------- +# Build the CA bundle and configure VS Code Server certs +# --------------------------------------------------------------------------- +build_ca_bundle +setup_vscode_certs + +# --------------------------------------------------------------------------- +# Render the managed block content +# --------------------------------------------------------------------------- +_render_zsh_block() { + printf '# :path\n' + + # 1. Nix profile script (sets NIX_SSL_CERT_FILE, XDG_DATA_DIRS, adds default bin) + for nix_profile in \ + "$HOME/.nix-profile/etc/profile.d/nix.sh" \ + /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; do + if [[ -f "$nix_profile" ]]; then + printf '. %s\n' "$nix_profile" + break + fi + done + + # 2. Nix profile PATH (prepend over nix-daemon defaults) + if [[ -d "$HOME/.nix-profile/bin" ]]; then + printf 'export PATH="$HOME/.nix-profile/bin:$PATH"\n' + fi + + # 3. Override NIX_SSL_CERT_FILE with merged CA bundle (MITM proxy support) + if [[ -f "$HOME/.config/certs/ca-bundle.crt" ]]; then + printf 'export NIX_SSL_CERT_FILE="$HOME/.config/certs/ca-bundle.crt"\n' + fi + + # 4. Nix aliases (generic aliases moved to managed env block) + if [[ -f "$HOME/.config/bash/aliases_nix.sh" ]] && command -v nix &>/dev/null; then + printf '\n# :aliases\n' + printf '. "$HOME/.config/bash/aliases_nix.sh"\n' + fi + if [[ -f "$HOME/.config/bash/aliases_git.sh" ]] && [[ -x "$HOME/.nix-profile/bin/git" ]]; then + printf '[ -f "$HOME/.config/bash/aliases_git.sh" ] && . "$HOME/.config/bash/aliases_git.sh"\n' + fi + if [[ -f "$HOME/.config/bash/aliases_kubectl.sh" ]] && [[ -x "$HOME/.nix-profile/bin/kubectl" ]]; then + printf '[ -f "$HOME/.config/bash/aliases_kubectl.sh" ] && . "$HOME/.config/bash/aliases_kubectl.sh"\n' + fi + + # 5. zsh plugins (must come before completions - zsh-autocomplete calls compinit + # which defines compdef, needed by uv/kubectl/fzf completions below) + printf '\n# :zsh plugins\n' + for plugin in "${ZSH_PLUGIN_ORDER[@]}"; do + local file="${ZSH_PLUGIN_FILES[$plugin]}" + if [[ -f "$ZSH_PLUGIN_DIR/$plugin/$file" ]]; then + printf 'source "$HOME/.zsh/%s/%s"\n' "$plugin" "$file" + fi + done + + # 6. fzf integration + if [[ -x "$HOME/.nix-profile/bin/fzf" ]]; then + printf '\n# :fzf\n' + printf '[ -x "$HOME/.nix-profile/bin/fzf" ] && eval "$(fzf --zsh)"\n' + fi + + # 7. uv / uvx completion + if [[ -x "$HOME/.nix-profile/bin/uv" ]]; then + printf '\n# :uv\n' + printf 'if [ -x "$HOME/.nix-profile/bin/uv" ]; then\n' + printf ' export UV_SYSTEM_CERTS=true\n' + printf ' eval "$(uv generate-shell-completion zsh)"\n' + printf ' eval "$(uvx --generate-shell-completion zsh)"\n' + printf 'fi\n' + fi + + # 8. kubectl completion + if [[ -x "$HOME/.nix-profile/bin/kubectl" ]]; then + printf '\n# :kubectl\n' + printf 'if [ -x "$HOME/.nix-profile/bin/kubectl" ]; then\n' + printf ' source <(kubectl completion zsh)\n' + printf 'fi\n' + fi + + # 9. oh-my-posh prompt + if [[ -x "$HOME/.nix-profile/bin/oh-my-posh" ]] && [[ -f "$HOME/.config/nix-env/omp/theme.omp.json" ]]; then + printf '\n# :oh-my-posh\n' + printf '[ -x "$HOME/.nix-profile/bin/oh-my-posh" ] && eval "$(oh-my-posh init zsh --config $HOME/.config/nix-env/omp/theme.omp.json)"\n' + fi + + # 10. starship prompt + if [[ -x "$HOME/.nix-profile/bin/starship" ]]; then + printf '\n# :starship\n' + printf '[ -x "$HOME/.nix-profile/bin/starship" ] && eval "$(starship init zsh)"\n' + fi + + # 11. keybindings + printf '\n# :keybindings\n' + printf "bindkey '^ ' autosuggest-accept\n" +} + +# --------------------------------------------------------------------------- +# Generic env block (local path + cert env vars) from env_block.sh. +# Not nix-specific - survives nix-env uninstall. +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Clean up legacy prompt init lines (now rendered inside managed block) +# --------------------------------------------------------------------------- +_cleanup_legacy_prompt() { + local rc="$1" + [[ -f "$rc" ]] || return 0 + if grep -qE 'oh-my-posh init|starship init' "$rc" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk ' + /^# (oh-my-posh|starship) prompt$/ { next } + /oh-my-posh init/ { next } + /starship init/ { next } + { print } + ' "$rc" >"$tmp" + mv -f "$tmp" "$rc" + fi +} + +# --------------------------------------------------------------------------- +# Write the managed blocks (managed env first - generic/persistent, +# nix-env managed second - added/removed with nix) +# --------------------------------------------------------------------------- +BLOCK_TMP="$(mktemp)" +ENV_TMP="$(mktemp)" +trap 'rm -f "$BLOCK_TMP" "$ENV_TMP"' EXIT + +_cleanup_legacy_prompt "$HOME/.zshrc" + +# migrate old cert-only markers +manage_block "$HOME/.zshrc" "nix-env certs" remove +manage_block "$HOME/.zshrc" "managed certs" remove + +# remove both blocks to enforce ordering on re-append +manage_block "$HOME/.zshrc" "$BLOCK_MARKER" remove +manage_block "$HOME/.zshrc" "$ENV_BLOCK_MARKER" remove + +render_env_block >"$ENV_TMP" +manage_block "$HOME/.zshrc" "$ENV_BLOCK_MARKER" upsert "$ENV_TMP" + +_render_zsh_block >"$BLOCK_TMP" +manage_block "$HOME/.zshrc" "$BLOCK_MARKER" upsert "$BLOCK_TMP" + +ok "zsh profile configured" diff --git a/nix/configure/starship.sh b/nix/configure/starship.sh new file mode 100755 index 00000000..5d45fcb6 --- /dev/null +++ b/nix/configure/starship.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Post-install starship prompt configuration (cross-platform, Nix variant) +# The config is always at a fixed path (~/.config/starship.toml). +: ' +# reconfigure with the existing theme +nix/configure/starship.sh +# set a specific theme +nix/configure/starship.sh base +nix/configure/starship.sh nerd +nix/configure/starship.sh omp_base +nix/configure/starship.sh omp_nerd +' +# Shell rc files point to this path once; config changes just replace the file. +set -eo pipefail + +starship_theme="${1:-}" + +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } + +if ! command -v starship &>/dev/null; then + exit 0 +fi + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# -- Fixed config path --------------------------------------------------------- +STARSHIP_CFG="$HOME/.config/starship.toml" + +# -- Install/update config file ------------------------------------------------ +if [[ -n "$starship_theme" ]]; then + info "setting starship theme to '$starship_theme'..." + theme_src="$SCRIPT_ROOT/.assets/config/starship_cfg/${starship_theme}.toml" + if [[ -f "$theme_src" ]]; then + cp -f "$theme_src" "$STARSHIP_CFG" + ok "installed starship theme '$starship_theme' to $STARSHIP_CFG" + else + warn "starship theme '$starship_theme' not found" + exit 1 + fi +elif [[ ! -f "$STARSHIP_CFG" ]]; then + # no theme specified and no existing config - install default (base) + if [[ -f "$SCRIPT_ROOT/.assets/config/starship_cfg/base.toml" ]]; then + cp -f "$SCRIPT_ROOT/.assets/config/starship_cfg/base.toml" "$STARSHIP_CFG" + ok "installed default starship theme (base) to $STARSHIP_CFG" + fi +fi diff --git a/nix/flake.nix b/nix/flake.nix new file mode 100644 index 00000000..ab270a2d --- /dev/null +++ b/nix/flake.nix @@ -0,0 +1,52 @@ +{ + description = "Cross-platform dev environment - scope-based package set"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + }; + + outputs = { nixpkgs, ... }: + let + cfg = import ./config.nix; + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + + mkEnv = system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + }; + + # always include base packages + basePkgs = import ./scopes/base.nix { inherit pkgs; }; + + # include init packages when no system-wide curl/jq + initPkgs = if cfg.isInit or false + then import ./scopes/base_init.nix { inherit pkgs; } + else [ ]; + + # include packages from enabled scopes + scopePkgs = builtins.concatMap (scope: + let file = ./scopes/${scope}.nix; + in if builtins.pathExists file + then import file { inherit pkgs; } + else [ ] + ) (cfg.scopes or [ ]); + + # include ad-hoc user packages from packages.nix (managed by nx CLI) + extraNames = if builtins.pathExists ./packages.nix + then import ./packages.nix + else [ ]; + extraPkgs = map (name: pkgs.${name}) extraNames; + + in pkgs.buildEnv { + name = "dev-env"; + paths = basePkgs ++ initPkgs ++ scopePkgs ++ extraPkgs; + }; + in + { + packages = nixpkgs.lib.genAttrs supportedSystems (system: { + default = mkEnv system; + }); + }; +} diff --git a/nix/lib/io.sh b/nix/lib/io.sh new file mode 100644 index 00000000..5db99b4f --- /dev/null +++ b/nix/lib/io.sh @@ -0,0 +1,16 @@ +# Side-effect wrappers for nix/setup.sh phases. +# Tests override these functions to stub external commands. + +# -- Output helpers ------------------------------------------------------------ +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } +err() { printf "\e[31;1m%s\e[0m\n" "$*" >&2; } + +# -- Thin shims for external commands ------------------------------------------ +# Phases call these instead of the raw commands. Tests redefine them to assert +# the right commands are issued without executing them. +_io_nix() { nix "$@"; } +_io_nix_eval() { nix eval --impure --raw --expr "$1"; } +_io_curl_probe() { curl -sS "$1" >/dev/null 2>&1; } +_io_run() { "$@"; } diff --git a/nix/lib/phases/bootstrap.sh b/nix/lib/phases/bootstrap.sh new file mode 100644 index 00000000..246f4193 --- /dev/null +++ b/nix/lib/phases/bootstrap.sh @@ -0,0 +1,270 @@ +# phase: bootstrap +# Root guard, path resolution, nix/jq detection, ENV_DIR sync, arg parsing. +# shellcheck disable=SC2034 # variables used by other phases +# +# Reads: BASH_SOURCE (for path resolution) +# Writes: SCRIPT_ROOT, NIX_ENV_VERSION, NIX_SRC, CONFIGURE_DIR, ENV_DIR, +# CONFIG_NIX, omp_theme, starship_theme, unattended, update_modules, +# upgrade_packages, quiet_summary, remove_scopes, any_scope, _scope_set + +phase_bootstrap_check_root() { + if [[ $EUID -eq 0 ]]; then + err "Do not run the script as root (sudo)." + exit 1 + fi +} + +phase_bootstrap_resolve_paths() { + SCRIPT_ROOT="${1:?phase_bootstrap_resolve_paths requires repo root}" + NIX_ENV_VERSION="$(git -C "$SCRIPT_ROOT" describe --tags --dirty 2>/dev/null \ + || cat "$SCRIPT_ROOT/VERSION" 2>/dev/null \ + || echo "unknown")" + export NIX_ENV_VERSION + NIX_SRC="$SCRIPT_ROOT/nix" + CONFIGURE_DIR="$SCRIPT_ROOT/nix/configure" + ENV_DIR="$HOME/.config/nix-env" + CONFIG_NIX="$ENV_DIR/config.nix" +} + +_source_nix_profile() { + for nix_profile in \ + "$HOME/.nix-profile/etc/profile.d/nix.sh" \ + /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; do + if [ -f "$nix_profile" ]; then + # shellcheck source=/dev/null + . "$nix_profile" + return 0 + fi + done + return 1 +} + +# Check whether a GID and UID range [base, base+32) are all unassigned on macOS. +_darwin_id_range_free() { + local base=$1 + local end=$((base + 31)) + dscl . -list /Groups PrimaryGroupID 2>/dev/null \ + | awk -v id="$base" '$NF == id {exit 1}' || return 1 + dscl . -list /Users UniqueID 2>/dev/null \ + | awk -v lo="$base" -v hi="$end" '$NF >= lo && $NF <= hi {exit 1}' || return 1 + return 0 +} + +_install_nix_darwin() { + info "Nix is not installed. Attempting automatic installation on macOS..." + + local id_base="" + local candidate + # prefer < 500 (macOS system band - hidden from login screen by default) + # 300/400 are commonly reserved by macOS system services + for candidate in 350 450 460 470 480 490 4000; do + if _darwin_id_range_free "$candidate"; then + id_base=$candidate + break + fi + warn "GID/UID range $candidate is already in use, trying next..." + done + + if [ -z "$id_base" ]; then + _ir_error="Nix install failed: no free GID/UID range found" + err "Could not find a free GID/UID range for Nix build users." + err "Install Nix manually with a free range, e.g.:" + err " curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | \\" + err " sh -s -- install --nix-build-group-id --nix-build-user-id-base " + exit 1 + fi + + local install_args=(install --no-confirm) + if [ "$id_base" -ne 350 ]; then + install_args+=(--nix-build-group-id "$id_base" --nix-build-user-id-base "$id_base") + info "using custom GID $id_base and UID base $id_base" + fi + + _io_run curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix \ + | sh -s -- "${install_args[@]}" + + _source_nix_profile + if ! command -v nix &>/dev/null && [ -x "$HOME/.nix-profile/bin/nix" ]; then + export PATH="$HOME/.nix-profile/bin:$PATH" + fi + + if ! command -v nix &>/dev/null; then + _ir_error="Nix installation completed but nix command not found" + err "Nix installation completed but 'nix' command is not available." + err "Open a new terminal and run setup.sh again." + exit 1 + fi + ok "Nix installed successfully" +} + +phase_bootstrap_detect_nix() { + if ! command -v nix &>/dev/null; then + _source_nix_profile || true + fi + if ! command -v nix &>/dev/null && [ -x "$HOME/.nix-profile/bin/nix" ]; then + export PATH="$HOME/.nix-profile/bin:$PATH" + fi + if ! command -v nix &>/dev/null; then + if [ "$(uname -s)" = "Darwin" ]; then + _install_nix_darwin + else + _ir_error="Nix is not installed" + err "Nix is not installed. Install it first (requires root, one-time):" + err " curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install" + exit 1 + fi + fi +} + +phase_bootstrap_verify_store() { + if ! _io_nix store info &>/dev/null; then + _ir_error="nix store is unreachable" + err "nix store is unreachable. Possible causes:" + err " - nix daemon is not running (check: systemctl status nix-daemon)" + err " - nix was installed without --no-daemon and systemd is missing" + err "Reinstall nix if needed: https://install.determinate.systems/nix" + exit 1 + fi +} + +phase_bootstrap_sync_env_dir() { + mkdir -p "$ENV_DIR" + cp "$NIX_SRC/flake.nix" "$ENV_DIR/" + cp -r "$NIX_SRC/scopes" "$ENV_DIR/" + cp "$SCRIPT_ROOT/.assets/lib/nx_doctor.sh" "$ENV_DIR/" + ok "synced nix declarations to $ENV_DIR" +} + +phase_bootstrap_install_jq() { + if ! command -v jq &>/dev/null; then + info "first run - installing base packages via nix..." + cat >"$CONFIG_NIX" <&1; then + warn "nix profile add failed (may already exist) - continuing with upgrade" + fi + _io_nix profile upgrade nix-env || + { _ir_error="nix bootstrap failed"; err "$_ir_error"; exit 1; } + fi +} + +usage() { + cat <<'EOF' +Usage: nix/setup.sh [options] + +Additive: scope flags add to the existing config. Without scope flags, +re-applies configuration using existing package versions (idempotent). +Use --upgrade to pull latest packages from nixpkgs. + +Scope flags (add new packages - merged with existing config): + --az Azure CLI + azcopy + --bun Bun JavaScript/TypeScript runtime + --conda Miniforge (conda-forge) + --docker Docker post-install check (Docker itself installed separately) + --gcloud Google Cloud CLI + --k8s-base kubectl, kubelogin, k9s, kubecolor, kubectx/kubens + --k8s-dev argo rollouts, cilium, flux, helm, hubble, kustomize, trivy + --k8s-ext minikube, k3d, kind + --nodejs Node.js + --pwsh PowerShell + --python uv + prek (python managed by uv/conda, not nix) + --rice btop, cmatrix, cowsay, fastfetch + --shell fzf, eza, bat, ripgrep, yq + --terraform terraform, tflint + --zsh zsh plugins (autosuggestions, syntax-highlighting, completions) + --all Enable all scopes above + +Options: + --remove [...] Remove one or more scopes (space-separated) + --upgrade Update flake.lock to latest nixpkgs and upgrade all packages + --omp-theme Install oh-my-posh with theme (base, nerd, powerline, ...) + --starship-theme Install starship with theme (base, nerd) + --unattended Skip all interactive steps (gh auth, SSH key, git config) + --update-modules Update installed PowerShell modules + -h, --help Show this help +EOF +} + +phase_bootstrap_parse_args() { + omp_theme="" + starship_theme="" + unattended="false" + update_modules="false" + quiet_summary="false" + upgrade_packages="false" + remove_scopes=() + _scope_set=" " + any_scope=false + + while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + _ir_skip=true + usage + exit 0 + ;; + --az | --bun | --conda | --docker | --gcloud | --k8s-base | --k8s-dev | --k8s-ext | \ + --nodejs | --pwsh | --python | --rice | --shell | --terraform | --zsh) + scope_add "${1#--}" + any_scope=true + ;; + --all) + for s in "${VALID_SCOPES[@]}"; do + [[ "$s" == "oh_my_posh" || "$s" == "starship" ]] && continue + scope_add "$s" + done + any_scope=true + ;; + --omp-theme) + omp_theme="${2:-}" + scope_add oh_my_posh + any_scope=true + shift + ;; + --starship-theme) + starship_theme="${2:-}" + scope_add starship + any_scope=true + shift + ;; + --remove) + shift + while [[ $# -gt 0 && "$1" != --* ]]; do + remove_scopes+=("${1//-/_}") + shift + done + if [[ ${#remove_scopes[@]} -eq 0 ]]; then + err "--remove requires at least one scope name" + usage + exit 2 + fi + continue + ;; + --unattended) + unattended="true" + ;; + --update-modules) + update_modules="true" + ;; + --upgrade) + upgrade_packages="true" + ;; + --quiet-summary) + quiet_summary="true" + ;; + *) + err "Unknown option: $1" + usage + exit 2 + ;; + esac + shift + done + + # normalize hyphenated flag names to underscored scope names + _scope_set="${_scope_set//-/_}" +} diff --git a/nix/lib/phases/configure.sh b/nix/lib/phases/configure.sh new file mode 100644 index 00000000..db29872f --- /dev/null +++ b/nix/lib/phases/configure.sh @@ -0,0 +1,45 @@ +# phase: configure +# GitHub CLI auth, git config, scope-based post-install configuration. +# shellcheck disable=SC2154 # globals set by bootstrap phase +# +# Reads: CONFIGURE_DIR, sorted_scopes, omp_theme, starship_theme +# Writes: GITHUB_TOKEN + +phase_configure_gh() { + local unattended="${1:-false}" + _io_run "$CONFIGURE_DIR/gh.sh" "$unattended" + if [[ -z "${GITHUB_TOKEN:-}" ]] && command -v gh &>/dev/null && gh auth token &>/dev/null; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null)" + export GITHUB_TOKEN + fi +} + +phase_configure_git() { + local unattended="${1:-false}" + if [[ "$unattended" != "true" ]]; then + _io_run "$CONFIGURE_DIR/git.sh" + fi +} + +phase_configure_per_scope() { + local sc + for sc in "${sorted_scopes[@]}"; do + case $sc in + docker) + _io_run "$CONFIGURE_DIR/docker.sh" + ;; + conda) + _io_run "$CONFIGURE_DIR/conda.sh" + ;; + az) + _io_run "$CONFIGURE_DIR/az.sh" + ;; + oh_my_posh) + _io_run "$CONFIGURE_DIR/omp.sh" "$omp_theme" + ;; + starship) + _io_run "$CONFIGURE_DIR/starship.sh" "$starship_theme" + ;; + esac + done +} diff --git a/nix/lib/phases/nix_profile.sh b/nix/lib/phases/nix_profile.sh new file mode 100644 index 00000000..7a500d50 --- /dev/null +++ b/nix/lib/phases/nix_profile.sh @@ -0,0 +1,94 @@ +# phase: nix-profile +# Flake update, nix profile upgrade, MITM proxy certificate detection. +# shellcheck disable=SC2154 # globals set by bootstrap phase +# +# Reads: ENV_DIR, upgrade_packages, SCRIPT_ROOT +# Writes: PINNED_REV, _ir_error + +should_update_flake() { + local upgrade_flag="${1:-false}" + [[ "$upgrade_flag" == "true" ]] && return 0 + return 1 +} + +phase_nix_profile_load_pinned_rev() { + PINNED_REV="" + if [[ -f "$ENV_DIR/pinned_rev" ]]; then + PINNED_REV="$(tr -d '[:space:]' <"$ENV_DIR/pinned_rev")" + fi +} + +phase_nix_profile_print_mode() { + if [[ ! -f "$ENV_DIR/flake.lock" ]]; then + info "first run - resolving nixpkgs and installing..." + elif should_update_flake "$upgrade_packages"; then + if [[ -n "$PINNED_REV" ]]; then + info "pinning nixpkgs to $PINNED_REV..." + else + info "upgrading all packages to latest (nix flake update + profile upgrade)..." + fi + else + info "applying nix configuration (use --upgrade to pull latest packages)..." + fi +} + +phase_nix_profile_update_flake() { + SECONDS=0 + if should_update_flake "$upgrade_packages"; then + if [[ -n "$PINNED_REV" ]]; then + _io_nix flake lock --override-input nixpkgs "github:nixos/nixpkgs/$PINNED_REV" --flake "$ENV_DIR" 2>/dev/null || + warn "flake lock failed - using existing lock" + else + _io_nix flake update --flake "$ENV_DIR" 2>/dev/null || + warn "flake update failed (network issue?) - using existing lock" + fi + fi +} + +phase_nix_profile_apply() { + if ! _io_nix profile add "path:$ENV_DIR" 2>&1; then + warn "nix profile add failed (may already exist) - continuing with upgrade" + fi + _io_nix profile upgrade nix-env || + { _ir_error="nix profile upgrade failed"; err "$_ir_error"; exit 1; } + ok "nix profile updated in ${SECONDS}s" +} + +phase_nix_profile_mitm_probe() { + # shellcheck source=../../../.assets/lib/certs.sh + source "$SCRIPT_ROOT/.assets/lib/certs.sh" + + # On macOS, build_ca_bundle exports from Keychain (no MITM probe needed). + # On Linux, it requires ca-custom.crt so we only call it after cert_intercept. + if [[ "$(uname -s)" == "Darwin" ]]; then + build_ca_bundle + fi + + # MITM probe (Linux, or macOS if Keychain export missed something). + # Use nix curl - system curl on macOS uses Keychain and passes behind + # MITM proxies that nix tools (isolated OpenSSL) reject. + local ca_bundle="$HOME/.config/certs/ca-bundle.crt" + if [[ ! -f "$ca_bundle" ]]; then + local _probe_failed=false + if [[ -x "$HOME/.nix-profile/bin/curl" ]]; then + "$HOME/.nix-profile/bin/curl" -sS "$NIX_ENV_TLS_PROBE_URL" >/dev/null 2>&1 || _probe_failed=true + else + _io_curl_probe "$NIX_ENV_TLS_PROBE_URL" || _probe_failed=true + fi + if [[ "$_probe_failed" == "true" ]] && command -v openssl &>/dev/null; then + warn "SSL verification failed - MITM proxy detected, intercepting certificates..." + # shellcheck source=../../../.assets/config/bash_cfg/functions.sh + source "$SCRIPT_ROOT/.assets/config/bash_cfg/functions.sh" + cert_intercept + build_ca_bundle + fi + fi + + # Configure env vars and git for all nix-built tools + if [[ -f "$ca_bundle" ]]; then + export NIX_SSL_CERT_FILE="$ca_bundle" + export SSL_CERT_FILE="$ca_bundle" + git config --global http.sslCAInfo "$ca_bundle" + ok "configured CA bundle for nix tools ($ca_bundle)" + fi +} diff --git a/nix/lib/phases/platform.sh b/nix/lib/phases/platform.sh new file mode 100644 index 00000000..3347143d --- /dev/null +++ b/nix/lib/phases/platform.sh @@ -0,0 +1,50 @@ +# phase: platform +# OS detection, overlay directory discovery, hook runner. +# +# Reads: ENV_DIR, NIX_ENV_OVERLAY_DIR (env) +# Writes: platform, NIX_ENV_PLATFORM, OVERLAY_DIR + +phase_platform_detect() { + local os + os="$(uname -s)" + case "$os" in + Darwin) platform="macOS" ;; + Linux) platform="Linux" ;; + *) platform="$os" ;; + esac + export NIX_ENV_PLATFORM="$platform" +} + +phase_platform_run_hooks() { + local hook_dir="$1" + [ -d "$hook_dir" ] || return 0 + local hook + for hook in "$hook_dir"/*.sh; do + [ -f "$hook" ] || continue + info "running hook: $(basename "$hook")" + # shellcheck source=/dev/null + source "$hook" + done +} + +phase_platform_discover_overlay() { + OVERLAY_DIR="" + if [ -n "${NIX_ENV_OVERLAY_DIR:-}" ] && [ -d "$NIX_ENV_OVERLAY_DIR" ]; then + OVERLAY_DIR="$NIX_ENV_OVERLAY_DIR" + elif [ -d "$ENV_DIR/local" ]; then + OVERLAY_DIR="$ENV_DIR/local" + fi + if [ -n "$OVERLAY_DIR" ]; then + info "overlay directory: $OVERLAY_DIR" + if [ -d "$OVERLAY_DIR/scopes" ]; then + local _overlay_nix _overlay_name + for _overlay_nix in "$OVERLAY_DIR/scopes"/*.nix; do + [ -f "$_overlay_nix" ] || continue + _overlay_name="local_$(basename "$_overlay_nix")" + cp "$_overlay_nix" "$ENV_DIR/scopes/$_overlay_name" + done + ok " synced overlay scopes" + fi + fi + export OVERLAY_DIR +} diff --git a/nix/lib/phases/post_install.sh b/nix/lib/phases/post_install.sh new file mode 100644 index 00000000..63312f1f --- /dev/null +++ b/nix/lib/phases/post_install.sh @@ -0,0 +1,20 @@ +# phase: post-install +# Common post-install setup and nix garbage collection. +# +# Reads: SCRIPT_ROOT, sorted_scopes + +phase_post_install_common() { + local do_update_modules="$1" + shift + if [[ "$do_update_modules" == "true" ]]; then + _io_run "$SCRIPT_ROOT/.assets/setup/setup_common.sh" --update-modules "$@" + else + _io_run "$SCRIPT_ROOT/.assets/setup/setup_common.sh" "$@" + fi +} + +phase_post_install_gc() { + info "cleaning up old nix profile generations..." + _io_nix profile wipe-history 2>/dev/null || true + _io_nix store gc 2>/dev/null || true +} diff --git a/nix/lib/phases/profiles.sh b/nix/lib/phases/profiles.sh new file mode 100644 index 00000000..dbed3b6f --- /dev/null +++ b/nix/lib/phases/profiles.sh @@ -0,0 +1,20 @@ +# phase: profiles +# Bash, zsh, and PowerShell shell profile setup. +# +# Reads: CONFIGURE_DIR + +phase_profiles_bash() { + _io_run "$CONFIGURE_DIR/profiles.sh" +} + +phase_profiles_zsh() { + if command -v zsh &>/dev/null; then + _io_run "$CONFIGURE_DIR/profiles.zsh" + fi +} + +phase_profiles_pwsh() { + if command -v pwsh &>/dev/null; then + _io_run pwsh -nop "$CONFIGURE_DIR/profiles.ps1" + fi +} diff --git a/nix/lib/phases/scopes.sh b/nix/lib/phases/scopes.sh new file mode 100644 index 00000000..b6f49405 --- /dev/null +++ b/nix/lib/phases/scopes.sh @@ -0,0 +1,119 @@ +# phase: scopes +# Load existing scopes, merge with CLI flags, resolve deps, write config.nix. +# Distinct from .assets/lib/scopes.sh (the shared scope-set library). +# shellcheck disable=SC2154 # globals set by bootstrap phase +# +# Reads: CONFIG_NIX, any_scope, remove_scopes, omp_theme, starship_theme +# Writes: _scope_set, sorted_scopes, NIX_ENV_SCOPES, _ir_phase, _ir_error + +phase_scopes_load_existing() { + if [[ -f "$CONFIG_NIX" ]]; then + if [[ "$any_scope" == "false" && ${#remove_scopes[@]} -eq 0 ]]; then + info "no scope flags provided - loading scopes from config.nix..." + else + info "loading existing scopes from config.nix and merging with CLI flags..." + fi + local nix_eval_output nix_eval_rc + nix_eval_output="$(_io_nix_eval ' + let cfg = import '"$CONFIG_NIX"'; + in builtins.concatStringsSep "\n" cfg.scopes + ' 2>&1)" + nix_eval_rc=$? + if [[ $nix_eval_rc -eq 0 ]]; then + local sc + while IFS= read -r sc; do + [[ -n "$sc" ]] && scope_add "$sc" + done <<<"$nix_eval_output" + else + warn "failed to read config.nix: $nix_eval_output" + if [[ ${#remove_scopes[@]} -gt 0 ]]; then + _ir_error="cannot --remove scopes when config.nix is unreadable" + err "$_ir_error" + exit 1 + fi + fi + elif [[ "$any_scope" == "false" && ${#remove_scopes[@]} -eq 0 ]]; then + info "no scope flags provided and no config.nix found - detecting from system..." + command -v oh-my-posh &>/dev/null && scope_add oh_my_posh || true + command -v docker &>/dev/null && scope_add docker || true + { [[ -x "$HOME/.local/bin/uv" ]] || [[ -x "$HOME/.nix-profile/bin/uv" ]]; } && scope_add python || true + command -v conda &>/dev/null && scope_add conda || true + fi +} + +phase_scopes_apply_removes() { + if [[ ${#remove_scopes[@]} -gt 0 ]]; then + validate_scopes "${remove_scopes[@]}" || exit 2 + local sc + for sc in "${remove_scopes[@]}"; do + if scope_has "$sc"; then + scope_del "$sc" + ok "removed scope: $sc" + else + warn "scope '$sc' is not currently configured - skipping" + fi + done + fi +} + +phase_scopes_enforce_prompt_exclusivity() { + if [[ -n "$omp_theme" && -n "$starship_theme" ]]; then + _ir_error="cannot use both --omp-theme and --starship-theme" + err "$_ir_error" + exit 2 + fi + if [[ -n "$omp_theme" ]]; then + scope_del starship + elif [[ -n "$starship_theme" ]]; then + scope_del oh_my_posh + fi +} + +phase_scopes_resolve_and_sort() { + resolve_scope_deps + sort_scopes + NIX_ENV_SCOPES="${sorted_scopes[*]:-}" + export NIX_ENV_SCOPES + + printf "\n\e[95;1m>> Dev Environment Setup via Nix (%s)\e[0m" "$platform" + # shellcheck disable=SC2154 # sorted_scopes is populated by sort_scopes + if ((${#sorted_scopes[@]} > 0)); then + printf " : \e[3;90m%s\e[0m" "${sorted_scopes[*]}" + fi + printf "\n\n" +} + +has_system_cmd() { + local cmd_path + cmd_path="$(command -v "$1" 2>/dev/null)" || return 1 + [[ "$cmd_path" != /nix/* && "$cmd_path" != */.nix-profile/* ]] +} + +phase_scopes_detect_init() { + is_init=false + if ! has_system_cmd jq || ! has_system_cmd curl; then + is_init=true + fi +} + +phase_scopes_write_config() { + local nix_scopes="" + local sc + for sc in "${sorted_scopes[@]}"; do + nix_scopes+=" \"$sc\""$'\n' + done + + cat >"$CONFIG_NIX" <>\e[0m\n" + if [[ "$_mode" == "remove" ]]; then + printf "\e[90mPlatform: %s | Mode: remove | Removed: %s | Scopes: %s\e[0m\n" "$platform" "${remove_scopes[*]}" "${sorted_scopes[*]}" + else + printf "\e[90mPlatform: %s | Mode: %s | Scopes: %s\e[0m\n" "$platform" "$_mode" "${sorted_scopes[*]}" + fi + local invoking_shell + invoking_shell="$(ps -o comm= -p "$PPID" 2>/dev/null | sed 's/^-//')" || true + invoking_shell="${invoking_shell:-$(basename "$SHELL")}" + case "$invoking_shell" in + zsh) printf "\e[97mRestart your terminal or run \e[4msource ~/.zshrc\e[24m to apply changes.\e[0m\n\n" ;; + bash) printf "\e[97mRestart your terminal or run \e[4msource ~/.bashrc\e[24m to apply changes.\e[0m\n\n" ;; + pwsh) printf "\e[97mRestart your terminal or run \e[4m. \$PROFILE\e[24m to apply changes.\e[0m\n\n" ;; + *) printf "\e[97mRestart your terminal to apply changes.\e[0m\n\n" ;; + esac +} diff --git a/nix/scopes/az.nix b/nix/scopes/az.nix new file mode 100644 index 00000000..0a292a0a --- /dev/null +++ b/nix/scopes/az.nix @@ -0,0 +1,5 @@ +# Azure CLI installed via uv (see nix/configure/az.sh); azcopy via Nix +# bins: azcopy +{ pkgs }: with pkgs; [ + azure-storage-azcopy +] diff --git a/nix/scopes/base.nix b/nix/scopes/base.nix new file mode 100644 index 00000000..f0e861c9 --- /dev/null +++ b/nix/scopes/base.nix @@ -0,0 +1,23 @@ +# Base packages - always installed +{ pkgs }: with pkgs; [ + bash-completion + cacert + coreutils + fd + findutils + gawk + gnupg + git + gh + bind # provides dig, nslookup, host + less + nmap + openssl + shfmt + tig + tree + unzip + vim + wget + whois +] diff --git a/nix/scopes/base_init.nix b/nix/scopes/base_init.nix new file mode 100644 index 00000000..7874641e --- /dev/null +++ b/nix/scopes/base_init.nix @@ -0,0 +1,5 @@ +# Pre-scope base packages needed before scope resolution. +# On WSL/Linux jq is bootstrapped by nix/setup.sh; curl is a system prerequisite. +{ pkgs }: with pkgs; [ + jq +] diff --git a/nix/scopes/bun.nix b/nix/scopes/bun.nix new file mode 100644 index 00000000..7723ae5a --- /dev/null +++ b/nix/scopes/bun.nix @@ -0,0 +1,5 @@ +# Bun - JavaScript/TypeScript runtime +# bins: bun +{ pkgs }: with pkgs; [ + bun +] diff --git a/nix/scopes/conda.nix b/nix/scopes/conda.nix new file mode 100644 index 00000000..96fdd036 --- /dev/null +++ b/nix/scopes/conda.nix @@ -0,0 +1,4 @@ +# Miniforge (conda) - installed via installer script, not nix. +# This scope only triggers configure/conda.sh. +# bins: conda +{ pkgs }: [ ] diff --git a/nix/scopes/distrobox.nix b/nix/scopes/distrobox.nix new file mode 100644 index 00000000..3debbeec --- /dev/null +++ b/nix/scopes/distrobox.nix @@ -0,0 +1,3 @@ +# Distrobox - installed traditionally, not via nix. +# bins: distrobox +{ pkgs }: [ ] diff --git a/nix/scopes/docker.nix b/nix/scopes/docker.nix new file mode 100644 index 00000000..4216bc9b --- /dev/null +++ b/nix/scopes/docker.nix @@ -0,0 +1,4 @@ +# Docker requires root and is installed traditionally (not via nix). +# This scope only triggers configure/docker.sh for post-install checks. +# bins: docker +{ pkgs }: [ ] diff --git a/nix/scopes/gcloud.nix b/nix/scopes/gcloud.nix new file mode 100644 index 00000000..232a872b --- /dev/null +++ b/nix/scopes/gcloud.nix @@ -0,0 +1,5 @@ +# Google Cloud CLI +# bins: gcloud +{ pkgs }: with pkgs; [ + google-cloud-sdk +] diff --git a/nix/scopes/k8s_base.nix b/nix/scopes/k8s_base.nix new file mode 100644 index 00000000..a18146a7 --- /dev/null +++ b/nix/scopes/k8s_base.nix @@ -0,0 +1,9 @@ +# Kubernetes base - kubectl, kubelogin, k9s, kubecolor, kubectx/kubens +# bins: kubectl kubelogin k9s kubecolor kubectx kubens +{ pkgs }: with pkgs; [ + kubectl + kubelogin + k9s + kubecolor + kubectx +] diff --git a/nix/scopes/k8s_dev.nix b/nix/scopes/k8s_dev.nix new file mode 100644 index 00000000..935a0aba --- /dev/null +++ b/nix/scopes/k8s_dev.nix @@ -0,0 +1,12 @@ +# Kubernetes dev - argo rollouts, cilium, flux, helm, hubble, humio, kustomize, trivy +# bins: kubectl-argo-rollouts cilium flux helm hubble humioctl kustomize trivy +{ pkgs }: with pkgs; [ + argo-rollouts + cilium-cli + fluxcd + humioctl + kubernetes-helm + hubble + kustomize + trivy +] diff --git a/nix/scopes/k8s_ext.nix b/nix/scopes/k8s_ext.nix new file mode 100644 index 00000000..1fa704b7 --- /dev/null +++ b/nix/scopes/k8s_ext.nix @@ -0,0 +1,7 @@ +# Kubernetes ext - local cluster tools +# bins: minikube k3d kind +{ pkgs }: with pkgs; [ + minikube + k3d + kind +] diff --git a/nix/scopes/nodejs.nix b/nix/scopes/nodejs.nix new file mode 100644 index 00000000..9f1809b0 --- /dev/null +++ b/nix/scopes/nodejs.nix @@ -0,0 +1,5 @@ +# Node.js +# bins: node +{ pkgs }: with pkgs; [ + nodejs +] diff --git a/nix/scopes/oh_my_posh.nix b/nix/scopes/oh_my_posh.nix new file mode 100644 index 00000000..21eb86bf --- /dev/null +++ b/nix/scopes/oh_my_posh.nix @@ -0,0 +1,5 @@ +# Oh My Posh prompt engine +# bins: oh-my-posh +{ pkgs }: with pkgs; [ + oh-my-posh +] diff --git a/nix/scopes/pwsh.nix b/nix/scopes/pwsh.nix new file mode 100644 index 00000000..0f87467a --- /dev/null +++ b/nix/scopes/pwsh.nix @@ -0,0 +1,5 @@ +# PowerShell +# bins: pwsh +{ pkgs }: with pkgs; [ + powershell +] diff --git a/nix/scopes/python.nix b/nix/scopes/python.nix new file mode 100644 index 00000000..f825c161 --- /dev/null +++ b/nix/scopes/python.nix @@ -0,0 +1,6 @@ +# Python tooling - python itself is managed by uv/conda, not nix +# bins: uv prek +{ pkgs }: with pkgs; [ + uv + prek +] diff --git a/nix/scopes/rice.nix b/nix/scopes/rice.nix new file mode 100644 index 00000000..9ce097dd --- /dev/null +++ b/nix/scopes/rice.nix @@ -0,0 +1,8 @@ +# Rice - eye candy / fun tools +# bins: btop cmatrix cowsay fastfetch +{ pkgs }: with pkgs; [ + btop + cmatrix + cowsay + fastfetch +] diff --git a/nix/scopes/shell.nix b/nix/scopes/shell.nix new file mode 100644 index 00000000..c14a70a2 --- /dev/null +++ b/nix/scopes/shell.nix @@ -0,0 +1,10 @@ +# Shell tools +# bins: bats fzf eza bat rg yq +{ pkgs }: with pkgs; [ + bats + fzf + eza + bat + ripgrep + yq-go +] diff --git a/nix/scopes/starship.nix b/nix/scopes/starship.nix new file mode 100644 index 00000000..b9e975a1 --- /dev/null +++ b/nix/scopes/starship.nix @@ -0,0 +1,5 @@ +# Starship cross-shell prompt +# bins: starship +{ pkgs }: with pkgs; [ + starship +] diff --git a/nix/scopes/terraform.nix b/nix/scopes/terraform.nix new file mode 100644 index 00000000..c084c2a1 --- /dev/null +++ b/nix/scopes/terraform.nix @@ -0,0 +1,6 @@ +# Terraform utilities +# bins: terraform tflint +{ pkgs }: with pkgs; [ + terraform + tflint +] diff --git a/nix/scopes/zsh.nix b/nix/scopes/zsh.nix new file mode 100644 index 00000000..13af4923 --- /dev/null +++ b/nix/scopes/zsh.nix @@ -0,0 +1,7 @@ +# Zsh plugins +# bins: zsh +{ pkgs }: with pkgs; [ + zsh-autosuggestions + zsh-syntax-highlighting + zsh-completions +] diff --git a/nix/setup.sh b/nix/setup.sh new file mode 100755 index 00000000..36a56aea --- /dev/null +++ b/nix/setup.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 # globals set by phase libraries (bootstrap, scopes, etc.) +# Universal dev environment setup - works on macOS, WSL/Linux, and containers. +# Uses Nix with a buildEnv flake for declarative, cross-platform package management. +# No root/sudo required after the one-time Nix install (see install_nix.sh). +# Additive: scope flags add to existing config; without flags, reconfigures +# using existing package versions. Use --upgrade to pull latest packages. +: ' +# :run without scope flags (reconfigure, re-use existing package versions) +nix/setup.sh +# :upgrade all packages to latest nixpkgs +nix/setup.sh --upgrade +# :add new scopes (merged with existing config) +nix/setup.sh --pwsh +nix/setup.sh --k8s-base --pwsh --python --omp-theme "base" +nix/setup.sh --az --conda --k8s-base --pwsh --terraform --nodejs +nix/setup.sh --az --k8s-ext --rice --pwsh +# :run with oh-my-posh theme +nix/setup.sh --shell --omp-theme "base" +# :run with starship prompt +nix/setup.sh --shell --starship-theme "nerd" +# :remove a scope +nix/setup.sh --remove oh_my_posh +# :unattended mode (skip all interactive steps - for MDM/Ansible/CI) +nix/setup.sh --all --unattended +# :install everything +nix/setup.sh --all +# :show help +nix/setup.sh --help +' +set -eo pipefail + +# ---- resolve paths ----------------------------------------------------------- +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB_DIR="$SCRIPT_ROOT/nix/lib" + +# ---- source libraries -------------------------------------------------------- +# shellcheck source=lib/io.sh +source "$LIB_DIR/io.sh" +for _p in bootstrap platform scopes nix_profile configure profiles post_install summary; do + # shellcheck source=/dev/null + source "$LIB_DIR/phases/$_p.sh" +done +# shellcheck source=../.assets/lib/install_record.sh +source "$SCRIPT_ROOT/.assets/lib/install_record.sh" + +# ---- trap + provenance ------------------------------------------------------- +_IR_ENTRY_POINT="nix" +_IR_SCRIPT_ROOT="$SCRIPT_ROOT" +_ir_phase="bootstrap" +_ir_skip=false + +_on_exit() { + local exit_code=$? + [[ "$_ir_skip" == "true" ]] && return 0 + local status="success" error="" + if [[ $exit_code -ne 0 ]]; then + status="failed" + error="${_ir_error:-exit code $exit_code}" + fi + _IR_SCOPES="${sorted_scopes[*]:-}" + _IR_MODE="${_mode:-unknown}" + _IR_PLATFORM="${platform:-unknown}" + write_install_record "$status" "$_ir_phase" "$error" +} +trap _on_exit EXIT + +# ---- run phases -------------------------------------------------------------- +phase_bootstrap_check_root +phase_bootstrap_resolve_paths "$SCRIPT_ROOT" +phase_bootstrap_detect_nix +phase_bootstrap_verify_store +phase_bootstrap_sync_env_dir +phase_bootstrap_install_jq + +# source scopes library (requires jq - must come after bootstrap) +# shellcheck source=../.assets/lib/scopes.sh +source "$SCRIPT_ROOT/.assets/lib/scopes.sh" + +phase_bootstrap_parse_args "$@" + +phase_platform_detect +_ir_phase="pre-setup" +export NIX_ENV_PHASE="pre-setup" +phase_platform_run_hooks "$ENV_DIR/hooks/pre-setup.d" +phase_platform_discover_overlay + +_ir_phase="scope-resolve" +phase_scopes_load_existing +phase_scopes_apply_removes +phase_scopes_enforce_prompt_exclusivity +phase_scopes_resolve_and_sort +phase_scopes_detect_init +phase_scopes_write_config + +_ir_phase="nix-profile" +phase_nix_profile_load_pinned_rev +phase_nix_profile_print_mode +phase_nix_profile_update_flake +phase_nix_profile_apply +phase_nix_profile_mitm_probe + +_ir_phase="configure" +phase_configure_gh "$unattended" +phase_configure_git "$unattended" +phase_configure_per_scope + +_ir_phase="profiles" +phase_profiles_bash +phase_profiles_zsh +phase_profiles_pwsh +export NIX_ENV_PHASE="post-setup" +phase_platform_run_hooks "$ENV_DIR/hooks/post-setup.d" + +_ir_phase="post-install" +phase_post_install_common "$update_modules" "${sorted_scopes[@]}" + +_ir_phase="complete" +phase_post_install_gc +phase_summary_detect_mode +phase_summary_print diff --git a/nix/uninstall.sh b/nix/uninstall.sh new file mode 100755 index 00000000..dc3b1003 --- /dev/null +++ b/nix/uninstall.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# Uninstall nix-env managed environment and optionally Nix itself. +# Two-phase: (1) remove nix-env user state, (2) optionally remove Nix. +# Handles both Determinate Systems installer and upstream --no-daemon installs. +: ' +# interactive uninstall (prompts before each phase) +nix/uninstall.sh +# non-interactive: remove nix-env only, keep Nix +nix/uninstall.sh --env-only +# non-interactive: remove everything including Nix +nix/uninstall.sh --all +# dry run - show what would be removed +nix/uninstall.sh --dry-run +' +set -eo pipefail + +# -- Guard: no root ----------------------------------------------------------- +if [[ $EUID -eq 0 ]]; then + printf '\e[31;1mDo not run the script as root (sudo).\e[0m\n' + exit 1 +fi + +# -- Helpers ------------------------------------------------------------------- +info() { printf "\e[96m%s\e[0m\n" "$*"; } +ok() { printf "\e[32m%s\e[0m\n" "$*"; } +warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; } +err() { printf "\e[31;1m%s\e[0m\n" "$*" >&2; } +removed() { printf "\e[90m removed %s\e[0m\n" "$*"; } +skipped() { printf "\e[90m skipped %s (not found)\e[0m\n" "$*"; } + +confirm() { + local prompt="$1" + printf "\e[93m%s [y/N] \e[0m" "$prompt" + read -r reply + [[ "$reply" =~ ^[Yy]$ ]] +} + +_rm() { + local target="$1" + if [[ "$DRY_RUN" == "true" ]]; then + [[ -e "$target" || -L "$target" ]] && printf "\e[90m would remove %s\e[0m\n" "$target" + return 0 + fi + if [[ -L "$target" ]]; then + rm -f "$target" && removed "$target" + elif [[ -d "$target" ]]; then + rm -rf "$target" && removed "$target" + elif [[ -f "$target" ]]; then + rm -f "$target" && removed "$target" + else + skipped "$target" + fi +} + +# Remove nix-managed #region blocks from a PowerShell profile file. +# Matches both new (nix:*) and old (base, nix, oh-my-posh, starship, uv) names. +# Leaves generic regions (certs, conda, make completer, etc.) untouched. +_clean_ps_nix_regions() { + local ps_profile="$1" + [[ -f "$ps_profile" ]] || return 0 + if grep -qE '#region (nix:|nix$|base$|oh-my-posh$|starship$|uv$)' "$ps_profile" 2>/dev/null; then + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would remove nix regions from %s\e[0m\n" "$ps_profile" + else + local tmp + tmp="$(mktemp)" + awk ' + /^#region nix:/ { skip=1; next } + /^#region nix$/ { skip=1; next } + /^#region base$/ { skip=1; next } + /^#region oh-my-posh$/ { skip=1; next } + /^#region starship$/ { skip=1; next } + /^#region uv$/ { skip=1; next } + skip && /^#endregion/ { skip=0; next } + !skip { print } + ' "$ps_profile" >"$tmp" + if [[ ! -s "$tmp" ]] || ! grep -q '[^[:space:]]' "$tmp" 2>/dev/null; then + rm -f "$ps_profile" "$tmp" + ok " removed $ps_profile (empty after cleanup)" + else + mv -f "$tmp" "$ps_profile" + ok " cleaned nix regions from $ps_profile" + fi + fi + fi +} + +# -- Parse args ---------------------------------------------------------------- +MODE="" +DRY_RUN="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-only) MODE="env-only" ;; + --all) MODE="all" ;; + --dry-run) DRY_RUN="true" ;; + -h | --help) + cat <<'EOF' +Usage: nix/uninstall.sh [options] + +Removes the nix-env managed environment and optionally Nix itself. + +Modes: + (default) Interactive - prompts before each phase + --env-only Remove nix-env state only, keep Nix installed + --all Remove everything including Nix (non-interactive) + --dry-run Show what would be removed without doing anything + + -h, --help Show this help +EOF + exit 0 + ;; + *) + err "Unknown option: $1" + exit 2 + ;; + esac + shift +done + +# -- Resolve paths ------------------------------------------------------------- +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB="$SCRIPT_ROOT/.assets/lib" +ENV_DIR="$HOME/.config/nix-env" +BLOCK_MARKER="nix-env managed" + +printf "\n\e[95;1m>> nix-env uninstaller\e[0m\n\n" + +if [[ "$DRY_RUN" == "true" ]]; then + info "[dry-run mode - no changes will be made]" + printf "\n" +fi + +# ============================================================================ +# Phase 1: Remove nix-env managed environment +# ============================================================================ +run_phase1() { + info "phase 1: removing nix-env managed environment..." + + # 1a. Remove nix-env managed block from shell rc files (leaves certs block) + info "removing managed blocks from shell profiles..." + if [[ -f "$LIB/profile_block.sh" ]]; then + # shellcheck source=../.assets/lib/profile_block.sh + source "$LIB/profile_block.sh" + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [[ -f "$rc" ]] || continue + if [[ "$DRY_RUN" == "true" ]]; then + if manage_block "$rc" "$BLOCK_MARKER" inspect >/dev/null 2>&1; then + printf "\e[90m would remove managed block from %s\e[0m\n" "$rc" + fi + else + if manage_block "$rc" "$BLOCK_MARKER" inspect >/dev/null 2>&1; then + manage_block "$rc" "$BLOCK_MARKER" remove + ok " removed managed block from $rc" + fi + fi + done + else + warn "profile_block.sh not found - falling back to manual block removal" + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [[ -f "$rc" ]] || continue + if grep -q "# >>> $BLOCK_MARKER >>>" "$rc" 2>/dev/null; then + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would remove managed block from %s\e[0m\n" "$rc" + else + local tmp + tmp="$(mktemp)" + awk -v begin="# >>> $BLOCK_MARKER >>>" -v end="# <<< $BLOCK_MARKER <<<" ' + $0 == begin { skip=1; next } + skip && $0 == end { skip=0; next } + !skip { print } + ' "$rc" >"$tmp" + mv -f "$tmp" "$rc" + ok " removed managed block from $rc" + fi + fi + done + fi + + # 1b. Remove legacy oh-my-posh/starship init lines outside managed block + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [[ -f "$rc" ]] || continue + if grep -qE 'oh-my-posh init|starship init' "$rc" 2>/dev/null; then + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would remove legacy prompt init lines from %s\e[0m\n" "$(basename "$rc")" + else + local tmp + tmp="$(mktemp)" + awk ' + /^# (oh-my-posh|starship) prompt$/ { next } + /oh-my-posh init/ { next } + /starship init/ { next } + { print } + ' "$rc" >"$tmp" + mv -f "$tmp" "$rc" + ok " removed legacy prompt init lines from $(basename "$rc")" + fi + fi + done + + # 1c. Remove conda shell init blocks (conda init writes its own block) + info "removing conda init blocks from shell profiles..." + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [[ -f "$rc" ]] || continue + if grep -q '>>> conda initialize >>>' "$rc" 2>/dev/null; then + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would remove conda init block from %s\e[0m\n" "$(basename "$rc")" + else + local tmp + tmp="$(mktemp)" + awk ' + /# >>> conda initialize >>>/ { skip=1; next } + skip && /# <<< conda initialize <<"$tmp" + mv -f "$tmp" "$rc" + ok " removed conda init block from $(basename "$rc")" + fi + fi + done + + # 1d. Remove nix:-prefixed PowerShell profile regions (keep generic ones) + info "removing nix: regions from PowerShell profiles..." + _clean_ps_nix_regions "$HOME/.config/powershell/profile.ps1" + _clean_ps_nix_regions "$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1" + + # 1e. Remove durable state directories and nix-specific config files + info "removing nix-env config and state files..." + _rm "$ENV_DIR" + _rm "$HOME/.config/bash/aliases_nix.sh" + rmdir "$HOME/.config/bash" 2>/dev/null || true + _rm "$HOME/.config/powershell/Scripts/_aliases_nix.ps1" + _rm "$HOME/.config/starship.toml" + + # 1f. Remove zsh plugins installed by profiles.zsh + info "removing zsh plugins..." + for plugin in zsh-autocomplete zsh-make-complete zsh-autosuggestions zsh-syntax-highlighting; do + _rm "$HOME/.zsh/$plugin" + done + if [[ -d "$HOME/.zsh" ]] && [[ "$DRY_RUN" != "true" ]]; then + rmdir "$HOME/.zsh" 2>/dev/null && removed "$HOME/.zsh (empty)" || true + fi + + # 1g. Remove miniforge/conda if installed + if [[ -d "$HOME/miniforge3" ]]; then + info "removing miniforge3..." + _rm "$HOME/miniforge3" + fi + + # 1h. Remove nix profile entry (after all _rm calls - removing the profile + # earlier would break tools resolved through ~/.nix-profile/bin). + if command -v nix &>/dev/null; then + if nix profile list --json 2>/dev/null | grep -q 'nix-env' \ + || nix profile list 2>/dev/null | grep -q 'nix-env'; then + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would remove nix profile entry 'nix-env'\e[0m\n" + else + nix profile remove nix-env 2>/dev/null && ok " removed nix profile entry 'nix-env'" + hash -r + fi + fi + fi + + # 1i. Remove nix profile symlink and local state + _rm "$HOME/.nix-profile" + _rm "$HOME/.local/state/nix" + + # 1j. Clean up nixenv backup files from shell rc files + if [[ "$DRY_RUN" != "true" ]]; then + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + for backup in "${rc}.nixenv-backup-"*; do + [[ -f "$backup" ]] && rm -f "$backup" && removed "$backup" + done + done + fi + + # 1k. Strip trailing blank lines from rc files (bash builtins only - + # external tools like awk/sed may have been removed with the nix profile). + if [[ "$DRY_RUN" != "true" ]]; then + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [[ -f "$rc" ]] || continue + local content + content="$(<"$rc")" + # parameter expansion: remove trailing newlines (bash does this + # naturally with command substitution), then write back with one. + printf '%s\n' "$content" >"$rc" + done + fi + + ok "phase 1 complete - nix-env environment removed" +} + +# ============================================================================ +# Phase 2: Uninstall Nix itself +# ============================================================================ +run_phase2() { + info "phase 2: uninstalling Nix..." + + if [[ -x /nix/nix-installer ]]; then + # Determinate Systems installer - has a built-in uninstaller + info "detected Determinate Systems Nix installer" + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would run: sudo /nix/nix-installer uninstall\e[0m\n" + else + info "running: sudo /nix/nix-installer uninstall" + info "(this will ask for your password and confirm before proceeding)" + sudo /nix/nix-installer uninstall + fi + else + # Single-user install (--no-daemon / upstream Nix installer). + # Remove /nix only when the user has root access (they installed it + # themselves). In pre-installed environments (Coder, CI) sudo is + # unavailable and /nix must be left in place. + info "detected single-user Nix install (no Determinate installer)" + if [[ -d /nix ]] && sudo -n true 2>/dev/null; then + if [[ "$DRY_RUN" == "true" ]]; then + printf "\e[90m would remove /nix\e[0m\n" + else + sudo rm -rf /nix && removed "/nix" + fi + elif [[ -d /nix ]]; then + info "/nix left in place (no root access - pre-installed environment)" + fi + fi + + # clean up items common to both installer types + _rm "$HOME/.nix-profile" + _rm "$HOME/.nix-defexpr" + _rm "$HOME/.nix-channels" + _rm "$HOME/.local/state/nix" + _rm "$HOME/.config/nix" + + ok "phase 2 complete - Nix uninstalled" +} + +# ============================================================================ +# Main +# ============================================================================ +case "$MODE" in +env-only) + run_phase1 + ;; +all) + run_phase1 + run_phase2 + ;; +*) + # Interactive mode + if confirm "Remove nix-env managed environment? (shell configs, aliases, scopes)"; then + run_phase1 + else + info "skipping phase 1" + fi + printf "\n" + if confirm "Uninstall Nix itself? (removes /nix, daemon, build users)"; then + run_phase2 + else + info "skipping phase 2 - Nix remains installed" + fi + ;; +esac + +printf "\n\e[95;1m<< Uninstall complete >>\e[0m\n" +printf "\e[97mRestart your terminal for changes to take effect.\e[0m\n\n" diff --git a/project-words.txt b/project-words.txt new file mode 100644 index 00000000..bc0b04ab --- /dev/null +++ b/project-words.txt @@ -0,0 +1,52 @@ +argorollouts +automount +azcopy +behaviour +blocklists +bootstrapper +buildx +cascadia +certifi +cloudsdk +coreutils +cuda +distrobox +distroboxes +distros +dockerfiles +fixcertpy +gerardog +gsudo +hyperv +intune +jamf +kubecolor +kubectx +kubelogin +kubeseal +kustomize +makefiles +mapfile +microsoftonline +miniforge +mkdocs +namerefs +nixos +nixpkgs +pkgs +powerline +powershellgallery +prek +pypi +readarray +resolv +ryanoasis +sileshn +sourceable +stdlib +tflint +vagranfile +vagrantfiles +venv +virtualenv +winget diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..9884025e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "linux-setup-scripts" +version = "0.1.0" +requires-python = "~=3.12.0" +dependencies = [] + +[project.optional-dependencies] +docs = [ + "mkdocs-material", + "mkdocs-mermaid2-plugin", +] + +[tool.uv] +native-tls = true diff --git a/scripts_egsave.ps1 b/scripts_egsave.ps1 index 2fd4c959..dd2a6ed9 100755 --- a/scripts_egsave.ps1 +++ b/scripts_egsave.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/pwsh -nop +#!/usr/bin/env pwsh #Requires -PSEdition Core -Version 7.3 <# .SYNOPSIS diff --git a/tests/bats/test_fixcertpy_discovery.bats b/tests/bats/test_fixcertpy_discovery.bats new file mode 100755 index 00000000..33fdc8fb --- /dev/null +++ b/tests/bats/test_fixcertpy_discovery.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# Unit tests for fixcertpy auto-discovery parsing patterns +# Tests the sed/grep patterns used to extract paths from pip show output. +# The actual fixcertpy function is tested with explicit paths in test_functions.bats; +# these tests cover the auto-discovery codepath's parsing logic. +# shellcheck disable=SC2030,SC2031 +bats_require_minimum_version 1.5.0 + +# Sample pip show -f certifi output +CERTIFI_SHOW='Name: certifi +Version: 2024.2.2 +Summary: Python package for providing Mozilla CA Bundle. +Home-page: https://github.com/certifi/python-certifi +Author: Kenneth Reitz +Location: /home/user/.local/lib/python3.12/site-packages +Files: + certifi/__init__.py + certifi/__main__.py + certifi/cacert.pem + certifi/core.py + certifi/py.typed' + +# Sample pip show -f pip output (cacert.pem is deeper) +PIP_SHOW='Name: pip +Version: 24.0 +Summary: The PyPA recommended tool for installing Python packages. +Location: /usr/lib/python3/dist-packages +Files: + pip/__init__.py + pip/_vendor/certifi/cacert.pem + pip/_vendor/certifi/core.py' + +# Sample output with no cacert.pem +NO_CACERT_SHOW='Name: requests +Version: 2.31.0 +Summary: Python HTTP library +Location: /home/user/.local/lib/python3.12/site-packages +Files: + requests/__init__.py + requests/api.py' + +# ============================================================================= +# Location extraction: sed -n 's/^Location: //p' +# ============================================================================= + +@test "location extraction: standard certifi output" { + run bash -c 'echo "$1" | sed -n "s/^Location: //p"' -- "$CERTIFI_SHOW" + [ "$output" = "/home/user/.local/lib/python3.12/site-packages" ] +} + +@test "location extraction: pip output" { + run bash -c 'echo "$1" | sed -n "s/^Location: //p"' -- "$PIP_SHOW" + [ "$output" = "/usr/lib/python3/dist-packages" ] +} + +@test "location extraction: empty when no Location field" { + run bash -c 'echo "Name: foo" | sed -n "s/^Location: //p"' + [ -z "$output" ] +} + +# ============================================================================= +# cacert.pem path extraction: grep -oE '[^[:space:]]+cacert\.pem$' +# ============================================================================= + +@test "cacert extraction: certifi package" { + run bash -c 'echo "$1" | grep -oE "[^[:space:]]+cacert\.pem$"' -- "$CERTIFI_SHOW" + [ "$output" = "certifi/cacert.pem" ] +} + +@test "cacert extraction: pip vendored certifi" { + run bash -c 'echo "$1" | grep -oE "[^[:space:]]+cacert\.pem$"' -- "$PIP_SHOW" + [ "$output" = "pip/_vendor/certifi/cacert.pem" ] +} + +@test "cacert extraction: empty when no cacert.pem" { + run bash -c 'echo "$1" | grep -oE "[^[:space:]]+cacert\.pem$"' -- "$NO_CACERT_SHOW" + [ -z "$output" ] +} + +# ============================================================================= +# Combined: build full path (Location + cacert) +# ============================================================================= + +@test "full path: certifi package" { + local location cacert + location=$(echo "$CERTIFI_SHOW" | sed -n 's/^Location: //p') + cacert=$(echo "$CERTIFI_SHOW" | grep -oE '[^[:space:]]+cacert\.pem$') + [ "${location}/${cacert}" = "/home/user/.local/lib/python3.12/site-packages/certifi/cacert.pem" ] +} + +@test "full path: pip vendored certifi" { + local location cacert + location=$(echo "$PIP_SHOW" | sed -n 's/^Location: //p') + cacert=$(echo "$PIP_SHOW" | grep -oE '[^[:space:]]+cacert\.pem$') + [ "${location}/${cacert}" = "/usr/lib/python3/dist-packages/pip/_vendor/certifi/cacert.pem" ] +} + +@test "full path: empty cacert yields no path addition" { + local location cacert + location=$(echo "$NO_CACERT_SHOW" | sed -n 's/^Location: //p') + cacert=$(echo "$NO_CACERT_SHOW" | grep -oE '[^[:space:]]+cacert\.pem$' || true) + [ -z "$cacert" ] +} + +# ============================================================================= +# Edge cases +# ============================================================================= + +@test "location with spaces in path" { + local show='Name: certifi +Location: /home/user/my projects/venv/lib/python3.12/site-packages +Files: + certifi/cacert.pem' + local location + location=$(echo "$show" | sed -n 's/^Location: //p') + [ "$location" = "/home/user/my projects/venv/lib/python3.12/site-packages" ] +} + +@test "multiple cacert.pem matches picks all" { + # grep -oE returns all matches; the function uses only the first one per pip show call + local show='Files: + certifi/cacert.pem + old/certifi/cacert.pem' + run bash -c 'echo "$1" | grep -oE "[^[:space:]]+cacert\.pem$"' -- "$show" + [ "${lines[0]}" = "certifi/cacert.pem" ] + [ "${lines[1]}" = "old/certifi/cacert.pem" ] + [ "${#lines[@]}" -eq 2 ] +} diff --git a/tests/bats/test_functions.bats b/tests/bats/test_functions.bats new file mode 100755 index 00000000..7c9baf7c --- /dev/null +++ b/tests/bats/test_functions.bats @@ -0,0 +1,215 @@ +#!/usr/bin/env bats +# Unit tests for .assets/config/bash_cfg/functions.sh +# Tests PEM parsing, fixcertpy (explicit paths), cert_intercept deduplication. +# Requires: openssl +# shellcheck disable=SC2034,SC2154 +bats_require_minimum_version 1.5.0 + +setup_file() { + # skip entire file if openssl is not available + command -v openssl &>/dev/null || skip "openssl not available" + + export TEST_DIR="$(mktemp -d)" + + # generate two self-signed test certificates + openssl req -x509 -newkey rsa:2048 -keyout "$TEST_DIR/key1.pem" -out "$TEST_DIR/cert1.pem" \ + -days 1 -nodes -subj "/CN=Test Cert One" 2>/dev/null + openssl req -x509 -newkey rsa:2048 -keyout "$TEST_DIR/key2.pem" -out "$TEST_DIR/cert2.pem" \ + -days 1 -nodes -subj "/CN=Test Cert Two" 2>/dev/null + + # create a multi-cert bundle + cat "$TEST_DIR/cert1.pem" "$TEST_DIR/cert2.pem" > "$TEST_DIR/ca-custom.crt" + + # extract serials for verification + export SERIAL1="$(openssl x509 -noout -serial < "$TEST_DIR/cert1.pem" | cut -d= -f2)" + export SERIAL2="$(openssl x509 -noout -serial < "$TEST_DIR/cert2.pem" | cut -d= -f2)" +} + +teardown_file() { + rm -rf "$TEST_DIR" +} + +setup() { + command -v openssl &>/dev/null || skip "openssl not available" + # shellcheck source=../../.assets/config/bash_cfg/functions.sh + source "$BATS_TEST_DIRNAME/../../.assets/config/bash_cfg/functions.sh" + # override HOME so cert functions use our test directory + export REAL_HOME="$HOME" + export HOME="$TEST_DIR" +} + +teardown() { + export HOME="$REAL_HOME" +} + +# ============================================================================= +# PEM bundle parsing (the shared pattern used by fixcertpy and cert_intercept) +# ============================================================================= + +@test "PEM parsing splits bundle into individual certs" { + local cert_pems=() + local current_pem="" + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + cert_pems+=("$current_pem") + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done < "$TEST_DIR/ca-custom.crt" + + [[ ${#cert_pems[@]} -eq 2 ]] +} + +@test "PEM parsing produces valid certificates" { + local cert_pems=() + local current_pem="" + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + cert_pems+=("$current_pem") + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done < "$TEST_DIR/ca-custom.crt" + + # each parsed cert should be readable by openssl + for pem in "${cert_pems[@]}"; do + run openssl x509 -noout -subject <<< "$pem" + [[ "$status" -eq 0 ]] + done +} + +@test "PEM parsing handles single cert" { + local cert_pems=() + local current_pem="" + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + cert_pems+=("$current_pem") + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done < "$TEST_DIR/cert1.pem" + + [[ ${#cert_pems[@]} -eq 1 ]] +} + +@test "PEM parsing handles empty file" { + touch "$TEST_DIR/empty.crt" + local cert_pems=() + local current_pem="" + while IFS= read -r line; do + if [[ "$line" == "-----BEGIN CERTIFICATE-----" ]]; then + current_pem="$line" + elif [[ "$line" == "-----END CERTIFICATE-----" ]]; then + current_pem+=$'\n'"$line" + cert_pems+=("$current_pem") + current_pem="" + elif [[ -n "$current_pem" ]]; then + current_pem+=$'\n'"$line" + fi + done < "$TEST_DIR/empty.crt" + + [[ ${#cert_pems[@]} -eq 0 ]] +} + +# ============================================================================= +# fixcertpy - with explicit paths (no pip/venv discovery) +# ============================================================================= + +@test "fixcertpy appends certs to explicit cacert.pem path" { + # set up fake cert bundle and target + mkdir -p "$TEST_DIR/.config/certs" + cp "$TEST_DIR/ca-custom.crt" "$TEST_DIR/.config/certs/ca-custom.crt" + # create a minimal target cacert.pem + echo "# existing bundle" > "$TEST_DIR/cacert.pem" + + run fixcertpy "$TEST_DIR/cacert.pem" + + # target should now contain both serials + grep -qw "$SERIAL1" "$TEST_DIR/cacert.pem" + grep -qw "$SERIAL2" "$TEST_DIR/cacert.pem" +} + +@test "fixcertpy does not duplicate certs on re-run" { + mkdir -p "$TEST_DIR/.config/certs" + cp "$TEST_DIR/ca-custom.crt" "$TEST_DIR/.config/certs/ca-custom.crt" + echo "# existing bundle" > "$TEST_DIR/cacert2.pem" + + # run twice + fixcertpy "$TEST_DIR/cacert2.pem" 2>/dev/null + local size_after_first + size_after_first=$(wc -c < "$TEST_DIR/cacert2.pem") + + fixcertpy "$TEST_DIR/cacert2.pem" 2>/dev/null + local size_after_second + size_after_second=$(wc -c < "$TEST_DIR/cacert2.pem") + + # file should not grow on second run + [[ "$size_after_first" -eq "$size_after_second" ]] +} + +@test "fixcertpy reports no certs when bundle is missing" { + # ensure no cert bundle exists + rm -f "$TEST_DIR/.config/certs/ca-custom.crt" + + run fixcertpy "$TEST_DIR/some_cacert.pem" + # should return 0 (no certs to add is not an error) + [[ "$status" -eq 0 ]] +} + +@test "fixcertpy skips nonexistent target paths" { + mkdir -p "$TEST_DIR/.config/certs" + cp "$TEST_DIR/ca-custom.crt" "$TEST_DIR/.config/certs/ca-custom.crt" + + # pass a path that doesn't exist + run fixcertpy "/nonexistent/path/cacert.pem" + [[ "$status" -eq 0 ]] + [[ "$output" == *"no certifi/cacert.pem bundles found"* ]] +} + +# ============================================================================= +# cert_intercept deduplication - string-based serial tracking +# ============================================================================= + +@test "serial dedup string correctly tracks added serials" { + local _existing_serials=" " + + # simulate adding serials + _existing_serials+="$SERIAL1 " + [[ " $_existing_serials " == *" $SERIAL1 "* ]] + + # serial2 should not be present yet + [[ " $_existing_serials " != *" $SERIAL2 "* ]] + + # add serial2 + _existing_serials+="$SERIAL2 " + [[ " $_existing_serials " == *" $SERIAL2 "* ]] +} + +@test "serial dedup string does not match partial serials" { + local _existing_serials=" ABC123 " + + # should not match partial + [[ " $_existing_serials " != *" ABC "* ]] + [[ " $_existing_serials " != *" 123 "* ]] + # should match full + [[ " $_existing_serials " == *" ABC123 "* ]] +} + +@test "cert_intercept returns 1 when openssl is missing" { + # shadow openssl + type() { return 1; } + run ! cert_intercept + [[ "$output" == *"openssl"*"required"* ]] +} diff --git a/tests/bats/test_git_aliases.bats b/tests/bats/test_git_aliases.bats new file mode 100755 index 00000000..e716bf8d --- /dev/null +++ b/tests/bats/test_git_aliases.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# Unit tests for .assets/config/bash_cfg/aliases_git.sh - git_resolve_branch +# shellcheck disable=SC2034,SC2154 +bats_require_minimum_version 1.5.0 + +setup() { + # source the git aliases file (it defines git_resolve_branch) + BASH_VERSION="${BASH_VERSION:-5.0}" # guard requires this + # shellcheck source=../../.assets/config/bash_cfg/aliases_git.sh + source "$BATS_TEST_DIRNAME/../../.assets/config/bash_cfg/aliases_git.sh" + + # create a temp git repo for integration tests + TEST_REPO="$(mktemp -d)" + git -C "$TEST_REPO" init --quiet + git -C "$TEST_REPO" config user.email "test@test.com" + git -C "$TEST_REPO" config user.name "Test" + # create initial commit so branches can exist + touch "$TEST_REPO/file" + git -C "$TEST_REPO" add file + git -C "$TEST_REPO" commit -m "init" --quiet +} + +teardown() { + rm -rf "$TEST_REPO" +} + +# ============================================================================= +# git_resolve_branch - pattern selection (case statement logic) +# ============================================================================= + +@test "git_resolve_branch with 'main' branch in repo" { + git -C "$TEST_REPO" branch main 2>/dev/null || true + cd "$TEST_REPO" + run git_resolve_branch "" + [[ "$output" == "main" ]] +} + +@test "git_resolve_branch 'm' resolves to main" { + git -C "$TEST_REPO" branch main 2>/dev/null || true + cd "$TEST_REPO" + run git_resolve_branch "m" + [[ "$output" == "main" ]] +} + +@test "git_resolve_branch 'm' resolves to master" { + git -C "$TEST_REPO" branch master 2>/dev/null || true + cd "$TEST_REPO" + run git_resolve_branch "m" + # could be main or master depending on default, just check it resolves + [[ "$output" == "main" || "$output" == "master" ]] +} + +@test "git_resolve_branch 'd' resolves to development branch" { + git -C "$TEST_REPO" branch development --quiet + cd "$TEST_REPO" + run git_resolve_branch "d" + [[ "$output" == "development" ]] +} + +@test "git_resolve_branch 'd' resolves to dev branch" { + git -C "$TEST_REPO" branch dev --quiet + cd "$TEST_REPO" + run git_resolve_branch "d" + [[ "$output" == "dev" ]] +} + +@test "git_resolve_branch 's' resolves to stage branch" { + git -C "$TEST_REPO" branch stage --quiet + cd "$TEST_REPO" + run git_resolve_branch "s" + [[ "$output" == "stage" ]] +} + +@test "git_resolve_branch 't' resolves to trunk branch" { + git -C "$TEST_REPO" branch trunk --quiet + cd "$TEST_REPO" + run git_resolve_branch "t" + [[ "$output" == "trunk" ]] +} + +@test "git_resolve_branch with custom pattern passes through" { + git -C "$TEST_REPO" branch feature-foo --quiet + cd "$TEST_REPO" + run git_resolve_branch "feature-foo" + [[ "$output" == "feature-foo" ]] +} + +@test "git_resolve_branch returns pattern when no branch matches" { + cd "$TEST_REPO" + run git_resolve_branch "nonexistent-branch-xyz" + [[ "$output" == "nonexistent-branch-xyz" ]] +} + +@test "git_resolve_branch 's' returns pattern when stage branch absent" { + cd "$TEST_REPO" + run git_resolve_branch "s" + # no stage branch exists, should return the pattern + [[ "$output" == *"stage"* ]] +} diff --git a/tests/bats/test_nix_setup.bats b/tests/bats/test_nix_setup.bats new file mode 100755 index 00000000..f249a1be --- /dev/null +++ b/tests/bats/test_nix_setup.bats @@ -0,0 +1,628 @@ +#!/usr/bin/env bats +# Integration tests for nix/setup.sh - config generation, scope merging, idempotency. +# These tests exercise the setup.sh argument parsing and config.nix generation logic +# without actually running nix commands (nix is stubbed out via io.sh overrides). +# shellcheck disable=SC2034,SC2154 +bats_require_minimum_version 1.5.0 + +# ============================================================================= +# Helpers +# ============================================================================= + +setup_file() { + export REPO_ROOT="$BATS_TEST_DIRNAME/../.." +} + +setup() { + # create an isolated environment + TEST_HOME="$(mktemp -d)" + TEST_ENV_DIR="$TEST_HOME/.config/nix-env" + mkdir -p "$TEST_ENV_DIR/scopes" + + # copy real scope and flake declarations + cp "$REPO_ROOT/nix/flake.nix" "$TEST_ENV_DIR/" + cp "$REPO_ROOT/nix/scopes/"*.nix "$TEST_ENV_DIR/scopes/" + cp "$REPO_ROOT/.assets/lib/scopes.json" "$TEST_HOME/scopes.json" + + # source libraries + # shellcheck source=../../nix/lib/io.sh + source "$REPO_ROOT/nix/lib/io.sh" + # shellcheck source=../../nix/lib/phases/bootstrap.sh + source "$REPO_ROOT/nix/lib/phases/bootstrap.sh" + # shellcheck source=../../nix/lib/phases/scopes.sh + source "$REPO_ROOT/nix/lib/phases/scopes.sh" + # shellcheck source=../../nix/lib/phases/nix_profile.sh + source "$REPO_ROOT/nix/lib/phases/nix_profile.sh" + # shellcheck source=../../nix/lib/phases/post_install.sh + source "$REPO_ROOT/nix/lib/phases/post_install.sh" + # shellcheck source=../../nix/lib/phases/summary.sh + source "$REPO_ROOT/nix/lib/phases/summary.sh" + + # source the scope library (needs jq) + export SCOPES_JSON="$REPO_ROOT/.assets/lib/scopes.json" + # shellcheck source=../../.assets/lib/scopes.sh + source "$REPO_ROOT/.assets/lib/scopes.sh" + + # stub side effects AFTER sourcing (overrides io.sh defaults) + _io_nix() { echo "nix $*" >>"$BATS_TEST_TMPDIR/nix.log"; } + _io_nix_eval() { nix eval --impure --raw --expr "$1"; } + _io_curl_probe() { return 0; } + _io_run() { echo "run $*" >>"$BATS_TEST_TMPDIR/run.log"; } + + # set up variables needed by phases + ENV_DIR="$TEST_ENV_DIR" + CONFIG_NIX="$ENV_DIR/config.nix" + _ir_skip=false + _ir_phase="test" +} + +teardown() { + rm -rf "$TEST_HOME" +} + +# Helper: read scopes back from config.nix using sed (no nix eval needed) +read_config_scopes() { + sed -n '/scopes[[:space:]]*=[[:space:]]*\[/,/\]/{ + s/^[[:space:]]*"\([^"]*\)".*/\1/p + }' "$TEST_ENV_DIR/config.nix" +} + +# ============================================================================= +# Config generation +# ============================================================================= + +@test "config.nix: generated with single scope" { + _scope_set=" " + scope_add "shell" + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + [[ "$scopes" == *"shell"* ]] +} + +@test "config.nix: --all generates all non-prompt scopes" { + _scope_set=" " + for s in "${VALID_SCOPES[@]}"; do + [[ "$s" == "oh_my_posh" || "$s" == "starship" ]] && continue + scope_add "$s" + done + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + # verify key scopes are present + echo "$scopes" | grep -qx "shell" + echo "$scopes" | grep -qx "python" + echo "$scopes" | grep -qx "docker" + echo "$scopes" | grep -qx "k8s_base" +} + +@test "config.nix: dependencies are included" { + _scope_set=" " + scope_add "az" + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + # az depends on python + echo "$scopes" | grep -qx "python" + echo "$scopes" | grep -qx "az" +} + +@test "config.nix: k8s_ext pulls full dependency chain" { + _scope_set=" " + scope_add "k8s_ext" + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + echo "$scopes" | grep -qx "docker" + echo "$scopes" | grep -qx "k8s_base" + echo "$scopes" | grep -qx "k8s_dev" + echo "$scopes" | grep -qx "k8s_ext" +} + +# ============================================================================= +# Scope merging (additive behavior) +# ============================================================================= + +@test "merge: new scopes are additive to existing config" { + # simulate existing config with shell scope + _scope_set=" " + scope_add "shell" + sort_scopes + is_init=false + phase_scopes_write_config + + # now simulate a second run adding python + _scope_set=" " + # load existing scopes (simulates nix eval reading config.nix) + while IFS= read -r sc; do + [[ -n "$sc" ]] && scope_add "$sc" + done <<<"$(read_config_scopes)" + # add new scope + scope_add "python" + resolve_scope_deps + sort_scopes + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + echo "$scopes" | grep -qx "shell" + echo "$scopes" | grep -qx "python" +} + +@test "merge: idempotent - same scopes produce identical config" { + _scope_set=" " + scope_add "shell" + scope_add "python" + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + local first + first="$(cat "$TEST_ENV_DIR/config.nix")" + + # re-run with same scopes + _scope_set=" " + scope_add "shell" + scope_add "python" + resolve_scope_deps + sort_scopes + phase_scopes_write_config + local second + second="$(cat "$TEST_ENV_DIR/config.nix")" + + [[ "$first" == "$second" ]] +} + +# ============================================================================= +# Scope removal +# ============================================================================= + +@test "remove: scope is removed from set" { + _scope_set=" " + scope_add "shell" + scope_add "python" + scope_add "rice" + # remove python + scope_del "python" + sort_scopes + is_init=false + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + echo "$scopes" | grep -qx "shell" + echo "$scopes" | grep -qx "rice" + ! echo "$scopes" | grep -qx "python" +} + +# ============================================================================= +# Prompt engine mutual exclusivity +# ============================================================================= + +@test "prompt: omp and starship are mutually exclusive" { + _scope_set=" " + scope_add "shell" + scope_add "oh_my_posh" + scope_add "starship" + # simulate --omp-theme taking precedence (setup.sh logic) + scope_del "starship" + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + + local scopes + scopes="$(read_config_scopes)" + echo "$scopes" | grep -qx "oh_my_posh" + ! echo "$scopes" | grep -qx "starship" +} + +# ============================================================================= +# Scope ordering +# ============================================================================= + +@test "order: scopes in config.nix follow install_order" { + _scope_set=" " + scope_add "rice" + scope_add "shell" + scope_add "python" + scope_add "docker" + resolve_scope_deps + sort_scopes + is_init=false + phase_scopes_write_config + + local -a scopes=() + while IFS= read -r sc; do + [[ -n "$sc" ]] && scopes+=("$sc") + done <<<"$(read_config_scopes)" + + # verify docker comes before python, python before shell, shell before rice + local docker_idx=-1 python_idx=-1 shell_idx=-1 rice_idx=-1 + for i in "${!scopes[@]}"; do + case "${scopes[$i]}" in + docker) docker_idx=$i ;; + python) python_idx=$i ;; + shell) shell_idx=$i ;; + rice) rice_idx=$i ;; + esac + done + + [[ $docker_idx -lt $python_idx ]] + [[ $python_idx -lt $shell_idx ]] + [[ $shell_idx -lt $rice_idx ]] +} + +# ============================================================================= +# Scope-to-nix-package mapping (verifies scopes/*.nix are well-formed) +# ============================================================================= + +_scope_pkgs() { + local file="$1" + [ -f "$file" ] || return 0 + sed -n '/\[/,/\]/{ + s/^[[:space:]]*\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p + }' "$file" +} + +@test "scope files: every valid scope has a corresponding .nix file" { + # some scopes are config-only (no nix packages) - they trigger configure scripts + local config_only_scopes=" conda docker distrobox " + for sc in "${VALID_SCOPES[@]}"; do + local nix_file="$TEST_ENV_DIR/scopes/${sc}.nix" + # some scopes have no .nix file (handled by builtins.pathExists in flake) + [[ -f "$nix_file" ]] || continue + if [[ "$config_only_scopes" == *" $sc "* ]]; then + continue + fi + local pkg_count + pkg_count="$(_scope_pkgs "$nix_file" | wc -l)" + [[ $pkg_count -gt 0 ]] || { echo "empty scope file: $nix_file" >&2; false; } + done +} + +@test "scope files: base.nix includes essential packages" { + local pkgs + pkgs="$(_scope_pkgs "$TEST_ENV_DIR/scopes/base.nix")" + echo "$pkgs" | grep -qx "git" + echo "$pkgs" | grep -qx "gh" + echo "$pkgs" | grep -qx "openssl" +} + +@test "scope files: shell.nix includes expected tools" { + local pkgs + pkgs="$(_scope_pkgs "$TEST_ENV_DIR/scopes/shell.nix")" + echo "$pkgs" | grep -qx "fzf" + echo "$pkgs" | grep -qx "eza" + echo "$pkgs" | grep -qx "bat" + echo "$pkgs" | grep -qx "ripgrep" +} + +@test "scope files: python.nix includes uv" { + local pkgs + pkgs="$(_scope_pkgs "$TEST_ENV_DIR/scopes/python.nix")" + echo "$pkgs" | grep -qx "uv" +} + +@test "scope files: k8s_base.nix includes kubectl" { + local pkgs + pkgs="$(_scope_pkgs "$TEST_ENV_DIR/scopes/k8s_base.nix")" + echo "$pkgs" | grep -qx "kubectl" + echo "$pkgs" | grep -qx "k9s" +} + +# ============================================================================= +# isInit detection +# ============================================================================= + +@test "config.nix: isInit is false by default" { + _scope_set=" " + scope_add "shell" + sort_scopes + is_init=false + phase_scopes_write_config + + grep -q 'isInit = false' "$TEST_ENV_DIR/config.nix" +} + +@test "config.nix: isInit is true when set" { + _scope_set=" " + scope_add "shell" + sort_scopes + is_init=true + phase_scopes_write_config + + grep -q 'isInit = true' "$TEST_ENV_DIR/config.nix" +} + +@test "config.nix: phase_scopes_detect_init sets true when jq not system-installed" { + has_system_cmd() { return 1; } + phase_scopes_detect_init + [[ "$is_init" == "true" ]] +} + +@test "config.nix: phase_scopes_detect_init sets false when system cmds available" { + has_system_cmd() { return 0; } + phase_scopes_detect_init + [[ "$is_init" == "false" ]] +} + +# ============================================================================= +# Flake structure +# ============================================================================= + +@test "flake.nix: references config.nix and scopes directory" { + grep -q 'import ./config.nix' "$TEST_ENV_DIR/flake.nix" + grep -q './scopes/' "$TEST_ENV_DIR/flake.nix" +} + +@test "flake.nix: supports all four platforms" { + grep -q 'x86_64-linux' "$TEST_ENV_DIR/flake.nix" + grep -q 'aarch64-linux' "$TEST_ENV_DIR/flake.nix" + grep -q 'x86_64-darwin' "$TEST_ENV_DIR/flake.nix" + grep -q 'aarch64-darwin' "$TEST_ENV_DIR/flake.nix" +} + +# ============================================================================= +# Upgrade path (should_update_flake) +# ============================================================================= + +@test "upgrade: --upgrade flag triggers update" { + should_update_flake "true" +} + +@test "upgrade: without --upgrade skips update" { + ! should_update_flake "false" +} + +# ============================================================================= +# Arg parser (phase_bootstrap_parse_args) +# ============================================================================= + +@test "parse_args: --shell sets scope and any_scope" { + phase_bootstrap_parse_args --shell + [[ "$any_scope" == "true" ]] + scope_has "shell" +} + +@test "parse_args: --all adds all non-prompt scopes" { + phase_bootstrap_parse_args --all + [[ "$any_scope" == "true" ]] + scope_has "shell" + scope_has "python" + scope_has "docker" + run ! scope_has "oh_my_posh" + run ! scope_has "starship" +} + +@test "parse_args: --omp-theme sets theme and adds oh_my_posh scope" { + phase_bootstrap_parse_args --omp-theme "base" + [[ "$omp_theme" == "base" ]] + [[ "$any_scope" == "true" ]] + scope_has "oh_my_posh" +} + +@test "parse_args: --starship-theme sets theme and adds starship scope" { + phase_bootstrap_parse_args --starship-theme "nerd" + [[ "$starship_theme" == "nerd" ]] + [[ "$any_scope" == "true" ]] + scope_has "starship" +} + +@test "parse_args: --remove collects scope names" { + phase_bootstrap_parse_args --remove oh_my_posh rice + [[ ${#remove_scopes[@]} -eq 2 ]] + [[ "${remove_scopes[0]}" == "oh_my_posh" ]] + [[ "${remove_scopes[1]}" == "rice" ]] +} + +@test "parse_args: --remove normalizes hyphens to underscores" { + phase_bootstrap_parse_args --remove oh-my-posh k8s-base + [[ "${remove_scopes[0]}" == "oh_my_posh" ]] + [[ "${remove_scopes[1]}" == "k8s_base" ]] +} + +@test "parse_args: --remove stops at next flag" { + phase_bootstrap_parse_args --remove rice --shell + [[ ${#remove_scopes[@]} -eq 1 ]] + [[ "${remove_scopes[0]}" == "rice" ]] + [[ "$any_scope" == "true" ]] + scope_has "shell" +} + +@test "parse_args: --upgrade sets upgrade_packages" { + phase_bootstrap_parse_args --upgrade + [[ "$upgrade_packages" == "true" ]] +} + +@test "parse_args: --unattended sets flag" { + phase_bootstrap_parse_args --unattended + [[ "$unattended" == "true" ]] +} + +@test "parse_args: --unattended combines with scope flags" { + phase_bootstrap_parse_args --shell --python --unattended + [[ "$unattended" == "true" ]] + scope_has "shell" + scope_has "python" +} + +@test "parse_args: --update-modules sets flag" { + phase_bootstrap_parse_args --update-modules + [[ "$update_modules" == "true" ]] +} + +@test "parse_args: --quiet-summary sets flag" { + phase_bootstrap_parse_args --quiet-summary + [[ "$quiet_summary" == "true" ]] +} + +@test "parse_args: multiple scope flags are additive" { + phase_bootstrap_parse_args --shell --python --docker + scope_has "shell" + scope_has "python" + scope_has "docker" +} + +@test "parse_args: no args sets defaults" { + phase_bootstrap_parse_args + [[ "$any_scope" == "false" ]] + [[ "$upgrade_packages" == "false" ]] + [[ "$unattended" == "false" ]] + [[ "$update_modules" == "false" ]] + [[ "$omp_theme" == "" ]] + [[ "$starship_theme" == "" ]] + [[ ${#remove_scopes[@]} -eq 0 ]] +} + +@test "parse_args: hyphenated flags normalize to underscores" { + phase_bootstrap_parse_args --k8s-base --k8s-dev + scope_has "k8s_base" + scope_has "k8s_dev" +} + +@test "parse_args: unknown option exits with code 2" { + run phase_bootstrap_parse_args --nonexistent + [[ $status -eq 2 ]] +} + +# ============================================================================= +# Summary mode detection +# ============================================================================= + +@test "summary: upgrade mode when upgrade_packages is true" { + upgrade_packages="true" + remove_scopes=() + any_scope=false + phase_summary_detect_mode + [[ "$_mode" == "upgrade" ]] +} + +@test "summary: remove mode when remove_scopes non-empty" { + upgrade_packages="false" + remove_scopes=(rice) + any_scope=false + phase_summary_detect_mode + [[ "$_mode" == "remove" ]] +} + +@test "summary: install mode when any_scope is true" { + upgrade_packages="false" + remove_scopes=() + any_scope=true + phase_summary_detect_mode + [[ "$_mode" == "install" ]] +} + +@test "summary: reconfigure mode by default" { + upgrade_packages="false" + remove_scopes=() + any_scope=false + phase_summary_detect_mode + [[ "$_mode" == "reconfigure" ]] +} + +# ============================================================================= +# Prompt exclusivity (phase_scopes_enforce_prompt_exclusivity) +# ============================================================================= + +@test "exclusivity: --omp-theme removes starship" { + _scope_set=" " + scope_add "oh_my_posh" + scope_add "starship" + omp_theme="base" + starship_theme="" + phase_scopes_enforce_prompt_exclusivity + scope_has "oh_my_posh" + run ! scope_has "starship" +} + +@test "exclusivity: --starship-theme removes oh_my_posh" { + _scope_set=" " + scope_add "oh_my_posh" + scope_add "starship" + omp_theme="" + starship_theme="nerd" + phase_scopes_enforce_prompt_exclusivity + run ! scope_has "oh_my_posh" + scope_has "starship" +} + +@test "exclusivity: both themes set exits with error" { + omp_theme="base" + starship_theme="nerd" + _ir_error="" + run phase_scopes_enforce_prompt_exclusivity + [[ $status -eq 2 ]] +} + +# ============================================================================= +# Nix profile phase +# ============================================================================= + +@test "nix_profile: pinned_rev loaded from file" { + echo "abc123" >"$TEST_ENV_DIR/pinned_rev" + phase_nix_profile_load_pinned_rev + [[ "$PINNED_REV" == "abc123" ]] +} + +@test "nix_profile: pinned_rev empty when file missing" { + rm -f "$TEST_ENV_DIR/pinned_rev" + phase_nix_profile_load_pinned_rev + [[ "$PINNED_REV" == "" ]] +} + +@test "nix_profile: pinned_rev strips whitespace" { + printf " abc123 \n" >"$TEST_ENV_DIR/pinned_rev" + phase_nix_profile_load_pinned_rev + [[ "$PINNED_REV" == "abc123" ]] +} + +@test "nix_profile: apply runs profile add and upgrade" { + : >"$BATS_TEST_TMPDIR/nix.log" + SECONDS=0 + phase_nix_profile_apply + grep -q 'nix profile add path:' "$BATS_TEST_TMPDIR/nix.log" + grep -q 'nix profile upgrade nix-env' "$BATS_TEST_TMPDIR/nix.log" +} + +@test "nix_profile: update_flake uses override-input when pinned" { + : >"$BATS_TEST_TMPDIR/nix.log" + PINNED_REV="abc123" + upgrade_packages="true" + SECONDS=0 + phase_nix_profile_update_flake + grep -q 'flake lock --override-input' "$BATS_TEST_TMPDIR/nix.log" +} + +@test "nix_profile: update_flake uses flake update when unpinned" { + : >"$BATS_TEST_TMPDIR/nix.log" + PINNED_REV="" + upgrade_packages="true" + SECONDS=0 + phase_nix_profile_update_flake + grep -q 'flake update' "$BATS_TEST_TMPDIR/nix.log" +} + +@test "nix_profile: gc runs wipe-history and store gc" { + : >"$BATS_TEST_TMPDIR/nix.log" + phase_post_install_gc + grep -q 'nix profile wipe-history' "$BATS_TEST_TMPDIR/nix.log" + grep -q 'nix store gc' "$BATS_TEST_TMPDIR/nix.log" +} diff --git a/tests/bats/test_nx_commands.bats b/tests/bats/test_nx_commands.bats new file mode 100755 index 00000000..ad154bca --- /dev/null +++ b/tests/bats/test_nx_commands.bats @@ -0,0 +1,486 @@ +#!/usr/bin/env bats +# Unit tests for nx CLI commands (pin, rollback, scope remove, scope edit, help) +bats_require_minimum_version 1.5.0 + +ALIASES_SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)/.assets/config/bash_cfg/aliases_nix.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + export HOME="$TEST_DIR" + + mkdir -p "$TEST_DIR/bin" + printf '#!/bin/sh\nexit 0\n' >"$TEST_DIR/bin/nix" + chmod +x "$TEST_DIR/bin/nix" + export PATH="$TEST_DIR/bin:$PATH" + + ENV_DIR="$HOME/.config/nix-env" + mkdir -p "$ENV_DIR/scopes" + + # shellcheck source=../../.assets/config/bash_cfg/aliases_nix.sh + source "$ALIASES_SCRIPT" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# -- help --------------------------------------------------------------------- + +@test "nx help shows usage" { + run nx help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: nx"* ]] + [[ "$output" == *"install"* ]] + [[ "$output" == *"upgrade"* ]] + [[ "$output" == *"pin"* ]] + [[ "$output" == *"rollback"* ]] +} + +@test "nx without args shows help" { + run nx + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: nx"* ]] +} + +@test "nx unknown command shows error" { + run nx fakecmd + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown command"* ]] +} + +# -- scope help (no default to list) ------------------------------------------ + +@test "nx scope without subcommand shows help" { + run nx scope + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: nx scope"* ]] + [[ "$output" == *"list"* ]] + [[ "$output" == *"add"* ]] + [[ "$output" == *"edit"* ]] + [[ "$output" == *"remove"* ]] +} + +# -- pin set (no args = read from flake.lock) --------------------------------- + +@test "pin set without rev reads from flake.lock" { + cat >"$ENV_DIR/flake.lock" <<'EOF' +{ + "nodes": { + "nixpkgs": { + "locked": { + "rev": "abc123def456" + } + } + } +} +EOF + run nx pin set + [ "$status" -eq 0 ] + [[ "$output" == *"Pinned nixpkgs to abc123def456"* ]] + [ -f "$ENV_DIR/pinned_rev" ] + [ "$(tr -d '[:space:]' <"$ENV_DIR/pinned_rev")" = "abc123def456" ] +} + +@test "pin set with explicit rev uses that rev" { + run nx pin set deadbeef123 + [ "$status" -eq 0 ] + [[ "$output" == *"Pinned nixpkgs to deadbeef123"* ]] + [ "$(tr -d '[:space:]' <"$ENV_DIR/pinned_rev")" = "deadbeef123" ] +} + +@test "pin set without rev fails when no flake.lock" { + run nx pin set + [ "$status" -eq 1 ] + [[ "$output" == *"No flake.lock found"* ]] +} + +@test "pin set overwrites existing pin" { + printf 'oldrev\n' >"$ENV_DIR/pinned_rev" + run nx pin set newrev + [ "$status" -eq 0 ] + [ "$(tr -d '[:space:]' <"$ENV_DIR/pinned_rev")" = "newrev" ] +} + +# -- pin show ----------------------------------------------------------------- + +@test "pin show displays current pin" { + printf 'abc123\n' >"$ENV_DIR/pinned_rev" + run nx pin show + [ "$status" -eq 0 ] + [[ "$output" == *"Pinned to:"* ]] + [[ "$output" == *"abc123"* ]] +} + +@test "pin show reports no pin when file missing" { + run nx pin show + [ "$status" -eq 0 ] + [[ "$output" == *"No pin set"* ]] +} + +# -- pin remove --------------------------------------------------------------- + +@test "pin remove deletes pin file" { + printf 'abc123\n' >"$ENV_DIR/pinned_rev" + run nx pin remove + [ "$status" -eq 0 ] + [[ "$output" == *"Pin removed"* ]] + [ ! -f "$ENV_DIR/pinned_rev" ] +} + +@test "pin remove reports no pin when file missing" { + run nx pin remove + [ "$status" -eq 0 ] + [[ "$output" == *"No pin set"* ]] +} + +# -- pin help ----------------------------------------------------------------- + +@test "pin help shows usage" { + run nx pin help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: nx pin"* ]] + [[ "$output" == *"set"* ]] + [[ "$output" == *"remove"* ]] + [[ "$output" == *"show"* ]] +} + +@test "pin without subcommand shows current pin status" { + run nx pin + [ "$status" -eq 0 ] + [[ "$output" == *"No pin set"* ]] +} + +# -- upgrade with pinned_rev -------------------------------------------------- + +@test "upgrade reads pinned_rev file when present" { + printf 'pinnedabc123\n' >"$ENV_DIR/pinned_rev" + run nx upgrade + [ "$status" -eq 0 ] + [[ "$output" == *"pinning nixpkgs to pinnedabc123"* ]] +} + +@test "upgrade without pin does normal update" { + run nx upgrade + [ "$status" -eq 0 ] + [[ "$output" != *"pinning nixpkgs"* ]] +} + +# -- scope remove with local_ prefix ----------------------------------------- + +@test "scope remove handles local_ prefix transparently" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + "local_devtools" + ]; +} +EOF + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/devtools.nix" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/scopes/local_devtools.nix" + + run nx scope remove devtools + [ "$status" -eq 0 ] + [[ "$output" == *"removed scope: devtools"* ]] + # config.nix should no longer have local_devtools + run ! grep -q 'local_devtools' "$ENV_DIR/config.nix" + # scope files should be cleaned up + [ ! -f "$ENV_DIR/local/scopes/devtools.nix" ] + [ ! -f "$ENV_DIR/scopes/local_devtools.nix" ] +} + +@test "scope remove handles repo scope by name" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + "python" + ]; +} +EOF + run nx scope remove python + [ "$status" -eq 0 ] + [[ "$output" == *"removed scope: python"* ]] + run ! grep -q '"python"' "$ENV_DIR/config.nix" + grep -q '"shell"' "$ENV_DIR/config.nix" +} + +@test "scope remove cleans orphaned overlay files" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/orphan.nix" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/scopes/local_orphan.nix" + + run nx scope remove orphan + [ "$status" -eq 0 ] + [ ! -f "$ENV_DIR/local/scopes/orphan.nix" ] + [ ! -f "$ENV_DIR/scopes/local_orphan.nix" ] +} + +@test "scope remove multiple scopes at once" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + "python" + "local_devtools" + ]; +} +EOF + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/devtools.nix" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/scopes/local_devtools.nix" + + run nx scope remove python devtools + [ "$status" -eq 0 ] + [[ "$output" == *"removed scope: python"* ]] + [[ "$output" == *"removed scope: devtools"* ]] + grep -q '"shell"' "$ENV_DIR/config.nix" + run ! grep -q '"python"' "$ENV_DIR/config.nix" + run ! grep -q 'local_devtools' "$ENV_DIR/config.nix" +} + +@test "scope remove reports unknown scope" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + run nx scope remove nonexistent + [[ "$output" == *"is not configured"* ]] +} + +# -- scope edit --------------------------------------------------------------- + +@test "scope edit fails for nonexistent scope" { + run nx scope edit nonexistent + [ "$status" -eq 1 ] + [[ "$output" == *"not found"* ]] +} + +@test "scope edit opens file and syncs copy" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/mytools.nix" + # use 'true' as EDITOR to simulate a no-op edit + EDITOR=true run nx scope edit mytools + [ "$status" -eq 0 ] + [[ "$output" == *"Synced scope"* ]] + [ -f "$ENV_DIR/scopes/local_mytools.nix" ] +} + +@test "scope edit falls back to vi when EDITOR unset" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/mytools.nix" + # create a fake vi that exits immediately + printf '#!/bin/sh\nexit 0\n' >"$TEST_DIR/bin/vi" + chmod +x "$TEST_DIR/bin/vi" + unset EDITOR + run nx scope edit mytools + [ "$status" -eq 0 ] +} + +# -- scope add with packages (validation stubbed) ---------------------------- + +@test "scope add creates scope and reports guidance" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run nx scope add newscope + [ "$status" -eq 0 ] + [[ "$output" == *"Created scope"* ]] + [[ "$output" == *"nx scope add newscope"* ]] + [ -f "$ENV_DIR/local/scopes/newscope.nix" ] +} + +@test "scope add to existing scope without packages shows hint" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/existing.nix" + run nx scope add existing + [ "$status" -eq 0 ] + [[ "$output" == *"already exists"* ]] +} + +# -- scope list --------------------------------------------------------------- + +@test "scope list shows installed scopes" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + "python" + ]; +} +EOF + run nx scope list + [ "$status" -eq 0 ] + [[ "$output" == *"shell"* ]] + [[ "$output" == *"python"* ]] +} + +@test "scope list shows no scopes when empty" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run nx scope list + [ "$status" -eq 0 ] + [[ "$output" == *"No scopes"* ]] +} + +# -- scope show --------------------------------------------------------------- + +@test "scope show displays packages in a scope" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + cat >"$ENV_DIR/scopes/shell.nix" <<'EOF' +{ pkgs }: with pkgs; [ + fzf + bat + ripgrep +] +EOF + run nx scope show shell + [ "$status" -eq 0 ] + [[ "$output" == *"fzf"* ]] + [[ "$output" == *"bat"* ]] + [[ "$output" == *"ripgrep"* ]] +} + +@test "scope show reports unknown scope" { + run nx scope show nonexistent + [[ "$output" == *"not found"* ]] || [[ "$output" == *"No scope file"* ]] +} + +# -- scope tree --------------------------------------------------------------- + +@test "scope tree shows scopes with packages" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + cat >"$ENV_DIR/scopes/shell.nix" <<'EOF' +{ pkgs }: with pkgs; [ + fzf + bat +] +EOF + cat >"$ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + run nx scope tree + [ "$status" -eq 0 ] + [[ "$output" == *"shell"* ]] + [[ "$output" == *"fzf"* ]] +} + +# -- _nx_scope_file_add helper ------------------------------------------------ + +@test "scope_file_add adds packages to scope file" { + local file="$TEST_DIR/test.nix" + printf '{ pkgs }: with pkgs; []\n' >"$file" + _nx_scope_file_add "$file" httpie jq + # verify the file contains the packages + grep -q 'httpie' "$file" + grep -q 'jq' "$file" +} + +@test "scope_file_add deduplicates existing packages" { + local file="$TEST_DIR/test.nix" + cat >"$file" <<'EOF' +{ pkgs }: with pkgs; [ + httpie +] +EOF + run _nx_scope_file_add "$file" httpie + [[ "$output" == *"already in scope"* ]] +} + +@test "scope_file_add sorts packages" { + local file="$TEST_DIR/test.nix" + printf '{ pkgs }: with pkgs; []\n' >"$file" + _nx_scope_file_add "$file" zoxide bat httpie + # read in order + local pkgs + pkgs="$(_nx_scope_pkgs "$file")" + local first second third + first="$(echo "$pkgs" | sed -n '1p')" + second="$(echo "$pkgs" | sed -n '2p')" + third="$(echo "$pkgs" | sed -n '3p')" + [ "$first" = "bat" ] + [ "$second" = "httpie" ] + [ "$third" = "zoxide" ] +} + +# -- _nx_validate_pkg helper -------------------------------------------------- + +@test "validate_pkg returns success for valid package" { + # override nix to echo a name + printf '#!/bin/sh\necho "test-1.0"\n' >"$TEST_DIR/bin/nix" + chmod +x "$TEST_DIR/bin/nix" + run _nx_validate_pkg testpkg + [ "$status" -eq 0 ] +} + +@test "validate_pkg returns failure for invalid package" { + printf '#!/bin/sh\nexit 1\n' >"$TEST_DIR/bin/nix" + chmod +x "$TEST_DIR/bin/nix" + run _nx_validate_pkg fakepkg + [ "$status" -ne 0 ] +} + +# -- rollback ----------------------------------------------------------------- + +@test "rollback succeeds when nix profile rollback succeeds" { + run nx rollback + [ "$status" -eq 0 ] + [[ "$output" == *"Rolled back"* ]] + [[ "$output" == *"Restart your shell"* ]] +} + +@test "rollback fails when nix profile rollback fails" { + printf '#!/bin/sh\nexit 1\n' >"$TEST_DIR/bin/nix" + chmod +x "$TEST_DIR/bin/nix" + run nx rollback + [ "$status" -eq 1 ] + [[ "$output" == *"rollback failed"* ]] +} + +# -- overlay help ------------------------------------------------------------- + +@test "overlay help shows usage" { + run nx overlay help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: nx overlay"* ]] +} diff --git a/tests/bats/test_nx_doctor.bats b/tests/bats/test_nx_doctor.bats new file mode 100755 index 00000000..0236a5f2 --- /dev/null +++ b/tests/bats/test_nx_doctor.bats @@ -0,0 +1,194 @@ +#!/usr/bin/env bats +# Unit tests for .assets/lib/nx_doctor.sh +bats_require_minimum_version 1.5.0 + +DOCTOR_SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)/.assets/lib/nx_doctor.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + export ENV_DIR="$TEST_DIR/nix-env" + export DEV_ENV_DIR="$TEST_DIR/dev-env" + export HOME="$TEST_DIR" + mkdir -p "$ENV_DIR/scopes" "$DEV_ENV_DIR" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# -- helpers ----------------------------------------------------------------- + +_write_install_json() { + cat >"$DEV_ENV_DIR/install.json" <<'EOF' +{ + "status": "success", + "phase": "complete", + "scopes": ["shell", "python"] +} +EOF +} + +_write_flake_lock() { + cat >"$ENV_DIR/flake.lock" <<'EOF' +{ + "nodes": { + "nixpkgs": { + "locked": { + "rev": "abc123" + } + } + } +} +EOF +} + +_write_scope_files() { + cat >"$ENV_DIR/scopes/shell.nix" <<'EOF' +# Shell tools +# bins: fzf bat +{ pkgs }: with pkgs; [ fzf bat ] +EOF + cat >"$ENV_DIR/scopes/python.nix" <<'EOF' +# Python +# bins: uv +{ pkgs }: with pkgs; [ uv ] +EOF +} + +# -- flake_lock check -------------------------------------------------------- + +@test "flake_lock passes when flake.lock exists with nixpkgs node" { + _write_flake_lock + _write_install_json + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"PASS flake_lock"* ]] +} + +@test "flake_lock fails when flake.lock is missing" { + _write_install_json + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"FAIL flake_lock"* ]] +} + +# -- install_record check --------------------------------------------------- + +@test "install_record passes with valid install.json" { + _write_flake_lock + _write_install_json + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"PASS install_record"* ]] +} + +@test "install_record warns when install.json is missing" { + _write_flake_lock + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"WARN install_record"* ]] +} + +@test "install_record warns on failed status" { + _write_flake_lock + cat >"$DEV_ENV_DIR/install.json" <<'EOF' +{ + "status": "failed", + "phase": "nix-profile", + "scopes": [] +} +EOF + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"WARN install_record"* ]] + [[ "$output" == *"failed"* ]] +} + +# -- scope_binaries check --------------------------------------------------- + +@test "scope_binaries warns on missing binary" { + _write_flake_lock + _write_install_json + # use a fake binary name that definitely won't be in PATH + cat >"$ENV_DIR/scopes/shell.nix" <<'EOF' +# bins: _nx_doctor_test_nonexistent_bin_ +{ pkgs }: with pkgs; [ fzf ] +EOF + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"WARN scope_binaries"* ]] + [[ "$output" == *"_nx_doctor_test_nonexistent_bin_"* ]] +} + +# -- shell_profile check ---------------------------------------------------- + +@test "shell_profile passes with exactly one managed block" { + _write_flake_lock + _write_install_json + cat >"$HOME/.bashrc" <<'EOF' +# >>> nix-env managed >>> +some content +# <<< nix-env managed <<< +EOF + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"PASS shell_profile"* ]] +} + +@test "shell_profile fails with duplicate managed blocks" { + _write_flake_lock + _write_install_json + cat >"$HOME/.bashrc" <<'EOF' +# >>> nix-env managed >>> +block 1 +# <<< nix-env managed <<< +# >>> nix-env managed >>> +block 2 +# <<< nix-env managed <<< +EOF + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"FAIL shell_profile"* ]] + [[ "$output" == *"duplicate"* ]] +} + +# -- cert_bundle check ------------------------------------------------------ + +@test "cert_bundle passes when no custom certs exist" { + _write_flake_lock + _write_install_json + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"PASS cert_bundle"* ]] +} + +@test "cert_bundle fails when custom certs exist but bundle is missing" { + _write_flake_lock + _write_install_json + mkdir -p "$HOME/.config/certs" + touch "$HOME/.config/certs/ca-custom.crt" + run bash "$DOCTOR_SCRIPT" + [[ "$output" == *"FAIL cert_bundle"* ]] + [[ "$output" == *"ca-bundle.crt missing"* ]] +} + +# -- JSON output ------------------------------------------------------------- + +@test "--json produces valid JSON with status field" { + _write_flake_lock + _write_install_json + run bash "$DOCTOR_SCRIPT" --json + # verify it's parseable JSON with the expected fields + echo "$output" | jq -e '.status' >/dev/null + echo "$output" | jq -e '.pass' >/dev/null + echo "$output" | jq -e '.checks' >/dev/null +} + +# -- exit code --------------------------------------------------------------- + +@test "exits 1 when any check fails" { + # no flake.lock -> flake_lock FAIL + _write_install_json + run bash "$DOCTOR_SCRIPT" + [ "$status" -eq 1 ] +} + +@test "json output reports failure count" { + _write_install_json + run bash "$DOCTOR_SCRIPT" --json + local fail_count + fail_count="$(echo "$output" | jq -r '.fail')" + [ "$fail_count" -gt 0 ] + [[ "$(echo "$output" | jq -r '.status')" == "broken" ]] +} diff --git a/tests/bats/test_nx_pkgs.bats b/tests/bats/test_nx_pkgs.bats new file mode 100755 index 00000000..0f81fe53 --- /dev/null +++ b/tests/bats/test_nx_pkgs.bats @@ -0,0 +1,140 @@ +#!/usr/bin/env bats +# Unit tests for _nx_read_pkgs / _nx_write_pkgs in aliases_nix.sh +# shellcheck disable=SC2030,SC2031 # subshell variable modifications are intentional in tests +bats_require_minimum_version 1.5.0 + +setup() { + _NX_ENV_DIR="$(mktemp -d)" + _NX_PKG_FILE="$_NX_ENV_DIR/packages.nix" + + # source only the helper functions (stub out nix/curl/jq to avoid side effects) + # shellcheck source=../../.assets/config/bash_cfg/aliases_nix.sh + _nx_read_pkgs() { + [ -f "$_NX_PKG_FILE" ] && sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' "$_NX_PKG_FILE" + } + + _nx_write_pkgs() { + local tmp + tmp="$(mktemp)" + printf '[\n' >"$tmp" + sort -u | while IFS= read -r name; do + [ -n "$name" ] && printf ' "%s"\n' "$name" >>"$tmp" + done + printf ']\n' >>"$tmp" + mv "$tmp" "$_NX_PKG_FILE" + } +} + +teardown() { + rm -rf "$_NX_ENV_DIR" +} + +# -- _nx_read_pkgs ------------------------------------------------------------ + +@test "read_pkgs returns empty when file does not exist" { + run _nx_read_pkgs + [ -z "$output" ] +} + +@test "read_pkgs returns empty for empty list" { + printf '[\n]\n' >"$_NX_PKG_FILE" + run _nx_read_pkgs + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "read_pkgs extracts package names" { + cat >"$_NX_PKG_FILE" <<'EOF' +[ + "ripgrep" + "fd" + "jq" +] +EOF + run _nx_read_pkgs + [ "$status" -eq 0 ] + [ "${lines[0]}" = "ripgrep" ] + [ "${lines[1]}" = "fd" ] + [ "${lines[2]}" = "jq" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "read_pkgs ignores comments and blank lines" { + cat >"$_NX_PKG_FILE" <<'EOF' +[ + # a comment + "ripgrep" + + "fd" +] +EOF + run _nx_read_pkgs + [ "${#lines[@]}" -eq 2 ] + [ "${lines[0]}" = "ripgrep" ] + [ "${lines[1]}" = "fd" ] +} + +# -- _nx_write_pkgs ------------------------------------------------------------ + +@test "write_pkgs creates valid nix list" { + printf 'ripgrep\nfd\n' | _nx_write_pkgs + run cat "$_NX_PKG_FILE" + [ "${lines[0]}" = "[" ] + [ "${lines[1]}" = ' "fd"' ] + [ "${lines[2]}" = ' "ripgrep"' ] + [ "${lines[3]}" = "]" ] +} + +@test "write_pkgs sorts and deduplicates" { + printf 'zoxide\nripgrep\nfd\nripgrep\n' | _nx_write_pkgs + run _nx_read_pkgs + [ "${lines[0]}" = "fd" ] + [ "${lines[1]}" = "ripgrep" ] + [ "${lines[2]}" = "zoxide" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "write_pkgs skips blank lines" { + printf '\nripgrep\n\n\nfd\n\n' | _nx_write_pkgs + run _nx_read_pkgs + [ "${#lines[@]}" -eq 2 ] +} + +@test "write_pkgs with empty input creates empty list" { + printf '' | _nx_write_pkgs + run cat "$_NX_PKG_FILE" + [ "${lines[0]}" = "[" ] + [ "${lines[1]}" = "]" ] + [ "${#lines[@]}" -eq 2 ] +} + +# -- round-trip ---------------------------------------------------------------- + +@test "round-trip: write then read preserves packages" { + printf 'jq\ncurl\nwget\n' | _nx_write_pkgs + run _nx_read_pkgs + [ "${lines[0]}" = "curl" ] + [ "${lines[1]}" = "jq" ] + [ "${lines[2]}" = "wget" ] +} + +@test "round-trip: add to existing list" { + printf 'fd\nripgrep\n' | _nx_write_pkgs + current="$(_nx_read_pkgs)" + { printf '%s\n' "$current"; printf 'jq\n'; } | _nx_write_pkgs + run _nx_read_pkgs + [ "${#lines[@]}" -eq 3 ] + [ "${lines[0]}" = "fd" ] + [ "${lines[1]}" = "jq" ] + [ "${lines[2]}" = "ripgrep" ] +} + +@test "round-trip: remove from existing list" { + printf 'fd\njq\nripgrep\n' | _nx_write_pkgs + current="$(_nx_read_pkgs)" + printf '%s\n' "$current" | grep -v '^jq$' | _nx_write_pkgs + run _nx_read_pkgs + [ "${#lines[@]}" -eq 2 ] + [ "${lines[0]}" = "fd" ] + [ "${lines[1]}" = "ripgrep" ] +} diff --git a/tests/bats/test_nx_scope.bats b/tests/bats/test_nx_scope.bats new file mode 100755 index 00000000..004ea906 --- /dev/null +++ b/tests/bats/test_nx_scope.bats @@ -0,0 +1,549 @@ +#!/usr/bin/env bats +# Unit tests for nx scope helpers and scope-aware install/remove validation +# Tests: _nx_scope_pkgs, _nx_scopes, _nx_is_init, _nx_all_scope_pkgs, +# install scope-check, remove scope-check +# shellcheck disable=SC2030,SC2031 +bats_require_minimum_version 1.5.0 + +setup() { + _NX_ENV_DIR="$(mktemp -d)" + _NX_PKG_FILE="$_NX_ENV_DIR/packages.nix" + mkdir -p "$_NX_ENV_DIR/scopes" + + # source helpers inline (same as aliases_nix.sh) to avoid side effects + _nx_read_pkgs() { + [ -f "$_NX_PKG_FILE" ] && sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' "$_NX_PKG_FILE" + } + + _nx_write_pkgs() { + local tmp + tmp="$(mktemp)" + printf '[\n' >"$tmp" + sort -u | while IFS= read -r name; do + [ -n "$name" ] && printf ' "%s"\n' "$name" >>"$tmp" + done + printf ']\n' >>"$tmp" + mv "$tmp" "$_NX_PKG_FILE" + } + + _nx_scope_pkgs() { + local file="$1" + [ -f "$file" ] || return 0 + sed -n '/\[/,/\]/{ + s/^[[:space:]]*\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p + }' "$file" + } + + _nx_scopes() { + local config_nix="$_NX_ENV_DIR/config.nix" + [ -f "$config_nix" ] || return 0 + sed -n '/scopes[[:space:]]*=[[:space:]]*\[/,/\]/{ + s/^[[:space:]]*"\([^"]*\)".*/\1/p + }' "$config_nix" + } + + _nx_is_init() { + local config_nix="$_NX_ENV_DIR/config.nix" + [ -f "$config_nix" ] || { echo "false"; return; } + sed -n -E 's/^[[:space:]]*isInit[[:space:]]*=[[:space:]]*(true|false).*/\1/p' "$config_nix" + } + + _nx_all_scope_pkgs() { + local scopes_dir="$_NX_ENV_DIR/scopes" + [ -d "$scopes_dir" ] || return 0 + local pkg + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t%s\n' "$pkg" "base" + done < <(_nx_scope_pkgs "$scopes_dir/base.nix") + if [ "$(_nx_is_init)" = "true" ]; then + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t%s\n' "$pkg" "base_init" + done < <(_nx_scope_pkgs "$scopes_dir/base_init.nix") + fi + local scopes s + scopes="$(_nx_scopes)" + if [ -n "$scopes" ]; then + while IFS= read -r s; do + while IFS= read -r pkg; do + [ -n "$pkg" ] && printf '%s\t%s\n' "$pkg" "$s" + done < <(_nx_scope_pkgs "$scopes_dir/$s.nix") + done <<<"$scopes" + fi + } +} + +teardown() { + rm -rf "$_NX_ENV_DIR" +} + +# ============================================================================= +# _nx_scope_pkgs +# ============================================================================= + +@test "scope_pkgs: parses standard scope file" { + cat >"$_NX_ENV_DIR/scopes/shell.nix" <<'EOF' +# Shell tools +{ pkgs }: with pkgs; [ + fzf + eza + bat + ripgrep +] +EOF + run _nx_scope_pkgs "$_NX_ENV_DIR/scopes/shell.nix" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "fzf" ] + [ "${lines[1]}" = "eza" ] + [ "${lines[2]}" = "bat" ] + [ "${lines[3]}" = "ripgrep" ] + [ "${#lines[@]}" -eq 4 ] +} + +@test "scope_pkgs: handles inline comments" { + cat >"$_NX_ENV_DIR/scopes/test.nix" <<'EOF' +{ pkgs }: with pkgs; [ + bind # provides dig, nslookup, host + git + openssl +] +EOF + run _nx_scope_pkgs "$_NX_ENV_DIR/scopes/test.nix" + [ "${lines[0]}" = "bind" ] + [ "${lines[1]}" = "git" ] + [ "${lines[2]}" = "openssl" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "scope_pkgs: handles packages with hyphens and underscores" { + cat >"$_NX_ENV_DIR/scopes/test.nix" <<'EOF' +{ pkgs }: with pkgs; [ + bash-completion + yq-go + k9s +] +EOF + run _nx_scope_pkgs "$_NX_ENV_DIR/scopes/test.nix" + [ "${lines[0]}" = "bash-completion" ] + [ "${lines[1]}" = "yq-go" ] + [ "${lines[2]}" = "k9s" ] +} + +@test "scope_pkgs: returns empty for nonexistent file" { + run _nx_scope_pkgs "$_NX_ENV_DIR/scopes/nonexistent.nix" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "scope_pkgs: returns empty for empty list" { + cat >"$_NX_ENV_DIR/scopes/empty.nix" <<'EOF' +{ pkgs }: with pkgs; [ +] +EOF + run _nx_scope_pkgs "$_NX_ENV_DIR/scopes/empty.nix" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "scope_pkgs: ignores comment-only lines inside list" { + cat >"$_NX_ENV_DIR/scopes/test.nix" <<'EOF' +{ pkgs }: with pkgs; [ + # this is a comment + git + # another comment + jq +] +EOF + run _nx_scope_pkgs "$_NX_ENV_DIR/scopes/test.nix" + [ "${lines[0]}" = "git" ] + [ "${lines[1]}" = "jq" ] + [ "${#lines[@]}" -eq 2 ] +} + +# ============================================================================= +# _nx_scopes +# ============================================================================= + +@test "scopes: returns empty when config.nix missing" { + run _nx_scopes + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "scopes: parses config.nix with multiple scopes" { + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = true; + + scopes = [ + "shell" + "python" + "docker" + ]; +} +EOF + run _nx_scopes + [ "${lines[0]}" = "shell" ] + [ "${lines[1]}" = "python" ] + [ "${lines[2]}" = "docker" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "scopes: parses config.nix with empty scopes" { + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + + scopes = [ + ]; +} +EOF + run _nx_scopes + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "scopes: parses single scope" { + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + + scopes = [ + "shell" + ]; +} +EOF + run _nx_scopes + [ "${lines[0]}" = "shell" ] + [ "${#lines[@]}" -eq 1 ] +} + +# ============================================================================= +# _nx_is_init +# ============================================================================= + +@test "is_init: returns false when config.nix missing" { + run _nx_is_init + [ "$output" = "false" ] +} + +@test "is_init: returns true when isInit is true" { + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = true; + scopes = []; +} +EOF + run _nx_is_init + [ "$output" = "true" ] +} + +@test "is_init: returns false when isInit is false" { + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run _nx_is_init + [ "$output" = "false" ] +} + +# ============================================================================= +# _nx_all_scope_pkgs +# ============================================================================= + +@test "all_scope_pkgs: includes base packages" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git + jq +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run _nx_all_scope_pkgs + [ "${lines[0]}" = "git base" ] + [ "${lines[1]}" = "jq base" ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "all_scope_pkgs: includes base_init when isInit is true" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/scopes/base_init.nix" <<'EOF' +{ pkgs }: with pkgs; [ + nano +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = true; + scopes = []; +} +EOF + run _nx_all_scope_pkgs + [ "${lines[0]}" = "git base" ] + [ "${lines[1]}" = "nano base_init" ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "all_scope_pkgs: excludes base_init when isInit is false" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/scopes/base_init.nix" <<'EOF' +{ pkgs }: with pkgs; [ + nano +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run _nx_all_scope_pkgs + [ "${lines[0]}" = "git base" ] + [ "${#lines[@]}" -eq 1 ] +} + +@test "all_scope_pkgs: includes configured scope packages" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/scopes/shell.nix" <<'EOF' +{ pkgs }: with pkgs; [ + fzf + bat +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + run _nx_all_scope_pkgs + [ "${lines[0]}" = "git base" ] + [ "${lines[1]}" = "fzf shell" ] + [ "${lines[2]}" = "bat shell" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "all_scope_pkgs: handles multiple scopes" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/scopes/shell.nix" <<'EOF' +{ pkgs }: with pkgs; [ + fzf +] +EOF + cat >"$_NX_ENV_DIR/scopes/python.nix" <<'EOF' +{ pkgs }: with pkgs; [ + uv +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + "python" + ]; +} +EOF + run _nx_all_scope_pkgs + [ "${lines[0]}" = "git base" ] + [ "${lines[1]}" = "fzf shell" ] + [ "${lines[2]}" = "uv python" ] + [ "${#lines[@]}" -eq 3 ] +} + +@test "all_scope_pkgs: returns empty when no scopes dir" { + rmdir "$_NX_ENV_DIR/scopes" + run _nx_all_scope_pkgs + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ============================================================================= +# Install: scope-aware validation +# ============================================================================= + +@test "install: detects package already in scope" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git + jq +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + + # simulate install logic + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^git " 2>/dev/null | cut -f2)" + [ "$in_scope" = "base" ] +} + +@test "install: allows package not in any scope" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^ripgrep " 2>/dev/null | cut -f2)" + [ -z "$in_scope" ] +} + +@test "install: detects package in configured scope" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/scopes/shell.nix" <<'EOF' +{ pkgs }: with pkgs; [ + fzf + bat +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^bat " 2>/dev/null | cut -f2)" + [ "$in_scope" = "shell" ] +} + +@test "install: detects already-installed extra package" { + printf 'ripgrep\nfd\n' | _nx_write_pkgs + local current + current="$(_nx_read_pkgs)" + run printf '%s\n' "$current" + # check the grep-based detection + printf '%s\n' "$current" | grep -qx "ripgrep" +} + +# ============================================================================= +# Remove: scope-aware validation +# ============================================================================= + +@test "remove: detects scope-managed package" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/scopes/shell.nix" <<'EOF' +{ pkgs }: with pkgs; [ + bat +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^bat " 2>/dev/null | cut -f2)" + [ "$in_scope" = "shell" ] +} + +@test "remove: allows removing extra package" { + printf 'ripgrep\nfd\n' | _nx_write_pkgs + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^ripgrep " 2>/dev/null | cut -f2)" + [ -z "$in_scope" ] +} + +@test "remove: filters out scope packages from args" { + cat >"$_NX_ENV_DIR/scopes/base.nix" <<'EOF' +{ pkgs }: with pkgs; [ + git +] +EOF + cat >"$_NX_ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + + # simulate the filtering logic from nx remove + local scope_pkgs + scope_pkgs="$(_nx_all_scope_pkgs)" + local args=("git" "ripgrep" "fd") + local filtered_args=() + local p + for p in "${args[@]}"; do + local in_scope + in_scope="$(printf '%s\n' "$scope_pkgs" | grep -m1 "^${p} " 2>/dev/null | cut -f2)" + if [ -z "$in_scope" ]; then + filtered_args+=("$p") + fi + done + [ "${#filtered_args[@]}" -eq 2 ] + [ "${filtered_args[0]}" = "ripgrep" ] + [ "${filtered_args[1]}" = "fd" ] +} diff --git a/tests/bats/test_overlay.bats b/tests/bats/test_overlay.bats new file mode 100755 index 00000000..b3f9cc1f --- /dev/null +++ b/tests/bats/test_overlay.bats @@ -0,0 +1,195 @@ +#!/usr/bin/env bats +# Unit tests for nx overlay and nx scope add commands +bats_require_minimum_version 1.5.0 + +ALIASES_SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)/.assets/config/bash_cfg/aliases_nix.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + export HOME="$TEST_DIR" + + # create fake nix binary so nx() function gets defined when sourcing aliases + mkdir -p "$TEST_DIR/bin" + printf '#!/bin/sh\nexit 0\n' >"$TEST_DIR/bin/nix" + chmod +x "$TEST_DIR/bin/nix" + export PATH="$TEST_DIR/bin:$PATH" + + ENV_DIR="$HOME/.config/nix-env" + mkdir -p "$ENV_DIR/scopes" + + # shellcheck source=../../.assets/config/bash_cfg/aliases_nix.sh + source "$ALIASES_SCRIPT" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# -- overlay list ------------------------------------------------------------ + +@test "overlay list reports no overlay when dir doesn't exist" { + run nx overlay list + [ "$status" -eq 0 ] + [[ "$output" == *"No overlay directory active"* ]] +} + +@test "overlay list discovers local dir" { + mkdir -p "$ENV_DIR/local" + run nx overlay list + [ "$status" -eq 0 ] + [[ "$output" == *"$ENV_DIR/local"* ]] +} + +@test "overlay list prefers NIX_ENV_OVERLAY_DIR over local dir" { + mkdir -p "$ENV_DIR/local" + local custom_dir="$TEST_DIR/custom-overlay" + mkdir -p "$custom_dir" + export NIX_ENV_OVERLAY_DIR="$custom_dir" + run nx overlay list + [[ "$output" == *"$custom_dir"* ]] +} + +@test "overlay list shows scope files" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/my_tools.nix" + run nx overlay list + [[ "$output" == *"Scopes:"* ]] + [[ "$output" == *"my_tools"* ]] +} + +@test "overlay list shows shell config files" { + mkdir -p "$ENV_DIR/local/bash_cfg" + touch "$ENV_DIR/local/bash_cfg/custom.sh" + run nx overlay list + [[ "$output" == *"Shell config:"* ]] + [[ "$output" == *"custom.sh"* ]] +} + +@test "overlay list shows hook files" { + mkdir -p "$ENV_DIR/local/hooks/post-setup.d" + touch "$ENV_DIR/local/hooks/post-setup.d/10-custom.sh" + run nx overlay list + [[ "$output" == *"Hooks (post-setup.d):"* ]] + [[ "$output" == *"10-custom.sh"* ]] +} + +@test "overlay list handles empty overlay directory" { + mkdir -p "$ENV_DIR/local" + run nx overlay list + [ "$status" -eq 0 ] +} + +# -- overlay status ---------------------------------------------------------- + +@test "overlay status shows none when no overlay dir" { + run nx overlay status + [ "$status" -eq 0 ] + [[ "$output" == *"none"* ]] + [[ "$output" == *"No overlay scopes synced"* ]] +} + +@test "overlay status shows synced overlay scopes" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/local/scopes/my_tools.nix" + cp "$ENV_DIR/local/scopes/my_tools.nix" "$ENV_DIR/scopes/local_my_tools.nix" + run nx overlay status + [[ "$output" == *"Overlay scopes (synced):"* ]] + [[ "$output" == *"my_tools"* ]] +} + +@test "overlay status shows modified indicator when source differs" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/scopes/local_my_tools.nix" + printf '{ pkgs }: with pkgs; [ hello ]\n' >"$ENV_DIR/local/scopes/my_tools.nix" + run nx overlay status + [[ "$output" == *"modified"* ]] +} + +@test "overlay status shows source missing when overlay dir has no source" { + printf '{ pkgs }: with pkgs; []\n' >"$ENV_DIR/scopes/local_orphan.nix" + run nx overlay status + [[ "$output" == *"source missing"* ]] +} + +@test "overlay status shows shell config sync status" { + mkdir -p "$ENV_DIR/local/bash_cfg" "$HOME/.config/bash" + printf 'echo hello\n' >"$ENV_DIR/local/bash_cfg/custom.sh" + cp "$ENV_DIR/local/bash_cfg/custom.sh" "$HOME/.config/bash/custom.sh" + run nx overlay status + [[ "$output" == *"synced"* ]] +} + +@test "overlay status shows differs indicator for changed shell config" { + mkdir -p "$ENV_DIR/local/bash_cfg" "$HOME/.config/bash" + printf 'echo hello\n' >"$ENV_DIR/local/bash_cfg/custom.sh" + printf 'echo world\n' >"$HOME/.config/bash/custom.sh" + run nx overlay status + [[ "$output" == *"differs"* ]] +} + +# -- scope add --------------------------------------------------------------- + +@test "scope add creates stub nix file in overlay directory" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + run nx scope add my_tools + [ "$status" -eq 0 ] + [[ "$output" == *"Created scope"* ]] + [ -f "$ENV_DIR/local/scopes/my_tools.nix" ] + [ -f "$ENV_DIR/scopes/local_my_tools.nix" ] +} + +@test "scope add registers scope in config.nix" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = [ + "shell" + ]; +} +EOF + nx scope add my_tools + grep -q 'local_my_tools' "$ENV_DIR/config.nix" + grep -q 'shell' "$ENV_DIR/config.nix" +} + +@test "scope add is idempotent for existing scope" { + mkdir -p "$ENV_DIR/local/scopes" + printf '{ pkgs }: with pkgs; [ hello ]\n' >"$ENV_DIR/local/scopes/my_tools.nix" + run nx scope add my_tools + [ "$status" -eq 0 ] + [[ "$output" == *"already exists"* ]] +} + +@test "scope add normalizes hyphens to underscores" { + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run nx scope add my-tools + [ "$status" -eq 0 ] + [ -f "$ENV_DIR/local/scopes/my_tools.nix" ] +} + +@test "scope add uses NIX_ENV_OVERLAY_DIR when set" { + local custom_dir="$TEST_DIR/custom-overlay" + mkdir -p "$custom_dir" + export NIX_ENV_OVERLAY_DIR="$custom_dir" + cat >"$ENV_DIR/config.nix" <<'EOF' +{ + isInit = false; + scopes = []; +} +EOF + run nx scope add my_tools + [ "$status" -eq 0 ] + [ -f "$custom_dir/scopes/my_tools.nix" ] +} diff --git a/tests/bats/test_profile_block.bats b/tests/bats/test_profile_block.bats new file mode 100755 index 00000000..d1a74192 --- /dev/null +++ b/tests/bats/test_profile_block.bats @@ -0,0 +1,254 @@ +#!/usr/bin/env bats +# Unit tests for manage_block in .assets/lib/profile_block.sh +bats_require_minimum_version 1.5.0 + +MARKER="nix-env managed" + +setup() { + TEST_DIR="$(mktemp -d)" + RC="$TEST_DIR/.bashrc" + CONTENT="$TEST_DIR/block_content.txt" + # shellcheck source=../../.assets/lib/profile_block.sh + source "$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)/.assets/lib/profile_block.sh" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +_write_content() { + printf '%s\n' "$@" >"$CONTENT" +} + +_rc_content() { + cat "$RC" +} + +# count how many times begin tag appears +_count_begin() { + grep -cF "# >>> $MARKER >>>" "$RC" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# inspect +# --------------------------------------------------------------------------- + +@test "inspect returns 1 when rc does not exist" { + run manage_block "$TEST_DIR/missing_rc" "$MARKER" inspect + [ "$status" -eq 1 ] +} + +@test "inspect returns 1 on empty rc" { + touch "$RC" + run manage_block "$RC" "$MARKER" inspect + [ "$status" -eq 1 ] +} + +@test "inspect returns 0 and prints line numbers when block is present" { + printf 'before\n# >>> %s >>>\ncontent\n# <<< %s <<<\nafter\n' "$MARKER" "$MARKER" >"$RC" + run manage_block "$RC" "$MARKER" inspect + [ "$status" -eq 0 ] + [[ "$output" =~ ^[0-9]+\ [0-9]+$ ]] +} + +# --------------------------------------------------------------------------- +# upsert - fresh rc (no existing block) +# --------------------------------------------------------------------------- + +@test "upsert creates rc file if missing" { + local new_rc="$TEST_DIR/new.bashrc" + _write_content "export FOO=bar" + run manage_block "$new_rc" "$MARKER" upsert "$CONTENT" + [ "$status" -eq 0 ] + [ -f "$new_rc" ] +} + +@test "upsert on empty rc inserts block" { + touch "$RC" + _write_content "export FOO=bar" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + grep -qF "# >>> $MARKER >>>" "$RC" + grep -qF "export FOO=bar" "$RC" + grep -qF "# <<< $MARKER <<<" "$RC" +} + +@test "upsert appends to non-empty rc without existing block" { + printf 'existing line\n' >"$RC" + _write_content "export NEW=1" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + grep -q "existing line" "$RC" + grep -qF "# >>> $MARKER >>>" "$RC" +} + +@test "upsert block count is exactly 1 on first insert" { + touch "$RC" + _write_content "line1" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + [ "$(_count_begin)" -eq 1 ] +} + +# --------------------------------------------------------------------------- +# upsert - idempotency (existing block) +# --------------------------------------------------------------------------- + +@test "upsert replaces existing block content" { + touch "$RC" + _write_content "old content" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + _write_content "new content" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + grep -q "new content" "$RC" + run grep -c "old content" "$RC" + [ "$output" -eq 0 ] +} + +@test "upsert is idempotent: block count stays 1 on repeated runs" { + touch "$RC" + _write_content "export X=1" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + [ "$(_count_begin)" -eq 1 ] +} + +@test "upsert preserves content before the block" { + printf 'user_alias\n' >"$RC" + _write_content "managed" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + grep -q "user_alias" "$RC" +} + +@test "upsert preserves content after the block" { + printf '# >>> %s >>>\nold\n# <<< %s <<<\nuser_below\n' "$MARKER" "$MARKER" >"$RC" + _write_content "new" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + grep -q "user_below" "$RC" +} + +@test "upsert repeated runs produce byte-identical output" { + touch "$RC" + _write_content "export STABLE=1" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + local hash1 + hash1="$(md5sum "$RC" | cut -d' ' -f1)" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + local hash2 + hash2="$(md5sum "$RC" | cut -d' ' -f1)" + [ "$hash1" = "$hash2" ] +} + +# --------------------------------------------------------------------------- +# upsert - duplicate block recovery +# --------------------------------------------------------------------------- + +@test "upsert collapses multiple existing blocks into one" { + printf '# >>> %s >>>\nfirst\n# <<< %s <<<\n# >>> %s >>>\nsecond\n# <<< %s <<<\n' \ + "$MARKER" "$MARKER" "$MARKER" "$MARKER" >"$RC" + _write_content "unified" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + [ "$(_count_begin)" -eq 1 ] + grep -q "unified" "$RC" +} + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + +@test "remove is a no-op when block is absent" { + printf 'untouched\n' >"$RC" + run manage_block "$RC" "$MARKER" remove + [ "$status" -eq 0 ] + grep -q "untouched" "$RC" +} + +@test "remove deletes the managed block" { + touch "$RC" + _write_content "managed line" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + manage_block "$RC" "$MARKER" remove + run grep -cF "# >>> $MARKER >>>" "$RC" + [ "$output" -eq 0 ] + run grep -c "managed line" "$RC" + [ "$output" -eq 0 ] +} + +@test "remove preserves content outside the block" { + printf 'before\n' >"$RC" + _write_content "inside" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + printf 'after\n' >>"$RC" + manage_block "$RC" "$MARKER" remove + grep -q "before" "$RC" + grep -q "after" "$RC" +} + +@test "remove after upsert leaves inspect returning 1" { + touch "$RC" + _write_content "x" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + manage_block "$RC" "$MARKER" remove + run manage_block "$RC" "$MARKER" inspect + [ "$status" -eq 1 ] +} + +@test "remove is idempotent" { + touch "$RC" + _write_content "x" + manage_block "$RC" "$MARKER" upsert "$CONTENT" + manage_block "$RC" "$MARKER" remove + run manage_block "$RC" "$MARKER" remove + [ "$status" -eq 0 ] +} + +@test "remove cleans up multiple duplicate blocks" { + printf '# >>> %s >>>\na\n# <<< %s <<<\n# >>> %s >>>\nb\n# <<< %s <<<\n' \ + "$MARKER" "$MARKER" "$MARKER" "$MARKER" >"$RC" + manage_block "$RC" "$MARKER" remove + run grep -cF "# >>> $MARKER >>>" "$RC" + [ "$output" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# error handling +# --------------------------------------------------------------------------- + +@test "upsert fails when content file is missing" { + touch "$RC" + run manage_block "$RC" "$MARKER" upsert "$TEST_DIR/nonexistent.txt" + [ "$status" -ne 0 ] +} + +@test "unknown action returns non-zero" { + touch "$RC" + run manage_block "$RC" "$MARKER" bogus + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# _pb_normalize_trailing +# --------------------------------------------------------------------------- + +@test "normalize strips trailing blank lines" { + printf 'line1\nline2\n\n\n\n' >"$RC" + _pb_normalize_trailing "$RC" + # file should end with exactly one newline; last non-empty line is line2 + run grep -c "^$" "$RC" + # only the one trailing newline (awk END print "") - but that's the final char, + # not a visible blank line in the output. Count non-empty lines. + run grep -c "line2" "$RC" + [ "$output" -eq 1 ] + # no blank lines should remain between EOF and line2 + local content + content="$(cat "$RC")" + [ "$content" = "$(printf 'line1\nline2')" ] || [ "$content" = "$(printf 'line1\nline2\n')" ] +} + +@test "normalize preserves internal blank lines" { + printf 'a\n\nb\n' >"$RC" + _pb_normalize_trailing "$RC" + grep -q "^$" "$RC" +} diff --git a/tests/bats/test_profile_migration.bats b/tests/bats/test_profile_migration.bats new file mode 100755 index 00000000..b60965ac --- /dev/null +++ b/tests/bats/test_profile_migration.bats @@ -0,0 +1,200 @@ +#!/usr/bin/env bats +# Integration tests for nx profile subcommand and legacy-to-managed-block migration +bats_require_minimum_version 1.5.0 + +REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" +ALIASES_NIX="$REPO_ROOT/.assets/config/bash_cfg/aliases_nix.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + export HOME="$TEST_DIR" + mkdir -p "$TEST_DIR/.config/bash" + + # Source the library and the aliases (nix guard won't fire - nix not available) + # We need manage_block available for the profile subcommand + # shellcheck source=../../.assets/lib/profile_block.sh + source "$REPO_ROOT/.assets/lib/profile_block.sh" + + # Stub nix so the `if command -v nix` guard in aliases_nix.sh fires + nix() { return 0; } + export -f nix + + # shellcheck source=../../.assets/config/bash_cfg/aliases_nix.sh + source "$ALIASES_NIX" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- +_write_legacy_bashrc() { + cat >"$HOME/.bashrc" <<'RC' +# existing user content +alias ll='ls -la' + +# Nix +export PATH="$HOME/.nix-profile/bin:$PATH" + +# nix aliases +. "$HOME/.config/bash/aliases_nix.sh" + +# git aliases +. "$HOME/.config/bash/aliases_git.sh" + +# fzf integration +[ -x "$HOME/.nix-profile/bin/fzf" ] && eval "$(fzf --bash)" + +# NODE_EXTRA_CA_CERTS handled elsewhere +RC +} + +_write_clean_bashrc_with_block() { + local marker="nix-env managed" + cat >"$HOME/.bashrc" <>> $marker >>> +export PATH="\$HOME/.nix-profile/bin:\$PATH" +. "\$HOME/.config/bash/aliases_nix.sh" +# <<< $marker <<< + +# user content below +alias gs='git status' +RC +} + +# --------------------------------------------------------------------------- +# nx profile doctor +# --------------------------------------------------------------------------- + +@test "profile doctor warns when no managed block" { + printf '# just some content\n' >"$HOME/.bashrc" + run nx profile doctor + [ "$status" -ne 0 ] + [[ "$output" =~ "no managed block" ]] +} + +@test "profile doctor passes when managed block present" { + _write_clean_bashrc_with_block + run nx profile doctor + [ "$status" -eq 0 ] + [[ "$output" =~ "healthy" ]] +} + +@test "profile doctor detects legacy marker outside managed block" { + _write_clean_bashrc_with_block + printf '\n# nix aliases\n. "$HOME/.config/bash/aliases_nix.sh"\n' >>"$HOME/.bashrc" + run nx profile doctor + [ "$status" -ne 0 ] + [[ "$output" =~ "legacy" ]] +} + +@test "profile doctor ignores legacy marker inside managed block" { + # The managed block itself contains 'aliases_nix' - doctor should not flag it + local marker="nix-env managed" + cat >"$HOME/.bashrc" <>> $marker >>> +. "\$HOME/.config/bash/aliases_nix.sh" +# <<< $marker <<< +RC + run nx profile doctor + [ "$status" -eq 0 ] +} + +@test "profile doctor fails on duplicate managed blocks" { + local marker="nix-env managed" + cat >"$HOME/.bashrc" <>> $marker >>> +export A=1 +# <<< $marker <<< +# >>> $marker >>> +export A=1 +# <<< $marker <<< +RC + run nx profile doctor + [ "$status" -ne 0 ] + [[ "$output" =~ "duplicate" ]] +} + +# --------------------------------------------------------------------------- +# nx profile migrate +# --------------------------------------------------------------------------- + +@test "profile migrate --dry-run reports legacy markers without modifying rc" { + _write_legacy_bashrc + local before + before="$(cat "$HOME/.bashrc")" + run nx profile migrate --dry-run + [ "$status" -eq 0 ] + [[ "$output" =~ "dry-run" ]] + [ "$(cat "$HOME/.bashrc")" = "$before" ] +} + +@test "profile migrate reports no legacy markers on clean rc" { + _write_clean_bashrc_with_block + run nx profile migrate + [ "$status" -eq 0 ] + [[ "$output" =~ "Nothing to migrate" ]] +} + +@test "profile migrate removes known legacy markers" { + _write_legacy_bashrc + run nx profile migrate + [ "$status" -eq 0 ] + # legacy markers should be gone from rc + run grep -c 'aliases_nix' "$HOME/.bashrc" + [ "$output" -eq 0 ] + run grep -c 'NODE_EXTRA_CA_CERTS' "$HOME/.bashrc" + [ "$output" -eq 0 ] +} + +@test "profile migrate preserves user content outside legacy lines" { + _write_legacy_bashrc + nx profile migrate + grep -q "alias ll='ls -la'" "$HOME/.bashrc" +} + +@test "profile migrate creates a backup file" { + _write_legacy_bashrc + nx profile migrate + local backups + backups="$(find "$HOME" -name '.bashrc.nixenv-backup-*' 2>/dev/null | wc -l)" + [ "$backups" -ge 1 ] +} + +# --------------------------------------------------------------------------- +# nx profile uninstall +# --------------------------------------------------------------------------- + +@test "profile uninstall removes managed block from bashrc" { + _write_clean_bashrc_with_block + run nx profile uninstall + [ "$status" -eq 0 ] + run grep -cF "# >>> nix-env managed >>>" "$HOME/.bashrc" + [ "$output" -eq 0 ] +} + +@test "profile uninstall preserves content outside the block" { + _write_clean_bashrc_with_block + nx profile uninstall + grep -q "alias ll='ls -la'" "$HOME/.bashrc" + grep -q "alias gs='git status'" "$HOME/.bashrc" +} + +@test "profile uninstall is a no-op on rc without managed block" { + printf 'just user content\n' >"$HOME/.bashrc" + run nx profile uninstall + [ "$status" -eq 0 ] + grep -q "just user content" "$HOME/.bashrc" +} + +@test "profile doctor fails after uninstall" { + _write_clean_bashrc_with_block + nx profile uninstall + run nx profile doctor + [ "$status" -ne 0 ] +} diff --git a/tests/bats/test_scopes.bats b/tests/bats/test_scopes.bats new file mode 100755 index 00000000..0ac96265 --- /dev/null +++ b/tests/bats/test_scopes.bats @@ -0,0 +1,161 @@ +#!/usr/bin/env bats +# Unit tests for .assets/lib/scopes.sh (bash 3.2 compatible helpers) +# shellcheck disable=SC2034,SC2154 # variables like omp_theme, sorted_scopes used by sourced lib +bats_require_minimum_version 1.5.0 + +setup() { + # shellcheck source=../../.assets/lib/scopes.sh + source "$BATS_TEST_DIRNAME/../../.assets/lib/scopes.sh" + _scope_set=" " +} + +# -- scope_add / scope_has / scope_del ---------------------------------------- + +@test "scope_has returns false on empty set" { + run ! scope_has "shell" +} + +@test "scope_add makes scope_has return true" { + scope_add "shell" + scope_has "shell" +} + +@test "scope_has returns false for absent scope" { + scope_add "shell" + run ! scope_has "python" +} + +@test "scope_add is idempotent - no duplicates" { + scope_add "shell" + scope_add "python" + scope_add "shell" + [[ "$_scope_set" == " shell python " ]] +} + +@test "scope_del removes a scope" { + scope_add "shell" + scope_add "python" + scope_del "shell" + run ! scope_has "shell" + scope_has "python" +} + +@test "scope_del on absent scope is a no-op" { + scope_add "python" + scope_del "nonexistent" + scope_has "python" +} + +@test "scope_has does not match partial names" { + scope_add "shell" + run ! scope_has "shel" + run ! scope_has "hell" + run ! scope_has "shells" +} + +# -- VALID_SCOPES / INSTALL_ORDER loaded from JSON ---------------------------- + +@test "VALID_SCOPES is non-empty" { + [[ ${#VALID_SCOPES[@]} -gt 0 ]] +} + +@test "INSTALL_ORDER is non-empty" { + [[ ${#INSTALL_ORDER[@]} -gt 0 ]] +} + +@test "shell is a valid scope" { + local found=false + for v in "${VALID_SCOPES[@]}"; do + [[ "$v" == "shell" ]] && found=true + done + [[ "$found" == "true" ]] +} + +# -- validate_scopes ---------------------------------------------------------- + +@test "validate_scopes accepts valid scopes" { + validate_scopes "shell" "python" +} + +@test "validate_scopes rejects unknown scope" { + run ! validate_scopes "nonexistent" +} + +# -- resolve_scope_deps ------------------------------------------------------- + +@test "pwsh pulls in shell" { + scope_add "pwsh" + resolve_scope_deps + scope_has "shell" +} + +@test "k8s_ext pulls in k8s_base, k8s_dev, and docker" { + scope_add "k8s_ext" + resolve_scope_deps + scope_has "k8s_base" + scope_has "k8s_dev" + scope_has "docker" +} + +@test "az pulls in python" { + scope_add "az" + resolve_scope_deps + scope_has "python" +} + +@test "omp_theme variable triggers oh_my_posh scope" { + scope_add "shell" + omp_theme="base" + resolve_scope_deps + scope_has "oh_my_posh" +} + +@test "oh_my_posh pulls in shell" { + scope_add "oh_my_posh" + resolve_scope_deps + scope_has "shell" +} + +@test "starship pulls in shell" { + scope_add "starship" + resolve_scope_deps + scope_has "shell" +} + +@test "zsh pulls in shell" { + scope_add "zsh" + resolve_scope_deps + scope_has "shell" +} + +# -- sort_scopes -------------------------------------------------------------- + +@test "sort_scopes respects install_order" { + scope_add "rice" + scope_add "shell" + scope_add "python" + scope_add "docker" + sort_scopes + [[ "${sorted_scopes[*]}" == "docker python shell rice" ]] +} + +@test "sort_scopes with empty set gives empty array" { + sort_scopes + [[ ${#sorted_scopes[@]} -eq 0 ]] +} + +@test "sort_scopes omits scopes not in set" { + scope_add "python" + sort_scopes + [[ "${sorted_scopes[*]}" == "python" ]] +} + +# -- hyphen normalization (nix/setup.sh pattern) ------------------------------ + +@test "global hyphen-to-underscore normalization works" { + _scope_set=" k8s-base k8s-ext " + _scope_set="${_scope_set//-/_}" + scope_has "k8s_base" + scope_has "k8s_ext" + run ! scope_has "k8s-base" +} diff --git a/tests/bats/test_source.bats b/tests/bats/test_source.bats new file mode 100755 index 00000000..da3b7c7e --- /dev/null +++ b/tests/bats/test_source.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# Unit tests for .assets/provision/source.sh +# shellcheck disable=SC2034,SC2154 +bats_require_minimum_version 1.5.0 + +setup() { + # shellcheck source=../../.assets/provision/source.sh + source "$BATS_TEST_DIRNAME/../../.assets/provision/source.sh" +} + +# ============================================================================= +# find_file +# ============================================================================= + +setup_file() { + export FIND_FILE_DIR="$(mktemp -d)" + mkdir -p "$FIND_FILE_DIR/a/b/c" + touch "$FIND_FILE_DIR/top.txt" + touch "$FIND_FILE_DIR/a/mid.txt" + touch "$FIND_FILE_DIR/a/b/c/deep.txt" +} + +teardown_file() { + rm -rf "$FIND_FILE_DIR" +} + +@test "find_file finds file in top-level directory" { + run find_file "$FIND_FILE_DIR" "top.txt" + [[ "$status" -eq 0 ]] + [[ "$output" == "$FIND_FILE_DIR/top.txt" ]] +} + +@test "find_file finds file in nested directory" { + run find_file "$FIND_FILE_DIR" "mid.txt" + [[ "$status" -eq 0 ]] + [[ "$output" == "$FIND_FILE_DIR/a/mid.txt" ]] +} + +@test "find_file finds file in deeply nested directory" { + run find_file "$FIND_FILE_DIR" "deep.txt" + [[ "$status" -eq 0 ]] + [[ "$output" == "$FIND_FILE_DIR/a/b/c/deep.txt" ]] +} + +@test "find_file returns 1 when file not found" { + run ! find_file "$FIND_FILE_DIR" "nonexistent.txt" +} + +@test "find_file returns 1 for empty directory" { + local empty_dir + empty_dir="$(mktemp -d)" + run ! find_file "$empty_dir" "anything.txt" + rmdir "$empty_dir" +} + +# ============================================================================= +# download_file - parameter validation (no network) +# ============================================================================= + +@test "download_file fails when uri is missing" { + run ! download_file --target_dir /tmp + [[ "$output" == *"uri"*"required"* ]] +} + +@test "download_file fails when curl is not available" { + # shadow curl with a function that doesn't exist + type() { + [[ "$1" != "curl" ]] && command type "$@" + return 1 + } + run ! download_file --uri "https://example.com/file.tar.gz" + [[ "$output" == *"curl"*"required"* ]] +} + +# ============================================================================= +# get_gh_release_latest - parameter validation (no network) +# ============================================================================= + +@test "get_gh_release_latest fails when owner is missing" { + run ! get_gh_release_latest --repo "somerepo" + [[ "$output" == *"owner"*"repo"*"required"* ]] +} + +@test "get_gh_release_latest fails when repo is missing" { + run ! get_gh_release_latest --owner "someowner" + [[ "$output" == *"owner"*"repo"*"required"* ]] +} + +# ============================================================================= +# semver extraction from tag_name (testing the sed pattern directly) +# ============================================================================= + +@test "semver extraction: v1.2.3 -> 1.2.3" { + result="$(echo "v1.2.3" | sed -E 's/[^0-9]*([0-9]+\.[0-9]+\.[0-9]+)/\1/')" + [[ "$result" == "1.2.3" ]] +} + +@test "semver extraction: release-10.20.30 -> 10.20.30" { + result="$(echo "release-10.20.30" | sed -E 's/[^0-9]*([0-9]+\.[0-9]+\.[0-9]+)/\1/')" + [[ "$result" == "10.20.30" ]] +} + +@test "semver extraction: 1.2.3 (no prefix) -> 1.2.3" { + result="$(echo "1.2.3" | sed -E 's/[^0-9]*([0-9]+\.[0-9]+\.[0-9]+)/\1/')" + [[ "$result" == "1.2.3" ]] +} + +@test "semver extraction: chart-1.0.0-beta keeps suffix" { + result="$(echo "chart-1.0.0-beta" | sed -E 's/[^0-9]*([0-9]+\.[0-9]+\.[0-9]+)/\1/')" + [[ "$result" == "1.0.0-beta" ]] +} + +# ============================================================================= +# install_github_release_user - VERSION placeholder (no network) +# ============================================================================= + +@test "VERSION placeholder replacement works" { + local file_name="tool-{VERSION}-linux-amd64.tar.gz" + local latest_release="1.5.2" + local file="${file_name//\{VERSION\}/$latest_release}" + [[ "$file" == "tool-1.5.2-linux-amd64.tar.gz" ]] +} + +@test "VERSION placeholder: no placeholder passes through unchanged" { + local file_name="tool-linux-amd64.tar.gz" + local latest_release="1.5.2" + local file="${file_name//\{VERSION\}/$latest_release}" + [[ "$file" == "tool-linux-amd64.tar.gz" ]] +} + +@test "VERSION placeholder: multiple placeholders replaced" { + local file_name="{VERSION}/tool-{VERSION}.tar.gz" + local latest_release="2.0.0" + local file="${file_name//\{VERSION\}/$latest_release}" + [[ "$file" == "2.0.0/tool-2.0.0.tar.gz" ]] +} + +@test "install_github_release_user fails when required params missing" { + run ! install_github_release_user --gh_owner "owner" + [[ "$output" == *"Missing required parameters"* ]] +} + +@test "install_github_release_user fails on unknown parameter" { + run ! install_github_release_user --unknown_param "value" + [[ "$output" == *"Unknown parameter"* ]] +} diff --git a/tests/hooks/align_tables.py b/tests/hooks/align_tables.py new file mode 100755 index 00000000..63a9b539 --- /dev/null +++ b/tests/hooks/align_tables.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Auto-align markdown table columns. + +Ensures all pipe characters in each table are at the same +column position across all rows (MD060 compliance). + +Usage: + python3 -m tests.hooks.align_tables docs/*.md +""" + +import sys +import unicodedata + + +def _display_width(text: str) -> int: + """Return the monospace display width of *text*. + + Wide characters (most emoji, CJK) count as 2 columns. + Zero-width characters (combining marks, variation selectors, ZWJ) count as 0. + A base character followed by VS16 (U+FE0F) is forced to width 2 (emoji presentation). + """ + width = 0 + chars = list(text) + for i, ch in enumerate(chars): + cat = unicodedata.category(ch) + # zero-width: combining marks (Mn/Mc/Me), format chars (Cf) like ZWJ/VS16 + if cat.startswith("M") or cat == "Cf": + continue + # check if next char is VS16 (emoji presentation selector) + has_vs16 = i + 1 < len(chars) and chars[i + 1] == "\ufe0f" + eaw = unicodedata.east_asian_width(ch) + if eaw in ("W", "F") or has_vs16: + width += 2 + else: + width += 1 + return width + + +def _pad(text: str, target_width: int) -> str: + """Pad *text* with spaces so its display width equals *target_width*.""" + return text + " " * (target_width - _display_width(text)) + + +def align_table(lines): + """Align all pipes in a markdown table.""" + rows = [] + for line in lines: + cells = [c.strip() for c in line.strip().strip("|").split("|")] + rows.append(cells) + + if len(rows) < 2: + return lines + + num_cols = len(rows[0]) + + # Find max display width per column (skip separator row) + widths = [0] * num_cols + for i, row in enumerate(rows): + if i == 1: + continue + for j, cell in enumerate(row): + if j < num_cols: + widths[j] = max(widths[j], _display_width(cell)) + + # Rebuild rows with aligned pipes + result = [] + for i, row in enumerate(rows): + if i == 1: + parts = ["| " + "-" * widths[j] + " " for j in range(num_cols)] + else: + parts = [ + "| " + _pad(row[j] if j < len(row) else "", widths[j]) + " " + for j in range(num_cols) + ] + result.append("".join(parts) + "|") + return result + + +def process_file(path): + """Process a single markdown file. Return True if changes were made.""" + with open(path) as f: + original = f.read() + + lines = original.splitlines() + result = [] + table_buf = [] + in_table = False + in_code_block = False + + for line in lines: + stripped = line.strip() + if stripped.startswith("```") or stripped.startswith("~~~"): + in_code_block = not in_code_block + is_table = ( + not in_code_block and stripped.startswith("|") and "|" in stripped[1:] + ) + if is_table: + table_buf.append(line) + in_table = True + else: + if in_table: + result.extend(align_table(table_buf)) + table_buf = [] + in_table = False + result.append(line) + + if table_buf: + result.extend(align_table(table_buf)) + + new_content = "\n".join(result) + "\n" + if new_content != original: + with open(path, "w") as f: + f.write(new_content) + return True + return False + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [file2.md ...]") + sys.exit(1) + + for path in sys.argv[1:]: + if process_file(path): + print(f"Aligned: {path}") + else: + print(f"OK: {path}") diff --git a/tests/hooks/check_bash32.py b/tests/hooks/check_bash32.py new file mode 100644 index 00000000..0501e456 --- /dev/null +++ b/tests/hooks/check_bash32.py @@ -0,0 +1,203 @@ +""" +Check nix-path shell scripts for bash 3.2 / BSD sed compatibility violations. + +Enforces the constraints documented in ARCHITECTURE.md: nix-path .sh files +must not use bash 4+ features or GNU-only sed/grep syntax, because they +run on macOS where bash 3.2 and BSD tools are the system default. + +The set of constrained files is defined by ARCHITECTURE.md "nix-path" table. + +# :example +python3 -m tests.hooks.check_bash32 nix/setup.sh +python3 -m tests.hooks.check_bash32 +""" + +import re +import sys +from pathlib import Path +from typing import NamedTuple + +# --------------------------------------------------------------------------- +# Checked file patterns (nix-path files + bats tests that run on macOS CI) +# --------------------------------------------------------------------------- +NIX_PATH_PATTERNS: tuple[str, ...] = ( + "nix/setup.sh", + "nix/configure/*.sh", + ".assets/lib/scopes.sh", + ".assets/lib/profile_block.sh", + ".assets/config/bash_cfg/aliases_nix.sh", + ".assets/config/bash_cfg/aliases_git.sh", + ".assets/config/bash_cfg/aliases_kubectl.sh", + ".assets/config/bash_cfg/functions.sh", + ".assets/setup/setup_common.sh", + ".assets/provision/install_copilot.sh", + "tests/bats/*.bats", +) + +# --------------------------------------------------------------------------- +# Rules: each is (compiled regex, human description) +# --------------------------------------------------------------------------- + + +class Rule(NamedTuple): + pattern: re.Pattern[str] + description: str + + +# Lines starting with # are comments - we skip them during checking. +# We also skip lines inside : '...' heredoc-style comment blocks. + +RULES: tuple[Rule, ...] = ( + # -- bash 4+ builtins / syntax ------------------------------------------ + Rule( + re.compile(r"\bmapfile\b"), + "mapfile is bash 4+ - use while IFS= read -r loop", + ), + Rule( + re.compile(r"\breadarray\b"), + "readarray is bash 4+ - use while IFS= read -r loop", + ), + Rule( + re.compile(r"\bdeclare\s+-A\b"), + "declare -A (associative array) is bash 4+ - use space-delimited strings", + ), + Rule( + re.compile(r"\bdeclare\s+-n\b"), + "declare -n (nameref) is bash 4+ - pass variable name as string", + ), + Rule( + re.compile(r"\$\{[a-zA-Z_][a-zA-Z0-9_]*,,\}"), + "${var,,} (lowercase) is bash 4+ - use tr '[:upper:]' '[:lower:]'", + ), + Rule( + re.compile(r"\$\{[a-zA-Z_][a-zA-Z0-9_]*\^\^\}"), + "${var^^} (uppercase) is bash 4+ - use tr '[:lower:]' '[:upper:]'", + ), + Rule( + re.compile(r"\$\{[a-zA-Z_][a-zA-Z0-9_]*\[-(0*[1-9][0-9]*)\]\}"), + "negative array index is bash 4.3+ - use ${arr[$((${#arr[@]}-N))]}", + ), + # -- GNU sed / grep extensions ------------------------------------------ + Rule( + re.compile(r"\bsed\b.*\\s"), + r"sed \s is a GNU extension - use [[:space:]]", + ), + Rule( + re.compile(r"\bsed\s+(-[^E ]*\s+)?-i\s+''"), + "sed -i '' is not portable - write to temp file + mv", + ), + Rule( + re.compile(r"\bsed\s+.*-r\b"), + "sed -r is GNU only - use sed -E", + ), + Rule( + re.compile(r"\bgrep\s+.*-P\b"), + "grep -P (PCRE) is GNU only - use grep -E or sed", + ), + Rule( + re.compile(r"\bgrep\b.*\\S"), + r"grep \S is a PCRE/GNU extension - use [^[:space:]]", + ), + Rule( + re.compile(r"\bgrep\b.*\\w"), + r"grep \w is a PCRE/GNU extension - use [a-zA-Z0-9_]", + ), + Rule( + re.compile(r"\bgrep\b.*\\d"), + r"grep \d is a PCRE/GNU extension - use [0-9]", + ), + # -- BSD sed grouping --------------------------------------------------- + Rule( + re.compile(r"\bsed\b.*\{.*[;/].*\}"), + "sed { cmd } on one line fails on BSD sed - put commands on separate lines", + ), +) + + +def _resolve_nix_path_files(repo_root: Path) -> set[Path]: + """Resolve glob patterns to actual files under repo_root.""" + files: set[Path] = set() + for pattern in NIX_PATH_PATTERNS: + for match in repo_root.glob(pattern): + if match.is_file(): + files.add(match) + return files + + +def _is_nix_path_file(filepath: Path, nix_files: set[Path]) -> bool: + """Check if a resolved filepath is in the nix-path set.""" + return filepath.resolve() in nix_files + + +def check_file(filepath: Path) -> list[str]: + """Check a single file for bash 3.2 / BSD compatibility violations.""" + problems: list[str] = [] + try: + lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return problems + + in_colon_heredoc = False + for lineno, line in enumerate(lines, start=1): + stripped = line.lstrip() + + # track : '...' comment blocks (used for runnable examples) + if not in_colon_heredoc and stripped.startswith(": '"): + in_colon_heredoc = True + continue + if in_colon_heredoc: + if stripped.rstrip() == "'": + in_colon_heredoc = False + continue + + # skip comments + if stripped.startswith("#"): + continue + + for rule in RULES: + if rule.pattern.search(line): + rel = filepath.as_posix() + problems.append(f"{rel}:{lineno}: {rule.description}") + return problems + + +def main(argv: list[str]) -> int: + repo_root = Path(__file__).resolve().parents[2] + nix_files = _resolve_nix_path_files(repo_root) + + if not nix_files: + return 0 + + # if filenames passed, filter to nix-path only; otherwise check all + if argv: + targets = [Path(f).resolve() for f in argv if Path(f).resolve() in nix_files] + else: + targets = sorted(nix_files) + + if not targets: + return 0 + + problems: list[str] = [] + for filepath in targets: + # make paths relative for readable output + try: + rel = filepath.relative_to(repo_root) + except ValueError: + rel = filepath + problems.extend(check_file(rel if rel.exists() else filepath)) + + if problems: + print("Bash 3.2 / BSD compatibility violations:", file=sys.stderr) + for p in problems: + print(f" {p}", file=sys.stderr) + print( + f"\n{len(problems)} violation(s) found. " + "See ARCHITECTURE.md 'Bash 3.2 / BSD sed constraints' for details.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/hooks/gremlins.py b/tests/hooks/gremlins.py index 19682042..8940a0dc 100644 --- a/tests/hooks/gremlins.py +++ b/tests/hooks/gremlins.py @@ -1,5 +1,12 @@ """ -Scan staged text files for unwanted Unicode characters and fail if found. +Scan staged text files for unwanted Unicode characters. + +Auto-fixes characters with obvious ASCII replacements (dashes, smart quotes, +fancy spaces, etc.) and reports any remaining unfixable gremlins. + +When auto-fixes are applied the hook prints what changed and exits 0; +pre-commit detects the modified file and blocks the commit so the user +can review, re-stage, and commit again. # :example python3 -m tests.hooks.gremlins wsl/wsl_setup.ps1 @@ -9,65 +16,48 @@ import sys import unicodedata from collections.abc import Iterable -from typing import Tuple - -FORBIDDEN_CHARS: Tuple[str, ...] = ( - # Zero-width / joiner - "\u200b", # U+200B ZERO WIDTH SPACE - "\u200c", # U+200C ZERO WIDTH NON-JOINER - "\u200d", # U+200D ZERO WIDTH JOINER - # Spaces / breaks - "\u00a0", # U+00A0 NO-BREAK SPACE - "\u202f", # U+202F NARROW NO-BREAK SPACE - "\u2009", # U+2009 THIN SPACE - "\u200a", # U+200A HAIR SPACE - # Control / formatting - "\u000c", # U+000C FORM FEED - "\u00ad", # U+00AD SOFT HYPHEN - # Dashes / hyphens - "\u2013", # U+2013 EN DASH - "\u2014", # U+2014 EM DASH - "\u2010", # U+2010 HYPHEN - # Quotes / punctuation that look like ASCII - "\u2018", # U+2018 LEFT SINGLE QUOTATION MARK - "\u2019", # U+2019 RIGHT SINGLE QUOTATION MARK - "\u201c", # U+201C LEFT DOUBLE QUOTATION MARK - "\u201d", # U+201D RIGHT DOUBLE QUOTATION MARK - "\u2026", # U+2026 HORIZONTAL ELLIPSIS - # Misc common problematic characters - "\u00b7", # U+00B7 MIDDLE DOT + +# Characters with a clear ASCII replacement -- auto-fixed in place. +AUTO_FIX: dict[str, str] = { + # Dashes / hyphens -> ASCII hyphen-minus + "\u2010": "-", # HYPHEN + "\u2013": "-", # EN DASH + "\u2014": "-", # EM DASH + # Fancy spaces -> regular space + "\u00a0": " ", # NO-BREAK SPACE + "\u202f": " ", # NARROW NO-BREAK SPACE + "\u2009": " ", # THIN SPACE + "\u200a": " ", # HAIR SPACE + # Smart quotes -> ASCII quotes + "\u2018": "'", # LEFT SINGLE QUOTATION MARK + "\u2019": "'", # RIGHT SINGLE QUOTATION MARK + "\u201c": '"', # LEFT DOUBLE QUOTATION MARK + "\u201d": '"', # RIGHT DOUBLE QUOTATION MARK + # Ellipsis -> three dots + "\u2026": "...", # HORIZONTAL ELLIPSIS + # Invisible / zero-width -> remove + "\u200b": "", # ZERO WIDTH SPACE + "\u200c": "", # ZERO WIDTH NON-JOINER + "\u200d": "", # ZERO WIDTH JOINER + "\u00ad": "", # SOFT HYPHEN + "\u000c": "", # FORM FEED +} + +# Characters that cannot be auto-fixed -- always reported for manual review. +REPORT_ONLY: tuple[str, ...] = ( + "\u00b7", # MIDDLE DOT ) +ALL_FORBIDDEN = set(AUTO_FIX) | set(REPORT_ONLY) -def find_forbidden_in_text(content: str, filename: str) -> list[str]: - """ - Scan the given text for forbidden Unicode characters and report their locations. - - Parameters - ---------- - content : str - The text content to scan for forbidden characters. - filename : str - The name of the file being scanned, used in the report output. - - Returns - ------- - list[str] - A list of human-readable report strings. Each string is formatted as: - "filename:lineno: contains ()" - For example: "example.py:10: contains ZERO WIDTH SPACE (U+200B)" - """ - reports: list[str] = [] - for lineno, line in enumerate(content.splitlines(), start=1): - for ch in FORBIDDEN_CHARS: - if ch in line: - code = f"U+{ord(ch):04X}" - try: - name = unicodedata.name(ch) - except ValueError: - name = "" - reports.append(f"{filename}:{lineno}: contains {name} ({code})") - return reports + +def _char_label(ch: str) -> str: + code = f"U+{ord(ch):04X}" + try: + name = unicodedata.name(ch) + except ValueError: + name = "" + return f"{name} ({code})" def is_text_file(path: str) -> bool: @@ -75,41 +65,70 @@ def is_text_file(path: str) -> bool: try: with open(path, "rb") as fh: chunk = fh.read(4096) - # if NUL bytes present it's likely binary return b"\x00" not in chunk except OSError: return False -def check_gremlins(argv: Iterable[str]) -> int: +def fix_and_report(path: str) -> tuple[list[str], list[str]]: + """Auto-fix what we can, report what we cannot. + + Returns (fixed_reports, unfixable_reports). """ - Check a list of text files for forbidden Unicode characters. + try: + with open(path, encoding="utf-8", errors="replace") as fh: + content = fh.read() + except OSError: + return [], [] + + fixed_chars: set[str] = set() + new_content = content + for ch, replacement in AUTO_FIX.items(): + if ch in new_content: + fixed_chars.add(ch) + new_content = new_content.replace(ch, replacement) + + if fixed_chars: + with open(path, "w", encoding="utf-8") as fh: + fh.write(new_content) + + fixed_reports = [] + if fixed_chars: + labels = ", ".join(sorted(_char_label(ch) for ch in fixed_chars)) + fixed_reports.append(f"{path}: fixed {labels}") + + unfixable_reports = [] + for lineno, line in enumerate(new_content.splitlines(), start=1): + for ch in REPORT_ONLY: + if ch in line: + unfixable_reports.append(f"{path}:{lineno}: contains {_char_label(ch)}") - Args: - argv (Iterable[str]): An iterable of file path strings to check. + return fixed_reports, unfixable_reports - Returns: - int: Returns 0 if no forbidden characters are found in any file, - or 1 if at least one forbidden character is found. - """ + +def check_gremlins(argv: Iterable[str]) -> int: files = list(argv) - problems: list[str] = [] + all_fixed: list[str] = [] + all_unfixable: list[str] = [] + for path in files: if not os.path.exists(path) or not is_text_file(path): continue - try: - with open(path, "r", encoding="utf-8", errors="replace") as fh: - content = fh.read() - except OSError: - continue - problems.extend(find_forbidden_in_text(content, path)) - - if problems: - print("Gremlin characters found:", file=sys.stderr) - for p in problems: - print(p, file=sys.stderr) - print("Remove or replace gremlin characters", file=sys.stderr) + fixed, unfixable = fix_and_report(path) + all_fixed.extend(fixed) + all_unfixable.extend(unfixable) + + if all_fixed: + print("Gremlin characters auto-fixed:", file=sys.stderr) + for r in all_fixed: + print(f" {r}", file=sys.stderr) + + if all_unfixable: + print("Gremlin characters found (manual fix required):", file=sys.stderr) + for r in all_unfixable: + print(f" {r}", file=sys.stderr) return 1 + return 0 diff --git a/tests/hooks/run_bats.py b/tests/hooks/run_bats.py new file mode 100644 index 00000000..2bb5c144 --- /dev/null +++ b/tests/hooks/run_bats.py @@ -0,0 +1,96 @@ +""" +Run bats tests for changed files. + +Scans tests/bats/*.bats for `source` directives to build a mapping of which +source files are covered by which test files. When any covered source file +(or a .bats file itself) is staged, runs the relevant tests. + +# :example +python3 -m tests.hooks.run_bats +# :run with explicit file list (as pre-commit passes them) +python3 -m tests.hooks.run_bats .assets/lib/scopes.sh tests/bats/test_scopes.bats +""" + +import re +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +BATS_DIR = REPO_ROOT / "tests" / "bats" + + +def build_source_map() -> dict[str, list[Path]]: + """Parse .bats files and return {relative_source_path: [bats_files]}.""" + source_to_tests: dict[str, list[Path]] = {} + + if not BATS_DIR.is_dir(): + return source_to_tests + + # match: source "path" or source 'path' with optional $BATS_TEST_DIRNAME/ prefix + source_re = re.compile( + r'^\s*source\s+["\']' + r"(?:\$BATS_TEST_DIRNAME/)?" + r'(.+?)["\']', + ) + + for bats_file in sorted(BATS_DIR.glob("*.bats")): + bats_rel = bats_file.relative_to(REPO_ROOT).as_posix() + + # the .bats file itself is always in scope + source_to_tests.setdefault(bats_rel, []).append(bats_file) + + for line in bats_file.read_text().splitlines(): + m = source_re.match(line) + if not m: + continue + + raw_path = m.group(1) + # resolve relative to the bats file directory + resolved = (BATS_DIR / raw_path).resolve() + try: + rel = resolved.relative_to(REPO_ROOT).as_posix() + except ValueError: + continue + + source_to_tests.setdefault(rel, []).append(bats_file) + + # also watch the sibling .json if the source is scopes.sh + if rel.endswith("scopes.sh"): + json_rel = rel.replace("scopes.sh", "scopes.json") + source_to_tests.setdefault(json_rel, []).append(bats_file) + + return source_to_tests + + +def main(argv: list[str] | None = None) -> int: + if not shutil.which("bats"): + print("bats not found, skipping tests", file=sys.stderr) + return 0 + + # files passed by pre-commit (or CLI) + changed_files = set(argv or []) + + source_map = build_source_map() + if not source_map: + return 0 + + # collect bats files to run + to_run: set[Path] = set() + for changed in changed_files: + # normalize to posix-style relative path + normalized = Path(changed).as_posix() + if normalized in source_map: + to_run.update(source_map[normalized]) + + if not to_run: + return 0 + + cmd = ["bats"] + sorted(str(f) for f in to_run) + result = subprocess.run(cmd) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/hooks/run_pester.py b/tests/hooks/run_pester.py new file mode 100755 index 00000000..d233101b --- /dev/null +++ b/tests/hooks/run_pester.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Run Pester tests for changed PowerShell files. + +Scans tests/pester/*.Tests.ps1 for dot-source directives to build a mapping +of which source files are covered by which test files. When any covered source +file (or a .Tests.ps1 file itself) is staged, runs the relevant tests. + +# :example +python3 -m tests.hooks.run_pester +# :run with explicit file list (as pre-commit passes them) +python3 -m tests.hooks.run_pester modules/SetupUtils/Functions/common.ps1 +""" + +import re +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +PESTER_DIR = REPO_ROOT / "tests" / "pester" + + +def build_source_map() -> dict[str, list[Path]]: + """Parse .Tests.ps1 files and return {relative_source_path: [test_files]}.""" + source_to_tests: dict[str, list[Path]] = {} + + if not PESTER_DIR.is_dir(): + return source_to_tests + + # match: . $PSScriptRoot/../../path/to/file.ps1 + # or: . "$PSScriptRoot/../../path/to/file.ps1" + source_re = re.compile( + r"^\s*\.\s+" + r'["\']?\$PSScriptRoot/' + r"(.+?\.ps1)" + r'["\']?\s*$', + ) + + for test_file in sorted(PESTER_DIR.glob("*.Tests.ps1")): + test_rel = test_file.relative_to(REPO_ROOT).as_posix() + + # the test file itself is always in scope + source_to_tests.setdefault(test_rel, []).append(test_file) + + for line in test_file.read_text().splitlines(): + m = source_re.match(line) + if not m: + continue + + raw_path = m.group(1) + # resolve relative to the pester test directory + resolved = (PESTER_DIR / raw_path).resolve() + try: + rel = resolved.relative_to(REPO_ROOT).as_posix() + except ValueError: + continue + + source_to_tests.setdefault(rel, []).append(test_file) + + # also watch scopes.json if source is scopes.ps1 + if rel.endswith("scopes.ps1"): + json_rel = rel.rsplit("/", 1)[0] + "/../../../.assets/lib/scopes.json" + json_resolved = (PESTER_DIR / json_rel).resolve() + try: + json_rel_norm = json_resolved.relative_to(REPO_ROOT).as_posix() + except ValueError: + continue + source_to_tests.setdefault(json_rel_norm, []).append(test_file) + + # also map _aliases_nix.ps1 to NxHelpers.Tests.ps1 + nx_test = PESTER_DIR / "NxHelpers.Tests.ps1" + if nx_test.exists(): + nx_source = ".assets/config/pwsh_cfg/_aliases_nix.ps1" + source_to_tests.setdefault(nx_source, []).append(nx_test) + + return source_to_tests + + +def main(argv: list[str] | None = None) -> int: + if not shutil.which("pwsh"): + print("pwsh not found, skipping Pester tests", file=sys.stderr) + return 0 + + # files passed by pre-commit (or CLI) + changed_files = set(argv or []) + + source_map = build_source_map() + if not source_map: + return 0 + + # collect test files to run + to_run: set[Path] = set() + for changed in changed_files: + normalized = Path(changed).as_posix() + if normalized in source_map: + to_run.update(source_map[normalized]) + + if not to_run: + return 0 + + # build Pester invocation with specific test files + test_paths = sorted(str(f) for f in to_run) + paths_arg = ", ".join(f"'{p}'" for p in test_paths) + # Use Configuration to enable exit-on-failure without XML report conflicts + pester_cmd = ( + "$cfg = New-PesterConfiguration; " + f"$cfg.Run.Path = @({paths_arg}); " + "$cfg.Run.Exit = $true; " + "$cfg.Output.Verbosity = 'Detailed'; " + "Invoke-Pester -Configuration $cfg" + ) + + result = subprocess.run(["pwsh", "-c", pester_cmd]) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/hooks/validate_docs_words.py b/tests/hooks/validate_docs_words.py new file mode 100755 index 00000000..427563e4 --- /dev/null +++ b/tests/hooks/validate_docs_words.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Validate docs words. + +Reads words from project-words.txt, checks if each word appears in the +checked documents (*.md in root and docs/), removes unused words, and +writes back a sorted, lowercase, deduplicated list. + +Usage: + python3 -m tests.hooks.validate_docs_words +""" + +import re +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + """Validate docs words against docs content.""" + root = Path(__file__).resolve().parent.parent.parent + print(f"Project root: {root}") + words_path = root / "project-words.txt" + + # read and normalize project words + raw_lines = words_path.read_text().splitlines() + words = sorted({line.strip().lower() for line in raw_lines if line.strip()}) + rel_path = words_path.relative_to(root) + print(f"Reading project words from ./{rel_path} ({len(words)} words)") + + # gather checked files (matching cspell scope) + files: list[Path] = [] + for g in ["*.md", "docs/**/*.md"]: + files.extend(root.glob(g)) + files = sorted(set(files)) + print(f"Gathering files... ({len(files)} files)") + + # read all content + filenames into one string + parts: list[str] = [] + for f in files: + parts.append(f.name) + parts.append(f.read_text()) + content = "\n".join(parts) + + # split camelCase/PascalCase and letter/digit boundaries (like cspell does) + content = re.sub(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", content) + content = re.sub(r"(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])", " ", content) + tokens = set(re.findall(r"[a-z]+", content.lower())) + + # keep only words that appear in the content + valid_words = sorted(w for w in words if w in tokens) + removed = set(words) - set(valid_words) + + # write back + words_path.write_text("\n".join(valid_words) + "\n") + + # report + if removed: + print(f"Removed {len(removed)} unused word(s): {', '.join(sorted(removed))}") + result = subprocess.run( + ["git", "status", "--porcelain", str(words_path)], + capture_output=True, + text=True, + cwd=root, + ) + if result.stdout.strip(): + rel = words_path.relative_to(root) + print(f"\033[33mThe file \033[4m./{rel}\033[24m has been updated.\033[0m") + else: + print("\033[32mAll project-words have been validated.\033[0m") + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/tests/hooks/validate_scopes.py b/tests/hooks/validate_scopes.py new file mode 100644 index 00000000..11ceba09 --- /dev/null +++ b/tests/hooks/validate_scopes.py @@ -0,0 +1,84 @@ +""" +Validate internal consistency of scope definitions. + +Checks: +- valid_scopes and install_order contain the same scopes (scopes.json) +- all dependency rule triggers and targets exist in valid_scopes +- every scope in valid_scopes has a matching .nix file +- every scope .nix file has a '# bins:' comment + +# :example +python3 -m tests.hooks.validate_scopes +""" + +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCOPES_JSON = REPO_ROOT / ".assets" / "lib" / "scopes.json" +SCOPES_DIR = REPO_ROOT / "nix" / "scopes" + +BINS_RE = re.compile(r"^# bins:\s+\S", re.MULTILINE) + + +def validate() -> int: + if not SCOPES_JSON.exists(): + print(f"ERROR: {SCOPES_JSON} not found", file=sys.stderr) + return 1 + + data = json.loads(SCOPES_JSON.read_text()) + errors: list[str] = [] + + valid = set(data["valid_scopes"]) + order = set(data["install_order"]) + + # valid_scopes and install_order must contain the same scopes + if valid != order: + only_valid = sorted(valid - order) + only_order = sorted(order - valid) + msg = "valid_scopes and install_order differ" + if only_valid: + msg += f"\n in valid_scopes only: {' '.join(only_valid)}" + if only_order: + msg += f"\n in install_order only: {' '.join(only_order)}" + errors.append(msg) + + # check for duplicates + if len(data["valid_scopes"]) != len(valid): + errors.append("valid_scopes contains duplicates") + if len(data["install_order"]) != len(order): + errors.append("install_order contains duplicates") + + # dependency rules: all triggers and targets must be valid scopes + for rule in data["dependency_rules"]: + trigger = rule["if"] + if trigger not in valid: + errors.append(f"dependency rule trigger '{trigger}' not in valid_scopes") + for target in rule["add"]: + if target not in valid: + errors.append( + f"dependency rule target '{target}' (from '{trigger}') not in valid_scopes" + ) + + # every scope must have a .nix file with a '# bins:' comment + for scope in sorted(valid): + nix_file = SCOPES_DIR / f"{scope}.nix" + if not nix_file.exists(): + errors.append(f"scope '{scope}' has no matching {nix_file.name}") + continue + content = nix_file.read_text() + if not BINS_RE.search(content): + errors.append(f"{nix_file.name} missing '# bins:' comment") + + if errors: + print("Scope definition errors:", file=sys.stderr) + for e in errors: + print(f" {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(validate()) diff --git a/tests/pester/ConvertCfg.Tests.ps1 b/tests/pester/ConvertCfg.Tests.ps1 new file mode 100644 index 00000000..1f3a3caf --- /dev/null +++ b/tests/pester/ConvertCfg.Tests.ps1 @@ -0,0 +1,177 @@ +#Requires -Modules Pester +# Unit tests for ConvertFrom-Cfg and ConvertTo-Cfg in SetupUtils module + +BeforeAll { + . $PSScriptRoot/../../modules/SetupUtils/Functions/common.ps1 +} + +Describe 'ConvertFrom-Cfg' { + It 'parses section with key-value pairs' { + $input = @( + '[section1]' + 'key1 = value1' + 'key2 = value2' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['key1'] | Should -Be 'value1' + $result['section1']['key2'] | Should -Be 'value2' + } + + It 'parses multiple sections' { + $input = @( + '[section1]' + 'key1 = value1' + '[section2]' + 'key2 = value2' + ) + $result = $input | ConvertFrom-Cfg + $result.Keys | Should -HaveCount 2 + $result['section1']['key1'] | Should -Be 'value1' + $result['section2']['key2'] | Should -Be 'value2' + } + + It 'preserves header comments in __header__ key' { + $input = @( + '# This is a header comment' + '# Another header line' + '[section1]' + 'key = value' + ) + $result = $input | ConvertFrom-Cfg + $result.Contains('__header__') | Should -BeTrue + $result['__header__'] | Should -BeLike '*header comment*' + } + + It 'preserves comments within sections' { + $input = @( + '[section1]' + '# inline comment' + 'key = value' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['Comment1'] | Should -Be '# inline comment' + $result['section1']['key'] | Should -Be 'value' + } + + It 'ignores non-comment lines before first section' { + $input = @( + 'stray line without section' + '[section1]' + 'key = value' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['key'] | Should -Be 'value' + $result.Contains('__header__') | Should -BeFalse + } + + It 'handles empty value' { + $input = @( + '[section1]' + 'key =' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['key'] | Should -Be '' + } + + It 'trims whitespace from values' { + $input = @( + '[section1]' + 'key = spaced value ' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['key'] | Should -Be 'spaced value' + } + + It 'handles semicolon comments' { + $input = @( + '[section1]' + '; semicolon comment' + 'key = value' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['Comment1'] | Should -Be '; semicolon comment' + } + + It 'resets comment counter per section' { + $input = @( + '[section1]' + '# comment in s1' + '[section2]' + '# comment in s2' + ) + $result = $input | ConvertFrom-Cfg + $result['section1']['Comment1'] | Should -Be '# comment in s1' + $result['section2']['Comment1'] | Should -Be '# comment in s2' + } + + It 'returns empty dict for empty input' { + $result = @() | ConvertFrom-Cfg + $result.Keys | Should -HaveCount 0 + } +} + +Describe 'ConvertTo-Cfg' { + It 'serializes ordered dictionary to cfg string' { + $dict = [ordered]@{ + section1 = [ordered]@{ + key1 = 'value1' + key2 = 'value2' + } + } + $result = $dict | ConvertTo-Cfg + $result | Should -Match '\[section1\]' + $result | Should -Match 'key1 = value1' + $result | Should -Match 'key2 = value2' + } + + It 'restores header comments' { + $dict = [ordered]@{ + '__header__' = '# header line' + section1 = [ordered]@{ key = 'value' } + } + $result = $dict | ConvertTo-Cfg + $result | Should -Match '# header line' + } + + It 'serializes comment keys back as comments' { + $dict = [ordered]@{ + section1 = [ordered]@{ + Comment1 = '# a comment' + key = 'value' + } + } + $result = $dict | ConvertTo-Cfg + $result | Should -Match '# a comment' + $result | Should -Not -Match 'Comment1' + } + + It 'handles LineFeed switch' { + $dict = [ordered]@{ + section1 = [ordered]@{ key = 'value' } + } + $result = $dict | ConvertTo-Cfg -LineFeed + $result | Should -Not -Match "`r`n" + } +} + +Describe 'ConvertFrom-Cfg / ConvertTo-Cfg roundtrip' { + It 'preserves content through roundtrip' { + $original = @( + '# file header' + '[core]' + 'autocrlf = true' + '# a comment' + 'editor = vim' + '[remote "origin"]' + 'url = https://github.com/foo/bar.git' + ) + $parsed = $original | ConvertFrom-Cfg + $serialized = $parsed | ConvertTo-Cfg -LineFeed + + # re-parse + $reparsed = $serialized.Split("`n") | ConvertFrom-Cfg + $reparsed['core']['autocrlf'] | Should -Be 'true' + $reparsed['core']['editor'] | Should -Be 'vim' + $reparsed['remote "origin"']['url'] | Should -Be 'https://github.com/foo/bar.git' + } +} diff --git a/tests/pester/ConvertPEM.Tests.ps1 b/tests/pester/ConvertPEM.Tests.ps1 new file mode 100644 index 00000000..5f535446 --- /dev/null +++ b/tests/pester/ConvertPEM.Tests.ps1 @@ -0,0 +1,119 @@ +#Requires -Modules Pester +# Unit tests for ConvertFrom-PEM and ConvertTo-PEM in SetupUtils module + +BeforeAll { + . $PSScriptRoot/../../modules/SetupUtils/Functions/certs.ps1 +} + +Describe 'ConvertFrom-PEM' { + BeforeAll { + # generate a self-signed test certificate + $cert = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=Pester Test Cert', + [System.Security.Cryptography.RSA]::Create(2048), + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ).CreateSelfSigned( + [DateTimeOffset]::UtcNow, + [DateTimeOffset]::UtcNow.AddDays(1) + ) + $base64 = [Convert]::ToBase64String($cert.RawData) + # build PEM string with 64-char line wrapping + $pemLines = @('-----BEGIN CERTIFICATE-----') + for ($i = 0; $i -lt $base64.Length; $i += 64) { + $length = [Math]::Min(64, $base64.Length - $i) + $pemLines += $base64.Substring($i, $length) + } + $pemLines += '-----END CERTIFICATE-----' + $Script:testPem = $pemLines -join "`n" + $Script:testCert = $cert + } + + It 'parses a single PEM certificate from string' { + $result = @($Script:testPem | ConvertFrom-PEM) + $result | Should -HaveCount 1 + $result[0].Subject | Should -BeLike '*Pester Test Cert*' + } + + It 'parses multiple PEM certificates from string' { + $multiPem = "$($Script:testPem)`n$($Script:testPem)" + # ConvertFrom-PEM deduplicates via HashSet, so two identical certs yield one + $result = @($multiPem | ConvertFrom-PEM) + $result | Should -HaveCount 1 + } + + It 'handles empty string input gracefully' { + # Empty string triggers a non-terminating parameter binding error. + # The function still returns no results. + $result = @('' | ConvertFrom-PEM -ErrorAction SilentlyContinue) + $result | Should -HaveCount 0 + } + + It 'handles string with no PEM markers' { + $result = @('not a certificate' | ConvertFrom-PEM) + $result | Should -HaveCount 0 + } +} + +Describe 'ConvertTo-PEM' { + BeforeAll { + # generate a self-signed test certificate + $Script:testCert = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=Pester PEM Test', + [System.Security.Cryptography.RSA]::Create(2048), + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ).CreateSelfSigned( + [DateTimeOffset]::UtcNow, + [DateTimeOffset]::UtcNow.AddDays(1) + ) + } + + It 'converts certificate to PEM string' { + $result = @($Script:testCert | ConvertTo-PEM) + $result | Should -HaveCount 1 + $result[0] | Should -Match 'BEGIN CERTIFICATE' + $result[0] | Should -Match 'END CERTIFICATE' + } + + It 'adds header with -AddHeader' { + $result = @($Script:testCert | ConvertTo-PEM -AddHeader) + $result[0] | Should -Match '# Subject:.*Pester PEM Test' + $result[0] | Should -Match '# Serial:' + $result[0] | Should -Match '# Issuer:' + } + + It 'wraps base64 at 64 chars' { + $result = @($Script:testCert | ConvertTo-PEM) + $pemContent = $result[0] + $lines = $pemContent.Split("`n") | Where-Object { $_ -and $_ -notmatch '^-' -and $_ -notmatch '^#' } + # all lines except the last should be exactly 64 chars + foreach ($line in $lines[0..($lines.Count - 2)]) { + $line.Length | Should -Be 64 + } + # last line should be <= 64 chars + $lines[-1].Length | Should -BeLessOrEqual 64 + } +} + +Describe 'ConvertFrom-PEM / ConvertTo-PEM roundtrip' { + BeforeAll { + $Script:testCert = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=Pester Roundtrip', + [System.Security.Cryptography.RSA]::Create(2048), + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ).CreateSelfSigned( + [DateTimeOffset]::UtcNow, + [DateTimeOffset]::UtcNow.AddDays(1) + ) + } + + It 'preserves certificate through roundtrip' { + $pem = @($Script:testCert | ConvertTo-PEM)[0] + $restored = @($pem | ConvertFrom-PEM) + $restored | Should -HaveCount 1 + $restored[0].Subject | Should -BeLike '*Pester Roundtrip*' + $restored[0].Thumbprint | Should -Be $Script:testCert.Thumbprint + } +} diff --git a/tests/pester/GetLogLine.Tests.ps1 b/tests/pester/GetLogLine.Tests.ps1 new file mode 100644 index 00000000..9fc8492d --- /dev/null +++ b/tests/pester/GetLogLine.Tests.ps1 @@ -0,0 +1,71 @@ +#Requires -Modules Pester +# Unit tests for Get-LogLine in SetupUtils module + +BeforeAll { + . $PSScriptRoot/../../modules/SetupUtils/Functions/logs.ps1 +} + +Describe 'Get-LogLine' { + BeforeAll { + $Script:testContext = [PSCustomObject]@{ + TimeStamp = [datetime]::new(2025, 6, 15, 10, 30, 45, 123) + Invocation = 'test.ps1:42' + Function = 'Test-Function():10' + IsVerbose = $false + IsDebug = $false + } + } + + Context 'Show line type' { + It 'contains timestamp, level, invocation, and function' { + $result = Get-LogLine -LogContext $Script:testContext -Message 'hello world' -Level 'INFO' -LineType 'Show' + $result | Should -Match '2025-06-15 10:30:45' + $result | Should -Match 'INFO' + $result | Should -Match 'test\.ps1:42' + $result | Should -Match 'hello world' + } + + It 'applies correct color for ERROR level' { + $result = Get-LogLine -LogContext $Script:testContext -Message 'fail' -Level 'ERROR' -LineType 'Show' + $result | Should -Match '91m.*ERROR' + } + + It 'applies correct color for WARNING level' { + $result = Get-LogLine -LogContext $Script:testContext -Message 'warn' -Level 'WARNING' -LineType 'Show' + $result | Should -Match '93m.*WARNING' + } + + It 'applies correct color for VERBOSE level' { + $result = Get-LogLine -LogContext $Script:testContext -Message 'info' -Level 'VERBOSE' -LineType 'Show' + $result | Should -Match '96m.*VERBOSE' + } + + It 'applies correct color for DEBUG level' { + $result = Get-LogLine -LogContext $Script:testContext -Message 'dbg' -Level 'DEBUG' -LineType 'Show' + $result | Should -Match '35m.*DEBUG' + } + } + + Context 'Write line type' { + It 'produces pipe-delimited plain text' { + $result = Get-LogLine -LogContext $Script:testContext -Message 'log entry' -Level 'INFO' -LineType 'Write' + $parts = $result.Split('|') + $parts | Should -HaveCount 5 + $parts[0] | Should -Match '2025-06-15 10:30:45\.123' + $parts[1] | Should -Be 'INFO' + $parts[2] | Should -Be 'test.ps1:42' + $parts[3] | Should -Be 'Test-Function():10' + $parts[4] | Should -Be 'log entry' + } + } + + Context 'LogContext parameter is used correctly' { + It 'works without $ctx in caller scope' { + # Verify that Get-LogLine uses the $LogContext parameter directly + # and does not depend on $ctx being in the caller scope. + Remove-Variable -Name ctx -ErrorAction SilentlyContinue + $result = Get-LogLine -LogContext $Script:testContext -Message 'test' -Level 'INFO' -LineType 'Write' + $result | Should -Match 'INFO' + } + } +} diff --git a/tests/pester/InvokeCommandRetry.Tests.ps1 b/tests/pester/InvokeCommandRetry.Tests.ps1 new file mode 100644 index 00000000..8005a18a --- /dev/null +++ b/tests/pester/InvokeCommandRetry.Tests.ps1 @@ -0,0 +1,38 @@ +#Requires -Modules Pester +# Unit tests for Invoke-CommandRetry in InstallUtils module + +BeforeAll { + . $PSScriptRoot/../../modules/InstallUtils/Functions/common.ps1 +} + +Describe 'Invoke-CommandRetry' { + It 'succeeds on first attempt' { + $Script:retryCounter = 0 + Invoke-CommandRetry -Command { $Script:retryCounter++ } -MaxRetries 3 + $Script:retryCounter | Should -Be 1 + } + + It 'stops after MaxRetries on persistent error' { + $Script:retryCounter = 0 + Invoke-CommandRetry -Command { + $Script:retryCounter++ + $ex = [System.IO.IOException]::new('persistent error') + throw $ex + } -MaxRetries 3 -ErrorAction SilentlyContinue -Verbose:$false + $Script:retryCounter | Should -BeLessOrEqual 3 + } + + It 'rethrows non-retryable exceptions' { + { + Invoke-CommandRetry -Command { + throw [InvalidOperationException]::new('bad') + } -MaxRetries 2 -ErrorAction Stop + } | Should -Throw + } + + It 'executes command successfully when no error' { + $Script:retryResult = $null + Invoke-CommandRetry -Command { $Script:retryResult = 'success' } + $Script:retryResult | Should -Be 'success' + } +} diff --git a/tests/pester/JoinStr.Tests.ps1 b/tests/pester/JoinStr.Tests.ps1 new file mode 100644 index 00000000..0f83b44a --- /dev/null +++ b/tests/pester/JoinStr.Tests.ps1 @@ -0,0 +1,43 @@ +#Requires -Modules Pester +# Unit tests for Join-Str in InstallUtils module + +BeforeAll { + . $PSScriptRoot/../../modules/InstallUtils/Functions/common.ps1 +} + +Describe 'Join-Str' { + It 'wraps with single quotes' { + $result = 'a', 'b' | Join-Str -SingleQuote + $result | Should -Be "'a' 'b'" + } + + It 'wraps with double quotes' { + $result = 'a', 'b' | Join-Str -DoubleQuote + $result | Should -Be '"a" "b"' + } + + It 'combines separator and single quotes' { + $result = 'x', 'y' | Join-Str -Separator ',' -SingleQuote + $result | Should -Be "'x','y'" + } + + It 'combines separator and double quotes' { + $result = 'x', 'y' | Join-Str -Separator ',' -DoubleQuote + $result | Should -Be '"x","y"' + } + + It 'handles single item with single quote' { + $result = 'only' | Join-Str -SingleQuote + $result | Should -Be "'only'" + } + + It 'handles pipeline array with single quote' { + $result = @('one', 'two', 'three') | Join-Str -Separator '-' -SingleQuote + $result | Should -Be "'one'-'two'-'three'" + } + + It 'joins without quotes when neither switch is specified' { + $result = 'a', 'b' | Join-Str + $result | Should -Be 'a b' + } +} diff --git a/tests/pester/NxCommands.Tests.ps1 b/tests/pester/NxCommands.Tests.ps1 new file mode 100644 index 00000000..85e517ab --- /dev/null +++ b/tests/pester/NxCommands.Tests.ps1 @@ -0,0 +1,496 @@ +#Requires -Modules Pester +# Unit tests for nx CLI commands (pin, rollback, scope remove, scope edit, help) + +BeforeAll { + # dot-source the aliases file (side effects are harmless: alias/function defs) + . (Join-Path $PSScriptRoot '../../.assets/config/pwsh_cfg/_aliases_nix.ps1') + + # override functions that call external commands + function _nxApply { } + function _nxValidatePkg { param([string]$Name); return $true } + function nix { param([Parameter(ValueFromRemainingArguments)][string[]]$a) } +} + +Describe 'nx commands' { + BeforeEach { + $Script:_nxEnvDir = Join-Path ([IO.Path]::GetTempPath()) "pester-nxcmd-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $Script:_nxEnvDir -ItemType Directory -Force | Out-Null + New-Item -Path (Join-Path $Script:_nxEnvDir 'scopes') -ItemType Directory -Force | Out-Null + $Script:_nxPkgFile = Join-Path $Script:_nxEnvDir 'packages.nix' + } + + AfterEach { + if (Test-Path $Script:_nxEnvDir) { + Remove-Item $Script:_nxEnvDir -Recurse -Force + } + } + + # ========================================================================= + # help + # ========================================================================= + + Context 'help' { + It 'nx help shows usage' { + $result = nx help 6>&1 | Out-String + $result | Should -Match 'Usage: nx' + $result | Should -Match 'install' + $result | Should -Match 'upgrade' + $result | Should -Match 'pin' + $result | Should -Match 'rollback' + } + + It 'nx without args shows help' { + $result = nx 6>&1 | Out-String + $result | Should -Match 'Usage: nx' + } + + It 'nx unknown command shows help' { + $result = nx fakecmd 6>&1 | Out-String + $result | Should -Match 'Usage: nx' + } + } + + # ========================================================================= + # scope help + # ========================================================================= + + Context 'scope help' { + It 'nx scope without subcommand shows help' { + $result = nx scope 6>&1 | Out-String + $result | Should -Match 'Usage: nx scope' + $result | Should -Match 'list' + $result | Should -Match 'add' + $result | Should -Match 'edit' + $result | Should -Match 'remove' + } + } + + # ========================================================================= + # pin set + # ========================================================================= + + Context 'pin set' { + It 'pin set without rev reads from flake.lock' { + $lockContent = @{ + nodes = @{ + nixpkgs = @{ + locked = @{ + rev = 'abc123def456' + } + } + } + } | ConvertTo-Json -Depth 5 + Set-Content -Path (Join-Path $Script:_nxEnvDir 'flake.lock') -Value $lockContent + + $result = nx pin set 6>&1 | Out-String + $result | Should -Match 'Pinned nixpkgs to abc123def456' + $pinFile = Join-Path $Script:_nxEnvDir 'pinned_rev' + Test-Path $pinFile | Should -BeTrue + (Get-Content $pinFile -Raw).Trim() | Should -Be 'abc123def456' + } + + It 'pin set with explicit rev uses that rev' { + $result = nx pin set deadbeef123 6>&1 | Out-String + $result | Should -Match 'Pinned nixpkgs to deadbeef123' + $pinFile = Join-Path $Script:_nxEnvDir 'pinned_rev' + (Get-Content $pinFile -Raw).Trim() | Should -Be 'deadbeef123' + } + + It 'pin set without rev fails when no flake.lock' { + $result = nx pin set 6>&1 | Out-String + $result | Should -Match 'No flake.lock found' + } + + It 'pin set overwrites existing pin' { + $pinFile = Join-Path $Script:_nxEnvDir 'pinned_rev' + Set-Content -Path $pinFile -Value "oldrev`n" + nx pin set newrev 6>&1 | Out-Null + (Get-Content $pinFile -Raw).Trim() | Should -Be 'newrev' + } + } + + # ========================================================================= + # pin show + # ========================================================================= + + Context 'pin show' { + It 'displays current pin' { + $pinFile = Join-Path $Script:_nxEnvDir 'pinned_rev' + Set-Content -Path $pinFile -Value "abc123`n" + $result = nx pin show 6>&1 | Out-String + $result | Should -Match 'Pinned to:' + $result | Should -Match 'abc123' + } + + It 'reports no pin when file missing' { + $result = nx pin show 6>&1 | Out-String + $result | Should -Match 'No pin set' + } + } + + # ========================================================================= + # pin remove + # ========================================================================= + + Context 'pin remove' { + It 'deletes pin file' { + $pinFile = Join-Path $Script:_nxEnvDir 'pinned_rev' + Set-Content -Path $pinFile -Value "abc123`n" + $result = nx pin remove 6>&1 | Out-String + $result | Should -Match 'Pin removed' + Test-Path $pinFile | Should -BeFalse + } + + It 'reports no pin when file missing' { + $result = nx pin remove 6>&1 | Out-String + $result | Should -Match 'No pin set' + } + } + + # ========================================================================= + # pin help + # ========================================================================= + + Context 'pin help' { + It 'shows usage' { + $result = nx pin help 6>&1 | Out-String + $result | Should -Match 'Usage: nx pin' + $result | Should -Match 'set' + $result | Should -Match 'remove' + $result | Should -Match 'show' + } + + It 'pin without subcommand shows current pin status' { + $result = nx pin 6>&1 | Out-String + $result | Should -Match 'No pin set' + } + } + + # ========================================================================= + # upgrade with pinned_rev + # ========================================================================= + + Context 'upgrade' { + It 'reads pinned_rev file when present' { + $pinFile = Join-Path $Script:_nxEnvDir 'pinned_rev' + Set-Content -Path $pinFile -Value "pinnedabc123`n" + $result = nx upgrade 6>&1 | Out-String + $result | Should -Match 'pinning nixpkgs to pinnedabc123' + } + + It 'without pin does normal update' { + $result = nx upgrade 6>&1 | Out-String + $result | Should -Not -Match 'pinning nixpkgs' + } + } + + # ========================================================================= + # scope remove with local_ prefix + # ========================================================================= + + Context 'scope remove' { + It 'handles local_ prefix transparently' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' "local_devtools"' + ' ];' + '}' + ) + $localDir = Join-Path $Script:_nxEnvDir 'local/scopes' + New-Item -Path $localDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $localDir 'devtools.nix') -Value '{ pkgs }: with pkgs; []' + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/local_devtools.nix') -Value '{ pkgs }: with pkgs; []' + + $result = nx scope remove devtools 6>&1 | Out-String + $result | Should -Match 'removed scope: devtools' + $configContent = Get-Content (Join-Path $Script:_nxEnvDir 'config.nix') -Raw + $configContent | Should -Not -Match 'local_devtools' + Test-Path (Join-Path $localDir 'devtools.nix') | Should -BeFalse + Test-Path (Join-Path $Script:_nxEnvDir 'scopes/local_devtools.nix') | Should -BeFalse + } + + It 'handles repo scope by name' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' "python"' + ' ];' + '}' + ) + + $result = nx scope remove python 6>&1 | Out-String + $result | Should -Match 'removed scope: python' + $configContent = Get-Content (Join-Path $Script:_nxEnvDir 'config.nix') -Raw + $configContent | Should -Not -Match '"python"' + $configContent | Should -Match '"shell"' + } + + It 'cleans orphaned overlay files' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' ];' + '}' + ) + $localDir = Join-Path $Script:_nxEnvDir 'local/scopes' + New-Item -Path $localDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $localDir 'orphan.nix') -Value '{ pkgs }: with pkgs; []' + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/local_orphan.nix') -Value '{ pkgs }: with pkgs; []' + + nx scope remove orphan 6>&1 | Out-Null + Test-Path (Join-Path $localDir 'orphan.nix') | Should -BeFalse + Test-Path (Join-Path $Script:_nxEnvDir 'scopes/local_orphan.nix') | Should -BeFalse + } + + It 'removes multiple scopes at once' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' "python"' + ' "local_devtools"' + ' ];' + '}' + ) + $localDir = Join-Path $Script:_nxEnvDir 'local/scopes' + New-Item -Path $localDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $localDir 'devtools.nix') -Value '{ pkgs }: with pkgs; []' + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/local_devtools.nix') -Value '{ pkgs }: with pkgs; []' + + $result = nx scope remove python devtools 6>&1 | Out-String + $result | Should -Match 'removed scope: python' + $result | Should -Match 'removed scope: devtools' + $configContent = Get-Content (Join-Path $Script:_nxEnvDir 'config.nix') -Raw + $configContent | Should -Match '"shell"' + $configContent | Should -Not -Match '"python"' + $configContent | Should -Not -Match 'local_devtools' + } + + It 'reports unknown scope' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' ];' + '}' + ) + $result = nx scope remove nonexistent 6>&1 | Out-String + $result | Should -Match 'not configured' + } + } + + # ========================================================================= + # scope edit + # ========================================================================= + + Context 'scope edit' { + It 'fails for nonexistent scope' { + $result = nx scope edit nonexistent 6>&1 | Out-String + $result | Should -Match 'not found' + } + + It 'opens file and syncs copy' { + $localDir = Join-Path $Script:_nxEnvDir 'local/scopes' + New-Item -Path $localDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $localDir 'mytools.nix') -Value '{ pkgs }: with pkgs; []' + # use 'true' as EDITOR to simulate a no-op edit + $env:EDITOR = 'true' + $result = nx scope edit mytools 6>&1 | Out-String + $result | Should -Match 'Synced scope' + Test-Path (Join-Path $Script:_nxEnvDir 'scopes/local_mytools.nix') | Should -BeTrue + Remove-Item Env:EDITOR -ErrorAction SilentlyContinue + } + } + + # ========================================================================= + # scope add + # ========================================================================= + + Context 'scope add' { + It 'creates scope and reports guidance' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + ' scopes = [];' + '}' + ) + + $result = nx scope add newscope 6>&1 | Out-String + $result | Should -Match 'Created scope' + $result | Should -Match 'nx scope add newscope' + $scopeFile = Join-Path $Script:_nxEnvDir 'local/scopes/newscope.nix' + Test-Path $scopeFile | Should -BeTrue + } + + It 'reports existing scope' { + $localDir = Join-Path $Script:_nxEnvDir 'local/scopes' + New-Item -Path $localDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $localDir 'existing.nix') -Value '{ pkgs }: with pkgs; []' + $result = nx scope add existing 6>&1 | Out-String + $result | Should -Match 'already exists' + } + } + + # ========================================================================= + # scope list + # ========================================================================= + + Context 'scope list' { + It 'shows installed scopes' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' "python"' + ' ];' + '}' + ) + $result = nx scope list 6>&1 | Out-String + $result | Should -Match 'shell' + $result | Should -Match 'python' + } + + It 'shows no scopes when empty' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + ' scopes = [];' + '}' + ) + $result = nx scope list 6>&1 | Out-String + $result | Should -Match 'No scopes' + } + } + + # ========================================================================= + # scope show + # ========================================================================= + + Context 'scope show' { + It 'displays packages in a scope' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/shell.nix') -Value @( + '{ pkgs }: with pkgs; [' + ' fzf' + ' bat' + ' ripgrep' + ']' + ) + $result = nx scope show shell 6>&1 | Out-String + $result | Should -Match 'fzf' + $result | Should -Match 'bat' + $result | Should -Match 'ripgrep' + } + + It 'reports unknown scope' { + $result = nx scope show nonexistent 6>&1 | Out-String + $result | Should -Match 'not found' + } + } + + # ========================================================================= + # scope tree + # ========================================================================= + + Context 'scope tree' { + It 'shows scopes with packages' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + '' + ' scopes = [' + ' "shell"' + ' ];' + '}' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/shell.nix') -Value @( + '{ pkgs }: with pkgs; [' + ' fzf' + ' bat' + ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [' + ' git' + ']' + ) + $result = nx scope tree 6>&1 | Out-String + $result | Should -Match 'shell' + $result | Should -Match 'fzf' + } + } + + # ========================================================================= + # _nxScopeFileAdd helper + # ========================================================================= + + Context '_nxScopeFileAdd' { + It 'adds packages to scope file' { + $file = Join-Path $Script:_nxEnvDir 'test.nix' + Set-Content -Path $file -Value '{ pkgs }: with pkgs; []' + _nxScopeFileAdd -File $file -Packages @('httpie', 'jq') + $content = Get-Content $file -Raw + $content | Should -Match 'httpie' + $content | Should -Match 'jq' + } + + It 'deduplicates existing packages' { + $file = Join-Path $Script:_nxEnvDir 'test.nix' + Set-Content -Path $file -Value @( + '{ pkgs }: with pkgs; [' + ' httpie' + ']' + ) + $result = _nxScopeFileAdd -File $file -Packages @('httpie') 6>&1 | Out-String + $result | Should -Match 'already in scope' + } + + It 'sorts packages' { + $file = Join-Path $Script:_nxEnvDir 'test.nix' + Set-Content -Path $file -Value '{ pkgs }: with pkgs; []' + _nxScopeFileAdd -File $file -Packages @('zoxide', 'bat', 'httpie') + $pkgs = @(_nxScopePkgs $file) + $pkgs[0] | Should -Be 'bat' + $pkgs[1] | Should -Be 'httpie' + $pkgs[2] | Should -Be 'zoxide' + } + } + + # ========================================================================= + # rollback + # ========================================================================= + + Context 'rollback' { + It 'succeeds when nix profile rollback succeeds' { + $result = nx rollback 6>&1 | Out-String + $result | Should -Match 'Rolled back' + $result | Should -Match 'Restart your shell' + } + } + + # ========================================================================= + # overlay help + # ========================================================================= + + Context 'overlay' { + It 'overlay help shows usage' { + $result = nx overlay help 6>&1 | Out-String + $result | Should -Match 'Usage: nx overlay' + } + } +} # end Describe 'nx commands' diff --git a/tests/pester/NxHelpers.Tests.ps1 b/tests/pester/NxHelpers.Tests.ps1 new file mode 100644 index 00000000..1afe08d2 --- /dev/null +++ b/tests/pester/NxHelpers.Tests.ps1 @@ -0,0 +1,537 @@ +#Requires -Modules Pester +# Unit tests for nx helper functions in _aliases_nix.ps1 +# Tests: _nxReadPkgs, _nxWritePkgs, _nxScopePkgs, _nxScopes, _nxIsInit, _nxAllScopePkgMap + +BeforeAll { + # set up script-scoped variables that the helpers rely on + $Script:_nxEnvDir = $null + $Script:_nxPkgFile = $null + + # source only the helper functions by extracting them + # (the file has side effects at top-level; re-define the helpers inline) + function _nxReadPkgs { + if ([IO.File]::Exists($Script:_nxPkgFile)) { + (Get-Content $Script:_nxPkgFile) | ForEach-Object { + if ($_ -match '^\s*"([^"]+)"') { $Matches[1] } + } + } + } + + function _nxWritePkgs { + param([string[]]$Packages) + $sorted = $Packages | Where-Object { $_ } | Sort-Object -Unique + $lines = @('[') + foreach ($p in $sorted) { $lines += " `"$p`"" } + $lines += ']' + $tmp = [IO.Path]::GetTempFileName() + [IO.File]::WriteAllLines($tmp, $lines) + [IO.File]::Move($tmp, $Script:_nxPkgFile, $true) + } + + function _nxScopePkgs { + param([string]$File) + if ([IO.File]::Exists($File)) { + (Get-Content $File) | ForEach-Object { + if ($_ -match '^\s*([a-zA-Z][a-zA-Z0-9_-]*)') { + $name = $Matches[1] + if ($name -notin 'pkgs', 'with') { $name } + } + } + } + } + + function _nxScopes { + $configNix = [IO.Path]::Combine($Script:_nxEnvDir, 'config.nix') + if ([IO.File]::Exists($configNix)) { + $inScopes = $false + foreach ($line in (Get-Content $configNix)) { + if ($line -match 'scopes\s*=\s*\[') { $inScopes = $true; continue } + if ($inScopes -and $line -match '\]') { break } + if ($inScopes -and $line -match '^\s*"([^"]+)"') { $Matches[1] } + } + } + } + + function _nxIsInit { + $configNix = [IO.Path]::Combine($Script:_nxEnvDir, 'config.nix') + if ([IO.File]::Exists($configNix)) { + foreach ($line in (Get-Content $configNix)) { + if ($line -match '\bisInit\s*=\s*(true|false)') { return $Matches[1] } + } + } + 'false' + } + + function _nxAllScopePkgMap { + $scopesDir = [IO.Path]::Combine($Script:_nxEnvDir, 'scopes') + $map = @{} + if (-not (Test-Path $scopesDir -PathType Container)) { return $map } + $baseFile = [IO.Path]::Combine($scopesDir, 'base.nix') + foreach ($p in @(_nxScopePkgs $baseFile)) { $map[$p] = 'base' } + if ((_nxIsInit) -eq 'true') { + $initFile = [IO.Path]::Combine($scopesDir, 'base_init.nix') + foreach ($p in @(_nxScopePkgs $initFile)) { $map[$p] = 'base_init' } + } + foreach ($s in @(_nxScopes)) { + $scopeFile = [IO.Path]::Combine($scopesDir, "$s.nix") + foreach ($p in @(_nxScopePkgs $scopeFile)) { $map[$p] = $s } + } + return $map + } +} + +Describe 'nx helpers' { + BeforeEach { + $Script:_nxEnvDir = Join-Path ([IO.Path]::GetTempPath()) "pester-nx-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $Script:_nxEnvDir -ItemType Directory -Force | Out-Null + New-Item -Path (Join-Path $Script:_nxEnvDir 'scopes') -ItemType Directory -Force | Out-Null + $Script:_nxPkgFile = Join-Path $Script:_nxEnvDir 'packages.nix' + } + + AfterEach { + if (Test-Path $Script:_nxEnvDir) { + Remove-Item $Script:_nxEnvDir -Recurse -Force + } + } + +# ============================================================================= +# _nxReadPkgs / _nxWritePkgs +# ============================================================================= + +Context '_nxReadPkgs' { + It 'returns nothing when file does not exist' { + $result = @(_nxReadPkgs) + $result | Should -HaveCount 0 + } + + It 'returns nothing for empty list' { + Set-Content -Path $Script:_nxPkgFile -Value @('[', ']') + $result = @(_nxReadPkgs) + $result | Should -HaveCount 0 + } + + It 'extracts package names' { + Set-Content -Path $Script:_nxPkgFile -Value @('[', ' "ripgrep"', ' "fd"', ' "jq"', ']') + $result = @(_nxReadPkgs) + $result | Should -HaveCount 3 + $result[0] | Should -Be 'ripgrep' + $result[1] | Should -Be 'fd' + $result[2] | Should -Be 'jq' + } + + It 'ignores comments and blank lines' { + Set-Content -Path $Script:_nxPkgFile -Value @('[', ' # comment', ' "ripgrep"', '', ' "fd"', ']') + $result = @(_nxReadPkgs) + $result | Should -HaveCount 2 + } +} + +Context '_nxWritePkgs' { + It 'creates valid nix list' { + _nxWritePkgs -Packages @('ripgrep', 'fd') + $content = Get-Content $Script:_nxPkgFile + $content[0] | Should -Be '[' + $content[1] | Should -Be ' "fd"' + $content[2] | Should -Be ' "ripgrep"' + $content[-1] | Should -Be ']' + } + + It 'sorts and deduplicates' { + _nxWritePkgs -Packages @('zoxide', 'ripgrep', 'fd', 'ripgrep') + $result = @(_nxReadPkgs) + $result | Should -HaveCount 3 + $result[0] | Should -Be 'fd' + $result[1] | Should -Be 'ripgrep' + $result[2] | Should -Be 'zoxide' + } + + It 'skips empty strings' { + _nxWritePkgs -Packages @('', 'ripgrep', '', 'fd', '') + $result = @(_nxReadPkgs) + $result | Should -HaveCount 2 + } + + It 'creates empty list for empty input' { + _nxWritePkgs -Packages @() + $content = Get-Content $Script:_nxPkgFile + $content[0] | Should -Be '[' + $content[-1] | Should -Be ']' + $content | Should -HaveCount 2 + } +} + +Context '_nxReadPkgs / _nxWritePkgs roundtrip' { + It 'write then read preserves packages' { + _nxWritePkgs -Packages @('jq', 'curl', 'wget') + $result = @(_nxReadPkgs) + $result[0] | Should -Be 'curl' + $result[1] | Should -Be 'jq' + $result[2] | Should -Be 'wget' + } + + It 'add to existing list' { + _nxWritePkgs -Packages @('fd', 'ripgrep') + $current = @(_nxReadPkgs) + _nxWritePkgs -Packages ($current + @('jq')) + $result = @(_nxReadPkgs) + $result | Should -HaveCount 3 + $result | Should -Contain 'jq' + } +} + +# ============================================================================= +# _nxScopePkgs +# ============================================================================= + +Context '_nxScopePkgs' { + It 'parses standard scope file' { + $file = Join-Path $Script:_nxEnvDir 'scopes/shell.nix' + Set-Content -Path $file -Value @( + '# Shell tools' + '{ pkgs }: with pkgs; [' + ' fzf' + ' eza' + ' bat' + ']' + ) + $result = @(_nxScopePkgs $file) + $result | Should -HaveCount 3 + $result | Should -Contain 'fzf' + $result | Should -Contain 'eza' + $result | Should -Contain 'bat' + } + + It 'filters out pkgs and with keywords' { + $file = Join-Path $Script:_nxEnvDir 'scopes/test.nix' + Set-Content -Path $file -Value @( + '{ pkgs }: with pkgs; [' + ' git' + ']' + ) + $result = @(_nxScopePkgs $file) + $result | Should -Not -Contain 'pkgs' + $result | Should -Not -Contain 'with' + $result | Should -Contain 'git' + } + + It 'handles inline comments' { + $file = Join-Path $Script:_nxEnvDir 'scopes/test.nix' + Set-Content -Path $file -Value @( + '{ pkgs }: with pkgs; [' + ' bind # provides dig' + ' git' + ']' + ) + $result = @(_nxScopePkgs $file) + $result | Should -Contain 'bind' + $result | Should -Contain 'git' + } + + It 'handles packages with hyphens' { + $file = Join-Path $Script:_nxEnvDir 'scopes/test.nix' + Set-Content -Path $file -Value @( + '{ pkgs }: with pkgs; [' + ' bash-completion' + ' yq-go' + ']' + ) + $result = @(_nxScopePkgs $file) + $result | Should -Contain 'bash-completion' + $result | Should -Contain 'yq-go' + } + + It 'returns nothing for nonexistent file' { + $result = @(_nxScopePkgs '/nonexistent/file.nix') + $result | Should -HaveCount 0 + } + + It 'returns nothing for empty list' { + $file = Join-Path $Script:_nxEnvDir 'scopes/empty.nix' + Set-Content -Path $file -Value @('{ pkgs }: with pkgs; [', ']') + $result = @(_nxScopePkgs $file) + $result | Should -HaveCount 0 + } +} + +# ============================================================================= +# _nxScopes +# ============================================================================= + +Context '_nxScopes' { + It 'returns nothing when config.nix missing' { + $result = @(_nxScopes) + $result | Should -HaveCount 0 + } + + It 'parses multiple scopes' { + $configFile = Join-Path $Script:_nxEnvDir 'config.nix' + Set-Content -Path $configFile -Value @( + '{' + ' isInit = true;' + '' + ' scopes = [' + ' "shell"' + ' "python"' + ' "docker"' + ' ];' + '}' + ) + $result = @(_nxScopes) + $result | Should -HaveCount 3 + $result[0] | Should -Be 'shell' + $result[1] | Should -Be 'python' + $result[2] | Should -Be 'docker' + } + + It 'parses empty scopes' { + $configFile = Join-Path $Script:_nxEnvDir 'config.nix' + Set-Content -Path $configFile -Value @( + '{' + ' isInit = false;' + ' scopes = [' + ' ];' + '}' + ) + $result = @(_nxScopes) + $result | Should -HaveCount 0 + } + + It 'parses single scope' { + $configFile = Join-Path $Script:_nxEnvDir 'config.nix' + Set-Content -Path $configFile -Value @( + '{' + ' isInit = false;' + ' scopes = [' + ' "shell"' + ' ];' + '}' + ) + $result = @(_nxScopes) + $result | Should -HaveCount 1 + $result[0] | Should -Be 'shell' + } +} + +# ============================================================================= +# _nxIsInit +# ============================================================================= + +Context '_nxIsInit' { + It 'returns false when config.nix missing' { + $result = _nxIsInit + $result | Should -Be 'false' + } + + It 'returns true when isInit is true' { + $configFile = Join-Path $Script:_nxEnvDir 'config.nix' + Set-Content -Path $configFile -Value @( + '{' + ' isInit = true;' + ' scopes = [];' + '}' + ) + $result = _nxIsInit + $result | Should -Be 'true' + } + + It 'returns false when isInit is false' { + $configFile = Join-Path $Script:_nxEnvDir 'config.nix' + Set-Content -Path $configFile -Value @( + '{' + ' isInit = false;' + ' scopes = [];' + '}' + ) + $result = _nxIsInit + $result | Should -Be 'false' + } + + It 'parses single-line config.nix format' { + $configFile = Join-Path $Script:_nxEnvDir 'config.nix' + Set-Content -Path $configFile -Value '{ isInit = true; scopes = []; }' + $result = _nxIsInit + $result | Should -Be 'true' + } +} + +# ============================================================================= +# _nxAllScopePkgMap +# ============================================================================= + +Context '_nxAllScopePkgMap' { + It 'includes base packages' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ' jq', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{ isInit = false; scopes = []; }' + ) + $map = _nxAllScopePkgMap + $map['git'] | Should -Be 'base' + $map['jq'] | Should -Be 'base' + } + + It 'includes base_init when isInit is true' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base_init.nix') -Value @( + '{ pkgs }: with pkgs; [', ' nano', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = true;' + ' scopes = [];' + '}' + ) + $map = _nxAllScopePkgMap + $map['nano'] | Should -Be 'base_init' + } + + It 'excludes base_init when isInit is false' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base_init.nix') -Value @( + '{ pkgs }: with pkgs; [', ' nano', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + ' scopes = [];' + '}' + ) + $map = _nxAllScopePkgMap + $map.ContainsKey('nano') | Should -BeFalse + } + + It 'includes configured scope packages' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/shell.nix') -Value @( + '{ pkgs }: with pkgs; [', ' fzf', ' bat', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + ' scopes = [' + ' "shell"' + ' ];' + '}' + ) + $map = _nxAllScopePkgMap + $map['fzf'] | Should -Be 'shell' + $map['bat'] | Should -Be 'shell' + $map['git'] | Should -Be 'base' + } + + It 'handles multiple scopes' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/shell.nix') -Value @( + '{ pkgs }: with pkgs; [', ' fzf', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/python.nix') -Value @( + '{ pkgs }: with pkgs; [', ' uv', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + ' scopes = [' + ' "shell"' + ' "python"' + ' ];' + '}' + ) + $map = _nxAllScopePkgMap + $map['fzf'] | Should -Be 'shell' + $map['uv'] | Should -Be 'python' + $map['git'] | Should -Be 'base' + } + + It 'returns empty when no scopes dir' { + Remove-Item (Join-Path $Script:_nxEnvDir 'scopes') -Recurse -Force + $map = _nxAllScopePkgMap + $map.Count | Should -Be 0 + } +} + +# ============================================================================= +# Install/remove scope-aware validation +# ============================================================================= + +Context 'nx install scope validation' { + It 'detects package already in scope' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ' jq', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{ isInit = false; scopes = []; }' + ) + $map = _nxAllScopePkgMap + $map.ContainsKey('git') | Should -BeTrue + $map['git'] | Should -Be 'base' + } + + It 'allows package not in any scope' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{ isInit = false; scopes = []; }' + ) + $map = _nxAllScopePkgMap + $map.ContainsKey('ripgrep') | Should -BeFalse + } +} + +Context 'nx remove scope validation' { + It 'detects scope-managed package' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/shell.nix') -Value @( + '{ pkgs }: with pkgs; [', ' bat', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{' + ' isInit = false;' + ' scopes = [' + ' "shell"' + ' ];' + '}' + ) + $map = _nxAllScopePkgMap + $map['bat'] | Should -Be 'shell' + } + + It 'allows removing extra package' { + _nxWritePkgs -Packages @('ripgrep', 'fd') + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{ isInit = false; scopes = []; }' + ) + $map = _nxAllScopePkgMap + $map.ContainsKey('ripgrep') | Should -BeFalse + } + + It 'filters scope packages from removal args' { + Set-Content -Path (Join-Path $Script:_nxEnvDir 'scopes/base.nix') -Value @( + '{ pkgs }: with pkgs; [', ' git', ']' + ) + Set-Content -Path (Join-Path $Script:_nxEnvDir 'config.nix') -Value @( + '{ isInit = false; scopes = []; }' + ) + $map = _nxAllScopePkgMap + $args = @('git', 'ripgrep', 'fd') + $filtered = $args | Where-Object { -not $map.ContainsKey($_) } + $filtered | Should -HaveCount 2 + $filtered | Should -Contain 'ripgrep' + $filtered | Should -Contain 'fd' + } +} +} # end Describe 'nx helpers' diff --git a/tests/pester/Scopes.Tests.ps1 b/tests/pester/Scopes.Tests.ps1 new file mode 100644 index 00000000..1421ed90 --- /dev/null +++ b/tests/pester/Scopes.Tests.ps1 @@ -0,0 +1,139 @@ +#Requires -Modules Pester +# Unit tests for Resolve-ScopeDeps and Get-SortedScopes in SetupUtils module + +BeforeAll { + # source the functions directly + . $PSScriptRoot/../../modules/SetupUtils/Functions/scopes.ps1 + + # load shared scope definitions (same as the module does) + $scopesData = [IO.File]::ReadAllText("$PSScriptRoot/../../.assets/lib/scopes.json") | ConvertFrom-Json + $Script:ValidScopes = [string[]]$scopesData.valid_scopes + $Script:InstallOrder = [string[]]$scopesData.install_order + $Script:ScopeDependencyRules = $scopesData.dependency_rules +} + +Describe 'Resolve-ScopeDeps' { + It 'az adds python' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('az')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'python' + } + + It 'k8s_ext adds docker, k8s_base, k8s_dev' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('k8s_ext')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'docker' + $set | Should -Contain 'k8s_base' + $set | Should -Contain 'k8s_dev' + } + + It 'pwsh adds shell' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('pwsh')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'shell' + } + + It 'zsh adds shell' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('zsh')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'shell' + } + + It 'oh_my_posh adds shell' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('oh_my_posh')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'shell' + } + + It 'starship adds shell' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('starship')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'shell' + } + + It 'OmpTheme parameter adds oh_my_posh and shell' { + $set = [System.Collections.Generic.HashSet[string]]::new() + $set.Add('_placeholder') | Out-Null + Resolve-ScopeDeps -ScopeSet $set -OmpTheme 'agnoster' + $set | Should -Contain 'oh_my_posh' + $set | Should -Contain 'shell' + } + + It 'empty OmpTheme does not add oh_my_posh' { + $set = [System.Collections.Generic.HashSet[string]]::new() + $set.Add('rice') | Out-Null + Resolve-ScopeDeps -ScopeSet $set -OmpTheme '' + $set | Should -Not -Contain 'oh_my_posh' + } + + It 'unknown scope has no dependencies' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('rice')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -HaveCount 1 + $set | Should -Contain 'rice' + } + + It 'is idempotent' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('az')) + Resolve-ScopeDeps -ScopeSet $set + $count1 = $set.Count + Resolve-ScopeDeps -ScopeSet $set + $set.Count | Should -Be $count1 + } + + It 'chains transitive dependencies' { + # k8s_ext -> k8s_dev -> k8s_base (transitively via two rules) + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('k8s_ext')) + Resolve-ScopeDeps -ScopeSet $set + $set | Should -Contain 'k8s_base' + $set | Should -Contain 'k8s_dev' + $set | Should -Contain 'docker' + } +} + +Describe 'Get-SortedScopes' { + It 'sorts scopes by install order' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('shell', 'docker', 'python')) + $sorted = Get-SortedScopes -ScopeSet $set + $sorted[0] | Should -Be 'docker' + $sorted[1] | Should -Be 'python' + $sorted[2] | Should -Be 'shell' + } + + It 'unknown scopes sort to end' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('shell', 'unknown_scope')) + $sorted = Get-SortedScopes -ScopeSet $set + $sorted[-1] | Should -Be 'unknown_scope' + } + + It 'handles single scope' { + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]@('python')) + $sorted = Get-SortedScopes -ScopeSet $set + # single-item HashSet may unwrap; force array + @($sorted) | Should -HaveCount 1 + @($sorted)[0] | Should -Be 'python' + } + + It 'returns empty for empty set' { + $set = [System.Collections.Generic.HashSet[string]]::new() + $set.Add('_') | Out-Null # need non-empty to pass validation + $set.Remove('_') | Out-Null + # HashSet is now empty but was once non-empty + # PowerShell Mandatory validation rejects truly empty collections + # so we test with a single-element set and verify count + $set.Add('python') | Out-Null + $sorted = Get-SortedScopes -ScopeSet $set + $set.Remove('python') | Out-Null + # just verify the function works; empty set cannot be passed due to validation + @($sorted) | Should -HaveCount 1 + } + + It 'full install order is respected' { + # use all scopes from install_order + $set = [System.Collections.Generic.HashSet[string]]::new([string[]]$Script:InstallOrder) + $sorted = Get-SortedScopes -ScopeSet $set + for ($i = 0; $i -lt $sorted.Count; $i++) { + $sorted[$i] | Should -Be $Script:InstallOrder[$i] + } + } +} diff --git a/tests/pester/WslSetup.Tests.ps1 b/tests/pester/WslSetup.Tests.ps1 new file mode 100644 index 00000000..63a6f967 --- /dev/null +++ b/tests/pester/WslSetup.Tests.ps1 @@ -0,0 +1,407 @@ +#Requires -Modules Pester +# Integration tests for wsl/wsl_setup.ps1 orchestration logic. +# Mocks wsl.exe and Windows-only functions to verify the correct sequence +# of provisioning calls for each scope and mode (legacy vs Nix). + +BeforeAll { + $Script:RepoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + + # allow the script to run on Linux (bypasses $IsLinux guard) + $env:WSL_SETUP_TESTING = '1' + # provide Windows-like env vars for SSH path computation (line 448) + $env:HOMEDRIVE = 'C:' + $env:HOMEPATH = '\Users\testuser' + + # import modules so the real functions exist (we will mock them) + Import-Module "$Script:RepoRoot/modules/InstallUtils" -Force + Import-Module "$Script:RepoRoot/modules/SetupUtils" -Force + + # helper: build a check_distro JSON response + function New-CheckDistro { + param( + [string]$User = 'testuser', + [int]$Uid = 1000, + [hashtable]$Flags = @{} + ) + $defaults = @{ + user = $User; uid = $Uid; def_uid = $Uid + az = $false; bun = $false; conda = $false; gcloud = $false + git_user = $true; git_email = $true; gtkd = $false + k8s_base = $false; k8s_dev = $false; k8s_ext = $false + nix = $false; oh_my_posh = $false + python = $false; pwsh = $false; shell = $false + ssh_key = $true; systemd = $true; terraform = $false + wsl_boot = $true; wslg = $false; zsh = $false + } + foreach ($key in $Flags.Keys) { $defaults[$key] = $Flags[$key] } + $defaults | ConvertTo-Json -Compress + } + + # collector for wsl.exe invocations + $global:WslTestCalls = [System.Collections.Generic.List[string[]]]::new() + + # define wsl.exe stub so Pester can mock it on Linux (where it does not exist) + if (-not (Get-Command 'wsl.exe' -ErrorAction SilentlyContinue)) { + function global:wsl.exe { } + } + + # default check_distro response (overridden per test) + $global:WslTestCheckDistroJson = New-CheckDistro + # default ssh setup response + $global:WslTestSshSetupJson = '{"sshKey":"exists"}' +} + +AfterAll { + $env:WSL_SETUP_TESTING = $null + $env:HOMEDRIVE = $null + $env:HOMEPATH = $null + Remove-Variable -Name WslTestCalls, WslTestCheckDistroJson, WslTestSshSetupJson -Scope Global -ErrorAction SilentlyContinue +} + +Describe 'wsl_setup.ps1 orchestration' { + BeforeEach { + $global:WslTestCalls.Clear() + + # mock wsl.exe - record calls and return canned responses + Mock wsl.exe { + $global:WslTestCalls.Add([string[]]$args) + $argStr = $args -join ' ' + # return appropriate responses based on the script being called + if ($argStr -match 'check_distro\.sh') { + return $global:WslTestCheckDistroJson + } + if ($argStr -match 'check_dns\.sh') { + return 'true' + } + if ($argStr -match 'check_ssl\.sh') { + return 'true' + } + if ($argStr -match 'setup_gh_https\.sh') { + return 'github.com' + } + if ($argStr -match 'setup_gh_ssh\.sh') { + return $global:WslTestSshSetupJson + } + if ($argStr -match 'id -un') { + return 'testuser' + } + if ($argStr -match 'command -v pwsh') { + return 'true' + } + # provision/install scripts: return a fake version string + if ($argStr -match 'install_\w+\.sh') { + return 'v1.0.0' + } + return '' + } + + # mock Windows-only functions + Mock Get-WslDistro { + [PSCustomObject]@{ Default = $true; Name = 'Ubuntu'; State = 'Running'; Version = 2 } + } + Mock Get-WslDistro -ParameterFilter { $FromRegistry } { + [PSCustomObject]@{ + Name = 'Ubuntu'; DefaultUid = 1000; Version = 2 + Flags = 15; BasePath = 'C:\fake'; Default = $true + } + } + Mock Set-WslConf {} + Mock Update-GitRepository { return 1 } + Mock Invoke-GhRepoClone { return 2 } + Mock Test-IsAdmin { return $false } + + # mock filesystem operations that would create side-effect dirs + Mock New-Item {} + Mock Remove-Item {} + + # prevent the script from re-importing modules (which overwrites our mocks) + Mock Import-Module {} + } + + BeforeAll { + # helper: extract the script path from recorded wsl.exe calls + function Get-WslScripts { + $global:WslTestCalls | ForEach-Object { + $joined = $_ -join ' ' + if ($joined -match '(?:--exec\s+)(\S+\.(?:sh|ps1))') { + $Matches[1] + } + } | Where-Object { $_ } + } + } + + Context 'Legacy mode with shell scope' { + It 'calls correct provision scripts in order' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + # base setup + $scripts | Should -Contain '.assets/fix/fix_no_file.sh' + $scripts | Should -Contain '.assets/fix/fix_secure_path.sh' + $scripts | Should -Contain '.assets/provision/upgrade_system.sh' + $scripts | Should -Contain '.assets/provision/install_base.sh' + # gh setup + $scripts | Should -Contain '.assets/provision/install_gh.sh' + $scripts | Should -Contain '.assets/setup/setup_gh_https.sh' + # shell scope scripts + $scripts | Should -Contain '.assets/provision/install_fzf.sh' + $scripts | Should -Contain '.assets/provision/install_eza.sh' + $scripts | Should -Contain '.assets/provision/install_bat.sh' + $scripts | Should -Contain '.assets/provision/install_ripgrep.sh' + $scripts | Should -Contain '.assets/provision/install_yq.sh' + $scripts | Should -Contain '.assets/setup/setup_profile_allusers.sh' + $scripts | Should -Contain '.assets/setup/setup_profile_user.sh' + $scripts | Should -Contain '.assets/provision/install_copilot.sh' + # should NOT contain nix setup + $scripts | Should -Not -Contain 'nix/setup.sh' + } + } + + Context 'Legacy mode with python and rice scopes' { + It 'installs python and rice tools' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('python', 'rice') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + # python + $scripts | Should -Contain '.assets/setup/setup_python.sh' + $scripts | Should -Contain '.assets/provision/install_uv.sh' + $scripts | Should -Contain '.assets/provision/install_prek.sh' + # rice + $scripts | Should -Contain '.assets/provision/install_btop.sh' + $scripts | Should -Contain '.assets/provision/install_cmatrix.sh' + $scripts | Should -Contain '.assets/provision/install_cowsay.sh' + $scripts | Should -Contain '.assets/provision/install_fastfetch.sh' + } + } + + Context 'Legacy mode with docker scope (systemd already enabled)' { + It 'installs docker without systemd restart' { + # systemd = $true so we skip wsl_systemd.ps1 call (not mockable on Linux) + $global:WslTestCheckDistroJson = New-CheckDistro -Flags @{ systemd = $true } + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('docker') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + $scripts | Should -Contain '.assets/provision/install_docker.sh' + # should NOT have called wsl.exe --shutdown (systemd already on) + $shutdownCalls = $global:WslTestCalls | Where-Object { ($_ -join ' ') -match '--shutdown' } + $shutdownCalls | Should -BeNullOrEmpty + } + } + + Context 'Legacy mode with az scope resolves python dependency' { + It 'includes python scope scripts when az is specified' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('az') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + # az + $scripts | Should -Contain '.assets/provision/install_azurecli_uv.sh' + $scripts | Should -Contain '.assets/provision/install_azcopy.sh' + # python (dependency of az) + $scripts | Should -Contain '.assets/setup/setup_python.sh' + $scripts | Should -Contain '.assets/provision/install_uv.sh' + } + } + + Context 'Legacy mode detects existing scopes from check_distro' { + It 'adds scopes detected from distro check' { + $global:WslTestCheckDistroJson = New-CheckDistro -Flags @{ python = $true; shell = $true } + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('rice') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + # rice (explicitly requested) + $scripts | Should -Contain '.assets/provision/install_btop.sh' + # shell (detected from distro) + $scripts | Should -Contain '.assets/provision/install_fzf.sh' + # python (detected from distro) + $scripts | Should -Contain '.assets/setup/setup_python.sh' + } + } + + Context 'Nix mode with shell and python scopes' { + It 'calls nix/setup.sh instead of individual install scripts' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell', 'python') -Nix -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + # should use nix path + $scripts | Should -Contain '.assets/provision/install_base_nix.sh' + $scripts | Should -Contain '.assets/provision/install_nix.sh' + $scripts | Should -Contain 'nix/setup.sh' + # should NOT call individual shell/python install scripts + $scripts | Should -Not -Contain '.assets/provision/install_fzf.sh' + $scripts | Should -Not -Contain '.assets/provision/install_uv.sh' + $scripts | Should -Not -Contain '.assets/setup/setup_python.sh' + } + + It 'passes correct flags to nix/setup.sh' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell', 'python') -Nix -SkipRepoUpdate 6>$null + + $nixCall = $global:WslTestCalls | Where-Object { ($_ -join ' ') -match 'nix/setup\.sh' } | Select-Object -First 1 + $nixArgs = $nixCall -join ' ' + $nixArgs | Should -Match '--shell' + $nixArgs | Should -Match '--python' + $nixArgs | Should -Match '--unattended' + } + } + + Context 'Nix mode with docker falls back to traditional install' { + It 'installs docker traditionally even in Nix mode' { + $global:WslTestCheckDistroJson = New-CheckDistro -Flags @{ systemd = $true } + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell', 'docker') -Nix -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + $scripts | Should -Contain 'nix/setup.sh' + $scripts | Should -Contain '.assets/provision/install_docker.sh' + } + } + + Context 'Nix mode auto-detected from distro' { + It 'uses Nix path when distro has nix installed' { + $global:WslTestCheckDistroJson = New-CheckDistro -Flags @{ nix = $true } + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + $scripts | Should -Contain '.assets/provision/install_base_nix.sh' + $scripts | Should -Contain 'nix/setup.sh' + $scripts | Should -Not -Contain '.assets/provision/install_fzf.sh' + } + } + + Context 'Nix mode with OmpTheme' { + It 'passes --omp-theme to nix/setup.sh' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell') -Nix -OmpTheme 'nerd' -SkipRepoUpdate 6>$null + + $nixCall = $global:WslTestCalls | Where-Object { ($_ -join ' ') -match 'nix/setup\.sh' } | Select-Object -First 1 + $nixArgs = $nixCall -join ' ' + $nixArgs | Should -Match '--omp-theme' + $nixArgs | Should -Match 'nerd' + } + } + + Context 'WSL1 distro removes incompatible scopes' { + BeforeEach { + # initial Get-WslDistro returns Version=2 to skip the interactive WSL1 prompt + # but -FromRegistry returns Version=1 which is used for scope filtering in process{} + Mock Get-WslDistro { + [PSCustomObject]@{ Default = $true; Name = 'Ubuntu'; State = 'Running'; Version = 2 } + } + Mock Get-WslDistro -ParameterFilter { $FromRegistry } { + [PSCustomObject]@{ + Name = 'Ubuntu'; DefaultUid = 1000; Version = 1 + Flags = 15; BasePath = 'C:\fake'; Default = $true + } + } + } + + It 'does not install docker or k8s_ext on WSL1' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('docker', 'shell') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + $scripts | Should -Not -Contain '.assets/provision/install_docker.sh' + # shell should still be installed + $scripts | Should -Contain '.assets/provision/install_fzf.sh' + } + } + + Context 'DNS failure halts execution' { + It 'exits with non-zero when DNS check fails' { + # run in subprocess since `exit 1` terminates the process + $result = pwsh -NoProfile -Command @" + `$env:WSL_SETUP_TESTING = '1' + `$env:HOMEDRIVE = 'C:' + `$env:HOMEPATH = '\Users\testuser' + Set-Location '$Script:RepoRoot' + Import-Module './modules/InstallUtils' -Force + Import-Module './modules/SetupUtils' -Force + function wsl.exe { + `$argStr = `$args -join ' ' + if (`$argStr -match 'check_distro\.sh') { return '$(New-CheckDistro)' } + if (`$argStr -match 'check_dns\.sh') { return 'false' } + if (`$argStr -match 'check_ssl\.sh') { return 'true' } + if (`$argStr -match 'setup_gh_https') { return 'github.com' } + if (`$argStr -match 'id -un') { return 'testuser' } + return '' + } + function Get-WslDistro { + [CmdletBinding()]param([switch]`$FromRegistry, [switch]`$Online) + if (`$FromRegistry) { + [PSCustomObject]@{ Name='Ubuntu'; DefaultUid=1000; Version=2; Flags=15; BasePath='C:\fake'; Default=`$true } + } else { + [PSCustomObject]@{ Default=`$true; Name='Ubuntu'; State='Running'; Version=2 } + } + } + function Set-WslConf {} + function Update-GitRepository { return 1 } + function Invoke-GhRepoClone { return 2 } + function Test-IsAdmin { return `$false } + & './wsl/wsl_setup.ps1' -Distro 'Ubuntu' -Scope @('shell') -SkipRepoUpdate *>`$null +"@ + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Legacy mode with k8s scopes' { + It 'installs kubernetes base and dev packages' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('k8s_dev') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + # k8s_base (dependency of k8s_dev) + $scripts | Should -Contain '.assets/provision/install_kubectl.sh' + $scripts | Should -Contain '.assets/provision/install_kubelogin.sh' + $scripts | Should -Contain '.assets/provision/install_k9s.sh' + $scripts | Should -Contain '.assets/provision/install_kubecolor.sh' + $scripts | Should -Contain '.assets/provision/install_kubectx.sh' + # k8s_dev + $scripts | Should -Contain '.assets/provision/install_argorolloutscli.sh' + $scripts | Should -Contain '.assets/provision/install_helm.sh' + $scripts | Should -Contain '.assets/provision/install_flux.sh' + $scripts | Should -Contain '.assets/provision/install_kustomize.sh' + $scripts | Should -Contain '.assets/provision/install_trivy.sh' + } + } + + Context 'Legacy mode with terraform scope' { + It 'installs terraform tools' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('terraform') -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + $scripts | Should -Contain '.assets/provision/install_terraform.sh' + $scripts | Should -Contain '.assets/provision/install_terrascan.sh' + $scripts | Should -Contain '.assets/provision/install_tflint.sh' + $scripts | Should -Contain '.assets/provision/install_tfswitch.sh' + } + } + + Context 'Legacy mode with oh_my_posh via OmpTheme' { + It 'installs oh-my-posh when OmpTheme is specified' { + $global:WslTestCheckDistroJson = New-CheckDistro + + & "$Script:RepoRoot/wsl/wsl_setup.ps1" -Distro 'Ubuntu' -Scope @('shell') -OmpTheme 'base' -SkipRepoUpdate 6>$null + + $scripts = Get-WslScripts + $scripts | Should -Contain '.assets/provision/install_omp.sh' + $scripts | Should -Contain '.assets/setup/setup_omp.sh' + } + } +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..e2aed0ee --- /dev/null +++ b/uv.lock @@ -0,0 +1,469 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "editorconfig" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "idna" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, +] + +[[package]] +name = "linux-setup-scripts" +version = "0.1.0" +source = { virtual = "." } + +[package.optional-dependencies] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocs-mermaid2-plugin" }, +] + +[package.metadata] +requires-dist = [ + { name = "mkdocs-material", marker = "extra == 'docs'" }, + { name = "mkdocs-mermaid2-plugin", marker = "extra == 'docs'" }, +] +provides-extras = ["docs"] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-mermaid2-plugin" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "jsbeautifier" }, + { name = "mkdocs" }, + { name = "pymdown-extensions" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/6d/308f443a558b6a97ce55782658174c0d07c414405cfc0a44d36ad37e36f9/mkdocs_mermaid2_plugin-1.2.3.tar.gz", hash = "sha256:fb6f901d53e5191e93db78f93f219cad926ccc4d51e176271ca5161b6cc5368c", size = 16220, upload-time = "2025-10-17T19:38:53.047Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4b/6fd6dd632019b7f522f1b1f794ab6115cd79890330986614be56fd18f0eb/mkdocs_mermaid2_plugin-1.2.3-py3-none-any.whl", hash = "sha256:33f60c582be623ed53829a96e19284fc7f1b74a1dbae78d4d2e47fe00c3e190d", size = 17299, upload-time = "2025-10-17T19:38:51.874Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] diff --git a/vagrant/hyperv/arch/Vagrantfile b/vagrant/hyperv/arch/Vagrantfile index 4f1beeed..7936b725 100644 --- a/vagrant/hyperv/arch/Vagrantfile +++ b/vagrant/hyperv/arch/Vagrantfile @@ -129,7 +129,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -142,17 +142,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/hyperv/debian/Vagrantfile b/vagrant/hyperv/debian/Vagrantfile index 3f01b57c..9cbbedc8 100644 --- a/vagrant/hyperv/debian/Vagrantfile +++ b/vagrant/hyperv/debian/Vagrantfile @@ -136,7 +136,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -149,17 +149,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/hyperv/fedora/Vagrantfile b/vagrant/hyperv/fedora/Vagrantfile index 671a69cb..c852b059 100644 --- a/vagrant/hyperv/fedora/Vagrantfile +++ b/vagrant/hyperv/fedora/Vagrantfile @@ -105,7 +105,7 @@ Vagrant.configure("2") do |config| end # node provision node.vm.provision "shell", name: "configure static ip", inline: script_configure_static_ip - node.vm.provision "shell", name: "fix secure_path in sudoers", path: "../../../.assets/provision/fix_secure_path.sh" + node.vm.provision "shell", name: "fix secure_path in sudoers", path: "../../../.assets/fix/fix_secure_path.sh" node.vm.provision "shell", name: "install base...", path: "../../../.assets/provision/install_base.sh" # copy source file with bash helper functions node.vm.provision "file", source: "../../../.assets/provision/source.sh", destination: ".assets/provision/" @@ -117,7 +117,7 @@ Vagrant.configure("2") do |config| node.vm.provision "shell", name: "install miniforge...", path: "../../../.assets/provision/install_miniforge.sh", privileged: false end if install_kubernetes - node.vm.provision "shell", name: "set ulimits on systemd", path: "../../../.assets/provision/set_ulimits.sh" + node.vm.provision "shell", name: "set ulimits on systemd", path: "../../../.assets/setup/set_ulimits.sh" node.vm.provision "shell", name: "install yq...", path: "../../../.assets/provision/install_yq.sh", :args => '>/dev/null' node.vm.provision "shell", name: "install docker...", path: "../../../.assets/provision/install_docker.sh" node.vm.provision "shell", name: "install kubectl...", path: "../../../.assets/provision/install_kubectl.sh", :args => '>/dev/null' @@ -135,7 +135,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -148,17 +148,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/hyperv/ubuntu/Vagrantfile b/vagrant/hyperv/ubuntu/Vagrantfile index 9e21f36f..f43ea554 100644 --- a/vagrant/hyperv/ubuntu/Vagrantfile +++ b/vagrant/hyperv/ubuntu/Vagrantfile @@ -135,7 +135,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -148,17 +148,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/libvirt/alpine/Vagrantfile b/vagrant/libvirt/alpine/Vagrantfile index 98b474ca..80358d56 100644 --- a/vagrant/libvirt/alpine/Vagrantfile +++ b/vagrant/libvirt/alpine/Vagrantfile @@ -98,17 +98,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/libvirt/arch/Vagrantfile b/vagrant/libvirt/arch/Vagrantfile index 50442648..7f1936ed 100644 --- a/vagrant/libvirt/arch/Vagrantfile +++ b/vagrant/libvirt/arch/Vagrantfile @@ -110,7 +110,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -123,17 +123,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/libvirt/debian/Vagrantfile b/vagrant/libvirt/debian/Vagrantfile index aa28fa21..08977327 100644 --- a/vagrant/libvirt/debian/Vagrantfile +++ b/vagrant/libvirt/debian/Vagrantfile @@ -99,7 +99,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -112,17 +112,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/libvirt/fedora/Vagrantfile b/vagrant/libvirt/fedora/Vagrantfile index b5406aba..ea7e38c0 100644 --- a/vagrant/libvirt/fedora/Vagrantfile +++ b/vagrant/libvirt/fedora/Vagrantfile @@ -110,7 +110,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -123,17 +123,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/libvirt/opensuse/Vagrantfile b/vagrant/libvirt/opensuse/Vagrantfile index f28dffea..62d9e1b3 100644 --- a/vagrant/libvirt/opensuse/Vagrantfile +++ b/vagrant/libvirt/opensuse/Vagrantfile @@ -82,7 +82,7 @@ Vagrant.configure("2") do |config| :autostart => true # node provision node.vm.provision "shell", name: "configure static ip", inline: script_configure_static_ip - node.vm.provision "shell", name: "fix secure_path in sudoers", path: "../../../.assets/provision/fix_secure_path.sh" + node.vm.provision "shell", name: "fix secure_path in sudoers", path: "../../../.assets/fix/fix_secure_path.sh" node.vm.provision "shell", name: "upgrade system...", path: "../../../.assets/provision/upgrade_system.sh" node.vm.provision "shell", name: "install base...", path: "../../../.assets/provision/install_base.sh" # copy source file with bash helper functions @@ -111,7 +111,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -124,17 +124,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/libvirt/ubuntu/Vagrantfile b/vagrant/libvirt/ubuntu/Vagrantfile index 9ba9604b..7d0b46a6 100644 --- a/vagrant/libvirt/ubuntu/Vagrantfile +++ b/vagrant/libvirt/ubuntu/Vagrantfile @@ -112,7 +112,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false node.vm.provision "shell", name: "install xrdp...", path: "../../../.assets/provision/install_xrdp.sh" end if install_kde @@ -125,17 +125,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/virtualbox/fedora/Vagrantfile b/vagrant/virtualbox/fedora/Vagrantfile index e62e1d4e..a38de494 100644 --- a/vagrant/virtualbox/fedora/Vagrantfile +++ b/vagrant/virtualbox/fedora/Vagrantfile @@ -99,7 +99,7 @@ Vagrant.configure("2") do |config| end # node provision node.vm.provision "shell", name: "configure static ip", inline: script_configure_static_ip - node.vm.provision "shell", name: "fix secure_path in sudoers", path: "../../../.assets/provision/fix_secure_path.sh" + node.vm.provision "shell", name: "fix secure_path in sudoers", path: "../../../.assets/fix/fix_secure_path.sh" node.vm.provision "shell", name: "install base...", path: "../../../.assets/provision/install_base.sh" # copy source file with bash helper functions node.vm.provision "file", source: "../../../.assets/provision/source.sh", destination: ".assets/provision/" @@ -111,7 +111,7 @@ Vagrant.configure("2") do |config| node.vm.provision "shell", name: "install miniforge...", path: "../../../.assets/provision/install_miniforge.sh", privileged: false end if install_kubernetes - node.vm.provision "shell", name: "set ulimits on systemd", path: "../../../.assets/provision/set_ulimits.sh" + node.vm.provision "shell", name: "set ulimits on systemd", path: "../../../.assets/setup/set_ulimits.sh" node.vm.provision "shell", name: "install yq...", path: "../../../.assets/provision/install_yq.sh", :args => '>/dev/null' node.vm.provision "shell", name: "install docker...", path: "../../../.assets/provision/install_docker.sh" node.vm.provision "shell", name: "install kubectl...", path: "../../../.assets/provision/install_kubectl.sh", :args => '>/dev/null' @@ -129,7 +129,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false end if install_kde node.vm.provision "shell", name: "install KDE...", path: "../../../.assets/provision/install_kde.sh" @@ -141,17 +141,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/vagrant/virtualbox/ubuntu/Vagrantfile b/vagrant/virtualbox/ubuntu/Vagrantfile index 3ec2f720..556a536a 100644 --- a/vagrant/virtualbox/ubuntu/Vagrantfile +++ b/vagrant/virtualbox/ubuntu/Vagrantfile @@ -132,7 +132,7 @@ Vagrant.configure("2") do |config| end if install_gnome node.vm.provision "shell", name: "install Gnome...", path: "../../../.assets/provision/install_gnome.sh" - node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/provision/setup_gnome.sh", privileged: false + node.vm.provision "shell", name: "set up Gnome...", path: "../../../.assets/setup/setup_gnome.sh", privileged: false end if install_kde node.vm.provision "shell", name: "install KDE...", path: "../../../.assets/provision/install_kde.sh" @@ -144,17 +144,17 @@ Vagrant.configure("2") do |config| if setup_bash || setup_pwsh node.vm.provision "file", source: "../../../.assets/config", destination: "tmp/" node.vm.provision "shell", name: "install oh-my-posh...", path: "../../../.assets/provision/install_omp.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/provision/setup_omp.sh" + node.vm.provision "shell", name: "set up oh-my-posh...", path: "../../../.assets/setup/setup_omp.sh" end if setup_bash - node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/provision/setup_profile_allusers.sh" - node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/provision/setup_profile_user.sh", privileged: false + node.vm.provision "shell", name: "set up bash for all users...", path: "../../../.assets/setup/setup_profile_allusers.sh" + node.vm.provision "shell", name: "set up bash for current user...", path: "../../../.assets/setup/setup_profile_user.sh", privileged: false end if setup_pwsh node.vm.provision "shell", name: "install pwsh...", path: "../../../.assets/provision/install_pwsh.sh", :args => '>/dev/null' - node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/provision/setup_profile_allusers.ps1" - node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/provision/setup_profile_user.ps1", privileged: false - node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/provision/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false + node.vm.provision "shell", name: "set up pwsh for all users...", path: "../../../.assets/setup/setup_profile_allusers.ps1" + node.vm.provision "shell", name: "set up pwsh for current user...", path: "../../../.assets/setup/setup_profile_user.ps1", privileged: false + node.vm.provision "shell", name: "clone ps-modules repo...", path: "../../../.assets/setup/setup_gh_repos.sh", :args => ['--repos', 'szymonos/linux-setup-scripts szymonos/ps-modules'], privileged: false node.vm.provision "shell", name: "install ps-modules...", inline: script_install_psmodules, privileged: false end if copy_ssh_key diff --git a/wsl/wsl_certs_add.ps1 b/wsl/wsl_certs_add.ps1 index 1c1fde41..b8fbb22e 100644 --- a/wsl/wsl_certs_add.ps1 +++ b/wsl/wsl_certs_add.ps1 @@ -142,6 +142,26 @@ process { # copy certificates to the distro specific cert directory and install them $cmnd = "mkdir -p $($crt.path) && install -m 0644 $($tmpFolder.Name)/*.crt $($crt.path) && $($crt.cmnd)" wsl -d $Distro -u root --exec bash -c $cmnd + + # write ca-custom.crt with MITM proxy certificates only + $bundleBuilder = [System.Text.StringBuilder]::new() + foreach ($cert in $certSet) { + $bundleBuilder.Append(($cert | ConvertTo-PEM -AddHeader)) | Out-Null + } + $bundlePath = [IO.Path]::Combine($tmpFolder, 'ca-custom.crt') + [IO.File]::WriteAllText($bundlePath, $bundleBuilder.ToString().Replace("`r`n", "`n")) + $userCertDir = '~/.config/certs' + $cmndCustom = "mkdir -p $userCertDir && install -m 0644 $($tmpFolder.Name)/ca-custom.crt $userCertDir/" + wsl -d $Distro --exec bash -c $cmndCustom + + # create ca-bundle.crt - symlink to system CA bundle (which already includes custom certs) + $cmndBundle = @( + "cd $userCertDir" + 'for f in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt' + 'do if [ -f "$f" ]; then ln -sf "$f" ca-bundle.crt; break; fi' + 'done' + ) -join '; ' + wsl -d $Distro --exec bash -c $cmndBundle } clean { diff --git a/wsl/wsl_setup.ps1 b/wsl/wsl_setup.ps1 index 46875631..7acd1017 100644 --- a/wsl/wsl_setup.ps1 +++ b/wsl/wsl_setup.ps1 @@ -45,8 +45,14 @@ Default: automatically detects based on the system theme. List of GitHub repositories in format "Owner/RepoName" to clone into the WSL. .PARAMETER AddCertificate Intercept and add certificates from chain into selected distro. +.PARAMETER Nix +Use Nix package manager instead of traditional per-tool install scripts. +When omitted, Nix mode is auto-detected from the distro (useful for updates). +Scopes not available in Nix (bun, distrobox, docker) are installed traditionally. .PARAMETER FixNetwork Set network settings from the selected network interface in Windows. +.PARAMETER SkipModulesUpdate +Skip updating installed PowerShell modules (Az, PSReadLine, etc.). .PARAMETER SkipRepoUpdate Skip updating current repository before running the setup. @@ -72,6 +78,10 @@ wsl/wsl_setup.ps1 $Distro -s $Scope -o $OmpTheme -AddCertificate $Repos = @('szymonos/linux-setup-scripts', 'szymonos/ps-modules') wsl/wsl_setup.ps1 $Distro -r $Repos -s $Scope -o $OmpTheme wsl/wsl_setup.ps1 $Distro -r $Repos -s $Scope -o $OmpTheme -AddCertificate +# :set up WSL distro using Nix package manager +wsl/wsl_setup.ps1 $Distro -Nix -s @('shell', 'pwsh') +wsl/wsl_setup.ps1 $Distro -Nix -s @('az', 'k8s_base', 'pwsh', 'docker') +wsl/wsl_setup.ps1 $Distro -Nix -s $Scope -o $OmpTheme # :update all existing WSL distros wsl/wsl_setup.ps1 @@ -95,8 +105,11 @@ param ( [Parameter(ParameterSetName = 'Setup')] [Parameter(ParameterSetName = 'GitHub')] [ValidateScript( - { $_.ForEach({ $_ -in @('az', 'bun', 'conda', 'distrobox', 'docker', 'gcloud', 'k8s_base', 'k8s_dev', 'k8s_ext', 'nodejs', 'oh_my_posh', 'pwsh', 'python', 'rice', 'shell', 'terraform', 'zsh') }) -notcontains $false }, - ErrorMessage = 'Wrong scope provided. Valid values: az conda distrobox docker gcloud k8s_base k8s_dev k8s_ext nodejs pwsh python rice shell terraform zsh') + { + $valid = ([System.IO.File]::ReadAllText("$PSScriptRoot/../.assets/lib/scopes.json") | ConvertFrom-Json).valid_scopes + $_.ForEach({ $_ -in $valid }) -notcontains $false + }, + ErrorMessage = 'Wrong scope provided. Run with -? to see valid values.') ] [string[]]$Scope, @@ -127,13 +140,19 @@ param ( [Parameter(ParameterSetName = 'GitHub')] [switch]$FixNetwork, + [Parameter(ParameterSetName = 'Setup')] + [Parameter(ParameterSetName = 'GitHub')] + [switch]$Nix, + + [switch]$SkipModulesUpdate, + [switch]$SkipRepoUpdate ) begin { $ErrorActionPreference = 'Stop' # check if the script is running on Windows - if ($IsLinux) { + if ($IsLinux -and -not $env:WSL_SETUP_TESTING) { Write-Warning 'This script is intended to be run on Windows only (outside of WSL).' exit 1 } @@ -142,7 +161,7 @@ begin { Push-Location "$PSScriptRoot/.." # import InstallUtils for the Invoke-GhRepoClone function Import-Module (Convert-Path './modules/InstallUtils') -Force - # import SetupUtils for the Set-WslConf function + # import SetupUtils for scope resolution, Set-WslConf, etc. Import-Module (Convert-Path './modules/SetupUtils') -Force if (-not $SkipRepoUpdate) { @@ -244,7 +263,7 @@ begin { $defDistro = $lxss.Where({ $_.Default }).Name if ($defDistro -ne $Distro) { $cmdArgs = @('-u', (wsl.exe --distribution $defDistro -- id -un), '-k') - $gh_cfg = wsl.exe --distribution $defDistro --user root --exec .assets/provision/setup_gh_https.sh @cmdArgs + $gh_cfg = wsl.exe --distribution $defDistro --user root --exec .assets/setup/setup_gh_https.sh @cmdArgs } # get installed distro details $lxss = Get-WslDistro -FromRegistry | Where-Object Name -EQ $Distro @@ -270,14 +289,23 @@ begin { # sets to track success and failed distros $script:successDistros = [System.Collections.Generic.SortedSet[string]]::new() $script:failDistros = [System.Collections.Generic.SortedSet[string]]::new() + # per-distro state for install provenance records + $script:distroRecords = @{} } process { foreach ($lx in $lxss) { $Distro = $lx.Name + $script:distroRecords[$Distro] = @{ + phase = 'distro-check' + scopes = @() + useNix = $false + mode = $PsCmdlet.ParameterSetName -eq 'Update' ? 'update' : 'install' + error = '' + } #region distro checks - $chkStr = wsl.exe -d $Distro --exec .assets/provision/check_distro.sh + $chkStr = wsl.exe -d $Distro --exec .assets/check/check_distro.sh try { $chk = $chkStr | ConvertFrom-Json -AsHashtable -ErrorAction Stop } catch { @@ -285,6 +313,7 @@ process { Show-LogContext "Failed to check the distro '$Distro'." -Level WARNING Write-Host "`nThe WSL seems to be not responding correctly. Run the script again!" Write-Host 'If the problem persists, run the wsl/wsl_restart.ps1 script as administrator and try again.' + $script:distroRecords[$Distro].error = 'distro check failed' exit 1 } if ($chk.uid -eq 0) { @@ -292,7 +321,7 @@ process { Write-Host "`nSetting up user profile in WSL distro. Type 'exit' when finished to proceed with WSL setup!`n" -ForegroundColor Yellow wsl.exe --distribution $Distro # rerun check_distro to get updated user - $chkStr = wsl.exe -d $Distro --exec .assets/provision/check_distro.sh + $chkStr = wsl.exe -d $Distro --exec .assets/check/check_distro.sh try { $chk = $chkStr | ConvertFrom-Json -AsHashtable -ErrorAction Stop } catch { @@ -300,6 +329,7 @@ process { Show-LogContext "Failed to check the distro '$Distro'." -Level WARNING Write-Host "`nThe WSL seems to be not responding correctly. Run the script again!" Write-Host 'If the problem persists, run the wsl/wsl_restart.ps1 script as administrator and try again.' + $script:distroRecords[$Distro].error = 'distro check failed' exit 1 } } else { @@ -310,11 +340,15 @@ process { ) Write-Host $msg # mark distro as failed + $script:distroRecords[$Distro].error = 'distro uses root user' $failDistros.Add($Distro) | Out-Null continue } } + # determine Nix mode: explicit -Nix flag or auto-detected from distro + $useNix = $PSBoundParameters.Nix -or $chk.nix + $scopeSet = [System.Collections.Generic.HashSet[string]]::new() $Scope.ForEach({ $scopeSet.Add($_) | Out-Null }) # *determine additional scopes from distro check @@ -331,18 +365,10 @@ process { { $_.shell } { $scopeSet.Add('shell') | Out-Null } { $_.terraform } { $scopeSet.Add('terraform') | Out-Null } } - # add corresponding scopes - switch (@($scopeSet)) { - az { $scopeSet.Add('python') | Out-Null } - k8s_dev { $scopeSet.Add('k8s_base') | Out-Null } - k8s_ext { @('docker', 'k8s_base', 'k8s_dev').ForEach({ $scopeSet.Add($_) | Out-Null }) } - pwsh { $scopeSet.Add('shell') | Out-Null } - zsh { $scopeSet.Add('shell') | Out-Null } - } - # determine 'oh_my_posh' scope - if ($lx.Version -eq 2 -and ($chk.oh_my_posh -or $OmpTheme)) { - @('oh_my_posh', 'shell').ForEach({ $scopeSet.Add($_) | Out-Null }) - } + # resolve dependencies using shared library + Resolve-ScopeDeps -ScopeSet $scopeSet -OmpTheme $( + if ($lx.Version -eq 2 -and ($chk.oh_my_posh -or $OmpTheme)) { $OmpTheme ? $OmpTheme : 'detect' } else { '' } + ) # remove scopes unavailable in WSL1 if ($lx.Version -eq 1) { $scopeSet.Remove('distrobox') | Out-Null @@ -351,82 +377,68 @@ process { $scopeSet.Remove('oh_my_posh') | Out-Null } - # sort scopes for the specific installation order - [string[]]$scopes = $scopeSet | Sort-Object -Unique { - switch ($_) { - 'docker' { 1 } - 'k8s_base' { 2 } - 'k8s_dev' { 3 } - 'k8s_ext' { 4 } - 'python' { 5 } - 'conda' { 6 } - 'az' { 7 } - 'gcloud' { 8 } - 'bun' { 9 } - 'nodejs' { 10 } - 'terraform' { 11 } - 'oh_my_posh' { 12 } - 'shell' { 13 } - 'zsh' { 14 } - 'pwsh' { 15 } - 'distrobox' { 16 } - 'rice' { 17 } - default { 18 } - } - } + # sort scopes using shared install order + [string[]]$scopes = Get-SortedScopes -ScopeSet $scopeSet # display distro name and installed scopes Write-Host "`n`e[95;1m${Distro}$($scopes.Count ? " :`e[0;90m $($scopes -join ', ')`e[0m" : "`e[0m")" + $script:distroRecords[$Distro].scopes = $scopes + $script:distroRecords[$Distro].useNix = $useNix + $script:distroRecords[$Distro].phase = 'base-setup' #endregion #region perform base setup # *fix WSL networking - $dnsOk = wsl.exe --distribution $Distro --exec .assets/provision/check_dns.sh + $dnsOk = wsl.exe --distribution $Distro --exec .assets/check/check_dns.sh if (-not $PSBoundParameters.FixNetwork -and $dnsOk -eq 'false') { $PSBoundParameters['FixNetwork'] = $FixNetwork = [System.Management.Automation.SwitchParameter]::new($true) } if ($PSBoundParameters.FixNetwork) { Show-LogContext 'fixing network' wsl/wsl_network_fix.ps1 $Distro - $dnsOk = wsl.exe --distribution $Distro --exec .assets/provision/check_dns.sh + $dnsOk = wsl.exe --distribution $Distro --exec .assets/check/check_dns.sh } if ($dnsOk -eq 'false') { + $script:distroRecords[$Distro].error = 'DNS resolution failed' Show-LogContext 'DNS resolution failed. Cannot resolve github.com from WSL. Script execution halted.' -Level ERROR exit 1 } # *install certificates - $sslOk = wsl.exe --distribution $Distro --user root --exec .assets/provision/check_ssl.sh + $sslOk = wsl.exe --distribution $Distro --user root --exec .assets/check/check_ssl.sh if (-not $PSBoundParameters.AddCertificate -and $sslOk -ne 'true') { $PSBoundParameters['AddCertificate'] = $AddCertificate = [System.Management.Automation.SwitchParameter]::new($true) } if ($PSBoundParameters.AddCertificate) { Show-LogContext 'adding certificates in chain' wsl/wsl_certs_add.ps1 $Distro - $sslOk = wsl.exe --distribution $Distro --user root --exec .assets/provision/check_ssl.sh + $sslOk = wsl.exe --distribution $Distro --user root --exec .assets/check/check_ssl.sh } if ($sslOk -eq 'false') { + $script:distroRecords[$Distro].error = 'SSL certificate verification failed' Show-LogContext 'SSL certificate problem: self-signed certificate in certificate chain. Script execution halted.' -Level ERROR exit 1 } # *install packages Show-LogContext 'updating system' - wsl.exe --distribution $Distro --user root --exec .assets/provision/fix_no_file.sh - wsl.exe --distribution $Distro --user root --exec .assets/provision/fix_secure_path.sh + wsl.exe --distribution $Distro --user root --exec .assets/fix/fix_no_file.sh + wsl.exe --distribution $Distro --user root --exec .assets/fix/fix_secure_path.sh wsl.exe --distribution $Distro --user root --exec .assets/provision/upgrade_system.sh - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_base.sh $chk.user - if ($PsCmdlet.ParameterSetName -eq 'Update' -and $chk.pixi) { - Show-LogContext 'updating pixi packages' - wsl.exe --distribution $Distro --cd ~ --exec .pixi/bin/pixi global update + if ($useNix) { + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_base_nix.sh + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_nix.sh + } else { + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_base.sh $chk.user } # *boot setup - wsl.exe --distribution $Distro --user root install -m 0755 .assets/provision/autoexec.sh /etc + wsl.exe --distribution $Distro --user root install -m 0755 .assets/setup/autoexec.sh /etc if (-not $chk.wsl_boot) { Set-WslConf -Distro $Distro -ConfDict ([ordered]@{ boot = @{ command = '"[ -x /etc/autoexec.sh ] && /etc/autoexec.sh || true"' } }) } #endregion + $script:distroRecords[$Distro].phase = 'github' #region setup GitHub # *setup GitHub CLI wsl.exe --distribution $Distro --user root --exec .assets/provision/install_gh.sh @@ -437,16 +449,18 @@ process { if ($Script:gh_cfg -match 'github\.com') { $cmdArgs.AddRange([string[]]@('-c', ($gh_cfg -join "`n"))) } - $gh_cfg = wsl.exe --distribution $Distro --user root --exec .assets/provision/setup_gh_https.sh @cmdArgs + $gh_cfg = wsl.exe --distribution $Distro --user root --exec .assets/setup/setup_gh_https.sh @cmdArgs if (-not $?) { + $script:distroRecords[$Distro].error = 'GitHub authentication failed' Write-Host "`nRun the script again to reconfigure GitHub authentication!`n" -ForegroundColor Yellow exit 1 } # *check SSH keys and create if necessary $sshKey = 'id_ed25519' - $winKey = "$HOME\.ssh\$sshKey" - $winKeyPub = "$HOME\.ssh\$sshKey.pub" + $sshDir = [System.IO.Path]::Combine($HOME, '.ssh') + $winKey = [System.IO.Path]::Combine($sshDir, $sshKey) + $winKeyPub = [System.IO.Path]::Combine($sshDir, "$sshKey.pub") $sshWinPath = "/mnt/$($env:HOMEDRIVE.Replace(':', '').ToLower())$($env:HOMEPATH.Replace('\', '/'))/.ssh" $winKeyExists = (Test-Path $winKey) -and (Test-Path $winKeyPub) @@ -460,10 +474,10 @@ process { wsl.exe --distribution $Distro --exec sh -c $cmnd } elseif (-not $winKeyExists) { # copy WSL SSH keys to Windows - if (Test-Path "$HOME\.ssh") { + if (Test-Path $sshDir) { Remove-Item $winKey, $winKeyPub -ErrorAction SilentlyContinue } else { - New-Item "$HOME\.ssh" -ItemType Directory | Out-Null + New-Item $sshDir -ItemType Directory | Out-Null } # build bash command to generate SSH key if needed and copy to Windows $cmnd = [string]::Join("`n", @@ -475,7 +489,7 @@ process { # generate new SSH key inside WSL if it does not exist $cmnd = [string]::Join("`n", '# generate SSH key if missing', - '.assets/provision/setup_ssh.sh', + '.assets/setup/setup_ssh.sh', $cmnd ) } @@ -485,7 +499,7 @@ process { # *add SSH key to GitHub if needed if ($sshStatus.sshKey -eq 'missing') { try { - $sshStatus = wsl.exe --distribution $Distro --exec .assets/provision/setup_gh_ssh.sh | ConvertFrom-Json -AsHashtable -ErrorAction Stop + $sshStatus = wsl.exe --distribution $Distro --exec .assets/setup/setup_gh_ssh.sh | ConvertFrom-Json -AsHashtable -ErrorAction Stop if ($sshStatus.sshKey -eq 'added') { Clear-Host # display message asking to authorize the SSH key @@ -503,106 +517,83 @@ process { } #endregion + $script:distroRecords[$Distro].phase = 'scopes' #region install scopes - switch ($scopes) { - az { - Show-LogContext 'installing azure-cli' - wsl.exe --distribution $Distro --exec .assets/provision/install_azurecli_uv.sh --fix_certify true - $rel_azcopy = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_azcopy.sh $Script:rel_azcopy - continue - } - bun { - Show-LogContext 'installing bun' - wsl.exe --distribution $Distro --exec .assets/provision/install_bun.sh - continue - } - conda { - Show-LogContext 'installing conda tools' - wsl.exe --distribution $Distro --exec .assets/provision/install_miniforge.sh --fix_certify true - wsl.exe --distribution $Distro --exec .assets/provision/install_pixi.sh - continue - } - distrobox { - Show-LogContext 'installing distrobox' - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_podman.sh - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_distrobox.sh $chk.user - continue - } - docker { + if ($useNix) { + # -- docker: WSL-specific systemd + traditional install (nix doesn't install docker) -- + if ('docker' -in $scopes -and $lx.Version -eq 2) { Show-LogContext 'installing docker' if (-not $chk.systemd) { - # turn on systemd for docker autostart wsl/wsl_systemd.ps1 $Distro -Systemd 'true' wsl.exe --shutdown } wsl.exe --distribution $Distro --user root --exec .assets/provision/install_docker.sh $chk.user - continue } - gcloud { - Show-LogContext 'installing google-cloud-cli' - $rel_gcloud = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_gcloud.sh $Script:rel_gcloud - wsl.exe --distribution $Distro --user root --exec .assets/provision/fix_gcloud_certs.sh - continue + + # -- build nix/setup.sh arguments -- + $nixArgs = [System.Collections.Generic.List[string]]::new() + $nixArgs.AddRange([string[]]@('--unattended', '--quiet-summary')) + if (-not $PSBoundParameters.SkipModulesUpdate) { + $nixArgs.Add('--update-modules') } - k8s_base { - Show-LogContext 'installing kubernetes base packages' - $rel_kubectl = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubectl.sh $Script:rel_kubectl && $($chk.k8s_base = $true) - $rel_kubelogin = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubelogin.sh $Script:rel_kubelogin - $rel_k9s = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_k9s.sh $Script:rel_k9s - $rel_kubecolor = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubecolor.sh $Script:rel_kubecolor - $rel_kubectx = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubectx.sh $Script:rel_kubectx - continue + # map scopes to nix flags (exclude distrobox, docker, pwsh - installed system-wide; + # oh_my_posh/starship - handled via --omp-theme/--starship-theme) + foreach ($sc in $scopes) { + if ($sc -notin @('distrobox', 'docker', 'oh_my_posh', 'pwsh', 'starship')) { + $nixArgs.Add("--$($sc -replace '_', '-')") + } } - k8s_dev { - Show-LogContext 'installing kubernetes dev packages' - $rel_argoroll = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_argorolloutscli.sh $Script:rel_argoroll - $rel_cilium = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_cilium.sh $Script:rel_cilium - $rel_flux = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_flux.sh $Script:rel_flux - $rel_helm = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_helm.sh $Script:rel_helm - $rel_hubble = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_hubble.sh $Script:rel_hubble - $rel_kustomize = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kustomize.sh $Script:rel_kustomize - $rel_trivy = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_trivy.sh $Script:rel_trivy - continue + if ($OmpTheme) { + $nixArgs.AddRange([string[]]@('--omp-theme', $OmpTheme)) } - k8s_ext { - wsl.exe --distribution $Distro --exec sh -c '[ -f /usr/bin/docker ] && true || false' - if ($?) { - Show-LogContext 'installing local kubernetes tools' - $rel_minikube = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_minikube.sh $Script:rel_minikube - $rel_k3d = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_k3d.sh $Script:rel_k3d - $rel_kind = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kind.sh $Script:rel_kind - } else { - Show-LogContext 'docker not found, skipping local kubernetes tools installation' -Level WARNING - } + + # -- run nix setup (packages + configure scripts + profiles) -- + Show-LogContext 'running nix setup' + wsl.exe --distribution $Distro --exec nix/setup.sh @nixArgs + if (-not $?) { + Show-LogContext 'nix/setup.sh failed' -Level ERROR + $script:distroRecords[$Distro].error = 'nix/setup.sh failed' + $failDistros.Add($Distro) | Out-Null continue } - nodejs { - Show-LogContext 'installing Node.js' - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_nodejs.sh - if ($AddCertificate) { - wsl.exe --distribution $Distro --user root --exec .assets/provision/fix_nodejs_certs.sh - } - continue + + # -- scopes not available in nix (traditional install) -- + if ('distrobox' -in $scopes -and $lx.Version -eq 2) { + Show-LogContext 'installing distrobox' + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_podman.sh + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_distrobox.sh $chk.user } - oh_my_posh { - Show-LogContext 'installing oh-my-posh' - $rel_omp = try { wsl.exe --distribution $Distro --user root --exec .assets/provision/install_omp.sh $Script:rel_omp.version $Script:rel_omp.download_url | ConvertFrom-Json } catch { $null } - if ($OmpTheme) { - wsl.exe --distribution $Distro --user root --exec .assets/provision/setup_omp.sh --theme $OmpTheme --user $chk.user - } - continue + + # -- nodejs cert fix (WSL-specific, after nix installs nodejs) -- + if ('nodejs' -in $scopes -and $AddCertificate) { + wsl.exe --distribution $Distro --user root --exec .assets/fix/fix_nodejs_certs.sh } - pwsh { + + # -- pwsh: system-wide install + ps-modules + Az modules + WSLENV -- + if ('pwsh' -in $scopes) { Show-LogContext 'installing pwsh' $rel_pwsh = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_pwsh.sh $Script:rel_pwsh && $($chk.pwsh = $true) + $pwshOk = wsl.exe --distribution $Distro --exec sh -c 'command -v pwsh >/dev/null && echo true || echo false' + if ($pwshOk -ne 'true') { + Show-LogContext 'pwsh installation failed, skipping PowerShell setup' -Level WARNING + } else { # setup profiles + $profileArgs = [System.Collections.Generic.List[string]]::new() + $profileArgs.AddRange([string[]]@('--distribution', $Distro, '--user', 'root', '--exec', '.assets/setup/setup_profile_allusers.ps1', '-UserName', $chk.user)) + if (-not $PSBoundParameters.SkipModulesUpdate) { $profileArgs.Add('-UpdateModules') } Show-LogContext 'setting up profile for all users' - wsl.exe --distribution $Distro --user root --exec .assets/provision/setup_profile_allusers.ps1 -UserName $chk.user + wsl.exe @profileArgs + $profileArgs = [System.Collections.Generic.List[string]]::new() + $profileArgs.AddRange([string[]]@('--distribution', $Distro, '--exec', '.assets/setup/setup_profile_user.ps1')) + if (-not $PSBoundParameters.SkipModulesUpdate) { $profileArgs.Add('-UpdateModules') } Show-LogContext 'setting up profile for current user' - wsl.exe --distribution $Distro --exec .assets/provision/setup_profile_user.ps1 + wsl.exe @profileArgs + # run nix-specific PowerShell profile setup (Nix PATH + devenv aliases) + # must run after pwsh install since nix/setup.sh skips it when pwsh is absent + Show-LogContext 'configuring nix PowerShell profile' + wsl.exe --distribution $Distro --exec pwsh -nop -f nix/configure/profiles.ps1 # *install PowerShell modules from ps-modules repository - # clone/refresh szymonos/ps-modules repository $repoClone = Invoke-GhRepoClone -OrgRepo 'szymonos/ps-modules' -Path '..' if ($repoClone) { Write-Verbose "Repository `"ps-modules`" $($repoClone -eq 1 ? 'cloned': 'refreshed') successfully." @@ -611,10 +602,8 @@ process { } Show-LogContext 'installing ps-modules' Write-Host "`e[32mAllUsers :`e[0;90m do-common`e[0m" - wsl.exe --distribution $Distro --user root --exec ../ps-modules/module_manage.ps1 'do-common' -CleanUp - # instantiate psmodules generic lists + wsl.exe --distribution $Distro --user root -- ../ps-modules/module_manage.ps1 'do-common' -CleanUp $modules = [System.Collections.Generic.SortedSet[String]]::new([string[]]@('aliases-git', 'do-linux')) - # determine modules to install if ('az' -in $scopes) { $modules.Add('do-az') | Out-Null Write-Verbose "Added `e[3mdo-az`e[23m to be installed from ps-modules." @@ -625,7 +614,7 @@ process { } Write-Host "`e[32mCurrentUser :`e[0;90m $($modules -join ', ')`e[0m" $cmd = "@($($modules | Join-String -SingleQuote -Separator ',')) | ../ps-modules/module_manage.ps1 -CleanUp" - wsl.exe --distribution $Distro --exec pwsh -nop -c $cmd + wsl.exe --distribution $Distro -- pwsh -nop -c $cmd # *install PowerShell Az modules if ('az' -in $scopes) { $cmd = [string]::Join("`n", @@ -655,59 +644,225 @@ process { } $pwshEnvSet = $false } - continue - } - python { - Show-LogContext 'installing python tools' - wsl.exe --distribution $Distro --user root --exec .assets/provision/setup_python.sh - $rel_uv = wsl.exe --distribution $Distro --exec .assets/provision/install_uv.sh $Script:rel_uv - $rel_prek = wsl.exe --distribution $Distro --exec .assets/provision/install_prek.sh $Script:rel_prek - continue - } - rice { - Show-LogContext 'ricing distro ' - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_btop.sh - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_cmatrix.sh - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_cowsay.sh - $rel_ff = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_fastfetch.sh $Script:rel_ff - continue - } - shell { - Show-LogContext 'installing shell packages' - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_fzf.sh - $rel_eza = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_eza.sh $Script:rel_eza - $rel_bat = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_bat.sh $Script:rel_bat - $rel_rg = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_ripgrep.sh $Script:rel_rg - $rel_yq = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_yq.sh $Script:rel_yq - # setup bash profiles - Show-LogContext 'setting up profile for all users' - wsl.exe --distribution $Distro --user root --exec .assets/provision/setup_profile_allusers.sh $chk.user - Show-LogContext 'setting up profile for current user' - wsl.exe --distribution $Distro --exec .assets/provision/setup_profile_user.sh - # install copilot-cli - Show-LogContext 'installing copilot-cli' - wsl.exe --distribution $Distro --exec .assets/provision/install_copilot.sh - continue - } - terraform { - Show-LogContext 'installing terraform utils' - $rel_tf = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_terraform.sh $Script:rel_tf - $rel_trs = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_terrascan.sh $Script:rel_trs - $rel_tfl = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_tflint.sh $Script:rel_tfl - $rel_tfs = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_tfswitch.sh $Script:rel_tfs - continue + } # else pwshOk } - zsh { - Show-LogContext 'installing zsh' - wsl.exe --distribution $Distro --user root --exec .assets/provision/install_zsh.sh - # setup profiles - Show-LogContext 'setting up zsh profile for current user' - wsl.exe --distribution $Distro --exec .assets/provision/setup_profile_user.zsh - continue + } else { + switch ($scopes) { + az { + Show-LogContext 'installing azure-cli' + wsl.exe --distribution $Distro --exec .assets/provision/install_azurecli_uv.sh --fix_certify true + $rel_azcopy = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_azcopy.sh $Script:rel_azcopy + continue + } + bun { + Show-LogContext 'installing bun' + wsl.exe --distribution $Distro --exec .assets/provision/install_bun.sh + continue + } + conda { + Show-LogContext 'installing conda tools' + wsl.exe --distribution $Distro --exec .assets/provision/install_miniforge.sh --fix_certify true + continue + } + distrobox { + Show-LogContext 'installing distrobox' + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_podman.sh + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_distrobox.sh $chk.user + continue + } + docker { + Show-LogContext 'installing docker' + if (-not $chk.systemd) { + # turn on systemd for docker autostart + wsl/wsl_systemd.ps1 $Distro -Systemd 'true' + wsl.exe --shutdown + } + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_docker.sh $chk.user + continue + } + gcloud { + Show-LogContext 'installing google-cloud-cli' + $rel_gcloud = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_gcloud.sh $Script:rel_gcloud + wsl.exe --distribution $Distro --user root --exec .assets/fix/fix_gcloud_certs.sh + continue + } + k8s_base { + Show-LogContext 'installing kubernetes base packages' + $rel_kubectl = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubectl.sh $Script:rel_kubectl && $($chk.k8s_base = $true) + $rel_kubelogin = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubelogin.sh $Script:rel_kubelogin + $rel_k9s = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_k9s.sh $Script:rel_k9s + $rel_kubecolor = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubecolor.sh $Script:rel_kubecolor + $rel_kubectx = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kubectx.sh $Script:rel_kubectx + continue + } + k8s_dev { + Show-LogContext 'installing kubernetes dev packages' + $rel_argoroll = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_argorolloutscli.sh $Script:rel_argoroll + $rel_cilium = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_cilium.sh $Script:rel_cilium + $rel_flux = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_flux.sh $Script:rel_flux + $rel_helm = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_helm.sh $Script:rel_helm + $rel_hubble = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_hubble.sh $Script:rel_hubble + $rel_kustomize = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kustomize.sh $Script:rel_kustomize + $rel_trivy = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_trivy.sh $Script:rel_trivy + continue + } + k8s_ext { + wsl.exe --distribution $Distro --exec sh -c '[ -f /usr/bin/docker ] && true || false' + if ($?) { + Show-LogContext 'installing local kubernetes tools' + $rel_minikube = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_minikube.sh $Script:rel_minikube + $rel_k3d = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_k3d.sh $Script:rel_k3d + $rel_kind = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_kind.sh $Script:rel_kind + } else { + Show-LogContext 'docker not found, skipping local kubernetes tools installation' -Level WARNING + } + continue + } + nodejs { + Show-LogContext 'installing Node.js' + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_nodejs.sh + if ($AddCertificate) { + wsl.exe --distribution $Distro --user root --exec .assets/fix/fix_nodejs_certs.sh + } + continue + } + oh_my_posh { + Show-LogContext 'installing oh-my-posh' + $rel_omp = try { wsl.exe --distribution $Distro --user root --exec .assets/provision/install_omp.sh $Script:rel_omp.version $Script:rel_omp.download_url | ConvertFrom-Json } catch { $null } + if ($OmpTheme) { + wsl.exe --distribution $Distro --user root --exec .assets/setup/setup_omp.sh --theme $OmpTheme --user $chk.user + } + continue + } + pwsh { + Show-LogContext 'installing pwsh' + $rel_pwsh = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_pwsh.sh $Script:rel_pwsh && $($chk.pwsh = $true) + $pwshOk = wsl.exe --distribution $Distro --exec sh -c 'command -v pwsh >/dev/null && echo true || echo false' + if ($pwshOk -ne 'true') { + Show-LogContext 'pwsh installation failed, skipping PowerShell setup' -Level WARNING + } else { + # setup profiles + $profileArgs = [System.Collections.Generic.List[string]]::new() + $profileArgs.AddRange([string[]]@('--distribution', $Distro, '--user', 'root', '--exec', '.assets/setup/setup_profile_allusers.ps1', '-UserName', $chk.user)) + if (-not $PSBoundParameters.SkipModulesUpdate) { $profileArgs.Add('-UpdateModules') } + Show-LogContext 'setting up profile for all users' + wsl.exe @profileArgs + $profileArgs = [System.Collections.Generic.List[string]]::new() + $profileArgs.AddRange([string[]]@('--distribution', $Distro, '--exec', '.assets/setup/setup_profile_user.ps1')) + if (-not $PSBoundParameters.SkipModulesUpdate) { $profileArgs.Add('-UpdateModules') } + Show-LogContext 'setting up profile for current user' + wsl.exe @profileArgs + + # *install PowerShell modules from ps-modules repository + # clone/refresh szymonos/ps-modules repository + $repoClone = Invoke-GhRepoClone -OrgRepo 'szymonos/ps-modules' -Path '..' + if ($repoClone) { + Write-Verbose "Repository `"ps-modules`" $($repoClone -eq 1 ? 'cloned': 'refreshed') successfully." + } else { + Write-Error 'Cloning ps-modules repository failed.' + } + Show-LogContext 'installing ps-modules' + Write-Host "`e[32mAllUsers :`e[0;90m do-common`e[0m" + wsl.exe --distribution $Distro --user root --exec ../ps-modules/module_manage.ps1 'do-common' -CleanUp + # instantiate psmodules generic lists + $modules = [System.Collections.Generic.SortedSet[String]]::new([string[]]@('aliases-git', 'do-linux')) + # determine modules to install + if ('az' -in $scopes) { + $modules.Add('do-az') | Out-Null + Write-Verbose "Added `e[3mdo-az`e[23m to be installed from ps-modules." + } + if ('k8s_base' -in $scopes) { + $modules.Add('aliases-kubectl') | Out-Null + Write-Verbose "Added `e[3maliases-kubectl`e[23m to be installed from ps-modules." + } + Write-Host "`e[32mCurrentUser :`e[0;90m $($modules -join ', ')`e[0m" + $cmd = "@($($modules | Join-String -SingleQuote -Separator ',')) | ../ps-modules/module_manage.ps1 -CleanUp" + wsl.exe --distribution $Distro --exec pwsh -nop -c $cmd + # *install PowerShell Az modules + if ('az' -in $scopes) { + $cmd = [string]::Join("`n", + 'if (-not (Get-Module -ListAvailable "Az")) {', + "`tWrite-Host 'installing Az...'", + "`tInvoke-CommandRetry { Install-PSResource Az -WarningAction SilentlyContinue -ErrorAction Stop }`n}", + 'if (-not (Get-Module -ListAvailable "Az.ResourceGraph")) {', + "`tWrite-Host 'installing Az.ResourceGraph...'", + "`tInvoke-CommandRetry { Install-PSResource Az.ResourceGraph -ErrorAction Stop }`n}" + ) + wsl.exe --distribution $Distro -- pwsh -nop -c $cmd + } + # *set persistent environment variables for pwsh + if ($pwshEnvSet) { + $envVars = @{ + POWERSHELL_TELEMETRY_OPTOUT = '1' + POWERSHELL_UPDATECHECK = 'Off' + } + foreach ($key in $envVars.Keys) { + if ([System.Environment]::GetEnvironmentVariable($key, 'User') -ne $envVars[$key]) { + [System.Environment]::SetEnvironmentVariable($key, $envVars[$key], 'User') + } + $wslEnv = [System.Environment]::GetEnvironmentVariable('WSLENV', 'User') + if ($wslEnv -notmatch "\b$key\b") { + [System.Environment]::SetEnvironmentVariable('WSLENV', "${wslEnv}$($wslEnv ? ':' : '')${key}/u", 'User') + } + } + $pwshEnvSet = $false + } + } # else pwshOk + continue + } + python { + Show-LogContext 'installing python tools' + wsl.exe --distribution $Distro --user root --exec .assets/setup/setup_python.sh + $rel_uv = wsl.exe --distribution $Distro --exec .assets/provision/install_uv.sh $Script:rel_uv + $rel_prek = wsl.exe --distribution $Distro --exec .assets/provision/install_prek.sh $Script:rel_prek + continue + } + rice { + Show-LogContext 'ricing distro ' + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_btop.sh + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_cmatrix.sh + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_cowsay.sh + $rel_ff = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_fastfetch.sh $Script:rel_ff + continue + } + shell { + Show-LogContext 'installing shell packages' + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_fzf.sh + $rel_eza = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_eza.sh $Script:rel_eza + $rel_bat = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_bat.sh $Script:rel_bat + $rel_rg = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_ripgrep.sh $Script:rel_rg + $rel_yq = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_yq.sh $Script:rel_yq + # setup bash profiles + Show-LogContext 'setting up profile for all users' + wsl.exe --distribution $Distro --user root --exec .assets/setup/setup_profile_allusers.sh $chk.user + Show-LogContext 'setting up profile for current user' + wsl.exe --distribution $Distro --exec .assets/setup/setup_profile_user.sh + # install copilot-cli + Show-LogContext 'installing copilot-cli' + wsl.exe --distribution $Distro --exec .assets/provision/install_copilot.sh + continue + } + terraform { + Show-LogContext 'installing terraform utils' + $rel_tf = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_terraform.sh $Script:rel_tf + $rel_trs = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_terrascan.sh $Script:rel_trs + $rel_tfl = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_tflint.sh $Script:rel_tfl + $rel_tfs = wsl.exe --distribution $Distro --user root --exec .assets/provision/install_tfswitch.sh $Script:rel_tfs + continue + } + zsh { + Show-LogContext 'installing zsh' + wsl.exe --distribution $Distro --user root --exec .assets/provision/install_zsh.sh + # setup profiles + Show-LogContext 'setting up zsh profile for current user' + wsl.exe --distribution $Distro --exec .assets/setup/setup_profile_user.zsh + continue + } } } #endregion + $script:distroRecords[$Distro].phase = 'post-install' #region set gtk theme for wslg if ($lx.Version -eq 2 -and $chk.wslg) { $GTK_THEME = if ($GtkTheme -eq 'light') { @@ -776,12 +931,13 @@ process { #endregion # mark distro as successfully set up + $script:distroRecords[$Distro].phase = 'complete' $successDistros.Add($Distro) | Out-Null } #region clone GitHub repositories if ($PsCmdlet.ParameterSetName -eq 'GitHub' -and $Distro -notin $failDistros) { Show-LogContext 'cloning GitHub repositories' - wsl.exe --distribution $Distro --exec .assets/provision/setup_gh_repos.sh --repos "$Repos" + wsl.exe --distribution $Distro --exec .assets/setup/setup_gh_repos.sh --repos "$Repos" } #endregion } @@ -806,5 +962,25 @@ end { } clean { + # write install provenance record inside each processed WSL distro + foreach ($name in $script:distroRecords.Keys) { + $rec = $script:distroRecords[$name] + $status = if ($name -in $script:successDistros) { 'success' } else { 'failed' } + $irScopes = ($rec.scopes -join ' ').Trim() + $bashCmd = [string]::Join("`n", + "source .assets/lib/install_record.sh", + "_IR_ENTRY_POINT='wsl/$($rec.useNix ? 'nix' : 'legacy')'", + "_IR_SCRIPT_ROOT='`$(pwd)'", + "_IR_SCOPES='$irScopes'", + "_IR_MODE='$($rec.mode)'", + "_IR_PLATFORM='WSL'", + "write_install_record '$status' '$($rec.phase)' '$($rec.error)'" + ) + try { + wsl.exe --distribution $name --exec bash -c $bashCmd 2>$null + } catch { + # best-effort: don't fail cleanup if WSL distro is unreachable + } + } Pop-Location }