The Shell Game Part 5: Making It Portable

Four parts in, the verdict has been consistent: Zsh for daily interactive use, Bash for anything that has to run as a script or on a box that isn’t mine, Fish left out entirely. That verdict is easy to state and much harder to make durable — it only means anything if the actual configuration survives being rebuilt on a fresh box, which is exactly what this post is: taking everything tested across Part 2, Part 3 and Part 4 and making it something I can actually deploy again, mistakes included.

Borrowing a better reference config

debian1’s Zsh setup started life as the bare zsh-newuser-install skeleton from Part 3 — functional, but nothing like the Zsh experience I actually wanted day to day. Rather than hand-assemble something from scratch, I pulled Kali’s own shipped ~/.zshrc — a real, running Kali box on the same lab network — and diffed it against what debian1 had.

The differences explain a fair amount of what Part 3 found surprising. Kali’s file sets:

setopt interactivecomments # allow comments in interactive mode
setopt nonomatch           # hide error message if there is no match for the pattern

which is exactly the pair of behaviours Part 3 flagged as hard errors Bash never produced — #comment text raising no matches found, and an unmatched glob refusing to run the command at all. It also carries a full zstyle block for completion (menu selection, fuzzy matching, coloured output via $LS_COLORS), explicit keybindings for Home/End/Ctrl+arrow navigation, and the two-line coloured prompt that produces the anon㉿debian1 format rather than the plain %m%# Part 3 found so sparse. Adopting the file wholesale — a straight cp over the existing .zshrc — got all of that in one step, verified by rerunning Part 3’s own tests afterward: echo done #nothing? printed cleanly, an unmatched glob no longer hard-errored.

The overwrite trap

That straight cp was also the first real mistake of this post. Part 3 had appended one line to the old .zshrc to source .bash_aliases:

[[ -f ~/.bash_aliases ]] && source ~/.bash_aliases

Overwriting the whole file wholesale replaced that line along with everything else. It didn’t announce itself — no error, just aliases quietly not being there any more:

$ grep bash_aliases ~/.zshrc
$ type mnt
mnt not found
$ type ll
ll is an alias for ls -l

That last line is worth pausing on. ll didn’t disappear — Kali’s own file defines its own alias ll='ls -l' in its “some more ls aliases” section, so ll still worked, just with a subtly different definition (no -a) to the one I’d actually been testing against since Part 3. The fix was appending the source line again, at the end of the file:

$ echo '[[ -f ~/.bash_aliases ]] && source ~/.bash_aliases' >> ~/.zshrc
$ exec zsh
$ type mnt
mnt is an alias for mount | awk -F' ' '{ printf "%s\t%s\n",$1,$3; }' | column -t | egrep ^/dev/ | sort
$ mnt
/dev/sda1    /
$ type ll
ll is an alias for ls -al

ll now correctly shows ls -al again — my version, not Kali’s — because whatever’s sourced last wins, and appending puts .bash_aliases after Kali’s own definitions. That’s a genuinely useful rule to carry forward: when two configs define the same alias, load order decides which one survives, so where you append matters as much as what you append.

The alias file was never actually version-controlled

Bringing that file forward assumed it lived in a synced Git repo somewhere. It didn’t — every host that has it got a manually recreated or scp’d copy, this series’ debian1 included (a Box1 copy, as covered back in Part 3). Worth admitting plainly rather than glossing over: there was no pipeline, just habit and manual discipline across a handful of boxes, which is exactly the kind of thing that quietly falls over the moment there’s more than one or two machines to keep in sync.

So this post is also where that got fixed for real, not described hypothetically:

$ sudo apt install git -y
$ git version
git version 2.47.3
$ mv ~/.bash_aliases ~/.bash_aliases.manual-backup
$ mkdir -p ~/.dotfiles
$ cp ~/.bash_aliases.manual-backup ~/.dotfiles/.bash_aliases
$ cd ~/.dotfiles
$ git init
$ git add .bash_aliases
$ git commit -m "Initial commit: shared .bash_aliases"
$ git branch -M main
$ git remote add origin git@github.com:<you>/dotfiles.git

A naming collision worth catching before it ships

Before reaching for GitHub’s own CLI tool to create the remote repo, a quick which gh turned up something worth knowing about first:

$ which gh
gh: aliased to history|grep

