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..675b657 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,118 @@ # 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, 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. -```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. +- `ansible/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..a1c5c46 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,11 @@ +# 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 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/ansible/package-providers/fedora.sh b/ansible/package-providers/fedora.sh new file mode 100755 index 0000000..d27fff3 --- /dev/null +++ b/ansible/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/ansible/packages/profile.txt b/ansible/packages/profile.txt new file mode 100644 index 0000000..d8d682b --- /dev/null +++ b/ansible/packages/profile.txt @@ -0,0 +1,28 @@ +# Stable application identifiers for the default portable command-line profile. +git +git-lfs +github-cli +c-compiler +openssh-client +curl +tree-sitter-cli +nodejs +npm +go +rust +cargo +rust-analyzer +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..3cc8583 --- /dev/null +++ b/ansible/packages/providers/fedora.txt @@ -0,0 +1,28 @@ +# logical-id Fedora package name +git git +git-lfs git-lfs +github-cli gh +c-compiler gcc +openssh-client openssh-clients +curl curl +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 +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..b347a3d 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] @@ -38,11 +40,9 @@ signingkey = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID+7IIgzSsQxb6Vys3v9d724lhaYnmnYXNQxsu5yHoDI [gpg] format = ssh -[gpg "ssh"] - program = "/opt/1Password/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 diff --git a/git/.local/bin/gh-credential b/git/.local/bin/gh-credential new file mode 100755 index 0000000..419dc90 --- /dev/null +++ b/git/.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/git/README.md b/git/README.md index 758604e..1260950 100644 --- a/git/README.md +++ b/git/README.md @@ -1,37 +1,9 @@ -# 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. Git uses the platform SSH signer and +the key exposed by the user's SSH agent, including 1Password's SSH agent. -## 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..c9871f5 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 | +| `../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 | + +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 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 new file mode 100755 index 0000000..2d3f24c --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +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") +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[@]}" "${provider_scripts[@]}" \ + "${git_bins[@]}" "${bash_fragments[@]}" + +if command -v shellcheck > /dev/null 2>&1; then + printf 'Running ShellCheck...\n' + 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 "${provider_scripts[@]}" "${git_bins[@]}"; 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' + 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 + +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..55d3d01 --- /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/ansible/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/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..125c3dc 100755 --- a/scripts/stow.sh +++ b/scripts/stow.sh @@ -2,223 +2,300 @@ 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[@]}" -} - -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") +valid_name() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] +} + +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" +} + +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" target + [[ -L "$link" ]] || return 1 + 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" 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|^\\.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" + "git|$HOME/.local/bin/op-ssh-sign|$root/git/.local/bin/op-ssh-sign" + ) + + for migration in "${migrations[@]}"; do + 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" + 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 +} + +bashrc_sources_fragments() { + local bashrc="$1" + [[ -r "$bashrc" ]] || return 1 + awk ' + /^[[:space:]]*#/ { next } + $1 == "for" && $3 == "in" && $0 ~ /bashrc\.d\/\*(\.sh)?([;"[:space:]]|$)/ { + loop_variable = $2 + } + $1 == "." || $1 == "source" { + argument = $2 + gsub(/^"|"$/, "", argument) + if (loop_variable != "" && argument == "$" loop_variable) { + found = 1 + } + } + $1 == "done" { loop_variable = "" } + 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() { + local root="$1" dry_run="$2" verbose="$3" + shift 3 + local -a packages=("$@") stow_args package_args failed=() + local package planned_ignore migration_owner ignore + + 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" "${packages[@]}" + 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" + 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 + 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 - done < <(find "./$host_dir" -maxdepth 1 -mindepth 1 -type d -print0) - - printf '%s\n' "${packages[@]}" + stow "${package_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) + 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" "$@" ;; + *) die "unknown command: $command" ;; esac } diff --git a/shell/.bashrc b/shell/.bashrc new file mode 100644 index 0000000..186de1d --- /dev/null +++ b/shell/.bashrc @@ -0,0 +1,13 @@ +# 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 + if [[ -r "$rc" ]]; then + # shellcheck disable=SC1090 + . "$rc" + fi +done +unset rc 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/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'