CliDeps
Declare the external command-line tools your app shells out to, check they are actually installed, and tell people how to install the ones that are missing.
def deps do
[{:cli_deps, "~> 0.1"}]
end
Why
Plenty of Elixir apps shell out — to ffmpeg, convert, pdftoppm,
pandoc, pg_dump. When one of those is missing, the failure usually
surfaces as a System.cmd/3 crash deep inside a background job, at the moment
someone was expecting something to work.
This turns "it broke" into "install ffmpeg", and makes it checkable in CI before it reaches anyone.
Usage
defmodule MyApp.Deps do
use CliDeps
tool :ffmpeg,
version: ">= 6.0.0",
args: ["-version"],
install: [
macos: "brew install ffmpeg",
linux: "apt install ffmpeg",
default: "https://ffmpeg.org/download.html"
]
tool :convert,
label: "ImageMagick",
version: ">= 7.0.0",
install: [macos: "brew install imagemagick"]
end
MyApp.Deps.check(:ffmpeg) #=> {:ok, "7.1.1"}
MyApp.Deps.check(:convert) #=> {:error, :not_found}
MyApp.Deps.available?(:ffmpeg) #=> true
MyApp.Deps.explain(:convert)
#=> "ImageMagick was not found on PATH. Install it with: brew install imagemagick"
MyApp.Deps.report()
#=> [%{name: :ffmpeg, ok?: true, result: {:ok, "7.1.1"}, message: "..."}, ...]
Fail the build instead of the request
mix cli_deps.check MyApp.Deps
Exits non-zero when anything is missing or too old, so it works as a CI step or a container-build gate.
Options
| Option | Purpose |
|---|---|
:version | A requirement such as ">= 6.0.0". Omit to check only that the tool exists. |
:args | Arguments that make it print its version. Defaults to ["--version"]; many tools want ["-version"]. |
:pattern | A regex with one capture group locating the version. The default finds the first dotted number. |
:executable | The binary name on PATH. Defaults to the tool's name. |
:label | A human name for messages. |
:install | Per-OS install commands, keyed :macos, :linux, :windows, :default. |
Things worth knowing
Version output is a mess, and that's the hard part.ffmpeg -version
prints ffmpeg version 7.1.1, ImageMagick prints Version: ImageMagick 7.1.1-47 Q16-HDRI, Postgres prints pg_dump (PostgreSQL) 17.6. The default
pattern takes the first dotted number, which handles all of those; :pattern
exists for when it doesn't.
Two normalisations happen because Version is stricter than reality: a
two-part version like 17.6 is padded to 17.6.0, and leading zeros are
stripped — poppler prints 25.04.0, and SemVer forbids a leading zero, so
Version.parse/1 rejects it outright.
Exit status is ignored when a version was found. A surprising number of
tools exit non-zero for --version, or print it to stderr. If a version can
be read, the tool is there.
Nothing is executed unless the executable exists.System.find_executable/1
is consulted first, so a missing tool costs a PATH scan rather than a
process spawn.
Results are cached for an hour. Whether ffmpeg is installed doesn't
change between requests, but it does change between deploys, so entries expire
rather than persisting for the life of the node. Use
use CliDeps, ttl: :timer.minutes(5), or ttl: :infinity to check once per
boot. invalidate/0,1 clears entries after installing something.
Declarations are validated at compile time. A malformed version requirement, a bad regex, or a duplicate tool name fails the build rather than the first check.
What this doesn't do
It doesn't run your tools, and it doesn't install anything. Running a
subprocess well — streaming, timeouts, not orphaning it when the VM dies — is
its own problem, and MuonTrap,
Rambo, and System.cmd/3 already solve it.
This answers only "is it there, and is it new enough".
Formatting
The package exports a formatter config, so declarations keep reading as declarations in your project too:
# .formatter.exs
[import_deps: [:cli_deps]]
Development
mix test
mix precommit # compile --warnings-as-errors, deps check, hex.audit,
# format, credo --strict, dialyzer, test
License
MIT