Skip to content

Bootstrap Detection

Eugene Lazutkin edited this page Apr 17, 2026 · 2 revisions

Bootstrap detection library

Shared OS identity + capability detection for the bootstrap pathway. Consumed by bootstrap-dotfiles, .chezmoi.toml.tmpl, and the run_onchange_* install scripts. Single source of truth for "what kind of machine is this and how do I install things on it?"

The library is capability-driven: the declared family (Debian / RHEL / SUSE / Arch / Alpine) is a diagnostic label, never a router. Installs are dispatched based on sniffed package-manager presence + per-capability candidate tables. This keeps downstream variants (Silverblue, MicroOS, Ubuntu Core, minimal containers, WSL) correct without per-distro branches.

Design rationale and full threat-model in dev-docs/bootstrap-detection-design.md.

Files

  • .chezmoitemplates/detect-distro.sh — library; deployed to ~/.local/libs/detect-distro.sh.
  • .chezmoitemplates/detect-packages.sh — candidate tables (logical capability → ordered mgr:pkg tuples); deployed to ~/.local/libs/detect-packages.sh.
  • tests/detect/ — 13 unit-test files + 11-distro /etc/os-release fixtures. Run with bash tests/detect/run-tests.sh.

Sourcing

. ~/.local/libs/detect-distro.sh
. ~/.local/libs/detect-packages.sh
detect::apply_overrides           # one-shot — applies the §3.8 decision table
detect::warmup                    # optional — bulk-probes candidates for perf
detect::pkg_ensure c_toolchain git jq

Both files are idempotent via __DETECT_DISTRO_LOADED / __DETECT_PACKAGES_LOADED guards. Sourcing has no side effects beyond populating the manager registry and candidate tables. The library never exits on its own — consumers decide whether probe failures are fatal.

Privilege model: every probe must run unprivileged. Running the library via sudo fails the detect::_assert_no_sudo falsifiability guard. Installs do use sudo (via install templates), but that's the only privileged step.

API reference

§1 — Identity (name-based, Pass 1)

Function Contract
detect::identity Populates DETECTED_ID, DETECTED_ID_LIKE, DETECTED_VERSION_ID, DETECTED_VARIANT_ID, DETECTED_NAME, DETECTED_FAMILY from $OS_RELEASE_PATH (default /etc/os-release). Memoized.
detect::family_contains <token> 0 if <token> appears as a whole word in ID or ID_LIKE. Don't use first-word-of-ID_LIKE heuristics; they break on Ubuntu-likes that declare multi-word ID_LIKE.
detect::is_version_at_least <n> Numeric compare on first integer component of VERSION_ID. Returns nonzero on rolling distros with no VERSION_ID.

DETECTED_FAMILY is one of debian, rhel, suse, arch, alpine, or unknown. Derived from ID + ID_LIKE tokens — Ubuntu → debian, Rocky/Alma → rhel, MicroOS → suse, Manjaro → arch, etc.

§2 — Capabilities (sniffed, Pass 2)

Probe What it answers
detect::has_cmd <cmd> Is <cmd> on $PATH? (memoized)
detect::arch uname -m (x86_64, aarch64, …)
detect::uname_s uname -s (Linux, Darwin, …)
detect::init_system systemd / openrc / runit / s6 / launchd / unknown
detect::is_immutable rpm-ostree / transactional-update / /run/ostree-booted present
detect::is_container Docker / Podman / LXC / systemd-nspawn (exit-code based via systemd-detect-virt -c)
detect::is_wsl $WSL_DISTRO_NAME or $WSL_INTEROP or binfmt marker
detect::has_ipv6 IPv6 route to a public anycast target via ip -6 route get (no ping; no CAP_NET_RAW needed)
detect::can_reach <url> HEAD request within 5 s (memoized per URL)
detect::sudo_group First of wheel / sudo / admin the user belongs to (empty = none)
detect::can_sudo_nopasswd sudo -n true succeeds
detect::pkgmgr Top-priority system pkgmgr name (or unknown)
detect::pkgmgrs_present Newline-separated list of all sniffed pkgmgrs, deduped
detect::has_brew Homebrew callable
detect::has_flatpak flatpak binary + at least one configured remote
detect::has_snap snap binary + snapd responding
detect::has_nix nix-env or the new nix CLI present
detect::has_{npm_global,pip_user,uv_tool,pipx,cargo,go_install} Language-scoped pkgmgr presence
detect::family_consistency consistent / inconsistent / unknown — cross-check declared family against sniffed pkgmgrs. Diagnostic only; never blocks.

§3 — Package resolution

Manager registry

detect::mgr_register <mgr> <avail-tmpl> <has-tmpl> <version-tmpl> <install-tmpl>
detect::mgr_register_avail_bulk <mgr> <bulk-avail-tmpl>

