Review & Verify Updated 2026-09 8 min read View as Markdown

What your script depends on, when there is no package manager

A shell script's dependencies are every external command it calls, every flag it assumes, and every installer it pipes into a shell. None of that is declared anywhere.

Shell has no package.json, so its dependencies are invisible: every jq, sed, curl and docker your script invokes is an undeclared requirement, and every one has version and platform variations.

The failure is not dramatic. It is a script that works on the author's Mac, fails in CI on Alpine with a confusing error, and gets "fixed" by someone adding a flag that breaks it back on the Mac.

Declare dependencies with a preflight check#

The single most valuable pattern here, and generated scripts never include it.

shell
require() {
  local missing=()
  for cmd in "$@"; do
    command -v -- "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
  done
  if (( ${#missing[@]} )); then
    printf 'missing required commands: %s\n' "${missing[*]}" >&2
    printf 'install with: brew install %s\n' "${missing[*]}" >&2
    exit 127
  fi
}

require jq curl git docker

Ten lines, and it converts "command not found" halfway through a deploy into a clear message before anything has happened. It also documents the dependencies, which is the other half of the value.

For anything where the version matters:

shell
require_version() {
  local cmd=$1 min=$2 actual
  actual=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
  [[ -n "$actual" ]] || { echo "cannot determine $cmd version" >&2; exit 1; }
  if [[ "$(printf '%s\n%s\n' "$min" "$actual" | sort -V | head -1)" != "$min" ]]; then
    echo "$cmd $min or newer required, found $actual" >&2
    exit 1
  fi
}

require_version jq 1.7

sort -V does version comparison correctly, which is worth knowing — hand-rolled version comparison in shell is almost always subtly wrong.

The real dependency problem: GNU versus BSD#

Most "works on my machine" shell failures are this. The commands have the same names and different behaviour.

CommandGNU (Linux)BSD (macOS)
sed -ised -i 's/a/b/' fsed -i '' 's/a/b/' f
datedate -d '1 day ago'date -v-1d
readlinkreadlink -f pathnot supported (use grealpath)
statstat -c '%s' fstat -f '%z' f
grep -Psupportednot supported
xargs -rsupportednot supported (BSD is -r-less by default)
find -printfsupportednot supported
base64 -w0supportednot supported
mktempmktemp -dmktemp -d (mostly compatible)

Generated scripts assume GNU, because most examples online are written on Linux.

Three ways to handle it, in increasing order of robustness:

1. Avoid the divergent flags.

shell
# instead of sed -i
sed 's/a/b/' f > f.tmp && mv -- f.tmp f

# instead of readlink -f
abs_path() { cd -- "$(dirname -- "$1")" && printf '%s/%s\n' "$PWD" "$(basename -- "$1")"; }

2. Detect and adapt.

shell
if sed --version >/dev/null 2>&1; then
  SED_INPLACE=(sed -i)          # GNU
else
  SED_INPLACE=(sed -i '')       # BSD
fi
"${SED_INPLACE[@]}" 's/a/b/' file

3. Require the GNU version explicitly.

shell
require gsed gdate grealpath    # brew install coreutils gnu-sed
SED=gsed

Pick one and state it in your AGENTS.md — otherwise every new script picks differently and you get a codebase where half the scripts work on each platform.

curl | sh is the shell supply chain#

Shell has no registry, so its equivalent of installing a package is fetching a script and running it. That makes it the highest-risk dependency operation in the ecosystem.

shell
curl -sSL https://example.com/install.sh | sh

You are executing unreviewed code, and a truncated download executes a partial script that can do something the whole one would not.

shell
url=https://example.com/install.sh
sha=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

curl -fsSL -o /tmp/install.sh -- "$url"          # -f: fail on HTTP error
echo "$sha  /tmp/install.sh" | sha256sum -c - || { echo "checksum mismatch" >&2; exit 1; }
bash /tmp/install.sh

The -f matters: without it curl writes the HTTP error page into your file and exits 0, and you then execute an HTML document.

For tools your scripts depend on, pin the version and the checksum:

shell
readonly JQ_VERSION=1.7.1
readonly JQ_SHA256=5942c9b0934e510ee61eb3e30273f1b3fe2590df93933a93d7c58b81d19c8ff5

install_jq() {
  local url="https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64"
  curl -fsSL -o "$BIN/jq" -- "$url"
  echo "${JQ_SHA256}  ${BIN}/jq" | sha256sum -c - || return 1
  chmod +x -- "$BIN/jq"
}

That is a lockfile, written by hand. It is more work than npm ci and it is the same guarantee.

Reduce what you depend on#

Every external command is a dependency. Bash has more built in than generated scripts assume:

Generated usesBash can do it
`echo "$x" \cut -d/ -f1`${x%%/*}
basename "$p" / dirname "$p"${p##*/} / ${p%/*}
`echo "$s" \tr a-z A-Z`${s^^} (bash 4+)
`echo "$s" \sed 's/foo/bar/'`${s/foo/bar}
expr $a + $b$(( a + b ))
`cat file \grep x`grep x file
wc -l < f in a conditionoften a while read loop you already have
seq 1 10{1..10}

Parameter expansion is not just fewer dependencies — it avoids a process spawn, which matters in a loop. See the performance page.

The counterpoint: jq for JSON and yq for YAML are dependencies worth having. Parsing structured data with sed and grep is always wrong, and generated scripts do it constantly.

Containers as the honest answer#

If a script has more than a handful of external dependencies and must run reproducibly, the shell-native tooling runs out and a container is the right answer:

dockerfile
FROM alpine:3.20
RUN apk add --no-cache bash jq curl git
COPY scripts/ /scripts/
ENTRYPOINT ["/scripts/deploy.sh"]

That pins every dependency and every platform variation at once. It is also an admission that the script has outgrown shell's dependency story — which is fine, and worth recognising rather than working around.

The policy

  1. A require preflight at the top of every script that calls external tools.
  2. Pick GNU-or-portable and state it in AGENTS.md. Do not mix.
  3. Test on Linux and macOS in CI if both matter.
  4. Never pipe a download into a shell. Download, checksum, read, then run.
  5. Use parameter expansion instead of spawning cut, basename, tr.

Common questions#

Is a preflight check worth it for a three-line script?#

No. It earns its place the moment a script is run by someone other than you, or in CI — which is where "command not found" halfway through a deploy is expensive rather than annoying.

Should I write POSIX sh for portability?#

Only if you genuinely need to run where bash is absent — Alpine's default shell, some BSD base systems, minimal containers. Otherwise the portability you actually need is GNU-versus-BSD coreutils, which POSIX sh does nothing about, and you have given up arrays and [[ ]] for nothing.

How do I pin the version of a tool a script uses?#

Download a specific release to a project-local bin/ directory with a checksum check, and put that directory first in PATH for the script's lifetime. It is a hand-written lockfile, and it is the only version of one shell offers.

Is command -v better than which?#

Yes. command -v is a shell builtin, is POSIX-specified, and has consistent exit status behaviour. which is an external program that varies between systems and is not reliable in scripts.

Get the Bash agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.