From fac2b6b51027212de574f6bc538c503f54bf7e06 Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 09:12:52 +1000 Subject: [PATCH 1/7] feat: add portable package bootstrap --- AGENTS.md | 12 +- README.md | 123 +++++--- ansible/README.md | 10 + ansible/packages/profile.txt | 18 ++ ansible/packages/providers/fedora.txt | 18 ++ bootstrap.sh | 71 +++++ ghostty/.config/ghostty/config | 10 + git/.config/git/config | 17 +- git/README.md | 41 +-- nvim/.config/nvim/init.lua | 2 + nvim/.config/nvim/lazy-lock.json | 38 +++ nvim/.config/nvim/lua/config/autocmds.lua | 1 + nvim/.config/nvim/lua/config/keymaps.lua | 1 + nvim/.config/nvim/lua/config/lazy.lua | 47 ++++ nvim/.config/nvim/lua/config/options.lua | 1 + nvim/.config/nvim/lua/plugins/init.lua | 2 + nvim/.config/nvim/stylua.toml | 3 + scripts/README.md | 17 +- scripts/check.sh | 76 +++++ scripts/install-packages.sh | 163 +++++++++++ scripts/package-providers/fedora.sh | 74 +++++ scripts/stow.md | 48 ---- scripts/stow.sh | 326 +++++++++++----------- shell/.bashrc.d/10-path.sh | 10 + shell/.bashrc.d/20-direnv.sh | 4 + shell/.bashrc.d/30-starship.sh | 4 + shell/.local/bin/gh-credential | 26 ++ shell/.local/bin/op-ssh-sign | 23 ++ stow-packages.txt | 7 + tmux/.tmux.conf | 15 + 30 files changed, 905 insertions(+), 303 deletions(-) create mode 100644 ansible/README.md create mode 100644 ansible/packages/profile.txt create mode 100644 ansible/packages/providers/fedora.txt create mode 100755 bootstrap.sh create mode 100644 ghostty/.config/ghostty/config create mode 100644 nvim/.config/nvim/init.lua create mode 100644 nvim/.config/nvim/lazy-lock.json create mode 100644 nvim/.config/nvim/lua/config/autocmds.lua create mode 100644 nvim/.config/nvim/lua/config/keymaps.lua create mode 100644 nvim/.config/nvim/lua/config/lazy.lua create mode 100644 nvim/.config/nvim/lua/config/options.lua create mode 100644 nvim/.config/nvim/lua/plugins/init.lua create mode 100644 nvim/.config/nvim/stylua.toml create mode 100755 scripts/check.sh create mode 100755 scripts/install-packages.sh create mode 100755 scripts/package-providers/fedora.sh delete mode 100644 scripts/stow.md create mode 100644 shell/.bashrc.d/10-path.sh create mode 100644 shell/.bashrc.d/20-direnv.sh create mode 100644 shell/.bashrc.d/30-starship.sh create mode 100755 shell/.local/bin/gh-credential create mode 100755 shell/.local/bin/op-ssh-sign create mode 100644 stow-packages.txt create mode 100644 tmux/.tmux.conf diff --git a/AGENTS.md b/AGENTS.md index e1e57a0..ffcc90f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Project Instructions -- When repairing symlinks for dotfiles in this project use @scripts/stow.sh - -## Git Workflow - -- Never commit directly to main. Always create a branch, commit there, push, and open a PR if one doesn't already exist for that branch. -- Use conventional commit messages. Body should be in list format and not overly verbose. +- Use `scripts/stow.sh` for dotfile deployment and symlink repair. +- Run `scripts/check.sh` before committing implementation changes. +- Keep application configuration portable. Put OS package names and system + provisioning only under `ansible/`. +- Never commit secrets, resolved 1Password references, access tokens, or private + keys. diff --git a/README.md b/README.md index 7647172..8201b01 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,116 @@ # dotfiles -Simple GNU Stow-based dotfiles with idempotent setup. +Portable application packages and configuration, deployed with GNU Stow. -## Layout +This repository has a deliberately narrow job: -- Top-level directories (excluding `hosts`, `scripts`, `.git*`, and `.claude`) are base stow packages. -- Package contents mirror `$HOME` exactly. -- Per-host overrides live under `hosts//` and are stowed after base packages. +1. Track a small profile of applications and command-line tools. +2. Map that profile to the current operating system's package names. +3. Link portable application configuration into the user's home directory. -Current base packages: +Full Fedora workstation provisioning—including repositories, drivers, desktop +policy, and system tuning—belongs in a separate repository. -- `git` -> `~/.config/git/config` -- `starship` -> `~/.config/starship.toml` +## Managed configuration -Example: +The default Stow deployment includes Git, Ghostty, Neovim/LazyVim, additive +Bash fragments, tmux, and Starship. Package contents mirror paths relative to +`$HOME`. -```text -hosts//hypr/.config/hypr/... +The Fedora package profile installs the core command-line applications and +utilities available from the configured DNF repositories on mutable Fedora +installations. Ghostty and Starship configuration is tracked here, but their +third-party repository or binary setup is intentionally left to workstation +provisioning. + +## Bootstrap + +Preview the package transaction and dotfile deployment: + +```bash +./bootstrap.sh --dry-run ``` -## Usage +On a fresh machine without GNU Stow, this lists the dotfile packages that will +be deployed; after Stow is installed, the same command also checks exact link +changes and conflicts. -Check that GNU Stow is installed: +Install missing packages and deploy all configured dotfiles: -```sh -stow --version +```bash +./bootstrap.sh ``` -Dry-run first: +The package step is idempotent: the Fedora adapter checks installed RPMs and +invokes DNF only for missing packages. The Stow step always uses `--restow` so +it also repairs managed symlinks. -```sh -./scripts/stow.sh base -n -./scripts/stow.sh host -n +Run either half independently: + +```bash +./bootstrap.sh --packages-only +./bootstrap.sh --dotfiles-only ``` -Stow all base packages: +Never run these scripts with `sudo`; the Fedora adapter requests elevation only +for the DNF transaction. -```sh -./scripts/stow.sh base +## Package tracking + +List the portable identifiers and their Fedora package mapping: + +```bash +./scripts/install-packages.sh --provider fedora --list ``` -Stow host-specific packages for this machine: +The package data is split into: -```sh -./scripts/stow.sh host +- `ansible/packages/profile.txt`: provider-neutral application identifiers. +- `ansible/packages/providers/fedora.txt`: Fedora package names. +- `scripts/package-providers/fedora.sh`: Fedora detection, planning, and + installation behavior. + +To add another tested provider, add its mapping and an executable adapter with +the same `detect`, `plan`, and `install` interface. The shared profile and +Stow packages do not change. + +## Dotfiles + +Preview or deploy the explicit default package list: + +```bash +./scripts/stow.sh base --dry-run +./scripts/stow.sh base ``` -Stow a specific host's packages: +Deploy selected portable packages: -```sh -./scripts/stow.sh host +```bash +./scripts/stow.sh base git nvim ``` -For low-level troubleshooting, this is the equivalent shape for one host -package: +Host-specific configuration remains available when needed: -```sh -stow -t "$HOME" --restow --no-folding -d hosts/ +```bash +./scripts/stow.sh host +./scripts/stow.sh host nexus-unbound ``` -## Notes +`stow-packages.txt` is the source of truth for the default deployment. Keeping +that allowlist separate prevents infrastructure and documentation directories +from being mistaken for dotfile packages. + +## Git authentication and signing -- Keep only source-of-truth files here; avoid generated artifacts. -- Host packages should only contain overrides, so the base packages stay portable. -- Use `scripts/stow.sh` for normal refreshes and symlink repairs so base and - host package discovery stays consistent. +Ordinary commits are unsigned so unattended tools do not block on 1Password. +Use `git cis` for an explicitly signed personal commit. GitHub HTTPS credentials +are delegated to `gh auth git-credential`; credentials and private keys are +never stored in this repository. + +## Validation + +Run the repository checks before committing implementation changes: + +```bash +./scripts/check.sh +``` diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..b3d965f --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,10 @@ +# Package manifests + +This directory is intentionally limited to operating-system-specific package +data. `packages/profile.txt` contains stable application identifiers, while +`packages/providers/.txt` maps those identifiers to concrete package +names. + +The shell dispatcher and provider adapters live under `scripts/`. Full system +provisioning, repository configuration, drivers, and workstation policy belong +in the separate Fedora setup repository. diff --git a/ansible/packages/profile.txt b/ansible/packages/profile.txt new file mode 100644 index 0000000..e7edda1 --- /dev/null +++ b/ansible/packages/profile.txt @@ -0,0 +1,18 @@ +# Stable application identifiers for the default portable command-line profile. +git +git-lfs +github-cli +neovim +stow +tmux +direnv +ripgrep +fd +fzf +bat +jq +yq +unzip +shellcheck +shfmt +clipboard diff --git a/ansible/packages/providers/fedora.txt b/ansible/packages/providers/fedora.txt new file mode 100644 index 0000000..637550c --- /dev/null +++ b/ansible/packages/providers/fedora.txt @@ -0,0 +1,18 @@ +# logical-id Fedora package name +git git +git-lfs git-lfs +github-cli gh +neovim neovim +stow stow +tmux tmux +direnv direnv +ripgrep ripgrep +fd fd-find +fzf fzf +bat bat +jq jq +yq yq +unzip unzip +shellcheck ShellCheck +shfmt shfmt +clipboard wl-clipboard diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..c5ff1a6 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat << 'EOF' +Usage: bootstrap.sh [options] + +Install the portable package profile, then deploy its dotfiles. + +Options: + -n, --dry-run Preview package and Stow changes + --provider NAME Override automatic package-provider detection + --packages-only Install packages without deploying dotfiles + --dotfiles-only Deploy dotfiles without installing packages + -h, --help Show this help message +EOF +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dry_run=false +packages_only=false +dotfiles_only=false +provider="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -n | --dry-run) dry_run=true ;; + --provider) + shift + [[ $# -gt 0 ]] || die "--provider requires a name" + provider="$1" + ;; + --provider=*) provider="${1#*=}" ;; + --packages-only) packages_only=true ;; + --dotfiles-only) dotfiles_only=true ;; + -h | --help) + usage + exit 0 + ;; + *) die "unknown option: $1" ;; + esac + shift +done + +"$packages_only" && "$dotfiles_only" && + die "--packages-only and --dotfiles-only cannot be used together" +[[ ${EUID:-$(id -u)} -ne 0 ]] || die "run bootstrap as your normal user, not root" + +if ! "$dotfiles_only"; then + package_args=() + "$dry_run" && package_args+=(--dry-run) + [[ -z "$provider" ]] || package_args+=(--provider "$provider") + "$root/scripts/install-packages.sh" "${package_args[@]}" +fi + +if ! "$packages_only"; then + if "$dry_run" && ! command -v stow > /dev/null 2>&1; then + printf 'GNU Stow is not installed; the package plan above includes it.\n' + printf 'Would deploy these dotfile packages after installation:\n' + "$root/scripts/stow.sh" list | sed 's/^/ /' + else + stow_args=(base) + "$dry_run" && stow_args+=(--dry-run) + "$root/scripts/stow.sh" "${stow_args[@]}" + fi +fi diff --git a/ghostty/.config/ghostty/config b/ghostty/.config/ghostty/config new file mode 100644 index 0000000..337f9b3 --- /dev/null +++ b/ghostty/.config/ghostty/config @@ -0,0 +1,10 @@ +# Follow the desktop's light/dark appearance automatically. +theme = dark:Catppuccin Frappe,light:Catppuccin Latte + +font-size = 12 +window-padding-x = 8 +window-padding-y = 6 +window-padding-balance = true + +# Protect terminals that still have a running process. +confirm-close-surface = true diff --git a/git/.config/git/config b/git/.config/git/config index 6bfe44a..d8c0d31 100644 --- a/git/.config/git/config +++ b/git/.config/git/config @@ -1,10 +1,12 @@ -# See https://git-scm.com/docs/git-config +# Portable Git defaults. Secrets and authentication tokens do not belong here. [alias] co = checkout sw = switch br = branch ci = commit + # Personal, explicitly signed commit. Ordinary/agent commits remain unattended. + cis = commit -S st = status [init] defaultBranch = main @@ -22,7 +24,7 @@ mnemonicPrefix = true # More intuitive refs in diff output [commit] verbose = true # Include diff comment in commit message template - gpgsign = true + gpgSign = false [column] ui = auto # Output in columns when possible [branch] @@ -39,10 +41,15 @@ [gpg] format = ssh [gpg "ssh"] - program = "/opt/1Password/op-ssh-sign" + program = ~/.local/bin/op-ssh-sign [credential "https://github.com"] helper = - helper = !/usr/bin/gh auth git-credential + helper = !~/.local/bin/gh-credential [credential "https://gist.github.com"] helper = - helper = !/usr/bin/gh auth git-credential + helper = !~/.local/bin/gh-credential +[filter "lfs"] + clean = git-lfs clean -- %f + smudge = git-lfs smudge -- %f + process = git-lfs filter-process + required = true diff --git a/git/README.md b/git/README.md index 758604e..37805a3 100644 --- a/git/README.md +++ b/git/README.md @@ -1,37 +1,8 @@ -# git +# Git configuration -Git configuration stow package. Stows to `~/.config/git/config`. +Ordinary commits are unsigned so unattended tools do not need access to a +personal signing key. Use `git cis` (an alias for `git commit -S`) when +intentionally creating a signed commit through 1Password. -## Aliases - -| Alias | Command | -|-------|------------| -| `co` | `checkout` | -| `sw` | `switch` | -| `br` | `branch` | -| `ci` | `commit` | -| `st` | `status` | - -## Key settings - -| Area | Setting | Effect | -|--------|-----------------------------|-------------------------------------------------------------| -| init | `defaultBranch = main` | New repos start on `main` | -| pull | `rebase = true` | Rebase instead of merge on pull | -| push | `autoSetupRemote` | Automatically sets upstream branch on first push | -| push | `followTags` | Pushes annotated tags that point to pushed commits | -| fetch | `prune = true` | Removes stale remote-tracking branches on fetch | -| fetch | `pruneTags = true` | Removes deleted remote tags on fetch | -| diff | `algorithm = histogram` | Clearer diffs on moved/edited lines | -| diff | `colorMoved = plain` | Highlights moved blocks in diffs | -| diff | `mnemonicPrefix = true` | Shows clearer source/destination prefixes in diffs | -| commit | `verbose = true` | Includes diff in commit message editor | -| commit | `gpgsign = true` | Signs all commits with SSH key via 1Password | -| column | `ui = auto` | Uses column output when Git can present it cleanly | -| branch | `sort = -committerdate` | Branch list sorted by most recent activity | -| tag | `sort = -version:refname` | Tags sorted by semantic version | -| rerere | `enabled + autoupdate` | Records and auto-replays conflict resolutions | - -## Signing - -Commits are signed with an SSH key (`ssh-ed25519`) via 1Password's SSH agent (`op-ssh-sign`). +Authentication is separate from signing. HTTPS GitHub credentials are supplied +by `gh auth git-credential`; no token is stored in this repository. diff --git a/nvim/.config/nvim/init.lua b/nvim/.config/nvim/init.lua new file mode 100644 index 0000000..8d625cb --- /dev/null +++ b/nvim/.config/nvim/init.lua @@ -0,0 +1,2 @@ +-- Bootstrap lazy.nvim, LazyVim, and local plugin specifications. +require("config.lazy") diff --git a/nvim/.config/nvim/lazy-lock.json b/nvim/.config/nvim/lazy-lock.json new file mode 100644 index 0000000..6cf6edc --- /dev/null +++ b/nvim/.config/nvim/lazy-lock.json @@ -0,0 +1,38 @@ +{ + "LazyVim": { "branch": "main", "commit": "c10948c50b18fae7f256433afdef09e432410480" }, + "SchemaStore.nvim": { "branch": "main", "commit": "e954496f8ef22904e8a84f5078f4a110fdc7a0d3" }, + "blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" }, + "bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" }, + "catppuccin": { "branch": "main", "commit": "05e8787020dcfdb937bf2ff23855ea2415b4e072" }, + "conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" }, + "crates.nvim": { "branch": "main", "commit": "694357861ec9ebf12475ddcdd04ea45a0923c32d" }, + "flash.nvim": { "branch": "main", "commit": "b6346946d10d07998efee029fb0f7a593806d0cd" }, + "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" }, + "gitsigns.nvim": { "branch": "main", "commit": "eb60cc7b94c46005237fd34170d76f3a089a90aa" }, + "grug-far.nvim": { "branch": "main", "commit": "c69859c1d5427ab5fc7ed12380ab521b4e336691" }, + "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, + "lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" }, + "lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" }, + "mason-lspconfig.nvim": { "branch": "main", "commit": "47059d71b42d74b0a1e9f61c1d99d301039c3b5b" }, + "mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" }, + "mini.ai": { "branch": "main", "commit": "6d43e74cae75771b780a7270fec1f160613ed873" }, + "mini.icons": { "branch": "main", "commit": "98faae31e9be1cc054ae63485e58ceb185efcad0" }, + "mini.pairs": { "branch": "main", "commit": "b1fd9df3bb4a41c8f45778a3859ee80ef6b367e3" }, + "noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" }, + "nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" }, + "nvim-lint": { "branch": "master", "commit": "a219b2c9e5b4765e5c845aba119dad55806fcaf1" }, + "nvim-lspconfig": { "branch": "master", "commit": "d224a1920728ba129880efc700d4a0180ac4ecbb" }, + "nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" }, + "nvim-treesitter-textobjects": { "branch": "main", "commit": "851e865342e5a4cb1ae23d31caf6e991e1c99f1e" }, + "nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" }, + "persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" }, + "plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" }, + "rustaceanvim": { "branch": "main", "commit": "f4ff9fb2f6cc6ef8ca9c1725628c79ef9347c56e" }, + "snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }, + "todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" }, + "tokyonight.nvim": { "branch": "main", "commit": "cdc07ac78467a233fd62c493de29a17e0cf2b2b6" }, + "trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" }, + "ts-comments.nvim": { "branch": "main", "commit": "a59d6092213447450191122c9346f309161504cb" }, + "venv-selector.nvim": { "branch": "main", "commit": "cc4bb3975de8835291f9bb45889e96c6b2795fc4" }, + "which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" } +} diff --git a/nvim/.config/nvim/lua/config/autocmds.lua b/nvim/.config/nvim/lua/config/autocmds.lua new file mode 100644 index 0000000..47c27e5 --- /dev/null +++ b/nvim/.config/nvim/lua/config/autocmds.lua @@ -0,0 +1 @@ +-- Add portable autocmds here. LazyVim's defaults are loaded automatically. diff --git a/nvim/.config/nvim/lua/config/keymaps.lua b/nvim/.config/nvim/lua/config/keymaps.lua new file mode 100644 index 0000000..b1164c7 --- /dev/null +++ b/nvim/.config/nvim/lua/config/keymaps.lua @@ -0,0 +1 @@ +-- Add portable keymaps here. LazyVim's defaults are loaded automatically. diff --git a/nvim/.config/nvim/lua/config/lazy.lua b/nvim/.config/nvim/lua/config/lazy.lua new file mode 100644 index 0000000..00f0707 --- /dev/null +++ b/nvim/.config/nvim/lua/config/lazy.lua @@ -0,0 +1,47 @@ +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +if not (vim.uv or vim.loop).fs_stat(lazypath) then + local lazyrepo = "https://github.com/folke/lazy.nvim.git" + local output = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) + if vim.v.shell_error ~= 0 then + vim.api.nvim_echo({ + { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, + { output, "WarningMsg" }, + }, true, {}) + os.exit(1) + end +end +vim.opt.rtp:prepend(lazypath) + +require("lazy").setup({ + spec = { + { "LazyVim/LazyVim", import = "lazyvim.plugins" }, + { import = "lazyvim.plugins.extras.lang.typescript" }, + { import = "lazyvim.plugins.extras.lang.python" }, + { import = "lazyvim.plugins.extras.lang.rust" }, + { import = "lazyvim.plugins.extras.lang.go" }, + { import = "lazyvim.plugins.extras.lang.docker" }, + { import = "lazyvim.plugins.extras.lang.json" }, + { import = "lazyvim.plugins.extras.lang.yaml" }, + { import = "plugins" }, + }, + defaults = { + lazy = false, + version = false, + }, + install = { colorscheme = { "tokyonight", "habamax" } }, + checker = { + enabled = true, + notify = false, + }, + performance = { + rtp = { + disabled_plugins = { + "gzip", + "tarPlugin", + "tohtml", + "tutor", + "zipPlugin", + }, + }, + }, +}) diff --git a/nvim/.config/nvim/lua/config/options.lua b/nvim/.config/nvim/lua/config/options.lua new file mode 100644 index 0000000..1eabb29 --- /dev/null +++ b/nvim/.config/nvim/lua/config/options.lua @@ -0,0 +1 @@ +-- Add portable options here. LazyVim's defaults are loaded automatically. diff --git a/nvim/.config/nvim/lua/plugins/init.lua b/nvim/.config/nvim/lua/plugins/init.lua new file mode 100644 index 0000000..5f1b2e5 --- /dev/null +++ b/nvim/.config/nvim/lua/plugins/init.lua @@ -0,0 +1,2 @@ +-- Add personal plugin specifications to this directory. +return {} diff --git a/nvim/.config/nvim/stylua.toml b/nvim/.config/nvim/stylua.toml new file mode 100644 index 0000000..0f90030 --- /dev/null +++ b/nvim/.config/nvim/stylua.toml @@ -0,0 +1,3 @@ +indent_type = "Spaces" +indent_width = 2 +column_width = 120 diff --git a/scripts/README.md b/scripts/README.md index f050a01..8602ef2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,7 +1,20 @@ # scripts -Helper scripts for managing dotfiles. +Helper scripts for installing the portable package profile and deploying +dotfiles. | Script | Description | |--------|-------------| -| [`stow.sh`](stow.md) | Stow base and host-specific packages using GNU Stow | +| `install-packages.sh` | Resolve the shared profile through an OS provider | +| `package-providers/fedora.sh` | Detect Fedora and idempotently install missing RPMs | +| `stow.sh` | Deploy or repair base and host-specific GNU Stow packages | +| `check.sh` | Validate manifests, scripts, configuration, and a Stow preview | + +Package adapters are intentionally small. Each executable adapter accepts: + +- `detect`: return success when it supports the current machine. +- `plan `: print the non-mutating install plan. +- `install `: install only the requested provider package names. + +Provider package names remain under `ansible/packages/providers/`; adapters +contain behavior only. diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..d189c9a --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +mapfile -d '' shell_scripts < <(find scripts -type f -name '*.sh' -print0) +mapfile -d '' shell_bins < <(find shell/.local/bin -type f -print0) +mapfile -d '' bash_fragments < <(find shell/.bashrc.d -type f -name '*.sh' -print0) + +printf 'Checking shell syntax...\n' +bash -n bootstrap.sh "${shell_scripts[@]}" "${shell_bins[@]}" "${bash_fragments[@]}" + +if command -v shellcheck > /dev/null 2>&1; then + printf 'Running ShellCheck...\n' + shellcheck bootstrap.sh "${shell_scripts[@]}" "${shell_bins[@]}" + shellcheck --shell=bash "${bash_fragments[@]}" +else + printf 'Skipping ShellCheck (not installed).\n' +fi + +printf 'Checking executable scripts...\n' +for script in bootstrap.sh scripts/stow.sh scripts/install-packages.sh scripts/package-providers/*.sh; do + [[ -x "$script" ]] || { + printf 'Script is not executable: %s\n' "$script" >&2 + exit 1 + } +done + +printf 'Checking Git configuration syntax...\n' +git config --file git/.config/git/config --list > /dev/null + +printf 'Checking JSON files...\n' +if command -v jq > /dev/null 2>&1; then + jq empty nvim/.config/nvim/lazy-lock.json +else + printf 'Skipping JSON validation (jq not installed).\n' +fi + +printf 'Checking package profile and Fedora mapping...\n' +./scripts/install-packages.sh --provider fedora --list > /dev/null + +printf 'Checking Stow package manifest...\n' +stow_packages=() +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + read -r package extra <<< "$line" + [[ -n "${package:-}" ]] || continue + [[ -z "${extra:-}" ]] || { + printf 'Expected one Stow package per line: %s\n' "$line" >&2 + exit 1 + } + [[ "$package" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && -d "$package" ]] || { + printf 'Invalid or missing Stow package: %s\n' "$package" >&2 + exit 1 + } + for existing in "${stow_packages[@]}"; do + [[ "$existing" != "$package" ]] || { + printf 'Duplicate Stow package: %s\n' "$package" >&2 + exit 1 + } + done + stow_packages+=("$package") +done < stow-packages.txt + +if command -v stow > /dev/null 2>&1; then + printf 'Checking Stow deployment...\n' + ./scripts/stow.sh base --dry-run +else + printf 'Skipping Stow dry-run (GNU Stow not installed).\n' +fi + +printf 'Checking patch whitespace...\n' +git diff --check + +printf 'All available checks passed.\n' diff --git a/scripts/install-packages.sh b/scripts/install-packages.sh new file mode 100755 index 0000000..d1d5005 --- /dev/null +++ b/scripts/install-packages.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat << 'EOF' +Usage: install-packages.sh [options] + +Install the package profile using an operating-system provider. + +Options: + -n, --dry-run Show the missing packages and install command + -l, --list List logical identifiers and provider package names + --provider NAME Override automatic provider detection + -h, --help Show this help message +EOF +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +repo_root() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd +} + +valid_identifier() { + [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]] +} + +detect_provider() { + local adapter detected="" + shopt -s nullglob + for adapter in "$provider_dir"/*.sh; do + [[ -x "$adapter" ]] || continue + if "$adapter" detect > /dev/null 2>&1; then + [[ -z "$detected" ]] || die "multiple package providers match this system" + detected="$(basename "$adapter" .sh)" + fi + done + shopt -u nullglob + [[ -n "$detected" ]] || die "no package provider supports this system; use --provider to inspect one" + printf '%s\n' "$detected" +} + +load_mapping() { + local manifest="$1" line logical package extra existing index + [[ -f "$manifest" ]] || die "provider mapping not found: $manifest" + + mapping_ids=() + mapping_packages=() + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + read -r logical package extra <<< "$line" + [[ -n "${logical:-}" ]] || continue + valid_identifier "$logical" || die "invalid logical package identifier in $manifest: $logical" + [[ -n "${package:-}" && -z "${extra:-}" ]] || + die "expected one package name for $logical in $manifest" + [[ "$package" =~ ^[A-Za-z0-9][A-Za-z0-9+_.:-]*$ ]] || + die "invalid provider package name in $manifest: $package" + + existing=false + for index in "${!mapping_ids[@]}"; do + if [[ "${mapping_ids[$index]}" == "$logical" ]]; then + existing=true + break + fi + done + "$existing" && die "duplicate mapping for $logical in $manifest" + + mapping_ids+=("$logical") + mapping_packages+=("$package") + done < "$manifest" +} + +lookup_package() { + local requested="$1" index + for index in "${!mapping_ids[@]}"; do + if [[ "${mapping_ids[$index]}" == "$requested" ]]; then + printf '%s\n' "${mapping_packages[$index]}" + return + fi + done + return 1 +} + +load_profile() { + local manifest="$1" line logical extra package index + [[ -f "$manifest" ]] || die "package profile not found: $manifest" + + profile_ids=() + packages=() + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + read -r logical extra <<< "$line" + [[ -n "${logical:-}" ]] || continue + [[ -z "${extra:-}" ]] || die "expected one logical identifier per line in $manifest" + valid_identifier "$logical" || die "invalid logical package identifier in $manifest: $logical" + for index in "${!profile_ids[@]}"; do + [[ "${profile_ids[$index]}" != "$logical" ]] || die "duplicate profile entry: $logical" + done + package="$(lookup_package "$logical")" || die "provider $provider has no mapping for $logical" + profile_ids+=("$logical") + packages+=("$package") + done < "$manifest" + + [[ ${#packages[@]} -gt 0 ]] || die "package profile is empty: $manifest" +} + +main() { + local root provider="" dry_run=false list_only=false adapter mapping profile index + root="$(repo_root)" + provider_dir="$root/scripts/package-providers" + + while [[ $# -gt 0 ]]; do + case "$1" in + -n | --dry-run) dry_run=true ;; + -l | --list) list_only=true ;; + --provider) + shift + [[ $# -gt 0 ]] || die "--provider requires a name" + provider="$1" + ;; + --provider=*) provider="${1#*=}" ;; + -h | --help) + usage + exit 0 + ;; + *) die "unknown option: $1" ;; + esac + shift + done + + if [[ -z "$provider" ]]; then + provider="$(detect_provider)" + fi + valid_identifier "$provider" || die "invalid provider name: $provider" + + adapter="$provider_dir/$provider.sh" + mapping="$root/ansible/packages/providers/$provider.txt" + profile="$root/ansible/packages/profile.txt" + [[ -x "$adapter" ]] || die "package provider adapter is not executable: $adapter" + + load_mapping "$mapping" + load_profile "$profile" + + printf 'Provider: %s\n' "$provider" + if "$list_only"; then + for index in "${!profile_ids[@]}"; do + printf '%-16s %s\n' "${profile_ids[$index]}" "${packages[$index]}" + done + exit 0 + fi + + [[ ${EUID:-$(id -u)} -ne 0 ]] || die "run package installation as your normal user, not root" + if "$dry_run"; then + "$adapter" plan "${packages[@]}" + else + "$adapter" install "${packages[@]}" + fi +} + +main "$@" diff --git a/scripts/package-providers/fedora.sh b/scripts/package-providers/fedora.sh new file mode 100755 index 0000000..d27fff3 --- /dev/null +++ b/scripts/package-providers/fedora.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +is_supported() { + [[ "$(uname -s)" == Linux ]] || return 1 + [[ -r /etc/os-release ]] || return 1 + + ( + # shellcheck source=/dev/null + source /etc/os-release + [[ "${ID:-}" == fedora ]] + ) || return 1 + + [[ ! -e /run/ostree-booted ]] || return 1 + command -v dnf > /dev/null 2>&1 && command -v rpm > /dev/null 2>&1 +} + +collect_missing() { + missing_packages=() + local package + for package in "$@"; do + [[ "$package" =~ ^[A-Za-z0-9][A-Za-z0-9+_.:-]*$ ]] || + die "invalid Fedora package name: $package" + rpm -q -- "$package" > /dev/null 2>&1 || missing_packages+=("$package") + done +} + +print_plan() { + collect_missing "$@" + if [[ ${#missing_packages[@]} -eq 0 ]]; then + printf 'All requested Fedora packages are already installed.\n' + return + fi + + printf 'Would install %d missing Fedora package(s):\n' "${#missing_packages[@]}" + printf ' sudo dnf install --assumeyes' + printf ' %q' "${missing_packages[@]}" + printf '\n' +} + +install_packages() { + collect_missing "$@" + if [[ ${#missing_packages[@]} -eq 0 ]]; then + printf 'All requested Fedora packages are already installed.\n' + return + fi + + command -v sudo > /dev/null 2>&1 || die "sudo is required to install Fedora packages" + sudo dnf install --assumeyes "${missing_packages[@]}" +} + +case "${1:-}" in + detect) + is_supported + ;; + plan) + shift + is_supported || die "this provider requires a mutable Fedora installation with dnf" + print_plan "$@" + ;; + install) + shift + is_supported || die "this provider requires a mutable Fedora installation with dnf" + install_packages "$@" + ;; + *) + die "provider usage: $(basename "$0") [package ...]" + ;; +esac diff --git a/scripts/stow.md b/scripts/stow.md deleted file mode 100644 index 65cebb4..0000000 --- a/scripts/stow.md +++ /dev/null @@ -1,48 +0,0 @@ -# stow.sh - -Stows base and host-specific packages using GNU Stow. - -## Usage - -```sh -./scripts/stow.sh [options] -``` - -## Commands - -| Command | Description | -|----------------------|--------------------------------------------------| -| `base` | Stow all base packages | -| `host [hostname]` | Stow host-specific packages (default: current hostname) | -| `help` | Show usage | - -## Options - -| Option | Description | -|-----------------|----------------------------------------------| -| `-n, --dry-run` | Show what would be stowed without making changes | - -## Examples - -```sh -# Stow all base packages -./scripts/stow.sh base - -# Dry run for base packages -./scripts/stow.sh base -n - -# Stow host packages for the current machine -./scripts/stow.sh host - -# Stow host packages for a specific host -./scripts/stow.sh host nexus-unbound -``` - -## Notes - -- Uses `--restow --no-folding` for all stow operations to prevent directory symlinks. -- Base packages are discovered from top-level directories outside `.git*`, - `.claude`, `hosts`, and `scripts`. -- Host packages are discovered from `hosts//` and must contain at - least one real file. -- Exits non-zero only if all packages fail; partial failures produce a warning. diff --git a/scripts/stow.sh b/scripts/stow.sh index 18fb88a..7766f6c 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -2,223 +2,209 @@ set -euo pipefail usage() { - cat < [options] + cat << 'EOF' +Usage: stow.sh [options] [package ...] Commands: - base Stow all base packages - host [hostname] Stow host-specific packages (default: current hostname) + base Deploy portable packages from stow-packages.txt + host [hostname] Deploy host-specific packages (default: this host) + list List the default portable packages without deploying help Show this help message Options: - -n, --dry-run Show what would be stowed without making changes + -n, --dry-run Preview changes without modifying links + -v, --verbose Show detailed GNU Stow output Examples: - $(basename "$0") base - $(basename "$0") base -n - $(basename "$0") host - $(basename "$0") host nexus-unbound + ./scripts/stow.sh base --dry-run + ./scripts/stow.sh base + ./scripts/stow.sh base git ghostty + ./scripts/stow.sh host EOF } -require_stow() { - command -v stow >/dev/null 2>&1 || { - echo "Error: stow is required but not installed. Please install it and retry." - exit 1 - } +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 } -resolve_repo_root() { - local repo_root - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - echo "$repo_root" +repo_root() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd } -discover_base_packages() { - local -a packages=() - - while IFS= read -r -d '' dir; do - pkg="${dir#.}" - pkg="${pkg#/}" - packages+=("$pkg") - done < <(find . -maxdepth 1 -mindepth 1 -type d \ - -not -path './.git*' \ - -not -path './.claude' \ - -not -path './scripts' \ - -not -path './hosts' \ - -print0) - - printf '%s\n' "${packages[@]}" +valid_name() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] } -discover_host_packages() { - local hostname="$1" - local host_dir="hosts/$hostname" - local -a packages=() - - if [[ ! -d "$host_dir" ]]; then - echo "Error: Host package directory not found: $host_dir" >&2 - exit 1 - fi - - while IFS= read -r -d '' dir; do - # Only include directories that contain actual files (not just subdirs) - if find "$dir" -type f -print -quit | grep -q .; then - pkg="${dir#./}" - packages+=("$pkg") +load_default_packages() { + local manifest="$1" line package extra + [[ -f "$manifest" ]] || die "Stow package manifest not found: $manifest" + + default_packages=() + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + read -r package extra <<< "$line" + [[ -n "${package:-}" ]] || continue + [[ -z "${extra:-}" ]] || die "expected one Stow package per line in $manifest" + valid_name "$package" || die "invalid Stow package name in $manifest: $package" + default_packages+=("$package") + done < "$manifest" + + [[ ${#default_packages[@]} -gt 0 ]] || die "Stow package manifest is empty: $manifest" +} + +link_points_to() { + local link="$1" expected="$2" + [[ -L "$link" ]] || return 1 + command -v realpath > /dev/null 2>&1 || return 1 + [[ "$(realpath --canonicalize-missing -- "$link")" == "$expected" ]] +} + +remove_obsolete_links() { + local root="$1" dry_run="$2" link expected + local -a migrations=( + "$HOME/.gitconfig|$root/git/.gitconfig" + "$HOME/.config/ghostty/config.ghostty|$root/ghostty/.config/ghostty/config.ghostty" + ) + + for migration in "${migrations[@]}"; do + IFS='|' read -r link expected <<< "$migration" + if link_points_to "$link" "$expected"; then + printf 'UNLINK: %s (obsolete dotfile path)\n' "$link" + "$dry_run" || rm -- "$link" fi - done < <(find "./$host_dir" -maxdepth 1 -mindepth 1 -type d -print0) - - printf '%s\n' "${packages[@]}" + done +} + +stow_base() { + local root="$1" dry_run="$2" verbose="$3" + shift 3 + local -a packages=("$@") stow_args failed=() + local package + + stow_args=(--dir "$root" --target "$HOME" --restow --no-folding) + "$dry_run" && stow_args+=(--simulate) + "$verbose" && stow_args+=(--verbose=2) + + remove_obsolete_links "$root" "$dry_run" + printf 'Target: %s\n' "$HOME" + "$dry_run" && printf 'Mode: dry-run\n' + + for package in "${packages[@]}"; do + valid_name "$package" || { + printf 'Error: invalid Stow package name: %s\n' "$package" >&2 + failed+=("$package") + continue + } + [[ -d "$root/$package" ]] || { + printf 'Error: Stow package directory does not exist: %s\n' "$package" >&2 + failed+=("$package") + continue + } + + printf 'Stowing %s\n' "$package" + stow "${stow_args[@]}" "$package" || failed+=("$package") + done + + [[ ${#failed[@]} -eq 0 ]] || die "failed Stow packages: ${failed[*]}" + printf 'Done.\n' } cmd_base() { - local repo_root dry_run=false - + local root dry_run=false verbose=false + local -a packages=() + root="$(repo_root)" + while [[ $# -gt 0 ]]; do case "$1" in - -n|--dry-run) - dry_run=true - ;; - *) - echo "Error: unknown option '$1' for base command" >&2 - exit 1 - ;; + -n | --dry-run) dry_run=true ;; + -v | --verbose) verbose=true ;; + -*) die "unknown base option: $1" ;; + *) packages+=("$1") ;; esac shift done - - repo_root="$(resolve_repo_root)" - cd "$repo_root" - - mapfile -t packages < <(discover_base_packages) - - if [[ ${#packages[@]} -eq 0 || -z "${packages[0]:-}" ]]; then - echo "No stow packages found." - exit 1 + + if [[ ${#packages[@]} -eq 0 ]]; then + load_default_packages "$root/stow-packages.txt" + packages=("${default_packages[@]}") fi - - echo "Stowing base packages: ${packages[*]}" - - local -a failed=() - local -a stow_args=() - for pkg in "${packages[@]}"; do - stow_args=(-t "$HOME" --restow --no-folding) - [[ "$dry_run" == true ]] && stow_args+=(-n) - stow_args+=("$pkg") - - echo " $pkg" - if ! stow "${stow_args[@]}"; then - failed+=("$pkg") - fi - done - - handle_results packages failed + + stow_base "$root" "$dry_run" "$verbose" "${packages[@]}" +} + +cmd_list() { + local root + root="$(repo_root)" + load_default_packages "$root/stow-packages.txt" + printf '%s\n' "${default_packages[@]}" } cmd_host() { - local repo_root dry_run=false hostname="" - + local root hostname="" dry_run=false verbose=false host_dir + local -a packages=() stow_args=() failed=() + local dir package + root="$(repo_root)" + while [[ $# -gt 0 ]]; do case "$1" in - -n|--dry-run) - dry_run=true - ;; - -*) - echo "Error: unknown option '$1' for host command" >&2 - exit 1 - ;; + -n | --dry-run) dry_run=true ;; + -v | --verbose) verbose=true ;; + -*) die "unknown host option: $1" ;; *) - if [[ -z "$hostname" ]]; then - hostname="$1" - else - echo "Error: unexpected argument '$1'" >&2 - exit 1 - fi + [[ -z "$hostname" ]] || die "unexpected host argument: $1" + hostname="$1" ;; esac shift done - - [[ -z "$hostname" ]] && hostname="$(hostname)" - - repo_root="$(resolve_repo_root)" - cd "$repo_root" - - mapfile -t packages < <(discover_host_packages "$hostname") - - if [[ ${#packages[@]} -eq 0 || -z "${packages[0]:-}" ]]; then - echo "No valid host packages found for: $hostname" - exit 1 - fi - - echo "Stowing host packages for '$hostname': ${packages[*]}" - - local -a failed=() - local -a stow_args=() - local pkg_dir pkg_name - for pkg in "${packages[@]}"; do - stow_args=(-t "$HOME" --restow --no-folding) - - # For host packages, use -d to specify the package directory - # since package names can't contain slashes - pkg_dir="${pkg%/*}" # e.g., hosts/nexus-unbound - pkg_name="${pkg##*/}" # e.g., hypr - [[ "$dry_run" == true ]] && stow_args+=(-n) - stow_args+=(-d "$pkg_dir" "$pkg_name") - - echo " $pkg_name" - if ! stow "${stow_args[@]}"; then - failed+=("$pkg") - fi + + [[ -n "$hostname" ]] || hostname="$(hostname)" + valid_name "$hostname" || die "invalid hostname: $hostname" + host_dir="$root/hosts/$hostname" + [[ -d "$host_dir" ]] || die "host package directory not found: $host_dir" + + while IFS= read -r -d '' dir; do + [[ -n "$(find "$dir" -type f -print -quit)" ]] || continue + packages+=("${dir##*/}") + done < <(find "$host_dir" -mindepth 1 -maxdepth 1 -type d -print0) + + [[ ${#packages[@]} -gt 0 ]] || die "no host packages found for: $hostname" + stow_args=(--dir "$host_dir" --target "$HOME" --restow --no-folding) + "$dry_run" && stow_args+=(--simulate) + "$verbose" && stow_args+=(--verbose=2) + + printf 'Target: %s\n' "$HOME" + "$dry_run" && printf 'Mode: dry-run\n' + for package in "${packages[@]}"; do + printf 'Stowing host package %s/%s\n' "$hostname" "$package" + stow "${stow_args[@]}" "$package" || failed+=("$package") done - - handle_results packages failed -} -handle_results() { - local -n _packages="$1" - local -n _failed="$2" - - # Exit non-zero only if ALL packages failed - if [[ ${#_failed[@]} -eq ${#_packages[@]} && ${#_packages[@]} -gt 0 ]]; then - echo "Error: all packages failed to stow" - exit 1 - fi - - if [[ ${#_failed[@]} -gt 0 ]]; then - echo "Warning: failed to stow: ${_failed[*]}" - else - echo "Done." - fi + [[ ${#failed[@]} -eq 0 ]] || die "failed host Stow packages: ${failed[*]}" + printf 'Done.\n' } main() { - require_stow - - [[ $# -eq 0 ]] && { + [[ $# -gt 0 ]] || { usage exit 1 } - - local cmd="$1" + + local command="$1" shift - - case "$cmd" in - base) - cmd_base "$@" - ;; - host) - cmd_host "$@" - ;; - help|--help|-h) - usage + case "$command" in + help | -h | --help) usage ;; + list) + [[ $# -eq 0 ]] || die "list does not accept arguments" + cmd_list ;; - *) - echo "Error: unknown command '$cmd'" >&2 - usage - exit 1 + base | host) + [[ ${EUID:-$(id -u)} -ne 0 ]] || die "do not run Stow as root or with sudo" + command -v stow > /dev/null 2>&1 || die "GNU Stow is required; run ./bootstrap.sh --packages-only first" + "cmd_$command" "$@" ;; + *) die "unknown command: $command" ;; esac } diff --git a/shell/.bashrc.d/10-path.sh b/shell/.bashrc.d/10-path.sh new file mode 100644 index 0000000..b97c09c --- /dev/null +++ b/shell/.bashrc.d/10-path.sh @@ -0,0 +1,10 @@ +# Make user-installed development tools available without installer-managed +# edits to shell startup files. +for user_bin in "$HOME/.cargo/bin" "$HOME/.local/bin"; do + case ":$PATH:" in + *":$user_bin:"*) ;; + *) PATH="$user_bin${PATH:+:$PATH}" ;; + esac +done +unset user_bin +export PATH diff --git a/shell/.bashrc.d/20-direnv.sh b/shell/.bashrc.d/20-direnv.sh new file mode 100644 index 0000000..9eff6fa --- /dev/null +++ b/shell/.bashrc.d/20-direnv.sh @@ -0,0 +1,4 @@ +# Load approved per-project environments when entering their directories. +if [[ $- == *i* ]] && command -v direnv > /dev/null 2>&1; then + eval "$(direnv hook bash)" +fi diff --git a/shell/.bashrc.d/30-starship.sh b/shell/.bashrc.d/30-starship.sh new file mode 100644 index 0000000..d5caab8 --- /dev/null +++ b/shell/.bashrc.d/30-starship.sh @@ -0,0 +1,4 @@ +# Use the shared prompt when Starship is installed. +if [[ $- == *i* ]] && command -v starship > /dev/null 2>&1; then + eval "$(starship init bash)" +fi diff --git a/shell/.local/bin/gh-credential b/shell/.local/bin/gh-credential new file mode 100755 index 0000000..419dc90 --- /dev/null +++ b/shell/.local/bin/gh-credential @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Git can invoke credential helpers with a restricted PATH. Locate the GitHub +# CLI in the standard package-manager locations supported by these dotfiles. +case "$(uname -s)" in + Linux) + candidates=(/usr/bin/gh /usr/local/bin/gh "$HOME/.local/bin/gh") + ;; + Darwin) + candidates=(/opt/homebrew/bin/gh /usr/local/bin/gh "$HOME/.local/bin/gh") + ;; + *) + printf 'Unsupported platform for GitHub credentials: %s\n' "$(uname -s)" >&2 + exit 1 + ;; +esac + +for gh_cli in "${candidates[@]}"; do + if [[ -x "$gh_cli" ]]; then + exec "$gh_cli" auth git-credential "$@" + fi +done + +printf 'GitHub CLI is unavailable in the supported install locations.\n' >&2 +exit 1 diff --git a/shell/.local/bin/op-ssh-sign b/shell/.local/bin/op-ssh-sign new file mode 100755 index 0000000..1d362f6 --- /dev/null +++ b/shell/.local/bin/op-ssh-sign @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 1Password installs its SSH signing helper in different locations by platform. +case "$(uname -s)" in + Linux) + helper=/opt/1Password/op-ssh-sign + ;; + Darwin) + helper='/Applications/1Password.app/Contents/MacOS/op-ssh-sign' + ;; + *) + printf 'Unsupported platform for 1Password SSH signing: %s\n' "$(uname -s)" >&2 + exit 1 + ;; +esac + +[[ -x "$helper" ]] || { + printf '1Password SSH signing helper is unavailable: %s\n' "$helper" >&2 + exit 1 +} + +exec "$helper" "$@" diff --git a/stow-packages.txt b/stow-packages.txt new file mode 100644 index 0000000..0fc9c21 --- /dev/null +++ b/stow-packages.txt @@ -0,0 +1,7 @@ +# Portable application configuration packages, in deployment order. +git +ghostty +nvim +shell +tmux +starship diff --git a/tmux/.tmux.conf b/tmux/.tmux.conf new file mode 100644 index 0000000..fe6ca35 --- /dev/null +++ b/tmux/.tmux.conf @@ -0,0 +1,15 @@ +# Portable tmux defaults for terminal development. +set -g mouse on +set -g history-limit 100000 +set -g base-index 1 +setw -g pane-base-index 1 +set -g renumber-windows on +set -g escape-time 10 +set -g focus-events on +set -g set-clipboard on + +# Modern terminals, including Ghostty, support true colour. +set -ga terminal-overrides ',*:Tc' + +# Reload configuration without restarting tmux. +bind r source-file ~/.tmux.conf \; display-message 'tmux configuration reloaded' From ee01e8d3661db0b881570d9b69cb1fb7cc9e8463 Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 09:26:21 +1000 Subject: [PATCH 2/7] fix: address package bootstrap reviews --- README.md | 8 +++++--- {shell => git}/.local/bin/gh-credential | 0 {shell => git}/.local/bin/op-ssh-sign | 0 scripts/check.sh | 10 +++++----- scripts/stow.sh | 25 +++++++++++++++++++++++-- shell/.bashrc | 11 +++++++++++ 6 files changed, 44 insertions(+), 10 deletions(-) rename {shell => git}/.local/bin/gh-credential (100%) rename {shell => git}/.local/bin/op-ssh-sign (100%) create mode 100644 shell/.bashrc diff --git a/README.md b/README.md index 8201b01..560d4ad 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,11 @@ policy, and system tuning—belongs in a separate repository. ## Managed configuration -The default Stow deployment includes Git, Ghostty, Neovim/LazyVim, additive -Bash fragments, tmux, and Starship. Package contents mirror paths relative to -`$HOME`. +The default Stow deployment includes Git, Ghostty, Neovim/LazyVim, Bash, tmux, +and Starship. Package contents mirror paths relative to `$HOME`. The managed +Bash startup file loads additive fragments from `~/.bashrc.d`. If an existing +`~/.bashrc` already loads that directory, deployment preserves it; otherwise +Stow stops instead of replacing personal shell startup commands. The Fedora package profile installs the core command-line applications and utilities available from the configured DNF repositories on mutable Fedora diff --git a/shell/.local/bin/gh-credential b/git/.local/bin/gh-credential similarity index 100% rename from shell/.local/bin/gh-credential rename to git/.local/bin/gh-credential diff --git a/shell/.local/bin/op-ssh-sign b/git/.local/bin/op-ssh-sign similarity index 100% rename from shell/.local/bin/op-ssh-sign rename to git/.local/bin/op-ssh-sign diff --git a/scripts/check.sh b/scripts/check.sh index d189c9a..b48dafe 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,22 +5,22 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root" mapfile -d '' shell_scripts < <(find scripts -type f -name '*.sh' -print0) -mapfile -d '' shell_bins < <(find shell/.local/bin -type f -print0) +mapfile -d '' git_bins < <(find git/.local/bin -type f -print0) mapfile -d '' bash_fragments < <(find shell/.bashrc.d -type f -name '*.sh' -print0) printf 'Checking shell syntax...\n' -bash -n bootstrap.sh "${shell_scripts[@]}" "${shell_bins[@]}" "${bash_fragments[@]}" +bash -n bootstrap.sh shell/.bashrc "${shell_scripts[@]}" "${git_bins[@]}" "${bash_fragments[@]}" if command -v shellcheck > /dev/null 2>&1; then printf 'Running ShellCheck...\n' - shellcheck bootstrap.sh "${shell_scripts[@]}" "${shell_bins[@]}" - shellcheck --shell=bash "${bash_fragments[@]}" + shellcheck bootstrap.sh "${shell_scripts[@]}" "${git_bins[@]}" + shellcheck --shell=bash shell/.bashrc "${bash_fragments[@]}" else printf 'Skipping ShellCheck (not installed).\n' fi printf 'Checking executable scripts...\n' -for script in bootstrap.sh scripts/stow.sh scripts/install-packages.sh scripts/package-providers/*.sh; do +for script in bootstrap.sh scripts/stow.sh scripts/install-packages.sh scripts/package-providers/*.sh "${git_bins[@]}"; do [[ -x "$script" ]] || { printf 'Script is not executable: %s\n' "$script" >&2 exit 1 diff --git a/scripts/stow.sh b/scripts/stow.sh index 7766f6c..2eb9f69 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -65,6 +65,9 @@ remove_obsolete_links() { local -a migrations=( "$HOME/.gitconfig|$root/git/.gitconfig" "$HOME/.config/ghostty/config.ghostty|$root/ghostty/.config/ghostty/config.ghostty" + "$HOME/.config/ghostty/config|$root/ghostty/.config/ghostty/config.ghostty" + "$HOME/.local/bin/gh-credential|$root/shell/.local/bin/gh-credential" + "$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign" ) for migration in "${migrations[@]}"; do @@ -76,10 +79,16 @@ remove_obsolete_links() { done } +bashrc_sources_fragments() { + local bashrc="$1" + [[ -r "$bashrc" ]] || return 1 + grep -Eq '(\$\{?HOME\}?|~)/\.bashrc\.d' "$bashrc" +} + stow_base() { local root="$1" dry_run="$2" verbose="$3" shift 3 - local -a packages=("$@") stow_args failed=() + local -a packages=("$@") stow_args package_args failed=() local package stow_args=(--dir "$root" --target "$HOME" --restow --no-folding) @@ -103,7 +112,19 @@ stow_base() { } printf 'Stowing %s\n' "$package" - stow "${stow_args[@]}" "$package" || failed+=("$package") + package_args=("${stow_args[@]}") + if [[ "$package" == shell && (-e "$HOME/.bashrc" || -L "$HOME/.bashrc") ]] && + ! link_points_to "$HOME/.bashrc" "$root/shell/.bashrc"; then + if bashrc_sources_fragments "$HOME/.bashrc"; then + printf 'Keeping existing .bashrc (it already loads ~/.bashrc.d).\n' + package_args+=(--ignore='^\.bashrc$') + else + printf 'Error: existing .bashrc does not load ~/.bashrc.d: %s\n' "$HOME/.bashrc" >&2 + failed+=("$package") + continue + fi + fi + stow "${package_args[@]}" "$package" || failed+=("$package") done [[ ${#failed[@]} -eq 0 ]] || die "failed Stow packages: ${failed[*]}" diff --git a/shell/.bashrc b/shell/.bashrc new file mode 100644 index 0000000..dfba841 --- /dev/null +++ b/shell/.bashrc @@ -0,0 +1,11 @@ +# Portable Bash startup. Keep machine-specific commands in ~/.bashrc.d instead. +if [[ -r /etc/bashrc ]]; then + # shellcheck source=/dev/null + . /etc/bashrc +fi + +for rc in "$HOME"/.bashrc.d/*.sh; do + # shellcheck disable=SC1090 + [[ -r "$rc" ]] && . "$rc" +done +unset rc From 555b83da90d7f08553fec51b0408e4180ae2f922 Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 09:41:31 +1000 Subject: [PATCH 3/7] fix: harden portable bootstrap paths --- ansible/packages/profile.txt | 1 + ansible/packages/providers/fedora.txt | 1 + git/.config/git/config | 2 +- scripts/check.sh | 17 +++++++-- scripts/stow.sh | 55 +++++++++++++++++++++------ 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/ansible/packages/profile.txt b/ansible/packages/profile.txt index e7edda1..d75a33e 100644 --- a/ansible/packages/profile.txt +++ b/ansible/packages/profile.txt @@ -2,6 +2,7 @@ git git-lfs github-cli +c-compiler neovim stow tmux diff --git a/ansible/packages/providers/fedora.txt b/ansible/packages/providers/fedora.txt index 637550c..03dfb4b 100644 --- a/ansible/packages/providers/fedora.txt +++ b/ansible/packages/providers/fedora.txt @@ -2,6 +2,7 @@ git git git-lfs git-lfs github-cli gh +c-compiler gcc neovim neovim stow stow tmux tmux diff --git a/git/.config/git/config b/git/.config/git/config index d8c0d31..7c959e3 100644 --- a/git/.config/git/config +++ b/git/.config/git/config @@ -41,7 +41,7 @@ [gpg] format = ssh [gpg "ssh"] - program = ~/.local/bin/op-ssh-sign + program = op-ssh-sign [credential "https://github.com"] helper = helper = !~/.local/bin/gh-credential diff --git a/scripts/check.sh b/scripts/check.sh index b48dafe..8f06cdf 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,9 +4,20 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root" -mapfile -d '' shell_scripts < <(find scripts -type f -name '*.sh' -print0) -mapfile -d '' git_bins < <(find git/.local/bin -type f -print0) -mapfile -d '' bash_fragments < <(find shell/.bashrc.d -type f -name '*.sh' -print0) +shell_scripts=() +while IFS= read -r -d '' file; do + shell_scripts+=("$file") +done < <(find scripts -type f -name '*.sh' -print0) + +git_bins=() +while IFS= read -r -d '' file; do + git_bins+=("$file") +done < <(find git/.local/bin -type f -print0) + +bash_fragments=() +while IFS= read -r -d '' file; do + bash_fragments+=("$file") +done < <(find shell/.bashrc.d -type f -name '*.sh' -print0) printf 'Checking shell syntax...\n' bash -n bootstrap.sh shell/.bashrc "${shell_scripts[@]}" "${git_bins[@]}" "${bash_fragments[@]}" diff --git a/scripts/stow.sh b/scripts/stow.sh index 2eb9f69..2ae25d5 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -53,25 +53,56 @@ load_default_packages() { [[ ${#default_packages[@]} -gt 0 ]] || die "Stow package manifest is empty: $manifest" } +normalize_path() { + local path="$1" part + local -a parts normalized=() + IFS='/' read -r -a parts <<< "$path" + for part in "${parts[@]}"; do + case "$part" in + '' | .) ;; + ..) + [[ ${#normalized[@]} -eq 0 ]] || unset "normalized[$((${#normalized[@]} - 1))]" + ;; + *) normalized+=("$part") ;; + esac + done + printf '/%s' "$( + IFS=/ + printf '%s' "${normalized[*]}" + )" +} + link_points_to() { - local link="$1" expected="$2" + local link="$1" expected="$2" target [[ -L "$link" ]] || return 1 - command -v realpath > /dev/null 2>&1 || return 1 - [[ "$(realpath --canonicalize-missing -- "$link")" == "$expected" ]] + target="$(readlink "$link")" || return 1 + [[ "$target" == /* ]] || target="$(dirname "$link")/$target" + [[ "$(normalize_path "$target")" == "$(normalize_path "$expected")" ]] +} + +package_selected() { + local requested="$1" package + shift + for package in "$@"; do + [[ "$package" != "$requested" ]] || return 0 + done + return 1 } remove_obsolete_links() { - local root="$1" dry_run="$2" link expected + local root="$1" dry_run="$2" owner link expected + shift 2 local -a migrations=( - "$HOME/.gitconfig|$root/git/.gitconfig" - "$HOME/.config/ghostty/config.ghostty|$root/ghostty/.config/ghostty/config.ghostty" - "$HOME/.config/ghostty/config|$root/ghostty/.config/ghostty/config.ghostty" - "$HOME/.local/bin/gh-credential|$root/shell/.local/bin/gh-credential" - "$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign" + "git|$HOME/.gitconfig|$root/git/.gitconfig" + "ghostty|$HOME/.config/ghostty/config.ghostty|$root/ghostty/.config/ghostty/config.ghostty" + "ghostty|$HOME/.config/ghostty/config|$root/ghostty/.config/ghostty/config.ghostty" + "git|$HOME/.local/bin/gh-credential|$root/shell/.local/bin/gh-credential" + "git|$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign" ) for migration in "${migrations[@]}"; do - IFS='|' read -r link expected <<< "$migration" + IFS='|' read -r owner link expected <<< "$migration" + package_selected "$owner" "$@" || continue if link_points_to "$link" "$expected"; then printf 'UNLINK: %s (obsolete dotfile path)\n' "$link" "$dry_run" || rm -- "$link" @@ -82,7 +113,7 @@ remove_obsolete_links() { bashrc_sources_fragments() { local bashrc="$1" [[ -r "$bashrc" ]] || return 1 - grep -Eq '(\$\{?HOME\}?|~)/\.bashrc\.d' "$bashrc" + grep -Eq '(~|"?\$\{?HOME\}?"?)/\.bashrc\.d' "$bashrc" } stow_base() { @@ -95,7 +126,7 @@ stow_base() { "$dry_run" && stow_args+=(--simulate) "$verbose" && stow_args+=(--verbose=2) - remove_obsolete_links "$root" "$dry_run" + remove_obsolete_links "$root" "$dry_run" "${packages[@]}" printf 'Target: %s\n' "$HOME" "$dry_run" && printf 'Mode: dry-run\n' From defadf3bf85a3530c2a4d03f3bacd02a23d47a7f Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 10:00:57 +1000 Subject: [PATCH 4/7] fix: complete bootstrap prerequisites --- ansible/packages/profile.txt | 2 ++ ansible/packages/providers/fedora.txt | 2 ++ scripts/stow.sh | 26 +++++++++++++++++++------- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ansible/packages/profile.txt b/ansible/packages/profile.txt index d75a33e..b89eda3 100644 --- a/ansible/packages/profile.txt +++ b/ansible/packages/profile.txt @@ -3,6 +3,8 @@ git git-lfs github-cli c-compiler +curl +tree-sitter-cli neovim stow tmux diff --git a/ansible/packages/providers/fedora.txt b/ansible/packages/providers/fedora.txt index 03dfb4b..02e4ee5 100644 --- a/ansible/packages/providers/fedora.txt +++ b/ansible/packages/providers/fedora.txt @@ -3,6 +3,8 @@ git git git-lfs git-lfs github-cli gh c-compiler gcc +curl curl +tree-sitter-cli tree-sitter-cli neovim neovim stow stow tmux tmux diff --git a/scripts/stow.sh b/scripts/stow.sh index 2ae25d5..844e2bd 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -90,22 +90,30 @@ package_selected() { } remove_obsolete_links() { - local root="$1" dry_run="$2" owner link expected + local root="$1" dry_run="$2" owner link expected replacement shift 2 + planned_ignores=() local -a migrations=( "git|$HOME/.gitconfig|$root/git/.gitconfig" "ghostty|$HOME/.config/ghostty/config.ghostty|$root/ghostty/.config/ghostty/config.ghostty" - "ghostty|$HOME/.config/ghostty/config|$root/ghostty/.config/ghostty/config.ghostty" - "git|$HOME/.local/bin/gh-credential|$root/shell/.local/bin/gh-credential" - "git|$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign" + "ghostty|$HOME/.config/ghostty/config|$root/ghostty/.config/ghostty/config.ghostty|^\\.config/ghostty/config$" + "git|$HOME/.local/bin/gh-credential|$root/shell/.local/bin/gh-credential|^\\.local/bin/gh-credential$" + "git|$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign|^\\.local/bin/op-ssh-sign$" ) for migration in "${migrations[@]}"; do - IFS='|' read -r owner link expected <<< "$migration" + IFS='|' read -r owner link expected replacement <<< "$migration" package_selected "$owner" "$@" || continue if link_points_to "$link" "$expected"; then printf 'UNLINK: %s (obsolete dotfile path)\n' "$link" - "$dry_run" || rm -- "$link" + if "$dry_run"; then + if [[ -n "${replacement:-}" ]]; then + printf 'RELINK: %s (during %s deployment)\n' "$link" "$owner" + planned_ignores+=("$owner|$replacement") + fi + else + rm -- "$link" + fi fi done } @@ -120,7 +128,7 @@ stow_base() { local root="$1" dry_run="$2" verbose="$3" shift 3 local -a packages=("$@") stow_args package_args failed=() - local package + local package planned_ignore migration_owner ignore stow_args=(--dir "$root" --target "$HOME" --restow --no-folding) "$dry_run" && stow_args+=(--simulate) @@ -144,6 +152,10 @@ stow_base() { printf 'Stowing %s\n' "$package" package_args=("${stow_args[@]}") + for planned_ignore in "${planned_ignores[@]}"; do + IFS='|' read -r migration_owner ignore <<< "$planned_ignore" + [[ "$migration_owner" != "$package" ]] || package_args+=(--ignore="$ignore") + done if [[ "$package" == shell && (-e "$HOME/.bashrc" || -L "$HOME/.bashrc") ]] && ! link_points_to "$HOME/.bashrc" "$root/shell/.bashrc"; then if bashrc_sources_fragments "$HOME/.bashrc"; then From 2a8f9779c7712cfecf508e8a0452ccedec263973 Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 10:22:47 +1000 Subject: [PATCH 5/7] fix: align portable bootstrap boundaries --- README.md | 2 +- ansible/README.md | 7 +++-- .../package-providers/fedora.sh | 0 ansible/packages/profile.txt | 1 + ansible/packages/providers/fedora.txt | 1 + git/.config/git/config | 7 ----- git/.local/bin/op-ssh-sign | 23 -------------- git/README.md | 3 +- scripts/README.md | 6 ++-- scripts/check.sh | 19 +++++++++--- scripts/install-packages.sh | 2 +- scripts/stow.sh | 30 +++++++++++++++++-- shell/.bashrc | 6 ++-- 13 files changed, 59 insertions(+), 48 deletions(-) rename {scripts => ansible}/package-providers/fedora.sh (100%) delete mode 100755 git/.local/bin/op-ssh-sign diff --git a/README.md b/README.md index 560d4ad..675b657 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The package data is split into: - `ansible/packages/profile.txt`: provider-neutral application identifiers. - `ansible/packages/providers/fedora.txt`: Fedora package names. -- `scripts/package-providers/fedora.sh`: Fedora detection, planning, and +- `ansible/package-providers/fedora.sh`: Fedora detection, planning, and installation behavior. To add another tested provider, add its mapping and an executable adapter with diff --git a/ansible/README.md b/ansible/README.md index b3d965f..a1c5c46 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -5,6 +5,7 @@ data. `packages/profile.txt` contains stable application identifiers, while `packages/providers/.txt` maps those identifiers to concrete package names. -The shell dispatcher and provider adapters live under `scripts/`. Full system -provisioning, repository configuration, drivers, and workstation policy belong -in the separate Fedora setup repository. +The package-provider adapters also live here because they perform the elevated +package transaction. The unprivileged dispatcher lives under `scripts/`. Full +system provisioning, repository configuration, drivers, and workstation policy +belong in the separate Fedora setup repository. diff --git a/scripts/package-providers/fedora.sh b/ansible/package-providers/fedora.sh similarity index 100% rename from scripts/package-providers/fedora.sh rename to ansible/package-providers/fedora.sh diff --git a/ansible/packages/profile.txt b/ansible/packages/profile.txt index b89eda3..bdcbd8f 100644 --- a/ansible/packages/profile.txt +++ b/ansible/packages/profile.txt @@ -3,6 +3,7 @@ git git-lfs github-cli c-compiler +openssh-client curl tree-sitter-cli neovim diff --git a/ansible/packages/providers/fedora.txt b/ansible/packages/providers/fedora.txt index 02e4ee5..d1626b6 100644 --- a/ansible/packages/providers/fedora.txt +++ b/ansible/packages/providers/fedora.txt @@ -3,6 +3,7 @@ git git git-lfs git-lfs github-cli gh c-compiler gcc +openssh-client openssh-clients curl curl tree-sitter-cli tree-sitter-cli neovim neovim diff --git a/git/.config/git/config b/git/.config/git/config index 7c959e3..b347a3d 100644 --- a/git/.config/git/config +++ b/git/.config/git/config @@ -40,16 +40,9 @@ signingkey = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID+7IIgzSsQxb6Vys3v9d724lhaYnmnYXNQxsu5yHoDI [gpg] format = ssh -[gpg "ssh"] - program = op-ssh-sign [credential "https://github.com"] helper = helper = !~/.local/bin/gh-credential [credential "https://gist.github.com"] helper = helper = !~/.local/bin/gh-credential -[filter "lfs"] - clean = git-lfs clean -- %f - smudge = git-lfs smudge -- %f - process = git-lfs filter-process - required = true diff --git a/git/.local/bin/op-ssh-sign b/git/.local/bin/op-ssh-sign deleted file mode 100755 index 1d362f6..0000000 --- a/git/.local/bin/op-ssh-sign +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# 1Password installs its SSH signing helper in different locations by platform. -case "$(uname -s)" in - Linux) - helper=/opt/1Password/op-ssh-sign - ;; - Darwin) - helper='/Applications/1Password.app/Contents/MacOS/op-ssh-sign' - ;; - *) - printf 'Unsupported platform for 1Password SSH signing: %s\n' "$(uname -s)" >&2 - exit 1 - ;; -esac - -[[ -x "$helper" ]] || { - printf '1Password SSH signing helper is unavailable: %s\n' "$helper" >&2 - exit 1 -} - -exec "$helper" "$@" diff --git a/git/README.md b/git/README.md index 37805a3..1260950 100644 --- a/git/README.md +++ b/git/README.md @@ -2,7 +2,8 @@ Ordinary commits are unsigned so unattended tools do not need access to a personal signing key. Use `git cis` (an alias for `git commit -S`) when -intentionally creating a signed commit through 1Password. +intentionally creating a signed commit. Git uses the platform SSH signer and +the key exposed by the user's SSH agent, including 1Password's SSH agent. Authentication is separate from signing. HTTPS GitHub credentials are supplied by `gh auth git-credential`; no token is stored in this repository. diff --git a/scripts/README.md b/scripts/README.md index 8602ef2..c9871f5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,7 +6,7 @@ dotfiles. | Script | Description | |--------|-------------| | `install-packages.sh` | Resolve the shared profile through an OS provider | -| `package-providers/fedora.sh` | Detect Fedora and idempotently install missing RPMs | +| `../ansible/package-providers/fedora.sh` | Detect Fedora and idempotently install missing RPMs | | `stow.sh` | Deploy or repair base and host-specific GNU Stow packages | | `check.sh` | Validate manifests, scripts, configuration, and a Stow preview | @@ -16,5 +16,5 @@ Package adapters are intentionally small. Each executable adapter accepts: - `plan `: print the non-mutating install plan. - `install `: install only the requested provider package names. -Provider package names remain under `ansible/packages/providers/`; adapters -contain behavior only. +Provider package names and elevated install behavior remain under `ansible/`. +The scripts directory contains only unprivileged dispatch and deployment tools. diff --git a/scripts/check.sh b/scripts/check.sh index 8f06cdf..2d3f24c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -3,12 +3,18 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root" +export GIT_CONFIG_GLOBAL=/dev/null shell_scripts=() while IFS= read -r -d '' file; do shell_scripts+=("$file") done < <(find scripts -type f -name '*.sh' -print0) +provider_scripts=() +while IFS= read -r -d '' file; do + provider_scripts+=("$file") +done < <(find ansible/package-providers -type f -name '*.sh' -print0) + git_bins=() while IFS= read -r -d '' file; do git_bins+=("$file") @@ -20,18 +26,19 @@ while IFS= read -r -d '' file; do done < <(find shell/.bashrc.d -type f -name '*.sh' -print0) printf 'Checking shell syntax...\n' -bash -n bootstrap.sh shell/.bashrc "${shell_scripts[@]}" "${git_bins[@]}" "${bash_fragments[@]}" +bash -n bootstrap.sh shell/.bashrc "${shell_scripts[@]}" "${provider_scripts[@]}" \ + "${git_bins[@]}" "${bash_fragments[@]}" if command -v shellcheck > /dev/null 2>&1; then printf 'Running ShellCheck...\n' - shellcheck bootstrap.sh "${shell_scripts[@]}" "${git_bins[@]}" + shellcheck bootstrap.sh "${shell_scripts[@]}" "${provider_scripts[@]}" "${git_bins[@]}" shellcheck --shell=bash shell/.bashrc "${bash_fragments[@]}" else printf 'Skipping ShellCheck (not installed).\n' fi printf 'Checking executable scripts...\n' -for script in bootstrap.sh scripts/stow.sh scripts/install-packages.sh scripts/package-providers/*.sh "${git_bins[@]}"; do +for script in bootstrap.sh scripts/stow.sh scripts/install-packages.sh "${provider_scripts[@]}" "${git_bins[@]}"; do [[ -x "$script" ]] || { printf 'Script is not executable: %s\n' "$script" >&2 exit 1 @@ -76,7 +83,11 @@ done < stow-packages.txt if command -v stow > /dev/null 2>&1; then printf 'Checking Stow deployment...\n' - ./scripts/stow.sh base --dry-run + stow_home="$(mktemp -d)" + trap 'rm -rf "$stow_home"' EXIT + HOME="$stow_home" ./scripts/stow.sh base --dry-run + rm -rf "$stow_home" + trap - EXIT else printf 'Skipping Stow dry-run (GNU Stow not installed).\n' fi diff --git a/scripts/install-packages.sh b/scripts/install-packages.sh index d1d5005..55d3d01 100755 --- a/scripts/install-packages.sh +++ b/scripts/install-packages.sh @@ -110,7 +110,7 @@ load_profile() { main() { local root provider="" dry_run=false list_only=false adapter mapping profile index root="$(repo_root)" - provider_dir="$root/scripts/package-providers" + provider_dir="$root/ansible/package-providers" while [[ $# -gt 0 ]]; do case "$1" in diff --git a/scripts/stow.sh b/scripts/stow.sh index 844e2bd..001ad9c 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -98,7 +98,8 @@ remove_obsolete_links() { "ghostty|$HOME/.config/ghostty/config.ghostty|$root/ghostty/.config/ghostty/config.ghostty" "ghostty|$HOME/.config/ghostty/config|$root/ghostty/.config/ghostty/config.ghostty|^\\.config/ghostty/config$" "git|$HOME/.local/bin/gh-credential|$root/shell/.local/bin/gh-credential|^\\.local/bin/gh-credential$" - "git|$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign|^\\.local/bin/op-ssh-sign$" + "git|$HOME/.local/bin/op-ssh-sign|$root/shell/.local/bin/op-ssh-sign" + "git|$HOME/.local/bin/op-ssh-sign|$root/git/.local/bin/op-ssh-sign" ) for migration in "${migrations[@]}"; do @@ -121,7 +122,28 @@ remove_obsolete_links() { bashrc_sources_fragments() { local bashrc="$1" [[ -r "$bashrc" ]] || return 1 - grep -Eq '(~|"?\$\{?HOME\}?"?)/\.bashrc\.d' "$bashrc" + awk ' + /^[[:space:]]*#/ { next } + $1 == "for" && $3 == "in" && $0 ~ /bashrc\.d/ { loop_variable = $2 } + $1 == "." || $1 == "source" { + argument = $2 + gsub(/^"|"$/, "", argument) + if (argument ~ /bashrc\.d/ || (loop_variable != "" && argument == "$" loop_variable)) { + found = 1 + } + } + END { exit found ? 0 : 1 } + ' "$bashrc" +} + +dry_run_requested() { + local argument + for argument in "$@"; do + case "$argument" in + -n | --dry-run) return 0 ;; + esac + done + return 1 } stow_base() { @@ -264,7 +286,9 @@ main() { cmd_list ;; base | host) - [[ ${EUID:-$(id -u)} -ne 0 ]] || die "do not run Stow as root or with sudo" + if [[ ${EUID:-$(id -u)} -eq 0 ]] && ! dry_run_requested "$@"; then + die "do not run Stow as root or with sudo" + fi command -v stow > /dev/null 2>&1 || die "GNU Stow is required; run ./bootstrap.sh --packages-only first" "cmd_$command" "$@" ;; diff --git a/shell/.bashrc b/shell/.bashrc index dfba841..186de1d 100644 --- a/shell/.bashrc +++ b/shell/.bashrc @@ -5,7 +5,9 @@ if [[ -r /etc/bashrc ]]; then fi for rc in "$HOME"/.bashrc.d/*.sh; do - # shellcheck disable=SC1090 - [[ -r "$rc" ]] && . "$rc" + if [[ -r "$rc" ]]; then + # shellcheck disable=SC1090 + . "$rc" + fi done unset rc From 2bacfb26adf91d19fb6129bab666c28225b2407d Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 10:38:38 +1000 Subject: [PATCH 6/7] fix: complete editor runtime profile --- ansible/packages/profile.txt | 3 +++ ansible/packages/providers/fedora.txt | 3 +++ scripts/stow.sh | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ansible/packages/profile.txt b/ansible/packages/profile.txt index bdcbd8f..253a63b 100644 --- a/ansible/packages/profile.txt +++ b/ansible/packages/profile.txt @@ -6,6 +6,9 @@ c-compiler openssh-client curl tree-sitter-cli +nodejs +npm +go neovim stow tmux diff --git a/ansible/packages/providers/fedora.txt b/ansible/packages/providers/fedora.txt index d1626b6..b19f876 100644 --- a/ansible/packages/providers/fedora.txt +++ b/ansible/packages/providers/fedora.txt @@ -6,6 +6,9 @@ c-compiler gcc openssh-client openssh-clients curl curl tree-sitter-cli tree-sitter-cli +nodejs nodejs22-bin +npm nodejs22-npm-bin +go golang neovim neovim stow stow tmux tmux diff --git a/scripts/stow.sh b/scripts/stow.sh index 001ad9c..ab5bc9c 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -128,10 +128,11 @@ bashrc_sources_fragments() { $1 == "." || $1 == "source" { argument = $2 gsub(/^"|"$/, "", argument) - if (argument ~ /bashrc\.d/ || (loop_variable != "" && argument == "$" loop_variable)) { + if (loop_variable != "" && argument == "$" loop_variable) { found = 1 } } + $1 == "done" { loop_variable = "" } END { exit found ? 0 : 1 } ' "$bashrc" } From 79345cc1dd13d7163545598fbff21c252d0e28f8 Mon Sep 17 00:00:00 2001 From: Mark Groves Date: Tue, 14 Jul 2026 10:51:45 +1000 Subject: [PATCH 7/7] fix: complete rust editor support --- ansible/packages/profile.txt | 3 +++ ansible/packages/providers/fedora.txt | 3 +++ scripts/stow.sh | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ansible/packages/profile.txt b/ansible/packages/profile.txt index 253a63b..d8d682b 100644 --- a/ansible/packages/profile.txt +++ b/ansible/packages/profile.txt @@ -9,6 +9,9 @@ tree-sitter-cli nodejs npm go +rust +cargo +rust-analyzer neovim stow tmux diff --git a/ansible/packages/providers/fedora.txt b/ansible/packages/providers/fedora.txt index b19f876..3cc8583 100644 --- a/ansible/packages/providers/fedora.txt +++ b/ansible/packages/providers/fedora.txt @@ -9,6 +9,9 @@ tree-sitter-cli tree-sitter-cli nodejs nodejs22-bin npm nodejs22-npm-bin go golang +rust rust +cargo cargo +rust-analyzer rust-analyzer neovim neovim stow stow tmux tmux diff --git a/scripts/stow.sh b/scripts/stow.sh index ab5bc9c..125c3dc 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -124,7 +124,9 @@ bashrc_sources_fragments() { [[ -r "$bashrc" ]] || return 1 awk ' /^[[:space:]]*#/ { next } - $1 == "for" && $3 == "in" && $0 ~ /bashrc\.d/ { loop_variable = $2 } + $1 == "for" && $3 == "in" && $0 ~ /bashrc\.d\/\*(\.sh)?([;"[:space:]]|$)/ { + loop_variable = $2 + } $1 == "." || $1 == "source" { argument = $2 gsub(/^"|"$/, "", argument)