Hard-coding strings is bad, yet you probably hard-code your PATH. This way is far more organised. You could even target it with your app!
Interested? Then read on!
- What is it?
- What does that do?
- Install Instructions
- How does the Apple one work?
- Why replace it?
- More drawbacks to Apple's way
- Do i need to be on Apple to use it? (Short answer, no)
- How does path_helper know what to put in the path?
- Per user paths
- Pre-req
- Way 1: Use the paths, Luke
- Way 2: paths.d/
- Why use the paths.d sub directory?
- Ordering
- Why Library/Paths/paths and not Library/paths?
- MAN and DYLD and C_INCLUDE and PKG_CONFIG
- MANPATH
- The DYLD paths
- C_INCLUDE_PATH
- PKG_CONFIG_PATH
- An example install
- The ability to debug your paths
- Development
- To get set up for development
- To run the specs
- Test output is TAP
- Shell in and have a play
- Licence
A replacement for Apple's /usr/libexec/path_helper.
Apple's path_helper helps set the PATH and MANPATH environment variables, which is good but there are some significant problems with the way they've done it. This one fixes the bad stuff and builds on the good stuff. The 3 most important features are:
- It has per user paths as well as system wide ones.
- It extends the concept to include other paths than just
PATHandMANPATH. - It's got some helpful output for debugging your paths.
and one more for luck
- It's got no side effects, you simply ask it for a path and it gives back a path, no eval or setting the
PATHinside the script.
It's just a script with no dependencies other than Ruby.
- Download it (e.g.
git cloneor a download link, you can even just copy and paste the script) - Make sure it has the correct permissions (
chmod +x) - Have a look at the help by running it with
-h. - Run the
--setup(take note of the--liband--configand their--no-counterparts) - Copy and paste the bit setup tells you to, and put it in your
~/.zprofileor~/.bash_profile - Find your life is so much better now it's easy to manage your paths
It doesn't need to be in /usr/local/bin, or any special place, just chmod +x it and call it by the full path and it'll plop out a string for you.
See An example install for more.
Segments of the path are defined in text files under /etc/paths.d and in /etc/paths. For example, on my machine:
$ tree /etc/paths.d
/etc/paths.d
├── 10-BitKeeper
├── 10-pkgsrc
├── 15-macports
├── 20-XCode
├── MacGPG2
├── dotnet
├── dotnet-cli-tools
├── go
├── mono-commands
└── workbooks
$ cat /etc/paths /etc/paths.d/*
/usr/local/bin
/usr/local/sbin
/usr/bin
/usr/sbin
/bin
/sbin
/Applications/GPAC.app/Contents/MacOS/
/Applications/BitKeeper.app/Contents/Resources/bitkeeper
/opt/pkg/sbin
/opt/pkg/bin
/opt/local/bin
/Library/Developer/CommandLineTools/usr/bin
/usr/local/MacGPG2/bin
/usr/local/share/dotnet
~/.dotnet/tools
/usr/local/go/bin
/Library/Frameworks/Mono.framework/Versions/Current/Commands
/Applications/Xamarin Workbooks.app/Contents/SharedSupport/path-bin
/usr/local/sbin
/usr/bin
/usr/sbin
/bin
/sbin
/Applications/GPAC.app/Contents/MacOS/Because Apple's one loads the system libraries to the front, take a look:
$ /usr/libexec/path_helper
PATH="/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:/Applications/GPAC.app/Contents/MacOS/:/usr/local/go/bin:/Library/Developer/CommandLineTools/usr/bin:<snip!>…the rest of the items are added after, which means anything you add to /etc/paths.d/ will end up after the system libraries.
Want your up-to-date OpenSSL installed via Macports to be first in the PATH? Apple says "too bad!"
Want your much newer version of LLVM installed via pkgsrc to be hit first? Apple says "too bad!"
Well, there are alternatives.
Where the Apple path_helper falls down is:
- It puts things in
/etc, meaning you need elevated permissions to add/remove path segments. - Being in
/etcalso makes them system wide. - It's only for
PATHandMANPATHbut development and administration often need headers and libraries accessible in the same way too. - The string it returns is designed to be
eval'd. I know thatevalisn't always evil but why not just return thePATHstring and allow it to be set to a variable? Maybe there's more to be added.
No, it should work on any unix-like system. The Ruby version is just a script and it has one dependency, Ruby. The Crystal version needs Crystal to build it, but the binary has no dependencies.
For Ruby, it should work with any system running Ruby 2.6 or above. That version was macOS's deprecated system Ruby (/usr/bin/ruby), as path_helper typically runs from a shell profile before a newer Ruby is put on the PATH. Anything beneath 2.6, you take your chances (though it was tested against 2.3.7 for a long while and I don't see why it wouldn't still work). If your system Ruby is older still, or you'd rather not depend on Ruby at all, use the Crystal build instead (src/path_helper.cr, shards build).
Apple has put paths in /etc/paths and further files are there for the user or apps to add under /etc/paths.d/. If you want to order them then prefixing a number works well, e.g.
$ tree /etc/paths.d
/etc/paths.d
├── 10-pkgsrc
└── MacGPG2
└── ImageMagickThe format of the file is simply a path per line, e.g.
$ cat /etc/paths.d/10-pkgsrc
/opt/pkg/bin
/opt/pkg/sbin
$ cat /etc/paths
/usr/local/bin
/usr/local/sbin
/usr/bin
/usr/sbin
/bin
/sbinBecause an empty component in PATH is the current working directory, blank lines are ignored, as are empty files.
One path per line. A line containing a colon is dropped, with a warning on stderr naming the file and the line so that you can fix it. --quiet silences the warning. --debug shows it in the tree marked with ⊘ along with the reason.
Note that the warning goes to stderr because stdout carries the path itself, so, for example, export PATH=$(path_helper -p) still works but will not contain those items that were on the same line as a colon.
The order within the file matters as well as the order the files are read/concatenated.
The /etc/paths file in Apple isn't set out fully or in the order I'd want so I changed mine, you may want to do the same.
This is the bit I like best.
Apple's path_helper doesn't help with paths that may only be applicable for a single user. This version will check the following per user directories for path info.
On macOS:
~/Library/Paths/paths.dand~/Library/Paths/paths
On Linux:
~/.config/paths/paths.d/and~/.config/paths/paths
You can use the --setup switch to have the path_helper set up the directory layout and files, you just have to fill them! It also prints a snippet to paste into your shell profile. The executable's path in that snippet is always single-quoted (an embedded ' is written '\''), so it works wherever path_helper is installed, even in a directory with spaces in its name. Any of --etc, --lib, --config or their --no- counterparts given to --setup are carried into the snippet, after the switch on each export line and always in that order (--etc, --lib, --config) whatever order you typed them in, so the profile reads the same segments that were set up. For example path_helper --setup --config --no-etc prints lines like export PATH=$(ruby '/path/to/path_helper' -p --no-etc --config). Segments you didn't mention are left to their defaults, and --dry-run and --quiet are not carried.
You can also start a path with the tilde ~ character and it will be replaced with the HOME env variable. Only a leading ~ on its own or followed by a / is expanded, so /opt/app~1/bin is left as it is, and so is ~user (other users' home directories are not looked up). For example, if I install Haskell and want to put it in my path I can take the following steps.
path_helper --setup --no-config --no-etcThis would set up the ~/Library/Paths for you, which fits a Mac very well.
path_helper --setup --no-lib --no-etcYou might choose this way if you're on a Mac or using Linux. It's up to you.
On my Mac, Haskell resides in ~/Library/Haskell.
$ echo '~/Library/Haskell/bin' > ~/Library/Paths/paths
$ tree ~/Library/Paths
/Users/iainb/Library/Paths
├── paths
└── paths.d
$ cat ~/Library/Paths/paths
~/Library/Haskell/binThat puts /Users/iainb/Library/Haskell/bin at the front of my path and will only apply to my account's PATH.
$ touch ~/Library/Paths/paths.d/60-Haskell
$ tree ~/Library/Paths
/Users/iainb/Library/Paths
├── paths
└── paths.d
└── 60-HaskellPerhaps if I show you my actual set up it'll become clearer:
$ tree ~/Library/Paths
/Users/iainb/Library/Paths
├── paths
└── paths.d
├── 05-pkgsrc
├── 08-homebrew
├── 10-keybase
├── 30-oh-my-zshell
├── 50-ngrok
├── 55-Crystal-opt
├── 60-Crystal
├── 61-Opam
├── 62-Haskell
├── 63-Erlang
├── 63-Go
├── 64-Pyenv
├── 65-Rust
└── 66-AntigenImagine uninstalling Haskell and wanting to remove it from the PATH - are you sure you removed all of it? All the right parts? Did you make a typo?
Imagine you've developed a tool but on install you have to get the user to manually edit their PATH, or perhaps you're going to rely on PATH="/my/obnoxious/munging:$PATH"?
Once you start installing various things it makes sense to keep their paths in their own file, it's easier to organise (and remove). It's also easy for apps to target this to easily add things to a path. Some apps already do this by adding to /etc/paths.d (although that obviously needs elevated privileges and makes things system wide, so again, per user paths are better).
There are three places path_helper can look, and I call each one a segment: ~/Library/Paths (--lib), ~/.config/paths (--config) and /etc (--etc). Within a segment the paths.d directory is read first, then the paths file.
Only one of the two per-user segments is on by default, the one that suits the platform, so on a Mac path_helper reads:
~/Library/Paths/paths.d~/Library/Paths/paths/etc/paths.d/etc/paths
and everywhere else it reads:
~/.config/paths/paths.d~/.config/paths/paths/etc/paths.d/etc/paths
Pass --config on a Mac, or --lib elsewhere, to turn the other per-user segment on as well. On a Mac ~/Library/Paths still comes first; elsewhere ~/.config/paths is read ahead of ~/Library/Paths. Either way /etc comes last. Any segment can be left out with --no-lib, --no-config or --no-etc.
If you don't have them, they are skipped. Files within the .d dirs are read in byte order (C locale order), not the order Finder or ls use, upper-case letters sort before lower-case, e.g. 10-Zeta comes before 10-alpha, and treated as characters, e.g. 9-foo comes after 10-bar.
Comparison is done on each line's text, not on the actual target directory, so /opt/x and /opt/x/ both survive, as do ~/bin and /Users/me/bin (~ is expanded afterwards), as do /opt/Foo/bin and /opt/foo/bin, even though on a Mac, case-insensitivity is the default thus they point at the same target, so it will appear in PATH twice.
The simple way to avoid this, if you consider it to be a problem, is to be consistent in the way things are written.
Because this is such a useful pattern that it can be extended for headers and includes, so ~/Library/Paths/paths is for the PATH, ~/Library/Paths/manpaths is for the MANPATH etc.
Apple has already dictated that /etc/manpaths and /etc/manpaths.d/ are the default paths for setting MANPATH, so the same pattern has been followed for that as with PATH:
~/Library/Paths/manpaths.d/~/Library/Paths/manpaths~/.config/paths/manpaths.d/~/.config/paths/manpaths/etc/manpaths.d//etc/manpaths
I can tell you it's a very pleasant experience typing man blah for the thing I just installed and getting the correct man page up.
There are four of these, and the directory names follow the env var names exactly, as everywhere else. DYLD_FALLBACK_LIBRARY_PATH:
~/Library/Paths/dyld_fallback_library_paths.d/~/Library/Paths/dyld_fallback_library_paths~/.config/paths/dyld_fallback_library_paths.d/~/.config/paths/dyld_fallback_library_paths/etc/dyld_fallback_library_paths.d//etc/dyld_fallback_library_paths
DYLD_FALLBACK_FRAMEWORK_PATH:
~/Library/Paths/dyld_fallback_framework_paths.d/~/Library/Paths/dyld_fallback_framework_paths~/.config/paths/dyld_fallback_framework_paths.d/~/.config/paths/dyld_fallback_framework_paths/etc/dyld_fallback_framework_paths.d//etc/dyld_fallback_framework_paths
DYLD_LIBRARY_PATH:
~/Library/Paths/dyld_library_paths.d/~/Library/Paths/dyld_library_paths~/.config/paths/dyld_library_paths.d/~/.config/paths/dyld_library_paths/etc/dyld_library_paths.d//etc/dyld_library_paths
DYLD_FRAMEWORK_PATH:
~/Library/Paths/dyld_framework_paths.d/~/Library/Paths/dyld_framework_paths~/.config/paths/dyld_framework_paths.d/~/.config/paths/dyld_framework_paths/etc/dyld_framework_paths.d//etc/dyld_framework_paths
The switches are --dyld-fallback-lib (short form -l), --dyld-fallback-fram (-f), --dyld-lib and --dyld-fram.
Two warnings, neither of which is this tool's doing:
The fallback vars are consulted only after a library or framework's linked install path has been tried, so they are a backstop and are hard to get wrong. The other two are consulted before it, so they override the install path and can shadow a system dylib with your own build of it. That is occasionally exactly what you want and usually not, so reach for the fallback pair first.
And System Integrity Protection strips every DYLD_* variable from the environment when a protected binary is exec'd, so anything under /usr/bin, /bin, /usr/sbin or /sbin will not see what you set here. The variables still reach your own builds and anything installed under /usr/local, /opt and friends.
Same again for C_INCLUDE_PATH:
~/Library/Paths/c_include_paths.d/~/Library/Paths/c_include_paths~/.config/paths/c_include_paths.d/~/.config/paths/c_include_paths/etc/c_include_paths.d//etc/c_include_paths
Did you know that there's a PKG_CONFIG_PATH? There is, check the man page, it's very helpful.
~/Library/Paths/pkg_config_paths.d/~/Library/Paths/pkg_config_paths~/.config/paths/pkg_config_paths.d/~/.config/paths/pkg_config_paths/etc/pkg_config_paths.d//etc/pkg_config_paths
You could put the path_helper in /usr/local/libexec and mirror the Apple set up, so that other accounts to be able to access its goodness, but you can put it anywhere you like.
sudo mkdir -p /usr/local/libexecCurrently I run one from ~/bin so I don't bother with that.
mkdir ~/binDownload the file then make sure it has the correct permissions:
chmod +x ~/bin/path_helperLook at the help because you're not like everyone else, you read instructions ;-)
~/bin/path_helper --helpYou need sudo to add the folders in /etc, see the --help if you don't want that. I don't want that, and let's say I prefer using ~/.config to ~/Library because I'm on a Linux system:
~/bin/path_helper --setup --no-etc --no-libSee what's already there and why:
~/bin/path_helper --path --debugNote: Apple's path_helper is in /usr/libexec, this install won't touch it, you can always use it or return to it if you wish.
And checking its output (debug shows you that too):
$ ~/bin/path_helper --path
/opt/pkg/sbin:/opt/pkg/bin:/opt/X11/bin:/opt/ImageMagick/bin:/usr/local/MacGPG2/bin:/usr/local/git/bin:/opt/puppetlabs/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin"To put it into the PATH via the command line:
$ PATH=$(~/bin/path_helper -p)
$ export PATHbut you'll probably use the helpful instructions --setup provides at the end of setting up:
# Put this in your ~/.bash_profile or your ~/.zprofile
if [ -x '/Users/you/bin/path_helper' ]; then
export C_INCLUDE_PATH=$(ruby '/Users/you/bin/path_helper' -c --no-etc --no-lib)
export DYLD_FALLBACK_FRAMEWORK_PATH=$(ruby '/Users/you/bin/path_helper' --dyld-fallback-fram --no-etc --no-lib)
export DYLD_FALLBACK_LIBRARY_PATH=$(ruby '/Users/you/bin/path_helper' --dyld-fallback-lib --no-etc --no-lib)
export DYLD_FRAMEWORK_PATH=$(ruby '/Users/you/bin/path_helper' --dyld-fram --no-etc --no-lib)
export DYLD_LIBRARY_PATH=$(ruby '/Users/you/bin/path_helper' --dyld-lib --no-etc --no-lib)
export MANPATH=$(ruby '/Users/you/bin/path_helper' -m --no-etc --no-lib)
export PKG_CONFIG_PATH=$(ruby '/Users/you/bin/path_helper' --pc --no-etc --no-lib)
export PATH=$(ruby '/Users/you/bin/path_helper' -p --no-etc --no-lib)
fiThe --no-etc --no-lib on each line are the segment switches given to --setup above.
That is the Ruby version. The Crystal build prints the same lines without the leading ruby, e.g. export PATH=$('/path/to/path_helper' -p).
Remember, it won't set the PATH, it returns a path, you have to set the path with it e.g. PATH=$(/path/to/path_helper -p). Call /path/to/path_helper -h to see all the options.
The because the Ruby team decided to spam us with warnings about everything so quite often recently I get a lot of unhelpful stuff filling up my terminal on open. Thanks, Ruby core team!
To quieten it down change:
PATH=$(ruby /path/to/path_helper -p)to:
PATH=$(ruby /path/to/path_helper -p 2>/dev/null)The --debug flag is really helpful. For example:
$ exe/path_helper -p --debug
Name: PATH
Options: {name: "PATH", current_path: nil, debug: true, verbose: true}
Search order: [:config, :etc]
/root/.config/paths/paths.d
/root/.config/paths/paths
/etc/paths.d
/etc/paths
Results: (duplicates marked by ✗, dropped lines by ⊘)
/root/.config/paths/paths.d/03-libiconv
└── ~/Library/Frameworks/Libiconv.framework/Versions/Current/bin
/root/.config/paths/paths.d/04-llvm
├── /opt/local/libexec/llvm-11/bin
├── /opt/pkg/bin
└── ~/Library/Frameworks/LLVM.framework/Programs
/root/.config/paths/paths.d/05-pkgsrc
├── /opt/pkg/bin ✗
├── /opt/pkg/sbin
└── /opt/pkg/gnu/bin
/root/.config/paths/paths.d/10-keybase
├── $HOME/gopath
└── $HOME/gopath/bin
/root/.config/paths/paths.d/30-oh-my-zshell
└── ~/.oh-my-zsh/custom/plugins/fzf/bin
/root/.config/paths/paths.d/50-ngrok
└── ~/Applications/ngrok
/root/.config/paths/paths.d/55-Crystal-opt
├── /opt/crystal/bin
└── /opt/crystal/embedded/bin
/root/.config/paths/paths.d/60-Crystal
├── ~/Library/Frameworks/Crystal.framework/Versions/Current/bin
└── ~/Library/Frameworks/Crystal.framework/Versions/Current/embedded/bin
/root/.config/paths/paths.d/61-Opam-and-OCaml
├── ~/Library/Frameworks/Opam.framework/Programs
├── ~/.opam/4.10.0/bin
└── ~/.opam/4.10.0/sbin
/root/.config/paths/paths.d/62-Haskell
└── ~/Library/Haskell/bin
/root/.config/paths/paths.d/63-Erlang
└── ~/Library/Frameworks/Erlang.framework/Programs
/root/.config/paths/paths.d/63-Go
└── ~/go/bin
/root/.config/paths/paths.d/64-Pyenv
└── ~/.pyenv/bin
/root/.config/paths/paths.d/65-Rust
└── ~/.cargo/bin
/root/.config/paths/paths.d/66-Antigen
└── ~/bin
/root/.config/paths/paths.d/67-Lua
└── ~/.lua/bin
/root/.config/paths/paths.d/68-Zig
└── ~/Library/Frameworks/Zig.framework/Programs
/root/.config/paths/paths.d/docker-scripts
└── ~/Projects/ThePrintedBird/scripts/docker
/root/.config/paths/paths.d/gcc
├── /opt/pkg/gcc7/bin
└── /opt/pkg/gcc48/bin
/root/.config/paths/paths
└── /opt/local/sbin
/etc/paths
Env var:
/root/Library/Frameworks/Libiconv.framework/Versions/Current/bin:/opt/local/libexec/llvm-11/bin:/opt/pkg/bin:/root/Library/Frameworks/LLVM.framework/Programs:/opt/pkg/sbin:/opt/pkg/gnu/bin:$HOME/gopath:$HOME/gopath/bin:/root/.oh-my-zsh/custom/plugins/fzf/bin:/root/Applications/ngrok:/opt/crystal/bin:/opt/crystal/embedded/bin:/root/Library/Frameworks/Crystal.framework/Versions/Current/bin:/root/Library/Frameworks/Crystal.framework/Versions/Current/embedded/bin:/root/Library/Frameworks/Opam.framework/Programs:/root/.opam/4.10.0/bin:/root/.opam/4.10.0/sbin:/root/Library/Haskell/bin:/root/Library/Frameworks/Erlang.framework/Programs:/root/go/bin:/root/.pyenv/bin:/root/.cargo/bin:/root/bin:/root/.lua/bin:/root/Library/Frameworks/Zig.framework/Programs:/root/Projects/ThePrintedBird/scripts/docker:/opt/pkg/gcc7/bin:/opt/pkg/gcc48/bin:/opt/local/sbinEverything you need to know! Very useful for working out when other things are manipulating the path too.
I'm happy to hear from you, email me or open an issue. Pull requests are fine too, try to bring me a spec or an example if you want a feature or find a bug.
The project supports both Ruby and Crystal implementations. A Makefile manages Docker/Podman builds and testing across multiple versions of both languages.
Development dependencies:
- make
- podman or docker*
- ruby (>= 2.6)
- crystal (with shards)
- actionlint**
- shellcheck**
- zizmor**
* The test suite is destructive, so it only runs in a container; see To run the specs.
** Used by make check; see "Pre-commit check" under To run the specs.
Build images for all Ruby versions:
make build-allBuild images for all Crystal versions:
make build-crystal-allBuild and test everything (Ruby + Crystal):
make allExtract Crystal binary from container:
make extract-crystal CRYSTAL_VER=latestThis extracts the compiled Crystal binary to bin/path_helper. Note: The binary is compiled for Linux inside the container -- against musl (Alpine) by default, or glibc (Ubuntu) with CRYSTAL_LIBC=gnu -- so it won't run directly on macOS/other systems. It's useful for:
- Deploying to Linux servers
- Including in Linux-based containers
- CI/CD artifacts
For local development on macOS, use the Ruby version or compile Crystal natively with shards build.
This uses git information for version tagging during development. For a release build with an explicit version:
VERSION=5.0.0 make allSee all available commands:
make helpRun tests for all Ruby versions:
make test-allRun tests for all Crystal versions:
make test-crystal-allRun tests for a specific version:
make test RUBY_VER=2.7
make test RUBY_VER=2.6 # macOS's system Ruby patch level; buildable on demand, not in the default matrix
make test-crystal CRYSTAL_VER=1.14.0Test Crystal against glibc as well as musl:
make test-crystal CRYSTAL_VER=1.14.0 CRYSTAL_LIBC=gnu
make test-crystal-all CRYSTAL_LIBC=gnuThe Crystal images build on crystallang/crystal:<version>-alpine (musl) unless
CRYSTAL_LIBC=gnu picks the plain tag, which is Ubuntu and glibc. The glibc images are
tagged crystal<version>-gnu, so both kinds can be built side by side, and every Crystal
target (build-, test-, shell-, extract-crystal and the -all ones) takes the switch.
These build the image they need first, so there is no need to run a build
target beforehand. That matters more than it sounds: the image tag includes
git describe, so a new commit changes the tag and any image you built
earlier no longer matches.
Run only some test files:
make test RUBY_VER=3.3 TESTS=path
make test-crystal CRYSTAL_VER=1.14.0 TESTS="error edge_case"The argument to TESTS is the name of a files in spec/tests/.
For example, path, path_test and path_test.sh will run spec/tests/path_test.sh.
This works with the -all targets too, so make test-all TESTS=path runs setup
and path on 2.7, 3.3 and 4.0.6 (2.6 is not in the default RUBY_VERSIONS list, but is
buildable on demand with make test RUBY_VER=2.6).
setup always runs first, as every other file relies on it, and the files run in
their usual order regardless of the order they are passed in.
A name with no matches halts the run with Bail out! and exit status 1.
Which shell runs it: the suite is plain sh, run by whatever /bin/sh is —
busybox ash in the Alpine images (which have bash only as something to test
with, see below), dash in the glibc
Crystal image and on Ubuntu, bash-as-sh on macOS. The only thing it relies on
beyond POSIX is local, which all of those support; a shell without it gets a
Bail out!. Because some shells field-split the value in local x=$(...), such
assignments are written local x="$(...)".
What else it needs: ruby, which the timing helper uses to read the clock
(date +%N is GNU-only). It is needed whichever implementation is under test, so
the Crystal images install it as well, and the suite Bail out!s if it is not on
PATH.
Optionally, script and tput, for the one test of colour output: script puts
the debug report on a pseudo-terminal (util-linux's, busybox's and BSD's are all
handled) and tput supplies the colours to expect. Where either is missing that
test point is reported as ok ... # SKIP with the reason rather than failing.
Ubuntu and macOS have both; the Alpine images add them (util-linux and
ncurses), but CI's Alpine jobs, which run in the stock images, skip it.
Real shells: spec/tests/shell_test.sh checks that the output works in sh
(whatever /bin/sh is, the run says which), bash and zsh. In each, run with
no rc files, an emptied environment, a scratch HOME whose paths include a
directory with a space in its name, it checks that export PATH=$(path_helper -p "$PATH")
exports exactly what -p prints, and that a program in the new PATH is found and runs.
It also checks that what --setup prints is correct, including that segment switches given to --setup are carried onto its export lines and that the snippet then exports what those switches give.
A shell that isn't installed is reported as ok ... # SKIP.
The images add bash and zsh (Alpine) or zsh (Ubuntu), as
do CI's Alpine jobs. The ubuntu-latest jobs skip zsh. macOS has both.
Keeping it portable: bash-as-sh on macOS is still BSD userland, so sed -i,
stat, readlink, realpath and date +%N are all off limits in the harness —
they're either GNU-only or behave differently on BSD. make lint runs
spec/lint_portability.sh, a POSIX sh script with no grep -P, over the harness
files, script/release.sh (also run by hand on a Mac) and the GitHub Actions run:
blocks and fails on any of those forms:
make lintIt also runs as a step in .github/actions/run-shell-tests, ahead of the suite
itself, so a GNU-only form is caught in CI before it can fail on the macOS runner.
Pre-commit check: jj has no git hooks to run this automatically, so before committing, run
make check by hand. It runs four checks, each also available as its own target:
make lint: the portability lint above.make actionlint: actionlint over.github/workflows/.make shellcheck: shellcheck over every*.shunderspec/,docker/andscript/, configured by.shellcheckrc. It disables SC3043 (local) and SC2155 (the quotedlocal x="$(...)"form is house style); any other exception is an inline# shellcheck disable=with a reason beside it.make zizmor: zizmor, a security audit of the workflows and composite actions, run offline..github/zizmor.ymlholds the action-pinning policy.
actionlint, shellcheck and zizmor must all be on PATH (brew install actionlint shellcheck zizmor); each target refuses to run with an install hint otherwise. In particular, without shellcheck
actionlint quietly skips the run: scripts, which CI's runners do check, so make check would pass
something CI fails. The versions CI pins (SHELLCHECK_VERSION, ACTIONLINT_VERSION and
ZIZMOR_VERSION at the top of the lint targets in the Makefile, matching lint.yml) are recorded
there too: each target prints a warning, without failing, when the host's tool differs, since another
version can pass here and fail in CI. All are fast, host-only checks (no container), and CI runs the same ones:
lint_portability.sh in run-shell-tests, and actionlint, shellcheck and zizmor in
.github/workflows/lint.yml.
make checkMeasure code coverage:
make coverage RUBY_VER=3.3
make coverage-crystal CRYSTAL_VER=1.14.0
make coverage RUBY_VER=3.3 TESTS=path # TESTS works here too
make coverage-all # every Ruby and Crystal version (slow)These run the same suite with line coverage switched on for the implementation
under test, then print a summary (lines, percentage and the uncovered line numbers
per file) as TAP comments after the plan. The report is copied out to
coverage/ruby/ or coverage/crystal/ (git-ignored; override with COVERAGE_RUBY_DIR /
COVERAGE_CRYSTAL_DIR). make coverage-all (or coverage-ruby-all / coverage-crystal-all)
runs every version in RUBY_VERSIONS / CRYSTAL_VERSIONS, each into its own directory,
coverage/ruby-<ver>/ and coverage/crystal-<ver>/, carries on past a failing version and
lists the reports at the end. It is deliberately not part of make all: coverage runs are
slow, and the Crystal one builds kcov from source.
summary.md— the summary, in Markdown.- Ruby:
path_helper.txt, the script with each line's hit count in the margin (#####marks a line never run), and.resultset.jsonin SimpleCov's format. - Crystal:
kcov/, kcov's HTML report (kcov/index.html) plus Cobertura, SonarQube and codecov output underkcov/kcov-merged/.
Every run of the executable is its own process, so coverage is collected per process
and merged. spec/lib/coverage/run.sh does the work in either case:
- Ruby needs nothing extra.
spec/lib/coverage/ruby_coverage.rbis loaded into every Ruby process throughRUBYOPTand uses the standard library'sCoverage, so there is no gem andexe/path_helperis untouched. - Crystal has no coverage tool of its own, so a debug build (a release build has
no line table) is run under kcov. kcov isn't
packaged for Ubuntu 24.04 or Alpine, so
docker/install-kcov.shbuilds it from source, and it doesn't build against musl, socoverage-crystalalways uses its own glibc image (Dockerfile.crystal-coverage), whateverCRYSTAL_LIBCsays. kcov turns off address randomisation in the process it traces, which the default seccomp profile blocks, so that container runs with--security-opt seccomp=unconfined.
Coverage doesn't change what the executable prints or its exit status, so the suite's
results are the same as a plain run's. The exit status is the suite's: it's a report, not
a gate. Total line coverage below 100% adds a warning line to the report (so to the output,
as a # comment, and to summary.md), for example **Warning:** line coverage is 98.75%, below the 100% threshold (3 lines uncovered). Set PATH_HELPER_COVERAGE_THRESHOLD to
another percentage to move the threshold (make coverage RUBY_VER=3.3 PATH_HELPER_COVERAGE_THRESHOLD=95). A partial run (TESTS=path) will naturally warn.
List available images:
make listspec/shell_spec.sh reports in TAP (Test Anything Protocol) version
14, so its output
is human readable and can also be piped into any TAP consumer:
TAP version 14
# Platform: linux
ok 1 - the paths are absent before setup runs
ok 2 - setup creates the path directories and files
ok 3 - path_spec
# Performance: path_spec took 189ms
...
ok 69 - an argument is appended for manpaths
# Performance: an argument is appended for manpaths took 192ms
1..69
The exit status is 0 when every test point passed and 1 otherwise, so nothing needs a TAP parser to tell pass from fail.
The Platform comment is darwin on macOS and linux everywhere else, taken
from uname -s. The default search order comes from the platform the
executable runs on and cannot be overridden.
Fixtures are searched for in spec/fixtures/<platform>/results/ first and then
in spec/fixtures/results/. The fixture a failure names is the name of the
file used for comparison.
A failing test shows a YAML block naming the fixture and the arguments used,
followed by cmp output with the expected and actual text as TAP comments:
not ok 3 - path_spec
---
message: 'output did not match the fixture'
severity: fail
data:
fixture: 'spec/fixtures/results/path.txt'
arguments: '-p'
...
# --- cmp ---
# cmp: EOF on /tmp/tmp.jFJnhK
# --- end cmp ---
# --- expected ---
...
Error cases are checked the same way, only on stderr: a refusal has to exit
non-zero, leave stdout empty — stdout carries the path, so a complaint written
there would end up inside PATH — and say why. Where the wording is the same in
every implementation it is compared byte for byte with a fixture
(spec/fixtures/results/error_no_kind.txt); where the refusal answers with the
whole help message, which the two option parsers lay out differently, stderr is
checked for a usage line and for coverage of every switch instead.
The dumps are comments rather than YAML block scalars on purpose: a diff can contain blank and space-indented lines, which are exactly what make a hand-rolled block scalar ambiguous to a YAML parser, whereas a comment can hold anything.
When PATH_HELPER_DOCKER_INSTANCE is unset the suite declines to run — these
tests are destructive — and says so as a skipped plan, exiting 0:
TAP version 14
# Platform: darwin
1..0 # SKIP set PATH_HELPER_DOCKER_INSTANCE to run these destructive tests
# These tests are destructive,
...
Consuming it
Any TAP 13 or 14 harness will do. Note that the prove bundled with system Perl
predates TAP 14 and will report TAP specified version 14 but we don't know about versions later than 13; it still reads the results, but for clean output
use a current TAP::Harness, or a parser such as
tapview,
tap-parser or
faucet:
podman run --rm path_helper:latest-ruby3.3 | tapviewSee https://testanything.org/ for more on TAP.
Open an interactive shell in a container:
make shell RUBY_VER=3.3
make shell-crystal CRYSTAL_VER=latestOr use docker/podman directly (unlike make, this will not build for you,
so build once first):
make build RUBY_VER=3.3
# or: make build-crystal CRYSTAL_VER=latest
# Ruby version
podman run --rm -ti --entrypoint sh path_helper:latest-ruby3.3
# Crystal version
podman run --rm -ti --entrypoint sh path_helper:latest-crystallatestIf the image is missing, podman treats the name as a remote one and reports a
registry error such as requested access to the resource is denied rather than
saying the image is not built locally.
Run some tests yourself:
podman run --rm -ti --entrypoint sh path_helper:latest-ruby3.3
./spec/shell_spec.sh
./spec/shell_spec.sh path error # only these test files, after setup
# Or test the Crystal binary directly
podman run --rm -ti --entrypoint sh path_helper:latest-crystallatest
./bin/path_helper --help
./bin/path_helper -p --debugSet up some paths using the test fixtures:
./exe/path_helper --setup --no-lib
cp -R spec/fixtures/moredirs/* ~/.config/pathsThe suite also fills the segment that is off by default (~/Library/Paths on
Linux) from spec/fixtures/otherdirs/, for the tests that switch it on with
--lib (--config on macOS):
./exe/path_helper --setup --lib --no-config --no-etc
cp -R spec/fixtures/otherdirs/* ~/Library/Paths
./exe/path_helper -p --lib --debugHave a look at the output by running through the available paths:
./exe/path_helper -p
./exe/path_helper -c
./exe/path_helper -f
./exe/path_helper -l
./exe/path_helper -m
./exe/path_helper --pc
./exe/path_helper -p --debugColour support (tput, from ncurses) is already in the image, so the debug
report is coloured in the interactive shell:
./exe/path_helper -p --debugYou may want to have the env vars set. Run:
source ~/.ashenv
echo $PATH
echo $C_INCLUDE_PATH
# etcModify some of the path files
apk add vim
vim ~/.config/paths/paths.d/03-libiconv
vim ~/.config/paths/paths.d/01-Nim
./exe/path_helper -p
# ...
exitThe project uses GitHub Actions for continuous integration and release builds. test-ruby.yml and
test-crystal.yml run on pushes and pull requests to the master and dev
branches (test-crystal.yml also runs on pushes to claude/path-helper-crystal-* branches);
release.yml builds and publishes binaries when a v*.*.* tag is pushed; lint.yml runs
actionlint, shellcheck and zizmor whenever .github/**, the shell scripts under spec/,
docker/ and script/, .shellcheckrc or the Makefile change.
- Language Version Matrices:
test-ruby.ymltests multiple Ruby versions (2.x, 3.x, 4.x), plus a dedicatedtest-ruby-macos-systemjob that runs the suite against macOS's own system Ruby (/usr/bin/ruby).test-crystal.ymltests multiple Crystal versions. - OS Matrix:
ubuntu-latest(glibc) andmacos-latest(arm64), plus an Alpine container job (musl, busyboxsh, no bash) in each workflow:ruby:<version>-alpinefor Ruby andcrystallang/crystal:<version>-alpinefor Crystal, the same images the Makefile builds on. - Manual Triggers: Both test workflows can be manually triggered via
workflow_dispatch; the release workflow also accepts manual triggering, with atag_nameinput. - Concurrency Control: Duplicate runs are cancelled when new commits are pushed.
- Path Filters: Each test workflow runs on a push or pull request only when something it reads
changes i.e. its implementation (
exe/orsrc/and the shard files),spec/,docker/assets/, the composite actions or its own workflow file (Crystal also hasdocker/install-kcov.sh), so a docs-only change runs neither.workflow_dispatchruns regardless. - Test Summaries: Results are displayed in the GitHub Actions UI.
- Code Coverage: One job per language (
coverage-rubyon Ruby 3.3,coverage-crystalon Crystal latest, both onubuntu-latest) runs the suite with line coverage on, asmake coveragedoes locally. The summary goes to the job summary and the full report is uploaded as thecoverage-ruby/coverage-crystalartifact. It is a report, not a test: the job only fails if the suite does. Below 100% it adds a warning to the summary and aCoveragewarning annotation.coverage-crystalbuilds kcov from source into a prefix under$HOMEand caches it withactions/cache, based on the kcov version, runner OS/arch/Ubuntu release anddocker/install-kcov.sh; a cache hit whose kcov runs does nothing more, otherwise kcov's runtime libraries are installed and it only rebuilds if it still won't run. - Compiler Cache: every Crystal job caches the compiler's own cache (
crystal env CRYSTAL_CACHE_DIR) withactions/cache, keyed on the fullcrystal --versionand the sources. The build still runs in full and links fresh; the compiler reuses its cached object file only when the LLVM IR it has just generated is byte-identical, which skips the--releaseoptimisation. The release workflow does not use it. - Artifact Retention: Test results are kept for 7 days, coverage reports for 14 days.
- Workflow Linting and Security Audit:
lint.ymlhas three jobs:actionlint(a pinned release, checksum-verified) over every workflow,shellcheck(likewise pinned) over the harness, the Docker install scripts andscript/release.sh, andzizmor(a pinned version) over the workflows and composite actions. To bump a tool, change its pin inlint.ymland the matchingMakefileversion together. It is triggered when.github/**,spec/**/*.sh,docker/*.sh,script/*.sh,.shellcheckrcor theMakefilechange. - Automated Dependency Updates: Dependabot checks for updates to GitHub Actions weekly and proposes
PRs to update them, targeting the
devbranch (the primary development branch).
Two workflows test the project, one per implementation:
.github/workflows/test-ruby.ymlrunstest-ruby(the Ruby version matrix),test-ruby-macos-system(macOS's own/usr/bin/ruby),test-ruby-alpine(the Alpine image) andcoverage-ruby..github/workflows/test-crystal.ymlrunstest-crystal(the Crystal version matrix, building the executable withshards buildfirst),test-crystal-alpine(the Alpine image) andcoverage-crystal.
Every job follows the same shape:
- Checks out the code
- Installs the language under test --
ruby/setup-rubyorcrystal-lang/install-crystal-- except in the Alpine jobs, which take Ruby or Crystal from the container image instead (the Crystal jobs also runshards build --release --no-debugto produce the binary under test) - Installs the suite and the executable under test in root's home (
setup-test-env), which exposesexecutableandhomeoutputs - Passes those outputs into
run-shell-tests, which lints the harness for GNU-only shell (spec/lint_portability.sh, see Keeping it portable above), then runs the shell-based test suite as root withHOME/PATH_HELPER_EXECUTABLEset. On the twocoverage-*jobs, acoverage: ruby|crystalinput runs it underspec/lib/coverage/run.shinstead, exposing the report directory as acoverage-diroutput - Generates test summaries and uploads artifacts (the coverage jobs also upload the coverage report and append its summary to the job summary)
The two composite actions in .github/actions/ are plain POSIX sh and only use sudo when
they aren't already root, so they work on hosted Ubuntu and macOS runners and in the Alpine
container jobs (test-ruby-alpine, test-crystal-alpine), which are root with no sudo or bash.
The Alpine jobs take their Ruby or Crystal from the image, since ruby/setup-ruby and
crystal-lang/install-crystal have no Alpine builds.
A third workflow, .github/workflows/release.yml, builds release binaries rather than running the
test suite. It triggers on v*.*.* tags (or manually, with a tag_name input); its build job
compiles a static (--static) Crystal binary for Linux x86_64 in the crystallang/crystal:latest-alpine
container, and plain --release --no-debug binaries on macos-15-intel and macos-14 for the two
macOS architectures, then tars and checksums each one. The Linux build is against musl rather than
glibc so that the binary has no runtime dependencies: a static glibc binary still loads the build
host's glibc at run time to look up the home directory. Its release job downloads all three, packages the Ruby
script the same way (path_helper-ruby.tar.gz, holding just exe/path_helper as path_helper, with
its own .sha256), and publishes them all as assets
on a GitHub Release for the tag with script/release.sh, which uses the runner's own gh rather
than a third-party action. If the release already exists (a re-run, say) its assets are replaced and
it is retitled, given the new notes and published; otherwise it is created, and on a manual run whose
tag doesn't exist yet the tag is made at the commit that was built.
The workflow's last step is a script, so the same release can be made (or fixed up) from a checkout
without remembering the gh invocation. You need gh logged in (gh auth login) or GH_TOKEN set, with write access to the repository:
script/release.sh [--target COMMIT] TAG NOTES_FILE ASSET...
# e.g.
script/release.sh v5.0.0 release_notes.md \
path_helper-*.tar.gz path_helper-*.tar.gz.sha256The release is titled Release TAG, its notes are taken from NOTES_FILE, and every ASSET is
attached. It is published, never left as a draft or prerelease. If a release for TAG already exists
its assets are replaced (any of the same name are overwritten), then its title and notes are, and it
is published. If the tag doesn't exist yet, GitHub makes it at --target COMMIT (a branch or full
SHA), or without one at the head of the default branch, so push the tag first or pass --target.
The repository is the checkout's own, or GH_REPO if set. It checks its arguments before asking
GitHub anything, and script/release.sh --help prints the usage.
A fourth workflow, .github/workflows/lint.yml, runs three jobs:
actionlintdownloads a pinnedactionlintrelease, verifies its checksum against the release's published checksums file, and runs against every workflow (composite actions are linted only as far as a workflow references them). It also installs the pinned shellcheck first, so actionlint's check of therun:scripts uses the same version as theshellcheckjob.shellcheckinstalls a pinnedshellcheckrelease (checksum-verified, from the workflow-levelSHELLCHECK_VERSION/SHELLCHECK_SHA256, not the runner's drifting copy) and runs the samefindasmake shellcheckoverspec/,docker/andscript/. It does not callmake, because the Makefile needs podman or docker just to be parsed.zizmorruns the SHA-pinnedzizmorcore/zizmor-actionat a pinned zizmorversion:(not the action's default,latest) over.github, with the online audits (default token) thatmake zizmorskips. Findings fail the job; nothing is uploaded as code scanning.
It runs when files under .github/**, spec/**/*.sh, docker/*.sh, script/*.sh,
.shellcheckrc or the Makefile change.
When making changes to the GitHub Actions workflow:
- Test locally first: Use act to test workflow changes locally
before pushing, and run
make check(lint_portability.sh, actionlint, shellcheck and zizmor, same as CI) before committing - Use a feature branch: Make workflow changes on a separate branch and verify they pass
- Update documentation: If adding new features, update this README section
- Maintain backwards compatibility: Ensure changes don't break existing test patterns
- Follow security best practices: Use minimal permissions, and avoid secrets in logs. Third-party
actions (anything not under
actions/) are pinned to a full commit SHA with the release version in a trailing# vX.Y.Zcomment -- bump the SHA and the comment together;actions/*(GitHub's own, lower risk) stay on their major-version tag (e.g.@v4);.github/zizmor.ymlenforces this. Everyactions/checkoutsetspersist-credentials: false, a${{ }}expression goes throughenv:rather than into arun:script, and write permissions are scoped to the one job that needs them (release.ymliscontents: readoverall,contents: writeon itsreleasejob only)
Key files:
.github/workflows/test-ruby.yml- Ruby test workflow.github/workflows/test-crystal.yml- Crystal test workflow.github/workflows/release.yml- Builds and publishes release binaries on version tagsscript/release.sh- Creates or updates a GitHub release withgh(release.yml's last step, or by hand).github/workflows/lint.yml- Runsactionlint,shellcheckandzizmor.github/zizmor.yml- zizmor policy (action pinning).shellcheckrc- shellcheck configuration for the harness, Docker and release scripts.github/actions/setup-test-env/- Installs the suite and the executable under test (language-agnostic).github/actions/run-shell-tests/- Lints and runs the suite, with optional coverage (language-agnostic)spec/shell_spec.sh- Shell-based test suitespec/lib/test_helpers.sh- TAP reporting, cleanup and assertions, sourced by the suitespec/tests/- The tests themselves (setup, path, error, edge case and case), sourced by the suite in that orderspec/fixtures/- Test fixtures and expected resultsspec/lib/coverage/- Runs the suite with line coverage and writes the report (run.sh,ruby_coverage.rb,report.rb)
Local Testing (e.g. Docker/Podman)
The recommended way to run tests locally is in a container, which provides an isolated environment. The Makefile drives this - see To run the specs for the full set of targets:
# Run the suite for one Ruby version (builds the image if needed)
make test RUBY_VER=3.3
# Every supported Ruby version, then every Crystal version
make test-all
make test-crystal-all
# Line coverage, report in coverage/ruby/ and coverage/crystal/
make coverage RUBY_VER=3.3
make coverage-crystal CRYSTAL_VER=1.14.0
# Coverage for every version, into coverage/ruby-<ver>/ and coverage/crystal-<ver>/
make coverage-all
# Interactive shell for debugging
make shell RUBY_VER=3.3Earlier versions built these images with Packer (docker/docker.pkr.hcl). That
has been replaced by the Makefile and Dockerfile.ruby; make packer-build remains
only as an alias for make build-all.
Local Testing (act)
To simulate the GitHub Actions environment locally:
# Install act (https://github.com/nektos/act)
# Then run a workflow
act push -W .github/workflows/test-ruby.yml
# Run with a specific Ruby version
act push -W .github/workflows/test-ruby.yml --matrix ruby-version:3.3
# Or the Crystal workflow, with a specific Crystal version
act push -W .github/workflows/test-crystal.yml --matrix crystal-version:1.16.0CI Testing
Tests automatically run on GitHub Actions when:
- Pushing to
masterordevbranches - Opening/updating pull requests to those branches
- Manually triggering via the Actions tab (workflow_dispatch)
Key Differences
| Aspect | Local (Docker) | CI (GitHub Actions) |
|---|---|---|
| Environment | Alpine Linux (musl), or Ubuntu for CRYSTAL_LIBC=gnu |
Ubuntu, macOS and an Alpine container |
| Ruby/Crystal setup | Pre-built in image | ruby/setup-ruby or crystal-lang/install-crystal action, or the image in the Alpine job |
| Test output | TAP to the console | TAP, plus artifacts + summary |
| Speed | Fast (cached image) | Depends on cache hits |
See the LICENCE file.