diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/README.md b/README.md new file mode 100644 index 0000000..214f253 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# ops-toolkit + +Team-blessed tools, configs, and helpers for ops techs. Clone it, run the +bootstrap, and you are on the same tool set the rest of the team trains on -- +no independent research required. + +Target environment: Ubuntu 22.04/24.04, bash, apt-first. Version FLOORS are +enforced where a tool needs one; nothing is version-pinned unless a pack's +README says otherwise and why. + +## Quickstart + + git clone https://git.baldurkeep.com/git/ops/ops-toolkit.git ~/ops-toolkit + cd ~/ops-toolkit + bash bootstrap.sh --list # see what is available + bash bootstrap.sh --dry-run --all # see exactly what would change + bash bootstrap.sh --all # install everything + +Always run the dry-run first. Every mutation the installers would make is +printed there; nothing in this repo changes your machine silently. + +Note: invoke scripts as `bash script.sh` (do not rely on the executable bit; +commits from Windows clients strip it, which is cosmetic under this rule). + +## Packs + +| Pack | What it gives you | +|------------|--------------------------------------------------------------| +| tmux | Durable terminal sessions; team baseline config + plugins | +| shell | Team aliases and helpers, sourced via one .bashrc fence | +| ops-scripts| Shared scripts in ~/.local/bin (validate-paste, ...) | +| tmuxinator | One-command tmux project workspaces from YAML | + +Each pack has its own README (why the team uses it, what installs, house +rules) and numbered component installers you can run standalone: + + bash packs/tmux/install-020-config.sh # just relink the tmux config + +## How installs behave (the contract) + +- **Symlink for team configs** (`~/.tmux.conf`, `~/.local/bin/*`): a + `git pull` here updates everyone. Anything replaced is backed up first to + `.bak.`. +- **Copy-if-absent for user-edited files** (tmuxinator project YAMLs): + re-installs never clobber your edits. +- **One fenced block in `~/.bashrc`**: re-installs replace the fence in + place, never append duplicates. Your content outside the fence is never + touched. +- **Personal overrides are first-class**: `~/.tmux.conf.local` and your own + dotfiles outside the fence. The toolkit never ships or edits them. +- **Idempotent and loud**: re-running any installer on a converged machine + prints OK/no-op lines and changes nothing. `TOOLKIT_DRY_RUN=1` (or + `bootstrap.sh --dry-run`) previews every mutation. +- Exit codes everywhere: `0` proceed, `1` a step failed, `2` precondition + missing (tool absent, version below floor, bad usage). + +## Repo rules + +- ASCII-only, LF endings (`.gitattributes` enforces normalization; check + with `grep -rnP '[^\x00-\x7F]' --include='*.sh' .`). +- No environment-specific values in shipped files: no IPs, IDs, hostnames, + or credentials. Templates carry placeholders; scripts discover at runtime. +- Every script opens with a header: purpose, what it MUTATES, usage, exit + codes. `Mutates NOTHING` scripts are safe to run out of curiosity. +- New packs: copy an existing pack's shape (`install-pack.sh` orchestrator + + numbered `install-NNN-*.sh` components, numbered with gaps so steps can be + inserted later) and source `lib/lib-install.sh` for the shared hardening. +- Changes go through PR. Tool adoption debates belong in the pack README so + the rationale travels with the tool. diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..46f1fbd --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# bootstrap.sh -- ops-toolkit entry point +# +# Discovers packs (directories under packs/ containing install-pack.sh) and +# runs the selected pack installers in order, stopping on first failure. +# +# Mutates: only what the selected packs install (each pack states its own +# mutations; every mutation is visible in --dry-run first). +# +# Usage: +# bash bootstrap.sh --list # show available packs +# bash bootstrap.sh --dry-run --all # show what would change +# bash bootstrap.sh --all # install every pack +# bash bootstrap.sh tmux shell # install named packs only +# +# Exit codes: 0 PROCEED, 1 HOLD (a pack failed), 2 precondition/usage error. +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/lib-install.sh +. "$SCRIPT_DIR/lib/lib-install.sh" + +usage() { + sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +discover_packs() { + local d + for d in "$SCRIPT_DIR"/packs/*/; do + [ -f "${d}install-pack.sh" ] && basename "$d" + done +} + +DRY_RUN=0 +LIST_ONLY=0 +ALL=0 +SELECTED=() + +while [ $# -gt 0 ]; do + case "$1" in + --list) LIST_ONLY=1 ;; + --dry-run) DRY_RUN=1 ;; + --all) ALL=1 ;; + -h|--help) usage; exit 0 ;; + -*) fail "unknown option: $1"; usage; exit 2 ;; + *) SELECTED+=("$1") ;; + esac + shift +done + +AVAILABLE=() +while IFS= read -r p; do AVAILABLE+=("$p"); done < <(discover_packs) + +if [ "${#AVAILABLE[@]}" -eq 0 ]; then + fail "no packs found under $SCRIPT_DIR/packs/" + exit 2 +fi + +if [ "$LIST_ONLY" = "1" ]; then + info "available packs:" + for p in "${AVAILABLE[@]}"; do + printf ' %-12s %s\n' "$p" \ + "$(head -n1 "$SCRIPT_DIR/packs/$p/README.md" 2>/dev/null | sed 's/^# *//')" + done + exit 0 +fi + +if [ "$ALL" = "1" ]; then + SELECTED=("${AVAILABLE[@]}") +fi + +if [ "${#SELECTED[@]}" -eq 0 ]; then + fail "no packs selected" + usage + exit 2 +fi + +# Validate selection before running anything (fail early, mutate nothing). +for p in "${SELECTED[@]}"; do + if [ ! -f "$SCRIPT_DIR/packs/$p/install-pack.sh" ]; then + fail "unknown pack: $p (try --list)" + exit 2 + fi +done + +export TOOLKIT_DRY_RUN="$DRY_RUN" +[ "$DRY_RUN" = "1" ] && info "DRY-RUN mode: no changes will be made" + +for p in "${SELECTED[@]}"; do + info "==== pack: $p ====" + if ! bash "$SCRIPT_DIR/packs/$p/install-pack.sh"; then + fail "pack '$p' failed; stopping (packs after it were not run)" + exit 1 + fi +done + +ok "all selected packs completed:$(printf ' %s' "${SELECTED[@]}")" diff --git a/lib/lib-install.sh b/lib/lib-install.sh new file mode 100644 index 0000000..87f3e1b --- /dev/null +++ b/lib/lib-install.sh @@ -0,0 +1,196 @@ +# shellcheck shell=bash +# lib/lib-install.sh -- shared install hardening for ops-toolkit +# (sourced, never executed -- hence no shebang; the directive above declares +# the dialect for linting) +# +# Sourced (never executed) by bootstrap.sh and every pack installer. +# Mutates NOTHING by itself; provides gated helpers the installers call. +# All mutating helpers respect TOOLKIT_DRY_RUN=1 (print, do not act). +# +# Exit-code contract for scripts USING this lib: +# 0 = PROCEED (all steps ok or correctly no-oped) +# 1 = HOLD (a step failed) +# 2 = precondition missing (tool absent, version below floor) +# +# ASCII + LF. + +# Guard against double-source +if [ -n "${_OPS_TOOLKIT_LIB_LOADED:-}" ]; then + return 0 2>/dev/null || exit 0 +fi +_OPS_TOOLKIT_LIB_LOADED=1 + +FENCE_BEGIN="# >>> ops-toolkit managed block >>>" +FENCE_END="# <<< ops-toolkit managed block <<<" + +# ---------------------------------------------------------------- logging --- +info() { printf 'INFO: %s\n' "$*"; } +ok() { printf 'OK: %s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*"; } +fail() { printf 'FAIL: %s\n' "$*" >&2; } + +# ------------------------------------------------------- dry-run plumbing --- +# run_mut CMD [ARGS...] -- execute a mutating command, or print it in dry-run. +# Returns the command's status (0 in dry-run). +run_mut() { + if [ "${TOOLKIT_DRY_RUN:-0}" = "1" ]; then + # NB: not "$*" -- callers set IFS to newline+tab, which would join + # the args with newlines and truncate the preview to one token. + printf 'DRY-RUN:' + printf ' %s' "$@" + printf '\n' + return 0 + fi + "$@" +} + +# -------------------------------------------------------- prerequisites ----- +# require_cmd CMD [HINT] -- exit 2 with a hint if CMD is not on PATH. +require_cmd() { + local cmd=$1 hint=${2:-} + if ! command -v "$cmd" >/dev/null 2>&1; then + fail "required command not found: $cmd${hint:+ ($hint)}" + exit 2 + fi + ok "found: $cmd" +} + +# version_ge INSTALLED MINIMUM -- true if INSTALLED >= MINIMUM (sort -V). +version_ge() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ] +} + +# require_version NAME INSTALLED MINIMUM -- exit 2 if below the floor. +# Floors, not pins: we check a minimum, we do not demand an exact version. +require_version() { + local name=$1 installed=$2 minimum=$3 + if [ -z "$installed" ]; then + fail "$name: could not determine installed version" + exit 2 + fi + if version_ge "$installed" "$minimum"; then + ok "$name $installed (floor $minimum)" + else + fail "$name $installed is below the required floor $minimum" + exit 2 + fi +} + +# ------------------------------------------------------------ apt helper ---- +# apt_install PKG... -- install packages, with sudo when not root. +apt_install() { + local sudo_cmd="" + if [ "$(id -u)" -ne 0 ]; then + require_cmd sudo "needed to apt-get install as non-root" + sudo_cmd="sudo" + fi + run_mut $sudo_cmd apt-get install -y "$@" +} + +# ------------------------------------------------------- symlink installs --- +# backup_then_link SRC DST +# - SRC must exist (repo file). DST is the live location (e.g. ~/.tmux.conf). +# - If DST already symlinks to SRC: no-op, say so (idempotency made visible). +# - If DST exists as anything else: move to DST.bak., then link. +backup_then_link() { + local src=$1 dst=$2 + if [ ! -e "$src" ]; then + fail "link source missing: $src" + return 2 + fi + local src_real + src_real=$(readlink -f "$src" || true) + if [ -L "$dst" ]; then + local dst_target + dst_target=$(readlink -f "$dst" || true) + if [ "$dst_target" = "$src_real" ]; then + ok "already linked: $dst -> $src_real" + return 0 + fi + fi + if [ -e "$dst" ] || [ -L "$dst" ]; then + local bak + bak="${dst}.bak.$(date +%Y%m%d-%H%M%S)" + run_mut mv "$dst" "$bak" || { fail "could not back up $dst"; return 1; } + warn "existing $dst backed up to $bak" + fi + run_mut ln -s "$src_real" "$dst" || { fail "could not link $dst"; return 1; } + ok "linked: $dst -> $src_real" +} + +# copy_if_absent SRC DST -- for user-editable files (never clobber user edits). +copy_if_absent() { + local src=$1 dst=$2 + if [ ! -e "$src" ]; then + fail "copy source missing: $src" + return 2 + fi + if [ -e "$dst" ]; then + ok "exists, left untouched (user-owned): $dst" + return 0 + fi + run_mut cp "$src" "$dst" || { fail "could not copy to $dst"; return 1; } + ok "installed: $dst (copy; edit freely, re-install never overwrites)" +} + +# ensure_dir DIR +ensure_dir() { + local dir=$1 + if [ -d "$dir" ]; then + return 0 + fi + run_mut mkdir -p "$dir" || { fail "could not create $dir"; return 1; } + ok "created: $dir" +} + +# ------------------------------------------------------------ rc fencing ---- +# bashrc_fence TARGET (desired block content on stdin, WITHOUT markers) +# Replace-or-insert the single fenced ops-toolkit block in TARGET. +# - Identical content already fenced: no-op, say so. +# - Otherwise: back up TARGET once, strip any existing fence, append the +# new fenced block at the end. Never append-duplicates. +bashrc_fence() { + local target=$1 + local content current tmp + content=$(cat) + + if [ -f "$target" ]; then + current=$(awk -v m1="$FENCE_BEGIN" -v m2="$FENCE_END" ' + $0 == m1 { inblk = 1; next } + $0 == m2 { inblk = 0; next } + inblk { print } + ' "$target") + if [ "$current" = "$content" ]; then + ok "fence up to date in $target" + return 0 + fi + fi + + if [ "${TOOLKIT_DRY_RUN:-0}" = "1" ]; then + printf 'DRY-RUN: would write ops-toolkit fence into %s\n' "$target" + return 0 + fi + + if [ -f "$target" ]; then + local bak + bak="${target}.bak.$(date +%Y%m%d-%H%M%S)" + cp "$target" "$bak" || { fail "could not back up $target"; return 1; } + warn "backed up $target to $bak" + else + : > "$target" || { fail "could not create $target"; return 1; } + fi + + tmp=$(mktemp) || { fail "mktemp failed"; return 1; } + awk -v m1="$FENCE_BEGIN" -v m2="$FENCE_END" ' + $0 == m1 { inblk = 1; next } + $0 == m2 { inblk = 0; next } + !inblk { print } + ' "$target" > "$tmp" + { + printf '%s\n' "$FENCE_BEGIN" + printf '%s\n' "$content" + printf '%s\n' "$FENCE_END" + } >> "$tmp" + mv "$tmp" "$target" || { fail "could not update $target"; return 1; } + ok "fence written to $target" +} diff --git a/packs/ops-scripts/README.md b/packs/ops-scripts/README.md new file mode 100644 index 0000000..99edfe4 --- /dev/null +++ b/packs/ops-scripts/README.md @@ -0,0 +1,23 @@ +# ops-scripts -- shared operational scripts + +Why the team uses it: one blessed copy of the small scripts everyone needs, +linked into `~/.local/bin` so `git pull` in the toolkit updates them for +everyone at once. + +What this pack installs: + +- `install-010-bin.sh` -- symlinks `bin/*` into `~/.local/bin` + +Provided scripts: + +- `validate-paste.sh ` -- pre-flight a bash script or paste block: + parse check, CR-byte detection (python byte count -- grep on CR literals + false-positives), ASCII-only check, bare-exit and destructive-command + warnings. Run it on every block BEFORE pasting into a live shell. + Remember its own caveat: `bash -n` is parse-only; behavior-test too. + +Scope note: project-specific tooling (ledger-scan, cloud-assert, run-logged, +preflight) stays in its project repo, where it versions with the project. +This pack is for scripts that are useful on ANY box the team touches. +Additions go through PR with a header contract (purpose, mutates-statement, +usage, exit codes) -- see validate-paste.sh as the template. diff --git a/packs/ops-scripts/bin/validate-paste.sh b/packs/ops-scripts/bin/validate-paste.sh new file mode 100644 index 0000000..90ff46c --- /dev/null +++ b/packs/ops-scripts/bin/validate-paste.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# validate-paste.sh -- pre-flight a bash script/paste block BEFORE running it. +# +# Mutates NOTHING. Checks a candidate script file for the failure modes that +# have actually bitten us: +# 1. bash -n parse errors (parse-only: passing this is NOT a correctness +# guarantee -- behavior-test separately) +# 2. CR bytes (Windows line endings) -- counted via python byte count, +# because grep for a CR literal false-positives on tokens containing $r +# 3. non-ASCII characters (silently corrupt some downstream consumers) +# 4. bare 'exit' outside a subshell wrapper -- kills the operator's login +# shell when pasted (WARN: heuristic, review hits by eye) +# 5. common destructive commands with no visible gate nearby (WARN only) +# +# Usage: bash validate-paste.sh +# Exit codes: 0 PROCEED, 1 HOLD (hard check failed), 2 usage/tool missing. +# ASCII + LF. + +set -uo pipefail +IFS=$'\n\t' + +if [ $# -ne 1 ] || [ ! -f "${1:-}" ]; then + echo "usage: validate-paste.sh " >&2 + exit 2 +fi +TARGET=$1 +HOLD=0 + +# --- 1. parse check ----------------------------------------------------------- +if bash -n "$TARGET" 2>/tmp/vp-parse.$$; then + echo "OK: bash -n parse (parse-only, not a correctness guarantee)" +else + echo "FAIL: bash -n parse errors:" + sed 's/^/ /' /tmp/vp-parse.$$ + HOLD=1 +fi +rm -f /tmp/vp-parse.$$ + +# --- 2. CR bytes (python byte count; grep on CR literals false-positives) ---- +if command -v python3 >/dev/null 2>&1; then + CRS=$(python3 -c 'import sys; print(open(sys.argv[1],"rb").read().count(b"\x0d"))' "$TARGET") + if [ "$CRS" -eq 0 ]; then + echo "OK: no CR bytes (LF-only)" + else + echo "FAIL: $CRS CR byte(s) found -- Windows line endings; fix before use" + HOLD=1 + fi +else + echo "WARN: python3 not found; CR check skipped" +fi + +# --- 3. non-ASCII ------------------------------------------------------------- +NONASCII=$(grep -nP '[^\x00-\x7F]' "$TARGET" || true) +if [ -z "$NONASCII" ]; then + echo "OK: ASCII-only" +else + echo "FAIL: non-ASCII characters found:" + printf '%s\n' "$NONASCII" | head -5 | sed 's/^/ /' + HOLD=1 +fi + +# --- 4. bare exit heuristic --------------------------------------------------- +# A paste block containing 'exit' at top level kills the operator's shell. +# Heuristic: flag files that use 'exit' but never open a subshell wrapper. +EXITS=$(grep -nE '(^|[;&| ])exit( |;|$)' "$TARGET" || true) +if [ -n "$EXITS" ] && ! grep -qE '^\(' "$TARGET"; then + echo "WARN: 'exit' present with no obvious subshell wrapper '( { ...; } )'" + echo " If this is a PASTE block, a bare exit kills the login shell:" + printf '%s\n' "$EXITS" | head -3 | sed 's/^/ /' +fi + +# --- 5. destructive patterns -------------------------------------------------- +DESTRUCTIVE=$(grep -nE '(^|[;&| ])(rm -rf|mkfs|dd if=|wipefs|sgdisk|parted) ' "$TARGET" || true) +if [ -n "$DESTRUCTIVE" ]; then + echo "WARN: destructive command(s) present -- confirm each is individually gated:" + printf '%s\n' "$DESTRUCTIVE" | head -5 | sed 's/^/ /' +fi + +if [ "$HOLD" -eq 1 ]; then + echo "FAIL: HOLD -- fix the failures above before pasting/running" + exit 1 +fi +echo "OK: PROCEED (remember: behavior-test; syntax checks are not enough)" diff --git a/packs/ops-scripts/install-010-bin.sh b/packs/ops-scripts/install-010-bin.sh new file mode 100644 index 0000000..b676a94 --- /dev/null +++ b/packs/ops-scripts/install-010-bin.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# packs/ops-scripts/install-010-bin.sh -- link team ops scripts into PATH. +# +# Mutates: symlinks this pack's bin/* into ~/.local/bin (created if needed). +# ~/.local/bin is on the default PATH via ~/.profile on Ubuntu; a note is +# printed if it is not currently on PATH. Existing files are backed up. +# +# Usage: bash install-010-bin.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +BIN_DST="$HOME/.local/bin" +ensure_dir "$BIN_DST" + +found=0 +for f in "$PACK_DIR"/bin/*; do + [ -f "$f" ] || continue + found=1 + backup_then_link "$f" "$BIN_DST/$(basename "$f")" +done +if [ "$found" -eq 0 ]; then + fail "no scripts found in $PACK_DIR/bin" + exit 2 +fi + +case ":$PATH:" in + *":$BIN_DST:"*) ok "$BIN_DST is on PATH" ;; + *) warn "$BIN_DST is not on PATH in this shell; log out/in (Ubuntu adds it via ~/.profile)" ;; +esac diff --git a/packs/ops-scripts/install-pack.sh b/packs/ops-scripts/install-pack.sh new file mode 100644 index 0000000..5132b1c --- /dev/null +++ b/packs/ops-scripts/install-pack.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# install-pack.sh -- pack orchestrator (identical across packs) +# +# Runs this pack's numbered component installers (install-[0-9]*.sh) in +# lexical order, stopping on first failure. Each component is also runnable +# standalone: bash install-NNN-name.sh +# +# Exit codes: 0 PROCEED, 1 HOLD (component failed), 2 precondition missing. +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' + +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/lib-install.sh +. "$PACK_DIR/../../lib/lib-install.sh" + +found=0 +for step in "$PACK_DIR"/install-[0-9]*.sh; do + [ -e "$step" ] || continue + found=1 + info "-- $(basename "$step")" + if ! bash "$step"; then + fail "component failed: $(basename "$step")" + exit 1 + fi +done + +if [ "$found" -eq 0 ]; then + fail "no install-NNN components found in $PACK_DIR" + exit 2 +fi +ok "pack complete: $(basename "$PACK_DIR")" diff --git a/packs/shell/README.md b/packs/shell/README.md new file mode 100644 index 0000000..3a4b8c0 --- /dev/null +++ b/packs/shell/README.md @@ -0,0 +1,22 @@ +# shell -- team aliases and ops helper functions + +Why the team uses it: a small, shared set of shell helpers so everyone's +muscle memory transfers between machines and screen-shares. Deliberately +minimal -- this is a baseline, not a dotfiles framework. + +What this pack installs: + +- `install-010-fence.sh` -- adds ONE fenced block to `~/.bashrc` that sources + `files/*.sh` from the toolkit clone. `git pull` in the toolkit updates the + helpers live; re-running the installer replaces the fence in place (never + duplicates it). Your own `~/.bashrc` content outside the fence is untouched. + +Provided helpers: + +- `ts ` -- create-or-attach a tmux session (pairs with the tmux pack) +- `tls` -- list tmux sessions +- `mkcd ` -- mkdir -p and cd +- aliases: `ll`, `la`, `gs` (git status --short), `gl` (git log oneline) + +House rule: personal aliases live in your own dotfiles OUTSIDE the fence. +Additions to the team set go through PR so everyone gets them. diff --git a/packs/shell/files/aliases.sh b/packs/shell/files/aliases.sh new file mode 100644 index 0000000..afefb3a --- /dev/null +++ b/packs/shell/files/aliases.sh @@ -0,0 +1,10 @@ +# shellcheck shell=bash +# packs/shell/files/aliases.sh -- team baseline aliases (sourced, not run). +# Kept deliberately small and non-surprising. Personal aliases belong in your +# own dotfiles, outside the ops-toolkit fence. ASCII + LF. + +alias ll='ls -alF' +alias la='ls -A' +alias grep='grep --color=auto' +alias gs='git status --short' +alias gl='git log --oneline -15' diff --git a/packs/shell/files/ops-functions.sh b/packs/shell/files/ops-functions.sh new file mode 100644 index 0000000..dcd7cae --- /dev/null +++ b/packs/shell/files/ops-functions.sh @@ -0,0 +1,27 @@ +# shellcheck shell=bash +# packs/shell/files/ops-functions.sh -- team ops helpers (sourced, not run). +# Everything here is read-only or explicitly named for what it does. +# ASCII + LF. + +# ts NAME -- create-or-attach a tmux session (the durable-session habit). +ts() { + if [ -z "${1:-}" ]; then + echo "usage: ts " >&2 + return 2 + fi + tmux new-session -A -s "$1" +} + +# tls -- list tmux sessions (empty list is a valid answer, not an error). +tls() { + tmux ls 2>/dev/null || echo "no tmux sessions" +} + +# mkcd DIR -- mkdir -p and cd into it. +mkcd() { + if [ -z "${1:-}" ]; then + echo "usage: mkcd " >&2 + return 2 + fi + mkdir -p -- "$1" && cd -- "$1" || return 1 +} diff --git a/packs/shell/install-010-fence.sh b/packs/shell/install-010-fence.sh new file mode 100644 index 0000000..924f46c --- /dev/null +++ b/packs/shell/install-010-fence.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# packs/shell/install-010-fence.sh -- source team shell files from ~/.bashrc. +# +# Mutates: writes/updates ONE fenced block in ~/.bashrc that sources this +# pack's files/*.sh from wherever the toolkit clone lives. Re-running +# replaces the fence (never append-duplicates). ~/.bashrc is backed up to +# ~/.bashrc.bak. before the first change. +# +# Usage: bash install-010-fence.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +# The fence references the clone location measured NOW (no hardcoded paths). +FILES_DIR="$PACK_DIR/files" + +bashrc_fence "$HOME/.bashrc" </dev/null || true +IFS=$'\n\t' + +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/lib-install.sh +. "$PACK_DIR/../../lib/lib-install.sh" + +found=0 +for step in "$PACK_DIR"/install-[0-9]*.sh; do + [ -e "$step" ] || continue + found=1 + info "-- $(basename "$step")" + if ! bash "$step"; then + fail "component failed: $(basename "$step")" + exit 1 + fi +done + +if [ "$found" -eq 0 ]; then + fail "no install-NNN components found in $PACK_DIR" + exit 2 +fi +ok "pack complete: $(basename "$PACK_DIR")" diff --git a/packs/tmux/README.md b/packs/tmux/README.md new file mode 100644 index 0000000..7081e2e --- /dev/null +++ b/packs/tmux/README.md @@ -0,0 +1,32 @@ +# tmux -- durable terminal sessions (team baseline) + +Why the team uses it: sessions on the jumphost survive SSH disconnects, so a +dropped connection no longer kills mid-stream work (deploys, Code sessions, +long verifies). Detach with `prefix + d`, reattach from any client with +`tmux attach` (or the `ts ` helper from the shell pack). + +What this pack installs: + +- `install-010-core.sh` -- tmux via apt; enforces version floor >= 3.2 +- `install-020-config.sh` -- symlinks the team `tmux.conf` to `~/.tmux.conf` +- `install-030-tpm.sh` -- clones TPM (plugin manager); then `prefix + I` + inside tmux installs the team plugin set + +Key decisions (debate welcome, change via PR): + +- Prefix stays at the default `C-b`. The common `C-a` rebind shadows readline + beginning-of-line, which ops shells use constantly. Rebind in your + `~/.tmux.conf.local` if you disagree for your own sessions. +- Baseline status bar is minimal. Powerline-style frameworks need patched + fonts on every client and fork per-user fast; they are a personal choice + for `~/.tmux.conf.local`, not team baseline. +- Plugins: `tmux-yank` (clipboard copy), `tmux-prefix-highlight` (visual cue + when the prefix is armed). Additions go through `plugins.list` + the + matching `@plugin` line in `files/tmux.conf`. + +Daily driver commands: + + tmux new -A -s main # create-or-attach a session named "main" + prefix + d # detach (session keeps running) + tmux ls # list sessions + prefix + r # reload config after edits diff --git a/packs/tmux/files/tmux.conf b/packs/tmux/files/tmux.conf new file mode 100644 index 0000000..1a6cba1 --- /dev/null +++ b/packs/tmux/files/tmux.conf @@ -0,0 +1,68 @@ +# ops-toolkit team baseline tmux configuration +# +# Managed by ops-toolkit (packs/tmux) as a SYMLINK: do not edit this file in +# place at ~/.tmux.conf -- your edits would land in the shared repo copy. +# Personal overrides go in ~/.tmux.conf.local, sourced near the end. +# Reload after changes: prefix + r +# ASCII + LF. + +# ---------------------------------------------------------------- general --- +set -g default-terminal "tmux-256color" +set -ag terminal-overrides ",xterm-256color:RGB" +set -g history-limit 50000 +set -sg escape-time 10 +set -g display-time 4000 +set -g mouse on +set -g focus-events on +setw -g mode-keys vi + +# Window/pane numbering: start at 1, keep contiguous +set -g base-index 1 +setw -g pane-base-index 1 +set -g renumber-windows on + +# Terminal titles (useful when several jumphost windows are open) +set -g set-titles on +set -g set-titles-string "#S:#W #{host_short}" + +# ----------------------------------------------------------------- prefix --- +# The prefix stays at the tmux default (C-b), deliberately. The common C-a +# rebind shadows readline beginning-of-line, which ops shells use constantly. +# If you prefer C-a anyway, rebind it in ~/.tmux.conf.local. + +# ------------------------------------------------------------ keybindings --- +# Reload this config +bind r source-file ~/.tmux.conf \; display-message "tmux.conf reloaded" + +# New panes/windows open in the current pane's working directory +bind '"' split-window -c "#{pane_current_path}" +bind % split-window -h -c "#{pane_current_path}" +bind c new-window -c "#{pane_current_path}" + +# ---------------------------------------------------------------- activity -- +setw -g monitor-activity on +set -g visual-activity off + +# ----------------------------------------------------------------- status --- +# Minimal baseline. Heavier status frameworks (powerline etc.) are a personal +# choice: enable them in ~/.tmux.conf.local, not here. +set -g status-interval 5 +set -g status-left-length 30 +set -g status-left "[#S] " +set -g status-right "#{prefix_highlight} #H %Y-%m-%d %H:%M" + +# ---------------------------------------------------------------- plugins --- +# Managed by TPM (installed by packs/tmux/install-030-tpm.sh). +# Inside tmux: prefix + I installs/updates the plugins listed here. +# This list is the team-blessed set; it mirrors packs/tmux/plugins.list. +set -g @plugin 'tmux-plugins/tpm' +set -g @plugin 'tmux-plugins/tmux-yank' +set -g @plugin 'tmux-plugins/tmux-prefix-highlight' + +# ---------------------------------------------------------- local override -- +# Never shipped or touched by the toolkit. Safe place for personal settings. +if-shell "test -f ~/.tmux.conf.local" "source-file ~/.tmux.conf.local" + +# TPM bootstrap -- keep this line LAST (guarded so the config still loads +# before TPM has been installed). +if-shell "test -d ~/.tmux/plugins/tpm" "run '~/.tmux/plugins/tpm/tpm'" diff --git a/packs/tmux/install-010-core.sh b/packs/tmux/install-010-core.sh new file mode 100644 index 0000000..99c3203 --- /dev/null +++ b/packs/tmux/install-010-core.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# packs/tmux/install-010-core.sh -- install tmux and check the version floor. +# +# Mutates: installs the 'tmux' apt package if absent (via sudo when non-root). +# Floor rationale: tmux >= 3.2 for popup -e / current plugin compatibility; +# Ubuntu 22.04 ships 3.2a, 24.04 ships 3.4 (floor, not pin). +# +# Usage: bash install-010-core.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +TMUX_MIN="3.2" + +if ! command -v tmux >/dev/null 2>&1; then + info "tmux not found; installing via apt" + apt_install tmux +fi + +if command -v tmux >/dev/null 2>&1; then + # Capture whole output, then extract (never pipe-and-hope). + TMUX_V_RAW=$(tmux -V 2>&1 || true) + TMUX_V=$(printf '%s' "$TMUX_V_RAW" | awk '{print $2}') + require_version tmux "$TMUX_V" "$TMUX_MIN" +else + if [ "${TOOLKIT_DRY_RUN:-0}" = "1" ]; then + warn "dry-run: tmux not installed yet, version floor not checked" + else + fail "tmux still not on PATH after install attempt" + exit 2 + fi +fi diff --git a/packs/tmux/install-020-config.sh b/packs/tmux/install-020-config.sh new file mode 100644 index 0000000..383a7a9 --- /dev/null +++ b/packs/tmux/install-020-config.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# packs/tmux/install-020-config.sh -- link the team tmux.conf into $HOME. +# +# Mutates: ~/.tmux.conf becomes a symlink to this pack's files/tmux.conf +# (any existing file is backed up to ~/.tmux.conf.bak. first). +# Personal settings belong in ~/.tmux.conf.local (never touched by us). +# +# Usage: bash install-020-config.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +backup_then_link "$PACK_DIR/files/tmux.conf" "$HOME/.tmux.conf" +info "personal overrides: create ~/.tmux.conf.local (sourced automatically)" diff --git a/packs/tmux/install-030-tpm.sh b/packs/tmux/install-030-tpm.sh new file mode 100644 index 0000000..d387c46 --- /dev/null +++ b/packs/tmux/install-030-tpm.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# packs/tmux/install-030-tpm.sh -- install TPM (tmux plugin manager). +# +# Mutates: clones https://github.com/tmux-plugins/tpm into +# ~/.tmux/plugins/tpm if not already present. No-ops if it is. +# Plugin installation itself is interactive: inside tmux, press prefix + I. +# +# Usage: bash install-030-tpm.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +require_cmd git "apt-get install git" + +TPM_DIR="$HOME/.tmux/plugins/tpm" +if [ -d "$TPM_DIR/.git" ]; then + ok "TPM already present: $TPM_DIR (update inside tmux with prefix + U)" +else + ensure_dir "$HOME/.tmux/plugins" + run_mut git clone --depth 1 https://github.com/tmux-plugins/tpm "$TPM_DIR" + ok "TPM installed; start tmux and press prefix + I to install plugins" +fi diff --git a/packs/tmux/install-pack.sh b/packs/tmux/install-pack.sh new file mode 100644 index 0000000..5132b1c --- /dev/null +++ b/packs/tmux/install-pack.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# install-pack.sh -- pack orchestrator (identical across packs) +# +# Runs this pack's numbered component installers (install-[0-9]*.sh) in +# lexical order, stopping on first failure. Each component is also runnable +# standalone: bash install-NNN-name.sh +# +# Exit codes: 0 PROCEED, 1 HOLD (component failed), 2 precondition missing. +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' + +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/lib-install.sh +. "$PACK_DIR/../../lib/lib-install.sh" + +found=0 +for step in "$PACK_DIR"/install-[0-9]*.sh; do + [ -e "$step" ] || continue + found=1 + info "-- $(basename "$step")" + if ! bash "$step"; then + fail "component failed: $(basename "$step")" + exit 1 + fi +done + +if [ "$found" -eq 0 ]; then + fail "no install-NNN components found in $PACK_DIR" + exit 2 +fi +ok "pack complete: $(basename "$PACK_DIR")" diff --git a/packs/tmux/plugins.list b/packs/tmux/plugins.list new file mode 100644 index 0000000..a1e2084 --- /dev/null +++ b/packs/tmux/plugins.list @@ -0,0 +1,6 @@ +# packs/tmux/plugins.list -- team-blessed TPM plugin set (documentation). +# The authoritative declarations live in files/tmux.conf (@plugin lines); +# keep the two in sync when changing the set. ASCII + LF. +tmux-plugins/tpm +tmux-plugins/tmux-yank +tmux-plugins/tmux-prefix-highlight diff --git a/packs/tmuxinator/README.md b/packs/tmuxinator/README.md new file mode 100644 index 0000000..2b5aa60 --- /dev/null +++ b/packs/tmuxinator/README.md @@ -0,0 +1,24 @@ +# tmuxinator -- one-command project workspaces + +Why the team uses it: repeatable tmux session layouts defined in YAML. +`tmuxinator start ` builds (or reattaches to) the whole workspace -- +windows, panes, startup commands -- so every tech gets the same layout for +the same job, and nothing is rebuilt by hand after a disconnect. + +What this pack installs: + +- `install-010-core.sh` -- tmuxinator via apt (one package source for + the team; no per-user gem environments) +- `install-020-templates.sh` -- seeds `~/.config/tmuxinator/` from + `templates/` (copy-if-absent: your edited + project files are never overwritten) + +House rules: + +- Templates in this repo carry NO environment-specific values (no IPs, IDs, + hostnames). Fill those in your local copy, or better: have the project + YAML discover them at runtime. +- A template that a whole team runs for a specific project belongs in THAT + project's repo; this pack ships generic starting points only. +- Depends on the tmux pack being installed first (bootstrap order handles + this when using --all). diff --git a/packs/tmuxinator/install-010-core.sh b/packs/tmuxinator/install-010-core.sh new file mode 100644 index 0000000..ea5357d --- /dev/null +++ b/packs/tmuxinator/install-010-core.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# packs/tmuxinator/install-010-core.sh -- install tmuxinator via apt. +# +# Mutates: installs the 'tmuxinator' apt package if absent (Ubuntu universe; +# pulls ruby as a dependency). apt over gem, deliberately: one package +# source for the whole team, no per-user gem environments to debug. +# +# Usage: bash install-010-core.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +if command -v tmuxinator >/dev/null 2>&1; then + ok "tmuxinator already installed: $(tmuxinator version 2>/dev/null || echo present)" +else + info "tmuxinator not found; installing via apt" + apt_install tmuxinator +fi diff --git a/packs/tmuxinator/install-020-templates.sh b/packs/tmuxinator/install-020-templates.sh new file mode 100644 index 0000000..d62de24 --- /dev/null +++ b/packs/tmuxinator/install-020-templates.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# packs/tmuxinator/install-020-templates.sh -- seed project templates. +# +# Mutates: creates ~/.config/tmuxinator/ and COPIES templates/*.template into +# it (dropping the .template suffix) ONLY where no file of that name exists. +# Copies, not symlinks, deliberately: project files are user-edited working +# documents; a re-install must never clobber them. +# +# Usage: bash install-020-templates.sh [TOOLKIT_DRY_RUN=1 to preview] +# Exit codes: 0 PROCEED, 1 HOLD, 2 precondition missing. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$PACK_DIR/../../lib/lib-install.sh" + +CFG_DIR="$HOME/.config/tmuxinator" +ensure_dir "$CFG_DIR" + +found=0 +for t in "$PACK_DIR"/templates/*.template; do + [ -f "$t" ] || continue + found=1 + base=$(basename "$t" .template) + copy_if_absent "$t" "$CFG_DIR/$base" +done +if [ "$found" -eq 0 ]; then + fail "no templates found in $PACK_DIR/templates" + exit 2 +fi +info "start a project with: tmuxinator start " diff --git a/packs/tmuxinator/install-pack.sh b/packs/tmuxinator/install-pack.sh new file mode 100644 index 0000000..5132b1c --- /dev/null +++ b/packs/tmuxinator/install-pack.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# install-pack.sh -- pack orchestrator (identical across packs) +# +# Runs this pack's numbered component installers (install-[0-9]*.sh) in +# lexical order, stopping on first failure. Each component is also runnable +# standalone: bash install-NNN-name.sh +# +# Exit codes: 0 PROCEED, 1 HOLD (component failed), 2 precondition missing. +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +IFS=$'\n\t' + +PACK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/lib-install.sh +. "$PACK_DIR/../../lib/lib-install.sh" + +found=0 +for step in "$PACK_DIR"/install-[0-9]*.sh; do + [ -e "$step" ] || continue + found=1 + info "-- $(basename "$step")" + if ! bash "$step"; then + fail "component failed: $(basename "$step")" + exit 1 + fi +done + +if [ "$found" -eq 0 ]; then + fail "no install-NNN components found in $PACK_DIR" + exit 2 +fi +ok "pack complete: $(basename "$PACK_DIR")" diff --git a/packs/tmuxinator/templates/example-project.yml.template b/packs/tmuxinator/templates/example-project.yml.template new file mode 100644 index 0000000..2454d3c --- /dev/null +++ b/packs/tmuxinator/templates/example-project.yml.template @@ -0,0 +1,17 @@ +# ops-toolkit tmuxinator project template. +# Copy to ~/.config/tmuxinator/.yml, then edit name/root/windows. +# Start with: tmuxinator start (attaches if already running) +# No environment-specific values ship in this template by design: fill in +# your own paths and commands. ASCII + LF. +name: example +root: ~/workspace/example +windows: + - main: + panes: + - # primary shell (leave blank for a plain prompt) + - logs: + panes: + - # e.g. tail -f path/to/logfile + - scratch: + panes: + -