Check that a machine has the tools and services you expect — a laptop, a CI runner, or a test box.
envprobe reads a config file that names what should be there, then verifies it:
executables on PATH (with their versions), TCP ports that answer, the Docker
daemon, environment variables, and files and directories. There are no built-in
defaults. What you write in the config is exactly what gets checked.
$ envprobe doctor
using /Users/you/envprobe.yaml
✓ git 2.50.1 9ms
✓ Java 21.0.11 93ms
✓ Go 1.26.5 6ms
✗ terraform 0s
✓ postgres 2ms
✗ redis 1ms (connection refused)
✓ docker-daemon 160ms
✓ AWS_PROFILE 0s
✗ ssh config 0s (not a file)
6 of 9 checks passedDownload a binary from the latest release, or build from source:
go install github.com/nchatzak/envprobe@latestLinux and macOS. Windows is out of scope.
Write a starter config, then run it:
envprobe config init # writes ./envprobe.yaml
envprobe doctor # run every checkdoctor looks for envprobe.yaml in the current directory, then your home
directory, then ~/.config/envprobe, and uses the first one it finds. The file
it chose is printed to stderr, so you always know which config produced the
output.
Checks run concurrently, each with a 5-second timeout, so one unreachable host does not hold up the rest. A check that times out is reported as a failure.
A row says why in parentheses when the ✗ alone would leave you guessing —
redis above was refused rather than slow. terraform gets none because
missing from PATH is the whole story.
| Command | What it does |
|---|---|
envprobe doctor |
Run every configured check and print a table |
envprobe doctor --json |
Same, as JSON on stdout |
envprobe doctor --ci |
Exit non-zero when a check fails (see below) |
envprobe config init |
Write an example config to ./envprobe.yaml (-o to change the path, --force to overwrite) |
envprobe config validate [file] |
Parse a config without running anything |
envprobe config example |
Print the annotated example config to stdout |
envprobe completion <shell> |
Print a shell completion script (bash, zsh, fish) |
envprobe --version |
Print the version (-v works too) |
envprobe completion <shell> prints a script — it does not install one. Until
that script is where your shell looks, TAB does nothing and nothing says why.
On macOS with zsh:
envprobe completion zsh > $(brew --prefix)/share/zsh/site-functions/_envprobeThen restart your shell; the session that wrote the file will not pick it
up. envprobe completion <shell> --help has the paths and setup steps for each
shell — zsh, bash and fish, on both macOS and Linux — including the
bash-completion package that bash needs.
Two things that help does not mention:
- zsh ignores the file unless its completion system is on, silently. Check with
(( $+functions[compdef] )) && echo on || echo off. If that printsoff, addautoload -Uz compinit && compinitto~/.zshrc— but check first, as many tools run it for you when sourced. To confirm envprobe itself loaded:print -l ${(k)_comps} | grep envprobe. - The script contains no command or flag names — it asks the binary on your
PATHas you type, so upgrades bring their own and it never needs regenerating.
Results go to stdout, diagnostics to stderr, so --json stays pipeable:
$ envprobe doctor --json | jq '.[] | select(.found | not) | .name'
"terraform"
"redis"Each result carries name, found and duration_ms. version and path
appear only when a check has one to report, so a port check has neither. A
binary check drops path when the executable is missing; a path check keeps it
either way, since the expanded target is the thing worth seeing when the check
failed.
problem carries the same cause the table prints in parentheses, under the
same rule. It is not limited to failures: wrong version_args give
found: true with problem: version command failed, unless
version_constraint is set — then there is nothing to compare and the check
fails.
The 5 of 7 checks passed line is a diagnostic, so it goes to stderr in both
formats — stdout stays exactly the results array. It prints on every run that
had checks to run, whether or not --ci is set, so one grep finds the tally in
any log.
Every check has the same three fields:
name— the label in the output and the key under--json. Must be unique.type—binary,port,docker-daemon,env, orpath.with— the payload for that type.
Keys on a check and keys inside with are both validated: an unknown key is an
error that names the key, so a typo fails loudly instead of being ignored. A
key beside with is caught before any check is built, so it is reported on its
own — fix it and the next run reports whatever is wrong inside with.
The checks: key itself is not. A file that misspells it parses as a file with
no checks in it, so doctor warns and exits 0 while --ci exits 2.
The five types:
| Type | Checks | with: |
|---|---|---|
binary |
The executable is on PATH, and its version if you ask for one |
target (defaults to name), version_args (optional), version_constraint (optional) |
port |
Something is listening, which proves a service is running rather than merely installed | target, required, as "host:port" |
docker-daemon |
docker info answers, which the binary check alone cannot tell you |
none |
env |
An environment variable is set, and matches a pattern if you ask for one | target (defaults to name), matches (optional regexp) |
path |
A file or directory exists, for the things a tool reads rather than the tool itself | target, required, kind (optional, file or dir) |
A binary check with version_constraint: ">= 1.24" fails when the tool is
older than that, rather than reporting a version and leaving the judgement to
you. It needs version_args, since there is otherwise nothing to compare, and
omitting them is a config error. The key is spelled apart from the version
field in --json, which is what the machine reported rather than what you
asked for.
A two-component version counts as its .0 release, so 1.24 satisfies
>= 1.24. A version with more than three components — 1.2.3.4, or output
holding no version at all — cannot be compared, and fails the check with
problem: cannot compare version rather than passing unverified. Such a tool
can still be checked for presence, just not constrained.
A prerelease suffix is dropped before the comparison, so 1.2.3-rc1 compares as
1.2.3 and satisfies >= 1.2.3 — the version such a constraint is most likely
written to exclude. The row prints 1.2.3 as well, so nothing on screen says
the judgement was made on a truncated string.
An env check with no matches asks only whether the variable is set, so an
empty value passes. Write matches: "." to require a non-empty one.
A path check takes no default from name, since a label like ssh config is
not a path. A leading ~ expands to your home directory; anywhere else it is a
literal character, so ~backup stays ~backup. A relative target resolves
against the directory you run envprobe from, not the one the config file sits
in. Symlinks are followed, so kind: file passes on a link to a file. Without
kind, a file and a directory both pass.
For a worked config with every type and the pitfalls annotated — which tools print their version to stderr, which want no dashes at all:
envprobe config example # print it
envprobe config init # write it to ./envprobe.yamlAn empty checks: list runs nothing and exits 0, with a warning on stderr.
| Code | Meaning |
|---|---|
| 0 | Every check passed, or there was nothing to check |
| 1 | The checks ran and at least one failed (--ci only) |
| 2 | envprobe could not check: no config file, no checks under --ci, a config it could not build, or a bad flag |
Without --ci, a failing check is information, not an error: doctor reports
it and exits 0. --ci turns the same run into a gate. The 1/2 split matters
there — 1 says the environment is wrong, 2 says envprobe never got far enough to
form an opinion, and a pipeline should treat those differently.
- name: Verify toolchain
run: envprobe doctor --ci./scripts/check.sh # gofmt, build, vet, test -race, lint — what CI runs
./scripts/exit-codes.sh # drives the built binary to verify the exit-code contractDesign rationale lives in docs/decisions.md.
MIT. See LICENSE.