BALLER uses the BallError enum for all error types. Commands propagate errors
via the ? operator. User-facing errors are printed with [Error]: prefix and
the process exits with a non-zero status code.
Under the global --json flag the error is instead the run's JSON document, on
stdout, and stderr stays empty:
{
"command": "build",
"error": "manifest not found: /x/baller.toml",
"code": "InvalidConfig"
}command is the subcommand that failed — null when the command line could
not be parsed far enough to name one — error is the same message the text
form prints, and code is the BallError variant name below, stable for
scripts to branch on. The exit code is still 1. The one exception is a command
that has already printed its document: baller --json referee … --fail-on
prints its report and then fails, so the RefereeAuditFailed message goes to
stderr as text and stdout keeps the report as its only document.
| Variant | Meaning |
|---|---|
UnsupportedOs |
The host cannot run what was asked for: an unsupported operating system at startup, a source that does not serve this platform (Chocolatey on Linux, System on Windows), or a CPU outside the package's architectures. See Platform Errors |
UnsupportedCommand |
Command not yet implemented |
FileIoErr |
File system I/O error |
InvalidConfig |
Configuration or database error |
NetworkError |
HTTP/network failure |
PackageNotFound |
Package not in database or registry. For registry lookups, may include an aggregated list of every source that was tried and why it failed (e.g. python not found. Sources tried:\n GitHub: ...\n Chocolatey: ...) |
HashMismatch |
Hash verification failed (SHA-256 for GitHub/Baller, SHA-512 for Chocolatey). For Chocolatey packages, the base64-encoded hash from the API is decoded and compared against the file's hex digest. |
ExtractionFailed |
Archive extraction error |
DependencyCycle |
Circular dependency detected |
VersionConflict |
Version constraint not satisfiable |
PackageFrozen |
Cannot modify a frozen package |
PackageManagerError |
System package manager error (no PM detected, command failed, unsupported manager, wrong code path tried to download a system package, or version unparseable as semver) |
NoMatchingAsset |
A GitHub release has no asset built for the host platform. Carries the package, the host <os>-<arch>, and every asset name the release offered |
NoBinaryFound |
An archive extracted cleanly but contained no executable (an empty file counts as none). Carries the package, version, extract directory and cached archive path |
PlatformMismatch |
The extracted binary is a definite executable for another platform — PE/Windows on Linux, ELF on Windows. Carries the package, version, detected format, the rejected binary and the cached archive path; the host <os>-<arch> is rendered in the message |
RefereeBlocked |
Referee's advisory gate refused the plan. Carries every package over the block threshold with its advisories and the reason. Raised before the install loop, so nothing was downloaded, linked or recorded |
RefereeScanBlocked |
Referee's artifact scan refused a downloaded package. Carries the package, version and every finding. Raised after extraction and before linking; the extract directory and cached archive are both purged (discarded = false on build's cargo path, where the user's own compiled binary is deliberately left in place) |
RefereeUnavailable |
The advisory service could not be reached. Fatal only under fail_policy = fail-closed; the default fail-open path reports the packages as unverified and continues |
ConfirmationAborted |
The user answered no to a confirmation prompt |
ConfirmationRequired |
A destructive command needed confirmation under --json, where nothing is ever prompted. Carries the question; pass --yes to proceed |
PipeRedirected |
A confirmation prompt could not be asked because stdin is not a terminal; pass --yes |
RefereeAuditFailed |
baller referee audit/check/scan --fail-on block|warn found an advisory or re-scan finding at or above that level; each package is named with the phase that tripped it. Raised after the report is printed, only to make the exit code non-zero for CI; nothing was changed |
Every command opens the package database (<baller dir>/db/baller.db) before
it does anything else, in four steps. A failure in any of them stops the
command at startup with a single InvalidConfig error naming the step:
| Step | Message starts with |
|---|---|
| Open the file | failed to open database at <path> |
| Apply pragmas | failed to set pragmas (a file that is not a SQLite database fails here) |
| Create missing tables | failed to create schema |
| Migrate the schema | failed to inspect installed_packages schema for <column> column or failed to migrate installed_packages: could not add <column> column, where <column> is user_installed or advisory |
Two migrations run at that step, each independent and each reported by its own
name. migrate_user_installed adds the user_installed column to databases
created before it existed. migrate_advisory adds the advisory column to
databases created before Referee existed; existing rows migrate to NULL, which
reads as "declared nothing" rather than an empty declaration. A healthy database
is migrated silently by both, and each is idempotent.
A database that gets past startup is fully migrated, so a later query error
from an unrelated command — no such column: user_installed or
no such column: advisory — points at a real defect, not at a migration that
silently did not happen.
A GitHub release usually ships one asset per platform, named with no agreed
convention: Rust projects use target triples (x86_64-unknown-linux-gnu), Go
projects use linux_amd64, and distro packages, checksum files and signatures
sit alongside them. src/http/github.rs picks one in two stages:
- Exclude — by lowercased asset name: forbidden extensions (
.deb,.rpm,.msi,.sig,.asc,.sha256,.sum,.txt,.json,.dsc,.buildinfo, …, which coverschecksums.txt), foreign-OS tokens (darwin,macos,apple,android,*bsd,wasm, … pluswindowson a Linux host andlinuxon a Windows host), and foreign-arch tokens (the arm64 family,riscv,ppc64,s390x,i686,386, … on an amd64 host, and the mirror set on an arm64 host). - Match — the surviving names are scanned for the host's candidate tags,
most specific first: full target triples (gnu before musl), then Go-style
os_archnames, then bare tokens such asx86_64/amd64. Where a release ships both a bare binary and an archive of the same build, the archive wins, because only an archive can be extracted.
There is no "first asset" fallback. When nothing matches, the fetch fails
with NoMatchingAsset, which names the host platform and lists every asset the
release offered:
[Error]: no linux-x86_64 asset for 'Rectangle' — available: Rectangle.pkg,
Rectangle1.100.dmg, Rectangle106-100.delta (specify a different source or version)
Previously the code matched the literal string linux-x86_64 — a scheme almost
no project uses — and fell back to release.assets.first(), so draft happily
downloaded a macOS .dmg or a .deb and reported success (issue #13).
If the download and extraction succeed but no executable is found in the
extracted tree, the command fails with NoBinaryFound rather than warning and
continuing. Every command that extracts an archive enforces this —
draft, build, substitute and update (both when it installs a newly
declared dependency and when it upgrades a package) all route the case through
Downloader::no_binary_error:
[Error]: no binary found in extracted package 'ripgrep' v15.2.0 — expected an
executable for linux in ~/.baller/cache/ripgrep-15.2.0 (archive: ~/.baller/cache/...)
The package is not recorded in the roster, no symlink is created, and both the extract directory and the cached archive are removed so a retry starts clean.
Both variants are hard errors. PackageNotFound is treated as skippable
during dependency resolution (core::dep_solver) only when every package that
depends on the missing name is itself system-sourced — the Debian/RPM
virtual-package window. Any other unresolvable dependency is recorded in
ResolveResult.unresolved, and draft/substitute abort with
UnresolvedDependencies (update stays best-effort and warns per name), so
a dependency with no usable asset can never be silently dropped from an
install. Inside the multi-source fallback chain, however, a NoMatchingAsset
from GitHub is folded into the aggregated PackageNotFound message like any
other per-source failure:
[Error]: package not found: rxhanson/Rectangle not found. Sources tried:
BallerRegistry: network error: ...
System: package manager error: apt-cache exited with status exit status: 100
Cargo: package manager error: cargo is not installed on this host
GitHub: no linux-x86_64 asset for 'Rectangle' — available: Rectangle.pkg, ...
Pin the source (--source github) to see the error on its own.
When RegistryClient::fetch_package falls through every source in
source_order without a hit, the resulting PackageNotFound error message
contains the per-source failure details:
python not found. Sources tried:
GitHub: package not found: https://api.github.com/...
BallerRegistry: network error: HTTP 404
Chocolatey: network error: HTTP 406
System: package not found: python
This makes it much easier to diagnose why a particular package is not resolvable — earlier errors are no longer silently swallowed.
System package installation (install_system_package) and the
PackageSource::System path of draft use BallError::PackageManagerError
exclusively. Common messages:
"'foo' is a system package — use native package manager"— whenDownloader::download_and_extractis called on a system package"apt install of 'foo' exited with status exit code: 1"— when the native PM fails"unsupported system package manager: <name>"— for managers other than apt/dnf/pacman"unparseable version '2:1.21-76' for 'foo' (system package format not supported by semver)"— whenparse_version_flexiblecannot normalize a system package version"invalid base64 hash: ..."— when a Chocolatey package's base64-encoded hash cannot be decoded
Crate installation (install_cargo_package) and the PackageSource::Cargo
path of draft use BallError::PackageManagerError exclusively. Common
messages:
"'foo' is a cargo package — use cargo install"— whenDownloader::download_and_extractis called on a cargo package"cargo install of 'foo' exited with status exit code: 1"— when cargo fails to build or install the crate"cargo is not installed on this host"— when the Cargo source is queried on a host with no cargo toolchain"--source cargo needs a cargo toolchain on PATH"— whenbuild --source cargois used on such a host"cargo packages always install the latest available version — cannot pin 'foo'"— when--versionis combined with the Cargo source
See docs/cargo-registry.md for the full error table.
Three checks keep an artifact built for another platform from being installed.
All of them are hard errors — the dependency resolver only ever skips
PackageNotFound, so a mismatch anywhere in the dependency graph aborts the
whole command.
A source the host cannot use — UnsupportedOs, raised before the dry-run
report, before any hook and before anything is downloaded (for --source,
before anything is even fetched):
[Error]: '7zip' uses the chocolatey source, which only serves windows hosts, so it cannot be installed on linux — declare a [source.linux] table for this platform in its manifest, or pass a different --source
A CPU outside architectures — UnsupportedOs, raised at the same point:
[Error]: 'tool' only supports riscv64 (its declared architectures), not this linux-x86_64 host
A foreign binary in the archive — PlatformMismatch, raised after
extraction from the binary's own header, whichever source supplied it:
[Error]: package 'petool' v1.0.0 ships a binary this linux-x86_64 host cannot run (PE/Windows executable) — rejected ~/.baller/cache/petool-1.0.0/petool (archive: ~/.baller/cache/<archive>)
Only a definite foreign binary is rejected: a shebang (#!) script, or any
file that is neither ELF nor PE, still installs, so script-based packages keep
working.
A package that fails one of the hard errors above leaves nothing of itself
on disk or in the database: no symlink, no roster row and no extract directory.
After NoBinaryFound the cached archive is removed as well, so a retry
re-downloads from scratch rather than reusing a half-usable cache. After
PlatformMismatch the cached archive is kept: it downloaded intact and is
exactly what the source serves, so fetching it again would only reproduce the
mismatch (sweep reclaims it). A platform UnsupportedOs fires before
anything is downloaded, so it leaves nothing to clean up at all.
Per command:
| Command | On NoBinaryFound or PlatformMismatch |
|---|---|
draft |
The package is not linked and not recorded. Packages installed earlier in the same run stay installed |
build |
Nothing is linked (neither the platform default nor --install-dir) and no row is written, so the manifest can be fixed and rebuilt |
substitute |
Fails before the old package is ejected, so the roster keeps the working package rather than losing it for an unusable replacement |
update |
The upgrade aborts. update prunes the old extract directory before unpacking the new archive, so the previously linked binary is gone — re-run draft --force (or update once upstream ships a usable asset) to restore it |
RefereeBlocked is raised by the Phase A gate, which runs on the whole resolved
plan before the install loop and before the pre_install hook. Nothing has
been fetched at that point, so the block is atomic by construction: a blocked
dependency in the middle of a five-package plan leaves zero symlinks, zero
roster rows and an empty cache.
RefereeScanBlocked is raised after an archive is extracted and before its
binary is linked. The extract directory and the cached archive are both removed
through the same cleanup NoBinaryFound uses, so a retry re-downloads rather
than reusing a rejected archive.
| Command | On RefereeScanBlocked |
|---|---|
draft |
The package is not linked and not recorded; earlier packages in the same run stay installed |
substitute |
Fails before the old package is ejected |
update |
The scan runs before the old extract directory is pruned, so a rejected upgrade leaves the previously linked binary intact and still runnable |
build (manifest) |
Same as draft: the download is purged and nothing is linked or recorded |
build (Cargo project) |
The scan covers the one compiled binary. It is not deleted — it is the user's own build output — so the error reads "…the compiled binary … was left in place and nothing was linked" instead of "the download was discarded" |
Each finding in the error is prefixed with its severity in capitals —
• BLOCK: [rule] path — evidence or • WARN: … — and block findings, including
a VirusTotal detection, are listed first.
Both carry the advisories or findings that caused them, and RefereeBlocked
names the escape hatches (--no-referee, or raising referee.block_at).
RefereeUnavailable is only fatal under fail_policy = fail-closed.
RefereeAuditFailed is not an install failure at all: it is how
baller referee --fail-on reports, through the exit code, that the audit found
something at or above the chosen level.
See referee.md.
When draft, substitute or update fails partway through its plan, every
package the run already changed is put back, newest first, before the original
error is returned:
- A package the run installed fresh is unlinked, removed from the roster, and its extract dir and cached archive are deleted.
- A package that was already installed (a
draft --forcereinstall, a dependencysubstitutere-fetched, the packageupdatewas upgrading) gets its previous roster row, dependency rows and link back. Itsinstalled_atis the one thing reset, to the time of the rollback.
A warning names the packages rolled back (Rolled back undid this run's changes to …). Cleanup is best-effort: a step that cannot be undone is logged at
--verbose and never replaces the original error. Rollback does not run hooks,
and does not uninstall what a system package manager or cargo install put on
the system — a warning says when such a package stays installed with only its
roster entry removed. Post-hooks still never fail a command, so a failing
post_install or post_update hook does not trigger a rollback.
Destructive operations require user confirmation unless --yes / -y is provided:
eject— removes a package and its orphaned dependenciessweep— clears cached archives (and, with--all, extracted packages)substitute— installs the replacement and removes the old packageinject— adds a command that runs an arbitrary binary (three prompts)
The prompt is written to stderr, so stdout only ever carries command output.
With stdin redirected the prompt cannot be answered and the command fails with
PipeRedirected; under --json nothing is prompted at all and the command
fails with ConfirmationRequired, reported as a JSON error. Either way nothing
has been changed, and --yes is the way through.
When ejecting a package, BALLER automatically removes dependencies that are no longer needed by any remaining installed package. A dependency is considered orphaned if:
- It was not explicitly installed by the user (
user_installed = false) - No other installed package lists it as a dependency
Orphaned packages are removed with a warning message. Their symlinks and database entries are cleaned up.
A failing pre-hook (pre_install, pre_eject, pre_update) aborts the
command before anything changes, with hook '<script>' failed with exit code N.
A failing post-hook (post_install, post_eject, post_update) cannot undo
work that already happened, so it is logged as a warning and the command still
succeeds — the package stays installed, ejected or updated. post_eject runs
only once the package is fully removed. See docs/hooks.md.