Templates use {pkg} (single) or {pkgs} (plural) substitution and are run through bash -c so they can contain pipes, awk, redirects. Pre-registered managers: apt, dnf, zypper, pacman, apk, brew, rpm-ostree, transactional-update. Bulk templates shipped for apt and brew; others are consumer-registered pending LXD-matrix verification.

Single-package probes

Function Returns
detect::pkg_avail <mgr> <pkg> 0 if <pkg> is in <mgr>'s index
detect::pkg_has <mgr> <pkg> 0 if <pkg> is installed via <mgr>
detect::pkg_version <mgr> <pkg> Raw version string (may include packaging tail)
detect::pkg_meets <mgr> <pkg> <min> 0 if available and normalized version ≥ <min>
detect::pkg_install <mgr> <pkg…> Run the install template with the packages batched

Bulk probes + warmup

Function Purpose
detect::pkg_avail_bulk <mgr> <pkg…> Run <mgr>'s bulk template; populate __DETECT_PKG_AVAIL_CACHE; emit name<TAB>avail|missing lines
detect::warmup Walk __DETECT_CANDIDATES, group + dedup packages by manager, fire one pkg_avail_bulk per manager with a bulk template registered

After warmup, pkg_avail reads the cache directly — turning N subprocesses into 1–2 per manager.

Candidate resolution

Function Result
detect::active_managers Newline-separated manager list the resolver will consider. Filters by __DETECT_MGR_DISABLED (set by apply_overrides) and $DETECT_OPT_OUT. Includes snap only when DETECT_ALLOW_SNAP=1.
detect::pkg_resolve <capability> Echoes <mgr> <pkg…> for the first candidate that matches (space-separated; one-or-more packages when the candidate entry uses the comma-list form like dnf:gcc,gcc-c++,make). Returns 1 if unresolved.
detect::pkg_ensure [--dry-run] [--strict] <caps…> Resolve each, skip installed, group by manager, one install call per manager

pkg_ensure exit codes: 0 = all resolved + installed/skipped, 1 = install failure, 2 = unresolved capability with --strict, 64 = unknown flag.

Flatpak dispatch

Three-leg --system vs --user decision for flatpak install, absorbed from the former standalone flatpak-install.sh library on 2026-04-17.

Function What it answers
detect::flatpak_has_polkit_rule Managed polkit rule at $DETECT_FLATPAK_POLKIT_RULE is present
detect::flatpak_in_sudo_group Current user is in sudo or wheel (the groups the polkit rule authorizes)
detect::flatpak_can_system A --system install is viable without a password prompt (polkit path OR nopass-sudo path)
detect::flatpak_chosen_scope system when viable, else user
detect::flatpak_system_needs_sudo --system install must be wrapped in sudo -n flatpak install --system … (nopass-sudo path) rather than plain flatpak install --system (polkit path)
detect::flatpak_scope_of <pkg> system / user / none — where a given ref is currently installed
detect::flatpak_has_remote <remote> [scope] Remote is configured in the given scope (or any scope if omitted)

Used by flatpak-install CLI. Env vars: DETECT_FLATPAK_POLKIT_RULE (path to the managed polkit rule; default /usr/local/share/polkit-1/rules.d/90-flatpak-ssh.rules), DETECT_FLATPAK_DEFAULT_REMOTE (default flathub).

§4 — Diagnostics

Function Output
detect::summary Human-readable probe snapshot for pre-flight. Goes to stdout; no ANSI.
detect::report_json Same state as flat JSON. Used by .chezmoi.toml.tmpl via output → .detect.* data keys.
detect::should_skip_systemd 0 if container with init_system=unknown (install scripts skip systemd-unit steps)
detect::should_force_ipv4 0 if no IPv6 route (consumer scripts should pass -4 to curl/wget)
detect::reset Clear memoization + applied overrides. Test-only escape hatch.

Candidate tables

Entries in detect-packages.sh are keyed by logical capability; values are ordered mgr:pkgs[:min_version] tuples. A single row may map one capability to multiple packages on a given manager via comma separation:

__DETECT_CANDIDATES[c_toolchain]='
apt:build-essential
dnf:gcc,gcc-c++,make
zypper:gcc,gcc-c++,make
pacman:base-devel
apk:build-base
brew:gcc
'

When a comma-list candidate matches, pkg_resolve emits the packages space-separated (dnf gcc gcc-c++ make) and pkg_ensure batches them into a single install call for that manager.

