Repository navigation
Bootstrap Detection
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.
-
.chezmoitemplates/detect-distro.sh— library; deployed to~/.local/libs/detect-distro.sh. -
.chezmoitemplates/detect-packages.sh— candidate tables (logical capability → orderedmgr:pkgtuples); deployed to~/.local/libs/detect-packages.sh. -
tests/detect/— 13 unit-test files + 11-distro/etc/os-releasefixtures. Run withbash tests/detect/run-tests.sh.
. ~/.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 jqBoth 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.
| 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.
| 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. |
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.
| 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 |
| 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.
| 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.
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).
| 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. |
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_versionfield triggerspkg_meetssemantics, applied to every package in a comma-list candidate. - Consumers are free to add / override entries before
pkg_resolve. -
pkg_ensurededupes across capabilities on the same manager (e.g. pacman'sgit_guiandgitkboth map totk;tkappears 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).
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.
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). |
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 skipsystemctl enablecalls. -
detect::should_force_ipv4— no IPv6 route → network tooling should pass-4.
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
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.
"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.
bash tests/detect/run-tests.sh13 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.
Initial setup
General information
- Shell Environment
- Git Configuration
- Application Notes
- Remote GUI
- Utilities
- Playbash Server Management
Day-to-day operations