From c77313c693d7ecf79920c0f78d3f488fd0846ebd Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 24 May 2026 19:55:32 +0200 Subject: [PATCH] docs(setup): use ~/.bashrc hand-off instead of chsh for zsh switch (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up fix on [#220](https://git.unespace.com/julien/apf_portal/pulls/220). The corp infra locks the default shell at user-provisioning time on the dev VM (`10.100.201.21`) — `chsh` is denied at the PAM level, so the `sudo chsh -s` block in [`20-zsh.sh`](docs/setup/scripts/20-zsh.sh) fails on every fresh VM. Switch to the `~/.bashrc` exec-zsh hand-off — the same proven pattern used in the legacy [`02-wsl-terminal-setup.md`](docs/setup/02-wsl-terminal-setup.md). UX-identical for the dev, zero infra escalation required. ## What lands | File | Change | | --- | --- | | `docs/setup/scripts/20-zsh.sh` | Drop the `sudo chsh -s` block. Append a guarded `exec zsh -l` block to `~/.bashrc` instead, marked with a managed-by comment for idempotency on re-runs. | | `docs/setup/01-dev-debian-vm-setup.md` | Table row for `20-zsh.sh` updated — "set as default shell" → "`~/.bashrc` (exec zsh on interactive shells — `chsh` is blocked on the corp VM)". | | `docs/setup/README.md` | Same descriptor adjustment. | ## The hand-off block ```bash # Managed by docs/setup/scripts/20-zsh.sh — apf-portal zsh hand-off. # Hand off to zsh on interactive shells. The `case $-` guard avoids # breaking non-interactive bash (scp, rsync, cron, …), and the # ZSH_VERSION check prevents an infinite re-exec loop. case $- in *i*) if command -v zsh >/dev/null 2>&1 && [ -z "${ZSH_VERSION-}" ]; then exec zsh -l fi ;; esac ``` Two safety nets in the block: - **`case $- in *i*)`** — only runs the hand-off when the shell flag set contains `i` (interactive). `scp` / `rsync` / non-interactive ssh executions go through bash and are not hijacked. - **`[ -z "${ZSH_VERSION-}" ]`** — `ZSH_VERSION` is set by zsh itself; if it's already set we're already in zsh and a re-exec would loop. ## Test plan - [ ] On the dev VM, run `20-zsh.sh` on a fresh state: bash, no existing `~/.bashrc` zsh hand-off → script exits without `chsh` error, `~/.bashrc` gets the block appended, `exit` + reconnect lands in zsh. - [ ] Re-run `20-zsh.sh` → reports `↪ skip zsh hand-off already in ~/.bashrc` (idempotency). - [ ] `scp some-file vm-dev:/tmp/` from the workstation still works (non-interactive bash not hijacked). - [x] `pnpm exec prettier --check` clean on the touched markdown files. - [x] `bash -n docs/setup/scripts/20-zsh.sh` (syntax check) clean. ## Why not amend #220 #220 has already merged. Squash-merge collapsed it to `8a04540` on main; a force-push to amend would rewrite that commit and break anyone who's pulled it. The fix lands as a separate commit on the same setup-doc family. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/221 --- docs/setup/01-dev-debian-vm-setup.md | 20 ++++++++-------- docs/setup/README.md | 24 ++++++++++---------- docs/setup/scripts/20-zsh.sh | 34 +++++++++++++++++++++------- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/docs/setup/01-dev-debian-vm-setup.md b/docs/setup/01-dev-debian-vm-setup.md index 9b5550b..971b49b 100644 --- a/docs/setup/01-dev-debian-vm-setup.md +++ b/docs/setup/01-dev-debian-vm-setup.md @@ -160,16 +160,16 @@ The `bootstrap.sh` script runs every numbered script (`10-…` → `80-…`) in What each script does: -| Script | Effect | Idempotent? | -| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| [`10-base-packages.sh`](scripts/10-base-packages.sh) | `apt update` + install base packages (curl wget git build-essential ca-certificates gnupg software-properties-common) | ✅ | -| [`20-zsh.sh`](scripts/20-zsh.sh) | Install zsh, set as default shell. Install Oh My Zsh, Powerlevel10k, plugins (autosuggestions, syntax-highlighting). Patch `~/.zshrc`. | ✅ | -| [`30-cli-tools.sh`](scripts/30-cli-tools.sh) | Install `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + `jq yq httpie make tree htop tmux direnv dnsutils unzip`. Aliases for the Debian-renamed binaries (`batcat`→`bat`, `fdfind`→`fd`). | ✅ | -| [`40-node.sh`](scripts/40-node.sh) | Install nvm. Install Node from the repo's `.nvmrc` (currently `24`). Enable corepack, pin pnpm to `package.json`'s `packageManager`. | ✅ | -| [`50-docker.sh`](scripts/50-docker.sh) | Install Docker CE + compose plugin from the Docker apt repo. Add the current user to the `docker` group. Enable `docker.service`. | ✅ | -| [`60-tuning.sh`](scripts/60-tuning.sh) | `fs.inotify.max_user_watches=524288` (Vite / Nx watch). Prompt for hostname (only if generic). Add a 4 GB swapfile if none. | ✅ | -| [`70-hardening.sh`](scripts/70-hardening.sh) | Best-effort UFW (allow SSH only by default), `unattended-upgrades` on security channel, `fail2ban`, sshd hardening (no root, no password). **Probes before applying** — skips what's already done. | ✅ | -| [`80-dotfiles.sh`](scripts/80-dotfiles.sh) | Symlink `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh`. Generate `~/.gitconfig` from `notes/gitconfig.txt`, prompt for identity (name + email). | ✅ | +| Script | Effect | Idempotent? | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| [`10-base-packages.sh`](scripts/10-base-packages.sh) | `apt update` + install base packages (curl wget git build-essential ca-certificates gnupg software-properties-common) | ✅ | +| [`20-zsh.sh`](scripts/20-zsh.sh) | Install zsh + Oh My Zsh + Powerlevel10k + plugins (autosuggestions, syntax-highlighting). Patch `~/.zshrc` (theme + plugins + fzf hook) and `~/.bashrc` (exec zsh on interactive shells — `chsh` is blocked on the corp VM). | ✅ | +| [`30-cli-tools.sh`](scripts/30-cli-tools.sh) | Install `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + `jq yq httpie make tree htop tmux direnv dnsutils unzip`. Aliases for the Debian-renamed binaries (`batcat`→`bat`, `fdfind`→`fd`). | ✅ | +| [`40-node.sh`](scripts/40-node.sh) | Install nvm. Install Node from the repo's `.nvmrc` (currently `24`). Enable corepack, pin pnpm to `package.json`'s `packageManager`. | ✅ | +| [`50-docker.sh`](scripts/50-docker.sh) | Install Docker CE + compose plugin from the Docker apt repo. Add the current user to the `docker` group. Enable `docker.service`. | ✅ | +| [`60-tuning.sh`](scripts/60-tuning.sh) | `fs.inotify.max_user_watches=524288` (Vite / Nx watch). Prompt for hostname (only if generic). Add a 4 GB swapfile if none. | ✅ | +| [`70-hardening.sh`](scripts/70-hardening.sh) | Best-effort UFW (allow SSH only by default), `unattended-upgrades` on security channel, `fail2ban`, sshd hardening (no root, no password). **Probes before applying** — skips what's already done. | ✅ | +| [`80-dotfiles.sh`](scripts/80-dotfiles.sh) | Symlink `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh`. Generate `~/.gitconfig` from `notes/gitconfig.txt`, prompt for identity (name + email). | ✅ | After bootstrap, reload your shell to pick up the new PATH + zsh: diff --git a/docs/setup/README.md b/docs/setup/README.md index ac86b05..04a58ff 100644 --- a/docs/setup/README.md +++ b/docs/setup/README.md @@ -13,18 +13,18 @@ Step-by-step setup procedures for new contributors. Each file is self-contained Modular setup scripts called from the doc above. Each is idempotent, each can be re-run safely. Run via the orchestrator (`bootstrap.sh`) or individually. -| Script | Purpose | -| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| [`bootstrap.sh`](scripts/bootstrap.sh) | Run every numbered script in order with a confirmation prompt at each step. | -| [`lib.sh`](scripts/lib.sh) | Common helpers — colour logging, idempotent `apt_install` / `ensure_line`. | -| [`10-base-packages.sh`](scripts/10-base-packages.sh) | apt + base packages every other script assumes (`curl wget git build-essential …`). | -| [`20-zsh.sh`](scripts/20-zsh.sh) | zsh, Oh My Zsh, Powerlevel10k, autosuggestions, syntax-highlighting. Patches `~/.zshrc`. | -| [`30-cli-tools.sh`](scripts/30-cli-tools.sh) | `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + dev extras (`jq yq httpie make tree htop tmux direnv`). | -| [`40-node.sh`](scripts/40-node.sh) | nvm + Node from the repo's `.nvmrc` + corepack pinned to `package.json#packageManager`'s pnpm version. | -| [`50-docker.sh`](scripts/50-docker.sh) | Docker CE + compose plugin from docker.com apt repo + user added to `docker` group + service enabled at boot. | -| [`60-tuning.sh`](scripts/60-tuning.sh) | inotify watches → 524288, optional 4 GB swapfile, optional hostname rename (only prompts if generic). | -| [`70-hardening.sh`](scripts/70-hardening.sh) | Best-effort UFW + unattended-upgrades + fail2ban + sshd hardening drop-in. Probes before applying — skips done items. | -| [`80-dotfiles.sh`](scripts/80-dotfiles.sh) | Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh`. Copies `notes/gitconfig.txt` to `~/.gitconfig` with prompted identity. | +| Script | Purpose | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`bootstrap.sh`](scripts/bootstrap.sh) | Run every numbered script in order with a confirmation prompt at each step. | +| [`lib.sh`](scripts/lib.sh) | Common helpers — colour logging, idempotent `apt_install` / `ensure_line`. | +| [`10-base-packages.sh`](scripts/10-base-packages.sh) | apt + base packages every other script assumes (`curl wget git build-essential …`). | +| [`20-zsh.sh`](scripts/20-zsh.sh) | zsh + Oh My Zsh + Powerlevel10k + autosuggestions + syntax-highlighting. Patches `~/.zshrc` (theme / plugins / fzf hook) and `~/.bashrc` (exec zsh — `chsh` blocked on corp VM). | +| [`30-cli-tools.sh`](scripts/30-cli-tools.sh) | `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + dev extras (`jq yq httpie make tree htop tmux direnv`). | +| [`40-node.sh`](scripts/40-node.sh) | nvm + Node from the repo's `.nvmrc` + corepack pinned to `package.json#packageManager`'s pnpm version. | +| [`50-docker.sh`](scripts/50-docker.sh) | Docker CE + compose plugin from docker.com apt repo + user added to `docker` group + service enabled at boot. | +| [`60-tuning.sh`](scripts/60-tuning.sh) | inotify watches → 524288, optional 4 GB swapfile, optional hostname rename (only prompts if generic). | +| [`70-hardening.sh`](scripts/70-hardening.sh) | Best-effort UFW + unattended-upgrades + fail2ban + sshd hardening drop-in. Probes before applying — skips done items. | +| [`80-dotfiles.sh`](scripts/80-dotfiles.sh) | Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh`. Copies `notes/gitconfig.txt` to `~/.gitconfig` with prompted identity. | ## `systemd/` diff --git a/docs/setup/scripts/20-zsh.sh b/docs/setup/scripts/20-zsh.sh index 6669bf7..ff943b4 100755 --- a/docs/setup/scripts/20-zsh.sh +++ b/docs/setup/scripts/20-zsh.sh @@ -9,15 +9,33 @@ require_not_root apt_install zsh -# Make zsh the default shell for $USER (idempotent). -current_shell="$(getent passwd "$USER" | cut -d: -f7)" -zsh_path="$(command -v zsh)" -if [[ "$current_shell" != "$zsh_path" ]]; then - log "Setting zsh as default shell for $USER (sudo will prompt)." - sudo chsh -s "$zsh_path" "$USER" - ok "Default shell set to $zsh_path. Takes effect on next login." +# Hand off to zsh from ~/.bashrc rather than `chsh -s`. The corp infra +# locks the default shell at user-provisioning time and `chsh` is denied +# (the standard PAM-level restriction). The .bashrc hand-off achieves +# the same UX without needing infra escalation, and it's compatible with +# both interactive SSH sessions and VSCode Remote-SSH's bash bootstrap. +BASHRC="$HOME/.bashrc" +[[ -f "$BASHRC" ]] || touch "$BASHRC" +MARKER='# Managed by docs/setup/scripts/20-zsh.sh — apf-portal zsh hand-off.' + +if grep -qF "$MARKER" "$BASHRC"; then + skip "zsh hand-off already in $BASHRC." else - skip "zsh already default shell." + cat >> "$BASHRC" <<'EOF' + +# Managed by docs/setup/scripts/20-zsh.sh — apf-portal zsh hand-off. +# Hand off to zsh on interactive shells. The `case $-` guard avoids +# breaking non-interactive bash (scp, rsync, cron, …), and the +# ZSH_VERSION check prevents an infinite re-exec loop. +case $- in + *i*) + if command -v zsh >/dev/null 2>&1 && [ -z "${ZSH_VERSION-}" ]; then + exec zsh -l + fi + ;; +esac +EOF + ok "Patched $BASHRC to exec zsh on interactive shells." fi # Oh My Zsh — install with RUNZSH=no to avoid spawning zsh during the script.