One of the aliases already under test throughout this series — gh, short for “grep history,” genuinely useful for digging up a half-remembered command from months back — shares its name with GitHub’s official gh CLI. Aliases resolve before a PATH lookup in both Bash and Zsh, so installing the real gh binary wouldn’t have replaced this; it would just have sat there unreachable by name, shadowed every time. Rather than rename a years-old habit on the spot, the simpler fix was sidestepping it entirely — plain git plus a repo created through the GitHub website does everything gh repo create would have, without the collision. The alias itself stays as-is for now, a known trade-off rather than a fix, and one worth remembering before this file gets rolled out anywhere else with the real GitHub CLI installed alongside it.

Two real authentication hurdles

The first push attempt used HTTPS and a password, the way it might have worked years ago:

$ git push -u origin main
Username for 'https://github.com': michealgarner
Password for 'https://michealgarner@github.com':
remote: Invalid username or token. Password authentication is not supported for Git operations.
fatal: Authentication failed for 'https://github.com/<you>/dotfiles.git/'

GitHub retired password auth for Git operations back in 2021 — a personal access token or an SSH key is required instead. debian1 already had an SSH keypair from setting up the Kali1 connection back when this series was working out its Zsh parity, so reusing that beat generating a fresh token:

$ git remote set-url origin git@github.com:<you>/dotfiles.git
$ git push -u origin main

That got further, but not all the way:

ERROR: Repository not found.
fatal: Could not read from remote repository.

Not a credentials problem this time — the empty repo simply didn’t exist yet on GitHub. Creating it through the website first, then repeating the same push, went through cleanly:

$ git push -u origin main
Enumerating objects: 3, done.
...
To github.com:<you>/dotfiles.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

A pasted “yes/no” prompt eating the next command

Symlinking the newly git-tracked file back into place produced a prompt rather than a clean result:

$ ln -s ~/.dotfiles/.bash_aliases ~/.bash_aliases
ln: replace '/home/anon/.bash_aliases'?

ln here is itself one of the safety aliases in the file being migrated (ln -i, prompting before overwriting anything), and it was sitting in the middle of a multi-line paste — the same issue flagged back in Part 3 with a literal <TAB> landing in a command, just biting differently this time. A prompt mid-paste can silently consume whatever the next pasted line was as its answer, rather than running that line as a separate command. Rather than trust what the terminal implied had happened, the honest move was checking directly:

$ ls -la ~/.bash_aliases
lrwxrwxrwx 1 anon anon 34 Sep 12 15:54 /home/anon/.bash_aliases -> /home/anon/.dotfiles/.bash_aliases

It had, in fact, gone through correctly — but re-running it cleanly, one command at a time, confirmed it beyond doubt rather than taking the ambiguous paste output on faith:

$ rm ~/.bash_aliases
$ ln -s ~/.dotfiles/.bash_aliases ~/.bash_aliases
$ ls -la ~/.bash_aliases
lrwxrwxrwx 1 anon anon 34 Sep 12 16:12 /home/anon/.bash_aliases -> /home/anon/.dotfiles/.bash_aliases

A genuine symlink into a git-tracked file, mnt and ll both still resolving exactly as they have since Part 3. Two separate paste-related surprises across this series now, from two completely different causes — a literal placeholder character typed in once, an interactive prompt consuming a line the second time. Neither is really about Bash, Zsh, or Fish specifically; both are a reminder that pasting several commands at once isn’t the same as typing them one at a time, and it’s worth staying alert to that regardless of which shell is on the other end.

Keeping the shebang boundary

None of the above touches scripts, deliberately. Part 2’s checkhosts.sh opens with #!/usr/bin/env bash; Part 3’s retest of it, checkhosts_zsh.sh, opens with #!/usr/bin/env zsh. Both still work today, unchanged, regardless of which shell is sitting interactively at the prompt that launches them — because a shebang pins the interpreter explicitly, independent of whatever $SHELL happens to be set to. Moving the daily driver to Zsh never puts a Bash-targeted script at risk, and it shouldn’t: scripts get an explicit, pinned interpreter; only the interactive session itself changes.

Where the series ends up

Part 1 guessed the answer before any of it was tested: Zsh as the daily driver, Bash kept for scripts and for any box that isn’t mine to configure, Fish admired and left alone. Four parts of real testing on a real lab VM landed in exactly that place, for reasons that turned out to be more concrete than the initial guess — Zsh’s actual compatibility with a decade of accumulated aliases, Fish’s genuine but costly incompatibility, and now, a config and an alias file that can actually survive being deployed to a machine other than the one they were built on. That last part turned out to be the more interesting post than expected: not the shells themselves, but the small, boring, very real failures that show up the moment “it works on my machine” has to become “it works on the next one too.”