From 9cb18a795d970d56b19421d160aa3d2f7fd5b7e4 Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Sun, 4 Oct 2026 23:44:18 -0400 Subject: [PATCH] Coexist with a desktop that already manages its tools (Omarchy) On an Omarchy laptop bootstrap left two of almost everything: VS Code from both pacman and the tarball (two menu entries, one shared ~/.config/Code profile), and kubectl/helm/k9s/kubectx/yq/jq/node/ansible in ~/.local/bin behind mise and pacman copies that always won on PATH. The zsh helpers (op*, grmb, secrets.env) also never reached the bash login shell. - lib/fetch.sh: add `shadowed`, true when another copy of a tool comes earlier on PATH than ~/.local/bin. Always false where zshrc puts ~/.local/bin first, so VM installs are unchanged. - bin/, node/, ansible/: skip tools that are shadowed. - vscode/: defer to any system `code`, and remove a tarball copy this module installed earlier. - bash/: new module; bash port of the zshrc helpers, sourced from ~/.bashrc via a marked block. No-ops without ~/.bashrc. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++++ ansible/install.sh | 9 ++++ bash/dotfiles.bash | 110 +++++++++++++++++++++++++++++++++++++++++++++ bash/install.sh | 47 +++++++++++++++++++ bin/install.sh | 43 ++++++++++-------- bootstrap.sh | 2 + lib/fetch.sh | 32 +++++++++++++ node/install.sh | 7 +++ vscode/install.sh | 42 ++++++++++++----- zsh/zshrc | 3 ++ 10 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 bash/dotfiles.bash create mode 100755 bash/install.sh diff --git a/README.md b/README.md index 1550ccb..b333552 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ Already have a clone? Just run it directly: (robbyrussell theme, git plugin), and sets zsh as the login shell. The managed rc sources `~/.zshrc.local` last for per-machine customizations (see below). +- `bash/` — for hosts that keep bash as the login shell (Omarchy), copies a + bash port of the `zsh/zshrc` helpers (`opr`/`opc`/`opd`/`opl`/`ops`, + `grmb`, and `secrets.env` loading) to `~/.config/dotfiles/dotfiles.bash` and + adds one marked source line to `~/.bashrc`. Never owns `~/.bashrc`; no-ops + when there is none. No sudo. - `git/` — sets git `user.name`/`user.email` and configures `credential.helper store` (plaintext `~/.git-credentials`, 0600) for HTTPS auth. @@ -48,6 +53,10 @@ Already have a clone? Just run it directly: redeploys with no reinstall. Idempotent: each tool records its version under `~/.local/bin/.versions` and is skipped when the pinned version is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`. + A tool is also skipped when another copy comes *earlier* on `PATH` than + `~/.local/bin` (e.g. mise or pacman on Omarchy, where `~/.local/bin` is last), + since a copy there would never run. `node/` and `ansible/` follow the same + rule. On boxes where zshrc puts `~/.local/bin` first, nothing changes. - `secrets/` — reads your 1Password vault once and writes every field to `~/.config/dotfiles/secrets.env` (0600), which the managed `~/.zshrc` sources, so opening a shell never calls `op`. One vault per user and one @@ -86,6 +95,10 @@ Already have a clone? Just run it directly: Also sets `password-store: basic` in `~/.vscode/argv.json` so KDE's kwallet does not prompt for an unlock on every launch (see below). **No-ops on headless hosts** — it's a ~400MB GUI app — override with `DOTFILES_VSCODE`. + **Defers to a system VS Code** (any `code` on `PATH` outside `~/.local/bin`, + e.g. Arch's `visual-studio-code-bin`): it skips the install and removes a + tarball copy it put there earlier, since two builds would show two menu + entries and share one `~/.config/Code` profile. - `docker-rootless-cgroup/` — on systemd-less hosts (e.g. Kasm), hand-delegates cgroup v2 controllers to the rootless Docker daemon so per-container cgroups work (rootless Docker otherwise comes up with diff --git a/ansible/install.sh b/ansible/install.sh index 22af17d..33c8159 100755 --- a/ansible/install.sh +++ b/ansible/install.sh @@ -15,6 +15,8 @@ REPO_ROOT="$(dirname -- "$SCRIPT_DIR")" # shellcheck source=../lib/os-detect.sh . "$REPO_ROOT/lib/os-detect.sh" +# shellcheck source=../lib/fetch.sh +. "$REPO_ROOT/lib/fetch.sh" say() { printf '%s\n' "$*" >&2; } @@ -22,6 +24,13 @@ VENV="$HOME/.local/opt/ansible" BIN_DIR="$HOME/.local/bin" ANSIBLE_CLIS="ansible ansible-playbook ansible-galaxy ansible-vault ansible-config ansible-doc ansible-inventory ansible-console ansible-pull" +# Another ansible (mise, pipx, a distro package) already wins on PATH, so this +# venv would never run -- see `shadowed` in lib/fetch.sh. +if _winner="$(shadowed ansible)"; then + say "ansible: $_winner comes first on PATH; skipping (a copy in $BIN_DIR would never run)." + exit 0 +fi + # Verify it actually runs, not just that the file exists: a Kasm redeploy can # wipe the system python3 the venv links to, leaving a present-but-broken # venv that must be rebuilt. diff --git a/bash/dotfiles.bash b/bash/dotfiles.bash new file mode 100644 index 0000000..937b040 --- /dev/null +++ b/bash/dotfiles.bash @@ -0,0 +1,110 @@ +# Managed by dotfiles/bash/install.sh — copied to ~/.config/dotfiles/dotfiles.bash +# and overwritten on the next bootstrap run. Edit bash/dotfiles.bash in the +# dotfiles repo instead. +# +# The bash port of the helpers in zsh/zshrc, for hosts that keep bash as the +# login shell (Omarchy ships its own bash config and the zsh module cannot +# install zsh there). Only the helpers: PATH, prompt and history stay with the +# host's own rc. Keep the two in sync — see zsh/zshrc for the full rationale +# behind each function. + +# grmb ... — delete remote branches; takes `origin/foo` or `foo`. +grmb() { + local remote=origin b rc=0 + if [ "$#" -eq 0 ]; then + echo "grmb: usage: grmb ... (origin/ prefix optional)" >&2 + return 2 + fi + git fetch --prune --quiet "$remote" || return 1 + for b in "$@"; do + b=${b#"${remote}"/} + if git push "$remote" --delete "$b"; then + echo "deleted ${remote}/${b}" + else + echo "grmb: could not delete ${b} (already gone? check: git ls-remote --heads ${remote})" >&2 + rc=1 + fi + done + return $rc +} + +# Secrets from this user's 1Password vault, written by dotfiles/secrets/install.sh. +# Reading a local 0600 file keeps shell startup off the network. +_DOTFILES_CONF="$HOME/.config/dotfiles" +[ -f "$_DOTFILES_CONF/secrets.env" ] && source "$_DOTFILES_CONF/secrets.env" + +# opr NAME | opc NAME[=VALUE] | opc NAME VALUE | opd NAME | opl [VAULT [ITEM]] | ops +# Same behaviour as zsh/zshrc. The service-account token is loaded only inside +# the subshells, never into this shell's environment. +_dotfiles_op() { + [ -f "$_DOTFILES_CONF/op.env" ] || { echo "no $_DOTFILES_CONF/op.env; run: ops" >&2; return 1; } + ( set -a; source "$_DOTFILES_CONF/op.env"; set +a; "$@" ) +} +_dotfiles_env_name() { + [[ $1 =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && return 0 + echo "$2: '$1' is not a valid variable name" >&2; return 2 +} +opr() { + _dotfiles_env_name "$1" opr || return + _dotfiles_op sh -c 'op read "op://$OP_VAULT/$OP_ITEM/$1"' opr "$1" +} +opc() { + local name=$1 value= + if [[ $1 == *=* ]]; then + # NAME=VALUE: split on the first '=' so values may contain '=' + name=${1%%=*} value=${1#*=} + elif (( $# >= 2 )); then + value=$2 + fi + _dotfiles_env_name "$name" opc || return + if [[ -z $value ]]; then + read -rsp "$name: " value && echo + [[ -n $value ]] || { echo "opc: empty value for $name; nothing changed" >&2; return 1; } + fi + # op's exit code is unreliable after an edit it did save; read the field back. + _dotfiles_op sh -c ' + clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; } + ref="op://$OP_VAULT/$OP_ITEM/$1" + err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" 2>&1 >/dev/null) + if [ "$(op read "$ref" 2>/dev/null)" = "$2" ]; then + printf "opc: saved %s\n" "$ref" >&2 + else + printf "opc: could not save %s\n op said: %s\n check vault, item and service-account write access with: opl\n" \ + "$ref" "$(clean "$err")" >&2 + exit 1 + fi' opc "$name" "$value" && ops +} +opd() { + _dotfiles_env_name "$1" opd || return + _dotfiles_op sh -c ' + clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; } + ref="op://$OP_VAULT/$OP_ITEM/$1" + err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" 2>&1 >/dev/null) + if ! item=$(op item get "$OP_ITEM" --vault "$OP_VAULT" --format json 2>&1); then + printf "opd: could not re-read %s to confirm the delete\n op said: %s\n" "$ref" "$(clean "$item")" >&2 + exit 1 + elif printf "%s" "$item" | jq -r ".fields[].label" | grep -qxF "$1"; then + printf "opd: could not delete %s\n op said: %s\n" "$ref" "$(clean "$err")" >&2 + exit 1 + fi + printf "opd: deleted %s\n" "$ref" >&2' opd "$1" \ + && unset "$1" && ops +} +opl() { + _dotfiles_op sh -c ' + vault=${1:-$OP_VAULT} item=${2:-$OP_ITEM} + printf "op://%s/%s\n" "$vault" "$item" >&2 + op item get "$item" --vault "$vault" --reveal --format json' opl "$@" \ + | jq -r ' + [.fields[] | select(.purpose != "NOTES")] as $f + | ([$f[] | .label // "" | length] | max // 0) as $w + | $f[] + | ((.label // "") + " " * ($w - ((.label // "") | length))) as $name + | ((.value // "") | split("\n")) as $lines + | "\($name) \($lines[0] // "")", + ($lines[1:][] | (" " * ($w + 2)) + .)' +} +ops() { + "${DOTFILES_DIR:-$HOME/.dotfiles}/secrets/install.sh" \ + && source "$_DOTFILES_CONF/secrets.env" +} diff --git a/bash/install.sh b/bash/install.sh new file mode 100755 index 0000000..0f76ec6 --- /dev/null +++ b/bash/install.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +# bash/install.sh — give bash the dotfiles shell helpers (opr/opc/opd/opl/ops, +# grmb, and secrets.env loading). +# +# WHY: those helpers lived only in the managed ~/.zshrc, so on a host that +# keeps bash as its login shell they silently did not exist. Omarchy is that +# host: it ships its own ~/.bashrc and default rc, and zsh/ cannot install zsh +# there (no dnf/apt). secrets/ still wrote secrets.env, but nothing loaded it, +# so every vault-backed variable was simply unset in new shells. +# +# Unlike zsh/, this never owns ~/.bashrc -- the host's rc stays in charge. It +# copies bash/dotfiles.bash to ~/.config/dotfiles/ and adds one marked source +# line, the same pattern mcp-config/ uses. Skipped when there is no ~/.bashrc +# (a zsh-only box has no use for it). No sudo. Idempotent. +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +say() { printf '%s\n' "$*" >&2; } + +BASHRC="$HOME/.bashrc" +CONF_DIR="$HOME/.config/dotfiles" +DEST="$CONF_DIR/dotfiles.bash" +MARK_BEGIN="# >>> dotfiles helpers >>>" +MARK_END="# <<< dotfiles helpers <<<" + +if [ ! -f "$BASHRC" ]; then + say "bash: no $BASHRC; skipping." + exit 0 +fi + +mkdir -p "$CONF_DIR" +chmod 700 "$CONF_DIR" +install -m 0644 "$SCRIPT_DIR/dotfiles.bash" "$DEST" +say "bash: wrote $DEST" + +if grep -qxF "$MARK_BEGIN" "$BASHRC"; then + say "bash: $BASHRC already sources it." +else + cat >>"$BASHRC" < also skips the tool when another already +# wins on PATH (see `shadowed` in lib/fetch.sh): a copy here would never run. FAILED="" try() { + if _winner="$(shadowed "$2")"; then + fsay "$2: $_winner comes first on PATH; skipping (a copy in $BIN_DIR would never run)." + return 0 + fi if ( set -e; "$1" ); then return 0; fi FAILED="$FAILED ${1#install_}" fsay "WARNING: ${1#install_} failed to install; continuing." } -try install_jq -try install_kubectl -try install_oidc_login -try install_helm -try install_k9s +try install_jq jq +try install_kubectl kubectl +try install_oidc_login kubectl-oidc_login +try install_helm helm +try install_k9s k9s if docker_full_available; then - try install_kind + try install_kind kind else fsay "Skipping kind: full (non-rootless) Docker not available." fi -try install_kubectx -try install_kubens -try install_stern -try install_yq -try install_tofu -try install_task -try install_terragrunt -try install_1p -try install_opencode -try install_zarf -try install_packer -try install_coder +try install_kubectx kubectx +try install_kubens kubens +try install_stern stern +try install_yq yq +try install_tofu tofu +try install_task task +try install_terragrunt terragrunt +try install_1p op +try install_opencode opencode +try install_zarf zarf +try install_packer packer +try install_coder coder if [ -n "$FAILED" ]; then fsay "" diff --git a/bootstrap.sh b/bootstrap.sh index c5118b0..2f832cd 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -78,6 +78,8 @@ run_module() { } run_module zsh +# bash/ is zsh/'s helpers for hosts that keep bash (Omarchy); no-ops without ~/.bashrc. +run_module bash run_module git run_module bin # Needs op and jq from bin/. Runs before everything that consumes a secret, and diff --git a/lib/fetch.sh b/lib/fetch.sh index a381d9d..fa2d231 100644 --- a/lib/fetch.sh +++ b/lib/fetch.sh @@ -55,6 +55,38 @@ have_version() { [ "$(cat "$VERSION_DIR/$1")" = "$2" ] } +# shadowed — true (printing the winner's path) when another sits +# EARLIER on PATH than $BIN_DIR, so a copy installed there would never run. +# +# WHY: on a desktop that already manages these tools (Omarchy: pacman for VS +# Code and jq, mise for kubectl/helm/node/...), ~/.local/bin is at the END of +# PATH. Installing there anyway left a second, older copy of every tool that +# nothing ran but `type -a` and the version stamps still reported. On the VMs +# this repo targets, zshrc puts ~/.local/bin FIRST, so nothing shadows it and +# this is always false -- installs there behave exactly as before. If $BIN_DIR +# is not on PATH at all (a fresh box, before zshrc), it is false too. +# +# A mise shim with no version configured is not a real provider (it errors when +# run), so shims only count when `mise which` resolves them. +shadowed() { + _sh_name="$1" + _sh_ifs="$IFS"; IFS=: + # shellcheck disable=SC2086 # split PATH on ':' deliberately + set -- $PATH + IFS="$_sh_ifs" + case ":$PATH:" in *":$BIN_DIR:"*) ;; *) return 1 ;; esac + for _sh_dir; do + [ "$_sh_dir" = "$BIN_DIR" ] && return 1 + [ -n "$_sh_dir" ] && [ -x "$_sh_dir/$_sh_name" ] && [ ! -d "$_sh_dir/$_sh_name" ] || continue + case "$_sh_dir" in + */mise/shims) mise which "$_sh_name" >/dev/null 2>&1 || continue ;; + esac + printf '%s\n' "$_sh_dir/$_sh_name" + return 0 + done + return 1 +} + # mark_version — record the installed version. mark_version() { mkdir -p "$VERSION_DIR" diff --git a/node/install.sh b/node/install.sh index 255ce2c..b43e0c5 100755 --- a/node/install.sh +++ b/node/install.sh @@ -29,6 +29,13 @@ PREFIX="$HOME/.local/opt/node" BIN_DIR="$HOME/.local/bin" NODE_CLIS="node npm npx" +# Another node (mise, nvm, a distro package) already wins on PATH, so ours +# would never run -- see `shadowed` in lib/fetch.sh. npx comes with it. +if _winner="$(shadowed node)"; then + say "node: $_winner comes first on PATH; skipping (a copy in $BIN_DIR would never run)." + exit 0 +fi + case "$(fetch_arch)" in x86_64) NODE_ARCH=x64 ;; aarch64) NODE_ARCH=arm64 ;; diff --git a/vscode/install.sh b/vscode/install.sh index 8efa679..8d9bd25 100755 --- a/vscode/install.sh +++ b/vscode/install.sh @@ -69,6 +69,37 @@ case "${DOTFILES_VSCODE:-auto}" in fi ;; esac +# --- defer to a system-wide VS Code ------------------------------------------- +# WHY: a distro package (Arch's visual-studio-code-bin on Omarchy, Microsoft's +# rpm/deb elsewhere) and this tarball both add a "Visual Studio Code" menu entry +# and both open the same ~/.config/Code profile. Two builds on one profile is +# the real hazard: the older one opens state the newer one already migrated, +# and extensions break in ways that look like extension bugs. The system copy +# also gets updates this pinned one does not, so it wins. +# +# PATH order is irrelevant here (unlike `shadowed` in bin/): the menu entries +# collide wherever the binaries sit. Anything we installed earlier is removed -- +# only ours, recognised by the .desktop Exec pointing into $PREFIX. +_sys_code="" +_sh_ifs="$IFS"; IFS=: +for _d in $PATH; do + [ "$_d" != "$BIN_DIR" ] && [ -x "$_d/code" ] && { _sys_code="$_d/code"; break; } +done +IFS="$_sh_ifs" +if [ -n "$_sys_code" ]; then + say "vscode: system VS Code at $_sys_code; skipping the tarball install." + if grep -qs "^Exec=$PREFIX/" "$DESKTOP_DIR/code.desktop"; then + rm -f "$DESKTOP_DIR/code.desktop" + [ "$(readlink "$BIN_DIR/code" 2>/dev/null)" = "$PREFIX/bin/code" ] && rm -f "$BIN_DIR/code" + rm -f "$VERSION_DIR/code" + rm -rf "$PREFIX" + command -v update-desktop-database >/dev/null 2>&1 && + update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true + say "vscode: removed the earlier tarball copy ($PREFIX, its menu entry and $BIN_DIR/code)." + fi + exit 0 +fi + # Upstream keys its download URLs on x64/arm64 rather than amd64/arm64. case "$(fetch_arch)" in x86_64) VSCODE_ARCH=x64 ;; @@ -202,15 +233,4 @@ else fi fi -# A system-wide VS Code (from Microsoft's rpm/deb repo) shadows this one in the -# menu and shows a duplicate entry. Flag it rather than touching it -- removing -# it needs the root this module deliberately avoids. -if [ -e /usr/share/applications/com.microsoft.VSCode.desktop ]; then - say "" - say "vscode: NOTE: a system-wide VS Code package is also installed." - say " You will see two menu entries until it is removed:" - say " sudo dnf remove code # or: sudo apt remove code" - say "" -fi - say "vscode: ready." diff --git a/zsh/zshrc b/zsh/zshrc index 9c44084..45cae09 100644 --- a/zsh/zshrc +++ b/zsh/zshrc @@ -21,6 +21,9 @@ SAVEHIST=10000 setopt SHARE_HISTORY setopt HIST_IGNORE_DUPS +# grmb and the op* helpers below have a bash port in bash/dotfiles.bash for +# hosts that keep bash; change both together. + # Delete remote branches without fighting tab-completion. # # `git push origin --delete ` completes to `origin/foo`, but `origin/foo` -- 2.54.0