diff --git a/AGENTS.md b/AGENTS.md index ec2ff47..f0b3ac1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,9 @@ If you touch provisioning code, preserve these: - `require_debian` inspects `/etc/os-release` for `ID=debian` or Debian-like `ID_LIKE`, matching `install/lib.sh`. The presence of `apt-get` is not OS detection. - Standalone modules cannot assume `base.sh` ran; pull prerequisites with `ensure_cmd [pkg]`. - `install/debian.sh` and `provision/workflow.sh` carry overlapping package lists on purpose. `install.sh` has to work on a Debian box that never ran `provision/`, and the suite stays standalone from `install/` (same reason `provision/lib.sh` duplicates `is_yes`). The drift is accepted; keep the two in sync by hand. -- Debian renames `bat` to `batcat` and `fd` to `fdfind`. Wrapper links go in `~/.local/bin`, never `/usr/local/bin` — no sudo, and it matches `install/debian.sh` so the two paths cannot leave conflicting links behind. A link is only written when the destination is absent or is already our own link: `ln -sf` unlinks first, and `has_cmd` cannot see a binary the user installed there while `~/.local/bin` is off `PATH`, so an unguarded link destroys it. +- Debian renames `bat` to `batcat` and `fd` to `fdfind`. Wrapper links go in `~/.local/bin`, never `/usr/local/bin` — no sudo, and it matches `install/debian.sh` so the two paths cannot leave conflicting links behind. A link is only written when the destination is absent or is already our own link (`ln -sf` unlinks first, so an unguarded link destroys whatever the user put there). That destination check runs *before* the "distro already ships the real name" `has_cmd` skip: `lib.sh` puts `~/.local/bin` on `PATH`, so our own wrapper — or the user's own binary — satisfies `has_cmd bat`, and checking in the other order would silently skip instead of reporting. +- `lib.sh` prepends `~/.local/bin` to `PATH` at source time, so a module sees what an earlier one installed (`claude`, the `bat`/`fd` wrappers) without a new login shell — `run-all.sh` runs modules as children, which inherit it. Interactive shells get the same path from Debian's `~/.profile` and `.zsh/common.zsh`. +- Each module ends by running what it installed through `show_versions` (`lib.sh`), which prints `cmd: ()` or warns when the command is not on `PATH` — an installed-but-unreachable binary has to fail loudly. It tries `--version`, `-V`, `-v` in order and takes the first clean exit (`tmux` needs `-V`, `dig` needs `-v`); `tailscale` is done by hand because its CLI has no version *flag* at all, only a `version` subcommand. - Packages that exist on only some Debian-like releases go through `apt_install_optional`, never the main `apt_install` list. `require_debian` accepts bookworm, trixie and Ubuntu; `lazygit` is trixie-only, and one missing package in a batched `apt-get install -y` exits 100 and takes the module — and `run-all.sh` — down under `set -e`. - `base.sh` installs `build-essential` rather than bare `make`: gcc/g++/libc6-dev are what native builds (node-gyp, Python wheels, nvim tree-sitter) actually need, and it supersedes `make`. No clang — nothing in the suite uses it. - `sshd.sh` must refuse to disable password auth without a verified escape hatch: an `authorized_keys` that `ssh-keygen -l` parses **and** effective `PubkeyAuthentication yes`, or Tailscale SSH up. Fail closed. diff --git a/provision/base.sh b/provision/base.sh index 4678c50..ae62412 100755 --- a/provision/base.sh +++ b/provision/base.sh @@ -21,3 +21,6 @@ require_sudo apt_update apt_install "${PACKAGES[@]}" log_ok "Base packages installed" + +log_info "Versions" +show_versions curl wget git gpg gcc xz dig diff --git a/provision/claude.sh b/provision/claude.sh index 19f235e..e4eee30 100755 --- a/provision/claude.sh +++ b/provision/claude.sh @@ -160,11 +160,7 @@ else log_warn "claude at $claude_cmd is not a native install under $VERSIONS_DIR; skipping verification" fi -"$claude_cmd" --version log_ok "claude is installed" - -# .zsh/common.zsh already puts ~/.local/bin on PATH; other shells may not. -case ":${PATH}:" in - *":$HOME/.local/bin:"*) ;; - *) log_warn "$HOME/.local/bin is not on this shell's PATH; start a new login shell to pick up claude" ;; -esac +# Resolved through PATH, not $claude_cmd: this doubles as the check that a +# fresh install is actually reachable by name (lib.sh adds ~/.local/bin). +show_versions claude diff --git a/provision/gh.sh b/provision/gh.sh index aace350..e21e352 100755 --- a/provision/gh.sh +++ b/provision/gh.sh @@ -41,5 +41,5 @@ log_ok "GitHub CLI apt repo configured" apt_update apt_install gh -gh --version log_ok "gh installed" +show_versions gh diff --git a/provision/lib.sh b/provision/lib.sh index 10bbe4f..2999511 100644 --- a/provision/lib.sh +++ b/provision/lib.sh @@ -70,3 +70,35 @@ apt_install_optional() { # Standalone module runs can't assume base.sh ran first. ensure_cmd [pkg] ensure_cmd() { has_cmd "$1" || apt_install "${2:-$1}"; } + +# Report what a module installed: proof each command resolves on PATH and runs. +# The version flag is not universal (tmux wants -V, dig wants -v), so try them +# in turn and take the first that exits clean. +show_versions() { + local cmd flag out + for cmd in "$@"; do + if ! has_cmd "$cmd"; then + log_warn "$cmd is not on PATH" + continue + fi + out="?" + for flag in --version -V -v; do + if out="$("$cmd" "$flag" 2>&1)"; then + out="$(head -n1 <<< "$out")" + break + fi + out="?" + done + log_ok "$cmd: $out ($(command -v "$cmd"))" + done +} + +# Modules install into ~/.local/bin (claude, and the bat/fd wrappers), which is +# not on a fresh Debian's non-login PATH. Prepend it here, at source time, so a +# later module sees what an earlier one installed without waiting for a new +# login shell — run-all.sh's children inherit this. Interactive shells get it +# from ~/.profile (Debian) or .zsh/common.zsh. +case ":${PATH}:" in +*":$HOME/.local/bin:"*) ;; +*) export PATH="$HOME/.local/bin:$PATH" ;; +esac diff --git a/provision/tailscale.sh b/provision/tailscale.sh index c5f7614..13febe9 100755 --- a/provision/tailscale.sh +++ b/provision/tailscale.sh @@ -16,6 +16,9 @@ else log_ok "tailscale installed" fi +# `tailscale version`, not show_versions: the CLI has no --version flag. +log_ok "tailscale: $(tailscale version | head -n1) ($(command -v tailscale))" + if tailscale status > /dev/null 2>&1; then sudo tailscale set --ssh=true log_warn "tailscale already up; ensured SSH is enabled" diff --git a/provision/test/inside.sh b/provision/test/inside.sh index af868a1..2643189 100755 --- a/provision/test/inside.sh +++ b/provision/test/inside.sh @@ -44,6 +44,14 @@ for want in bat fd; do || fail "workflow.sh did not create a usable ~/.local/bin/$want wrapper" done +# Every module reports a version per tool it installed — the proof that each one +# is on PATH and runnable, not merely unpacked. +out="$(./provision/workflow.sh < /dev/null)" +for cmd in nvim tmux mosh rg fzf bat fd jq; do + grep -q "$cmd: " <<< "$out" || fail "workflow.sh did not report a $cmd version" +done +! grep -q ': ?' <<< "$out" || fail "workflow.sh could not read some version" + # A package this release does not carry must warn, not abort under set -e. # lazygit is the real case (trixie has it, bookworm and Ubuntu do not). ( @@ -78,12 +86,13 @@ out="$(./provision/claude.sh < /dev/null)" grep -q "already installed" <<< "$out" \ || fail "claude.sh rerun did not take the skip path" -# ...including when ~/.local/bin is off PATH, which is the state right after -# a fresh install in the same shell. +# ...including when the caller's PATH lacks ~/.local/bin, which is the state +# right after a fresh install in the same shell. lib.sh must repair PATH so the +# module finds claude by name rather than by hardcoded path. out="$(env PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ ./provision/claude.sh < /dev/null)" -grep -q "not on PATH" <<< "$out" \ - || fail "claude.sh did not detect the off-PATH install" +grep -q "already installed at $HOME/.local/bin/claude" <<< "$out" \ + || fail "lib.sh did not put ~/.local/bin on PATH for a caller that lacks it" # A bypass flag set to a value that reads as "off" must keep verification on. out="$(PROVISION_SKIP_CLAUDE_VERIFY=0 ./provision/claude.sh < /dev/null)" diff --git a/provision/workflow.sh b/provision/workflow.sh index 31d30a8..c5c860b 100755 --- a/provision/workflow.sh +++ b/provision/workflow.sh @@ -41,25 +41,27 @@ link_wrapper() { # Separate statement: local expands every argument before assigning any of # them, so a dest that reads $want on the same line trips set -u. dest="$HOME/.local/bin/$want" - # Guard on the real name: a distro that ships `bat` as bat needs no wrapper. - if has_cmd "$want" || ! has_cmd "$have"; then - return 0 - fi + # Nothing to bridge if the distro never renamed it. + has_cmd "$have" || return 0 src="$(command -v "$have")" - # ln -sf unlinks whatever is there first, and has_cmd cannot see a binary the - # user installed here while ~/.local/bin is off PATH — exactly the case this - # script warns about. Only ever replace nothing, or our own link. + # ln -sf unlinks whatever is there first, so check before writing: only ever + # replace nothing, or our own link. Checked before the has_cmd guard below — + # lib.sh puts ~/.local/bin on PATH, so a binary the user dropped here would + # otherwise look like the distro shipping the real name and be skipped + # silently instead of reported. if [ -e "$dest" ] && [ "$(readlink -f "$dest")" != "$(readlink -f "$src")" ]; then log_warn "$dest already exists and is not a link to $src; leaving it alone" return 0 fi + # A distro that ships `bat` as bat, elsewhere on PATH, needs no wrapper. + if has_cmd "$want" && [ ! -e "$dest" ]; then + return 0 + fi ln -sf "$src" "$dest" log_ok "link $dest -> $src" } link_wrapper bat batcat link_wrapper fd fdfind -case ":${PATH}:" in - *":$HOME/.local/bin:"*) ;; - *) log_warn "$HOME/.local/bin is not on this shell's PATH; start a new login shell to pick up bat/fd" ;; -esac +log_info "Versions" +show_versions nvim tmux mosh rg fzf bat fd jq lazygit