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

> Source: https://learn-bash.net/review/dependencies/
> Part of Learn Bash, free to read.

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.

```bash
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:

```bash
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.

| Command | GNU (Linux) | BSD (macOS) |
|---|---|---|
| `sed -i` | `sed -i 's/a/b/' f` | `sed -i '' 's/a/b/' f` |
| `date` | `date -d '1 day ago'` | `date -v-1d` |
| `readlink` | `readlink -f path` | not supported (use `grealpath`) |
| `stat` | `stat -c '%s' f` | `stat -f '%z' f` |
| `grep -P` | supported | not supported |
| `xargs -r` | supported | not supported (BSD is `-r`-less by default) |
| `find -printf` | supported | not supported |
| `base64 -w0` | supported | not supported |
| `mktemp` | `mktemp -d` | `mktemp -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.**

```bash
# 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.**

```bash
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.**

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

Pick one and state it in [your `AGENTS.md`](/ai/agents-md/) — otherwise every new script picks differently and you get a codebase where half the scripts work on each platform.

:::tip Test on the other platform in CI
```yaml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
```
Running your shell tests on both is nearly free and catches this class of bug at the point it is introduced rather than when someone's laptop hits it.
:::

## `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.

```bash
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.

```bash
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:

```bash
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 uses | Bash 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 condition | often 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](/review/performance/).

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.

:::verdict 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.