Rules:

  • Native system managers first. Secondary (brew, snap) last.
  • Blank lines and # comments are ignored.
  • A third :min_version field triggers pkg_meets semantics, applied to every package in a comma-list candidate.
  • Consumers are free to add / override entries before pkg_resolve.
  • pkg_ensure dedupes across capabilities on the same manager (e.g. pacman's git_gui and gitk both map to tk; tk appears once in the install command).

Starter capabilities shipped: c_toolchain, micro_editor, git, git_gui, gitk, curl, jq, lxd, kubectl, brew_linux_prereqs (the POSIX-basics bundle — file, ca-certificates, tar, gzip, bzip2, xz, unzip, procps, util-linux — that RHEL / SUSE / Arch / Alpine minimal images don't ship by default; deliberately unresolved on debian-family since build-essential + the base image already provides these).

Manager alias table

Candidate-table names follow the canonical form. Common aliases you might type elsewhere:

Alias Canonical
apt-get, aptitude apt
dnf5 dnf
npm (global mode) npm-global
pip, pip3, pip --user pip-user
uv tool install … uv-tool

Language-scoped pkgmgrs (npm-global, pip-user, uv-tool) are not default candidates — version-switching runtimes (nvm, pyenv) orphan binaries installed against one version when the user switches. pipx, cargo, and go install are safer because their binary paths survive version swaps.

Override knobs

Environment variables consumed by the library:

Variable Effect
OS_RELEASE_PATH Path to the os-release file (default /etc/os-release). Tests use this to point at fixtures.
DETECT_OPT_OUT Space-separated list of manager names to exclude from active_managers (user blacklist).
DETECT_ALLOW_SNAP Set to 1 to include snap in candidate resolution. Off by default so CLI tools don't accidentally route through snap.
DETECT_USER_OVERRIDE Pin the user name returned by detect::_user (for tests / multi-user diagnostics).
DETECT_SYSTEMD_RUN, DETECT_OSTREE_BOOTED, DETECT_OPENRC_SOFTLEVEL, DETECT_DOCKERENV, DETECT_WSL_BINFMT Overridable paths for capability probes (tests point these at tmpdirs).

Decision-table overrides (§3.8)

detect::apply_overrides applies the following rules once, then the resolver routes accordingly:

Condition Effect
rhel family + immutable + rpm-ostree present dnf and dnf5 disabled — installs flow through rpm-ostree
suse family + immutable + transactional-update present zypper disabled — installs flow through transactional-update
unknown family + pkgmgr detected Warn once on stderr; proceed with the sniffed pkgmgr

Two rules are exposed as predicates instead of overrides (they're consumer concerns, not resolver routing):

  • detect::should_skip_systemd — container with no init system → install scripts skip systemctl enable calls.
  • detect::should_force_ipv4 — no IPv6 route → network tooling should pass -4.

Pre-flight report

detect::summary

Distro:              ubuntu 25.10 (debian family)
Pretty name:         Ubuntu
Kernel:              Linux x86_64
Init:                systemd
Pkgmgrs:             apt brew
Primary pkgmgr:      apt
Family check:        consistent
Immutable FS:        no
Container:           no
WSL:                 no
IPv6:                yes
Sudo group:          sudo
Sudo nopasswd:       no
Brew:                yes
Flatpak:             yes
Snap:                yes
Nix:                 no
Lang pkgmgrs:        npm pip uv cargo go

detect::report_json

Flat JSON with the same fields as summary, plus pkgmgrsPresent as a JSON array. Keys are stable and map to chezmoi data under .detect.* (e.g., .detect.pkgmgr, .detect.family, .detect.isImmutable). Validated as parseable JSON by jq in the test suite when available.

Troubleshooting

"library must not be invoked via sudo" — _assert_no_sudo fired. The sniffing phase runs in the logged-in SSH session; don't sudo bash -c 'source detect-distro.sh; …'. The install templates use sudo internally; that's the only privileged step.

"no candidate resolved for 'X'" — pkg_resolve walked __DETECT_CANDIDATES[X] and no row matched an active manager with the package available. Check detect::active_managers output (the expected manager may be disabled by apply_overrides or DETECT_OPT_OUT), verify the candidate row exists, and confirm pkg_avail <mgr> <pkg> returns 0 for the expected candidate.

Warmup is slow — brew's brew info is the slow half. That's expected; warmup is amortized across many pkg_resolve calls. Skipping warmup (not calling detect::warmup) is fine — individual pkg_avail calls fall back to single-pkg templates.

snap candidates never match — DETECT_ALLOW_SNAP isn't set to 1. Default is off; set the env var before calling pkg_resolve.

family_consistency reports inconsistent — the declared family disagrees with sniffed pkgmgrs (e.g., rhel family but only apt present). Diagnostic only; the library proceeds with whatever is sniffed. Surface the value in your own logs if you want to notice.

Tests

bash tests/detect/run-tests.sh

13 test files: identity across 11 distro fixtures, capabilities, pkgmgr, version, pkg_probes, install, resolver, ensure, overrides, warmup, network, diagnostics, privilege. Tests stub detect::_which, detect::_run, detect::_net_get, detect::_user indirection points for deterministic execution; no network or subprocess side effects during the unit run.

Clone this wiki locally